0.2.64 update
This commit is contained in:
@@ -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">
|
||||
|
||||
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user