232 lines
7.5 KiB
TypeScript
232 lines
7.5 KiB
TypeScript
import { useEffect, useRef, type ReactNode } from 'react';
|
|
import { useTranslation } from 'react-i18next';
|
|
import type { PageId } from '../../lib/urlState';
|
|
import { useBackdropClose } from '../../lib/useBackdropClose';
|
|
|
|
export interface NavItem {
|
|
id: PageId;
|
|
/** i18n key resolved against the `layout` namespace at render time. */
|
|
labelKey: string;
|
|
}
|
|
|
|
interface NavDrawerProps {
|
|
open: boolean;
|
|
onClose: () => void;
|
|
visibleNav: NavItem[];
|
|
currentPage: PageId;
|
|
onNavigate: (page: PageId) => void;
|
|
appName: string;
|
|
logoUrl: string | null;
|
|
returnFocusRef?: React.RefObject<HTMLElement>;
|
|
}
|
|
|
|
const ICON_PROPS = {
|
|
width: 22,
|
|
height: 22,
|
|
viewBox: '0 0 24 24',
|
|
fill: 'none',
|
|
stroke: 'currentColor',
|
|
strokeWidth: 1.7,
|
|
strokeLinecap: 'round' as const,
|
|
strokeLinejoin: 'round' as const,
|
|
'aria-hidden': true,
|
|
};
|
|
|
|
const NAV_ICONS: Record<PageId, ReactNode> = {
|
|
tasks: (
|
|
<svg {...ICON_PROPS}>
|
|
<line x1="8" y1="6" x2="20" y2="6" />
|
|
<line x1="8" y1="12" x2="20" y2="12" />
|
|
<line x1="8" y1="18" x2="20" y2="18" />
|
|
<circle cx="4" cy="6" r="1.4" />
|
|
<circle cx="4" cy="12" r="1.4" />
|
|
<circle cx="4" cy="18" r="1.4" />
|
|
</svg>
|
|
),
|
|
schedules: (
|
|
<svg {...ICON_PROPS}>
|
|
<circle cx="12" cy="12" r="9" />
|
|
<polyline points="12 7 12 12 15 14" />
|
|
</svg>
|
|
),
|
|
pieces: (
|
|
<svg {...ICON_PROPS}>
|
|
<path d="M19 11h-4V7a2 2 0 0 0-4 0H7a2 2 0 0 0-2 2v4h4a2 2 0 0 1 0 4H5v4a2 2 0 0 0 2 2h4v-2a2 2 0 0 1 4 0v2h4a2 2 0 0 0 2-2v-4a2 2 0 0 1 0-4Z" />
|
|
</svg>
|
|
),
|
|
captcha: (
|
|
<svg {...ICON_PROPS}>
|
|
<path d="M12 3 4 6v6c0 5 3.5 8.5 8 9 4.5-.5 8-4 8-9V6Z" />
|
|
<path d="m9 12 2 2 4-4" />
|
|
</svg>
|
|
),
|
|
settings: (
|
|
<svg {...ICON_PROPS}>
|
|
<circle cx="12" cy="12" r="3" />
|
|
<path d="M19.4 15a1.7 1.7 0 0 0 .3 1.8l.1.1a2 2 0 1 1-2.8 2.8l-.1-.1a1.7 1.7 0 0 0-1.8-.3 1.7 1.7 0 0 0-1 1.5V21a2 2 0 0 1-4 0v-.1a1.7 1.7 0 0 0-1.1-1.5 1.7 1.7 0 0 0-1.8.3l-.1.1a2 2 0 1 1-2.8-2.8l.1-.1a1.7 1.7 0 0 0 .3-1.8 1.7 1.7 0 0 0-1.5-1H3a2 2 0 0 1 0-4h.1a1.7 1.7 0 0 0 1.5-1.1 1.7 1.7 0 0 0-.3-1.8l-.1-.1a2 2 0 1 1 2.8-2.8l.1.1a1.7 1.7 0 0 0 1.8.3H9a1.7 1.7 0 0 0 1-1.5V3a2 2 0 0 1 4 0v.1a1.7 1.7 0 0 0 1 1.5 1.7 1.7 0 0 0 1.8-.3l.1-.1a2 2 0 1 1 2.8 2.8l-.1.1a1.7 1.7 0 0 0-.3 1.8V9c.3.6.9 1 1.5 1H21a2 2 0 0 1 0 4h-.1a1.7 1.7 0 0 0-1.5 1Z" />
|
|
</svg>
|
|
),
|
|
users: (
|
|
<svg {...ICON_PROPS}>
|
|
<path d="M16 21v-2a4 4 0 0 0-4-4H6a4 4 0 0 0-4 4v2" />
|
|
<circle cx="9" cy="7" r="4" />
|
|
<path d="M22 21v-2a4 4 0 0 0-3-3.9" />
|
|
<path d="M16 3.1a4 4 0 0 1 0 7.8" />
|
|
</svg>
|
|
),
|
|
help: (
|
|
<svg {...ICON_PROPS}>
|
|
<circle cx="12" cy="12" r="9" />
|
|
<path d="M9.1 9a3 3 0 0 1 5.8 1c0 2-3 3-3 3" />
|
|
<line x1="12" y1="17" x2="12" y2="17.01" />
|
|
</svg>
|
|
),
|
|
userfolder: (
|
|
<svg {...ICON_PROPS}>
|
|
<path d="M3 7a2 2 0 0 1 2-2h4l2 2h8a2 2 0 0 1 2 2v9a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2Z" />
|
|
</svg>
|
|
),
|
|
usage: (
|
|
<svg {...ICON_PROPS}>
|
|
<line x1="4" y1="20" x2="20" y2="20" />
|
|
<rect x="6" y="11" width="3" height="6" />
|
|
<rect x="11" y="7" width="3" height="10" />
|
|
<rect x="16" y="13" width="3" height="4" />
|
|
</svg>
|
|
),
|
|
};
|
|
|
|
export function NavDrawer({
|
|
open,
|
|
onClose,
|
|
visibleNav,
|
|
currentPage,
|
|
onNavigate,
|
|
appName,
|
|
logoUrl,
|
|
returnFocusRef,
|
|
}: NavDrawerProps) {
|
|
const { t } = useTranslation('layout');
|
|
const panelRef = useRef<HTMLDivElement>(null);
|
|
const firstItemRef = useRef<HTMLButtonElement>(null);
|
|
const backdrop = useBackdropClose(onClose);
|
|
|
|
useEffect(() => {
|
|
if (!open) return;
|
|
const timeout = window.setTimeout(() => {
|
|
(firstItemRef.current ?? panelRef.current)?.focus();
|
|
}, 0);
|
|
return () => {
|
|
window.clearTimeout(timeout);
|
|
returnFocusRef?.current?.focus();
|
|
};
|
|
}, [open, returnFocusRef]);
|
|
|
|
useEffect(() => {
|
|
if (!open) return;
|
|
const onKey = (e: KeyboardEvent) => {
|
|
if (e.key === 'Escape') {
|
|
e.stopPropagation();
|
|
onClose();
|
|
}
|
|
};
|
|
document.addEventListener('keydown', onKey);
|
|
return () => document.removeEventListener('keydown', onKey);
|
|
}, [open, onClose]);
|
|
|
|
useEffect(() => {
|
|
if (!open) return;
|
|
const prev = document.documentElement.style.overflow;
|
|
document.documentElement.style.overflow = 'hidden';
|
|
return () => {
|
|
document.documentElement.style.overflow = prev;
|
|
};
|
|
}, [open]);
|
|
|
|
const onPanelKeyDown = (e: React.KeyboardEvent) => {
|
|
if (e.key !== 'Tab' || !panelRef.current) return;
|
|
const focusable = panelRef.current.querySelectorAll<HTMLElement>(
|
|
'button:not([disabled]), [href], [tabindex]:not([tabindex="-1"])',
|
|
);
|
|
if (focusable.length === 0) return;
|
|
const first = focusable[0];
|
|
const last = focusable[focusable.length - 1];
|
|
if (e.shiftKey && document.activeElement === first) {
|
|
e.preventDefault();
|
|
last.focus();
|
|
} else if (!e.shiftKey && document.activeElement === last) {
|
|
e.preventDefault();
|
|
first.focus();
|
|
}
|
|
};
|
|
|
|
return (
|
|
<>
|
|
<div
|
|
aria-hidden
|
|
{...backdrop}
|
|
className={`fixed inset-0 z-40 bg-black/40 backdrop-blur-sm transition-opacity duration-200 ${
|
|
open ? 'opacity-100 pointer-events-auto' : 'opacity-0 pointer-events-none'
|
|
}`}
|
|
/>
|
|
<div
|
|
ref={panelRef}
|
|
id="nav-drawer"
|
|
role="dialog"
|
|
aria-modal="true"
|
|
aria-label={t('drawer.nav')}
|
|
aria-hidden={!open}
|
|
tabIndex={-1}
|
|
onKeyDown={onPanelKeyDown}
|
|
{...(!open && { inert: '' })}
|
|
className={`fixed left-0 top-0 bottom-0 z-50 w-[min(280px,80vw)] bg-surface shadow-xl flex flex-col motion-safe:transition-transform duration-200 ease-out ${
|
|
open ? 'translate-x-0' : '-translate-x-full'
|
|
}`}
|
|
style={{
|
|
paddingTop: 'env(safe-area-inset-top, 0px)',
|
|
paddingBottom: 'env(safe-area-inset-bottom, 0px)',
|
|
}}
|
|
>
|
|
<div className="flex items-center gap-3 px-4 py-3 border-b border-hairline">
|
|
<img
|
|
src={logoUrl ?? `${import.meta.env.BASE_URL}favicon.svg`}
|
|
alt=""
|
|
className="h-6 w-auto max-w-[120px] object-contain"
|
|
/>
|
|
<span className="flex-1 text-sm font-semibold tracking-tight text-slate-900 truncate">
|
|
{appName}
|
|
</span>
|
|
<span className="text-[10px] font-mono text-slate-400 flex-shrink-0">
|
|
v{__APP_VERSION__}
|
|
</span>
|
|
</div>
|
|
<nav className="flex-1 overflow-y-auto py-2" aria-label={t('nav.mainNav')}>
|
|
{visibleNav.map((item, idx) => {
|
|
const active = currentPage === item.id;
|
|
return (
|
|
<button
|
|
key={item.id}
|
|
type="button"
|
|
ref={idx === 0 ? firstItemRef : undefined}
|
|
onClick={() => {
|
|
onNavigate(item.id);
|
|
onClose();
|
|
}}
|
|
aria-current={active ? 'page' : undefined}
|
|
className={`w-full h-12 px-4 flex items-center gap-3 text-sm transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-accent-ring ${
|
|
active
|
|
? 'font-semibold text-accent bg-accent-soft'
|
|
: 'text-slate-700 hover:bg-surface'
|
|
}`}
|
|
>
|
|
<span className="flex-shrink-0 text-slate-500">{NAV_ICONS[item.id]}</span>
|
|
<span className="flex-1 text-left">{t(item.labelKey)}</span>
|
|
</button>
|
|
);
|
|
})}
|
|
</nav>
|
|
</div>
|
|
</>
|
|
);
|
|
}
|