0.2.64 update

This commit is contained in:
ponzischeme89
2026-08-15 09:23:26 +12:00
parent a2ca7e8061
commit d5d47473a2
90 changed files with 9188 additions and 451 deletions
+68 -13
View File
@@ -1,5 +1,5 @@
import { useEffect, useState } from 'react';
import { NavLink, Outlet, useLocation } from 'react-router-dom';
import { matchPath, NavLink, Outlet, useLocation } from 'react-router-dom';
import { Icon } from './Icon';
import { OmniSearch } from './OmniSearch';
import { NotificationBell } from './NotificationBell';
@@ -29,15 +29,40 @@ function Rail({ open, onNavigate }: { open: boolean; onNavigate: () => void }) {
}
});
const toggle = (id: string) => {
const storeCollapsed = (next: Record<string, boolean>) => {
try {
localStorage.setItem('memby-admin-nav', JSON.stringify(next));
} catch {
// Private-mode storage refusals cost the memory of which group is open, and
// nothing else.
}
};
// The labelled groups are an accordion. Following a destination opens its group and
// folds the one the operator has just left, including navigation from search or Back.
useEffect(() => {
const activeGroup = nav.find((group) =>
group.items.some((item) => matchPath({ path: item.path, end: true }, location.pathname)),
);
if (!activeGroup) return;
setCollapsed((current) => {
const next = { ...current, [id]: !current[id] };
try {
localStorage.setItem('memby-admin-nav', JSON.stringify(next));
} catch {
// Private-mode storage refusals cost the memory of which groups were open, and
// nothing else.
}
const next = { ...current };
nav.forEach((group) => {
if (group.collapsible !== false) next[group.id] = group.id !== activeGroup.id;
});
storeCollapsed(next);
return next;
});
}, [location.pathname]);
const toggle = (id: string, defaultCollapsed = false) => {
setCollapsed((current) => {
const next = { ...current };
const collapseSelected = !(current[id] ?? defaultCollapsed);
nav.forEach((group) => {
if (group.collapsible !== false) next[group.id] = group.id === id ? collapseSelected : true;
});
storeCollapsed(next);
return next;
});
};
@@ -47,20 +72,25 @@ function Rail({ open, onNavigate }: { open: boolean; onNavigate: () => void }) {
{nav.map((group) => {
const items = group.items.filter((item) => !item.hidden);
if (items.length === 0) return null;
const holdsCurrent = items.some((item) => location.pathname === item.path);
const expanded = holdsCurrent || !collapsed[group.id];
const holdsCurrent = items.some((item) =>
matchPath({ path: item.path, end: true }, location.pathname),
);
const collapsible = group.collapsible !== false;
const expanded = holdsCurrent || !collapsible || !(collapsed[group.id] ?? group.defaultCollapsed ?? false);
return (
<div className="rail-group" key={group.id}>
{group.label ? (
{group.label && collapsible ? (
<button
type="button"
className="rail-head"
aria-expanded={expanded}
onClick={() => toggle(group.id)}
onClick={() => toggle(group.id, group.defaultCollapsed)}
>
{group.label}
<Icon name="caret" className="ico caret" />
</button>
) : group.label ? (
<div className="rail-head rail-head-static">{group.label}</div>
) : null}
{expanded
? items.map((item) => (
@@ -124,6 +154,23 @@ export function Layout() {
// it sits over the page that was just opened.
useEffect(() => setRailOpen(false), [location.pathname]);
// An open tablet drawer is modal navigation: keep the page underneath still and let
// Escape close it. The media query decides whether the rail is overlaid; applying this
// on desktop is harmless because the desktop rail cannot be opened by a hidden button.
useEffect(() => {
if (!railOpen) return;
const previous = document.body.style.overflow;
document.body.style.overflow = 'hidden';
const closeOnEscape = (event: KeyboardEvent) => {
if (event.key === 'Escape') setRailOpen(false);
};
window.addEventListener('keydown', closeOnEscape);
return () => {
document.body.style.overflow = previous;
window.removeEventListener('keydown', closeOnEscape);
};
}, [railOpen]);
return (
<>
<a className="skip" href="#main">
@@ -178,6 +225,14 @@ export function Layout() {
</div>
</header>
{railOpen ? (
<button
type="button"
className="rail-scrim"
aria-label="Close sections"
onClick={() => setRailOpen(false)}
/>
) : null}
<Rail open={railOpen} onNavigate={() => setRailOpen(false)} />
<main className="page" id="main">
+14 -3
View File
@@ -1,6 +1,8 @@
import { type ReactNode, useEffect, useId, useRef, useState } from 'react';
import { matchPath, useLocation } from 'react-router-dom';
import { Glyph, Icon, type IconName } from './Icon';
import type { Tone } from '../lib/format';
import { allNavItems } from '../nav';
/* The component vocabulary. Every page is built from what is below and adds nothing of its
own: a screen that needs a look it cannot get from here is a missing component, not a
@@ -13,19 +15,28 @@ export function PageHead({
intro,
actions,
crumbs,
icon,
}: {
title: string;
intro?: string;
actions?: ReactNode;
crumbs?: ReactNode;
icon?: IconName;
}) {
const location = useLocation();
const pageIcon = icon ?? allNavItems.find((item) =>
matchPath({ path: item.path, end: true }, location.pathname),
)?.icon;
return (
<header className="page-head">
{crumbs ? <nav className="crumbs">{crumbs}</nav> : null}
<div className="page-head-row">
<div>
<h1>{title}</h1>
{intro ? <p>{intro}</p> : null}
<div className="page-head-title">
{pageIcon ? <span className="page-head-icon" aria-hidden="true"><Icon name={pageIcon} /></span> : null}
<div className="page-head-text">
<h1>{title}</h1>
{intro ? <p>{intro}</p> : null}
</div>
</div>
{actions ? <div className="page-head-actions">{actions}</div> : null}
</div>