maestro/ui/src/components/layout/NavDrawer.tsx
oss-sync 3b1645cc91
Some checks failed
CI / build-and-test (push) Has been cancelled
sync: update from private repo (d31b280)
2026-06-11 11:28:40 +00:00

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>
</>
);
}