This commit is contained in:
estifanos
2026-08-21 07:38:01 +00:00
parent 5e671b8ac0
commit d4a5ba7673
3 changed files with 127 additions and 71 deletions

View File

@@ -1,5 +1,5 @@
import { useCallback, useMemo, useState } from 'react'; import { useCallback, useMemo, useState } from 'react';
import { AppShell, Drawer } from '@mantine/core'; import { AppShell, Box, Drawer, Group, Text } from '@mantine/core';
import { useDisclosure } from '@mantine/hooks'; import { useDisclosure } from '@mantine/hooks';
import { Outlet, useLocation, useNavigate } from 'react-router-dom'; import { Outlet, useLocation, useNavigate } from 'react-router-dom';
import { useTranslation } from 'react-i18next'; import { useTranslation } from 'react-i18next';
@@ -26,6 +26,14 @@ const BADGE_POLL_MS = 60_000;
const HEADER_HEIGHT = 116; const HEADER_HEIGHT = 116;
/**
* Horizontal inset of the header chrome. `AppHeader` adds its own `px="lg"`
* inside this, so the nav strip below needs the sum to line up with the
* controls above it — it used to start 20px to their left.
*/
const CHROME_PAD_X = 32;
const NAV_STRIP_PAD_X = CHROME_PAD_X + 20;
/** /**
* A desk left unlocked with a license-review or medical-record screen open is * A desk left unlocked with a license-review or medical-record screen open is
* the actual threat model here, not a slow token. 15 minutes of no mouse, * the actual threat model here, not a slow token. 15 minutes of no mouse,
@@ -144,7 +152,9 @@ export function BackofficeLayout() {
return ( return (
<AppShell <AppShell
header={{ height: isSidebar ? 74 : HEADER_HEIGHT }} // The top layout drops its nav strip on small screens — the drawer is
// the nav there — so the header shrinks back to a single row with it.
header={{ height: isSidebar ? 74 : { base: 74, sm: HEADER_HEIGHT } }}
navbar={ navbar={
isSidebar isSidebar
? { ? {
@@ -162,13 +172,28 @@ export function BackofficeLayout() {
<AppShell.Header <AppShell.Header
style={{ style={{
background: "var(--mantine-color-body)", background: "var(--mantine-color-body)",
borderBottom: "1px solid var(--mantine-color-gray-2)", borderBottom: "1px solid var(--mantine-color-default-border)",
display: "flex", display: "flex",
flexDirection: "column", flexDirection: "column",
}} }}
> >
<div style={{ height: 74, flexShrink: 0, padding: "0 32px" }}> <div
style={{ height: 74, flexShrink: 0, padding: `0 ${CHROME_PAD_X}px` }}
>
<AppHeader <AppHeader
brand={
isSidebar ? undefined : (
<Group gap="xs" wrap="nowrap">
<BrandMark size={28} />
<Text fw={700} size="sm" lh={1.1} visibleFrom="xs">
{t('app.name')}
</Text>
</Group>
)
}
// Nothing to toggle on a desktop top bar; on mobile it opens the
// drawer below.
burgerHiddenFrom={isSidebar ? undefined : 'sm'}
onToggleNav={toggleNav} onToggleNav={toggleNav}
onToggleSidebar={isSidebar ? handleToggleCollapse : toggleNav} onToggleSidebar={isSidebar ? handleToggleCollapse : toggleNav}
navOpened={opened} navOpened={opened}
@@ -182,13 +207,14 @@ export function BackofficeLayout() {
</div> </div>
{!isSidebar && ( {!isSidebar && (
<div <Box
visibleFrom="sm"
style={{ style={{
display: 'flex', display: 'flex',
alignItems: 'center', alignItems: 'center',
padding: '0 32px', padding: `0 ${NAV_STRIP_PAD_X}px`,
height: 42, height: 42,
borderTop: '1px solid var(--mantine-color-gray-1)', borderTop: '1px solid var(--mantine-color-default-border)',
flexShrink: 0, flexShrink: 0,
}} }}
> >
@@ -199,7 +225,7 @@ export function BackofficeLayout() {
activePath={location.pathname} activePath={location.pathname}
onNavigate={go} onNavigate={go}
/> />
</div> </Box>
)} )}
</AppShell.Header> </AppShell.Header>
@@ -210,7 +236,7 @@ export function BackofficeLayout() {
overflow: "hidden", overflow: "hidden",
transition: "width 200ms ease", transition: "width 200ms ease",
background: "var(--mantine-color-body)", background: "var(--mantine-color-body)",
borderRight: "1px solid var(--mantine-color-gray-2)", borderRight: "1px solid var(--mantine-color-default-border)",
}} }}
> >
<AppSidebar <AppSidebar
@@ -238,30 +264,28 @@ export function BackofficeLayout() {
{/* Mobile nav: a proper Drawer (sized, backdrop, closes on outside {/* Mobile nav: a proper Drawer (sized, backdrop, closes on outside
click) instead of AppShell's full-width mobile navbar. Mirrors the click) instead of AppShell's full-width mobile navbar. Mirrors the
landing page's mobile menu. */} landing page's mobile menu. */}
{isSidebar && ( <Drawer
<Drawer opened={opened}
opened={opened} onClose={closeNav}
onClose={closeNav} hiddenFrom="sm"
hiddenFrom="sm" size="75%"
size="75%" padding={0}
padding={0} withCloseButton={false}
withCloseButton={false} >
> <AppSidebar
<AppSidebar navItems={sections}
navItems={sections} collapsed={false}
collapsed={false} activePath={location.pathname}
activePath={location.pathname} onToggleCollapse={handleToggleCollapse}
onToggleCollapse={handleToggleCollapse} onNavigate={(item) => {
onNavigate={(item) => { go(item);
go(item); closeNav();
closeNav(); }}
}} brandName={t('app.name')}
brandName={t('app.name')} brandSubtitle={t('app.authority')}
brandSubtitle={t('app.authority')} brandLogo={<BrandMark size={32} />}
brandLogo={<BrandMark size={32} />} />
/> </Drawer>
</Drawer>
)}
</AppShell> </AppShell>
); );
} }

View File

@@ -14,6 +14,7 @@ import {
IconLogout, IconLogout,
IconUserCircle, IconUserCircle,
} from '@tabler/icons-react'; } from '@tabler/icons-react';
import type { ReactNode } from 'react';
import { useTranslation } from 'react-i18next'; import { useTranslation } from 'react-i18next';
import { LanguageSwitcher } from './LanguageSwitcher'; import { LanguageSwitcher } from './LanguageSwitcher';
import { ColorSchemeToggle } from './ColorSchemeToggle'; import { ColorSchemeToggle } from './ColorSchemeToggle';
@@ -36,6 +37,17 @@ interface AppHeaderProps {
supportedLanguages: readonly string[]; supportedLanguages: readonly string[];
onNotificationsClick?: () => void; onNotificationsClick?: () => void;
notificationCount?: number; notificationCount?: number;
/**
* Rendered at the far left. The sidebar layout carries the brand in the
* sidebar itself; the top-bar layout has no sidebar, so it passes the brand
* here rather than leaving the chrome unbranded.
*/
brand?: ReactNode;
/**
* Breakpoint from which the burger is hidden. The top-bar layout only needs
* it on small screens, where the drawer replaces the nav strip.
*/
burgerHiddenFrom?: string;
} }
export function AppHeader({ export function AppHeader({
@@ -50,17 +62,21 @@ export function AppHeader({
supportedLanguages, supportedLanguages,
onNotificationsClick, onNotificationsClick,
notificationCount, notificationCount,
brand,
burgerHiddenFrom,
}: AppHeaderProps) { }: AppHeaderProps) {
const isMobile = typeof window !== 'undefined' && window.innerWidth < 768; const isMobile = typeof window !== 'undefined' && window.innerWidth < 768;
return ( return (
<Group h="100%" px="lg" justify="space-between" wrap="nowrap"> <Group h="100%" px="lg" justify="space-between" wrap="nowrap">
<Group gap="md" wrap="nowrap"> <Group gap="md" wrap="nowrap">
{brand}
{/* Hamburger — styled like user-management Top.tsx */} {/* Hamburger — styled like user-management Top.tsx */}
{/* The Burger itself owns the click so the control is a real, keyboard {/* The Burger itself owns the click so the control is a real, keyboard
reachable <button>; the Box is chrome only. It previously wrapped a reachable <button>; the Box is chrome only. It previously wrapped a
no-op button, which no keyboard user could operate. */} no-op button, which no keyboard user could operate. */}
<Box <Box
hiddenFrom={burgerHiddenFrom}
style={{ style={{
display: 'flex', display: 'flex',
alignItems: 'center', alignItems: 'center',

View File

@@ -1,4 +1,5 @@
import { Badge, Group, Menu, UnstyledButton, rem } from '@mantine/core'; import { Badge, Group, Menu, UnstyledButton, rem } from '@mantine/core';
import { forwardRef } from 'react';
import { IconChevronDown } from '@tabler/icons-react'; import { IconChevronDown } from '@tabler/icons-react';
import { useTranslation } from 'react-i18next'; import { useTranslation } from 'react-i18next';
import type { NavItem } from './AppSidebar'; import type { NavItem } from './AppSidebar';
@@ -23,7 +24,9 @@ interface AppTopNavProps {
* scrolling strip, so with twenty-odd of them most were off-screen and the * scrolling strip, so with twenty-odd of them most were off-screen and the
* grouping that the sidebar already had was thrown away. Here each section * grouping that the sidebar already had was thrown away. Here each section
* collapses to a single labelled dropdown, which fits and keeps the same * collapses to a single labelled dropdown, which fits and keeps the same
* information architecture as the sidebar. * information architecture as the sidebar. Sections that still do not fit
* scroll horizontally rather than dropping off the edge — under ~1100px the
* last one or two were simply unreachable.
*/ */
export function AppTopNav({ navItems, activePath, onNavigate }: AppTopNavProps) { export function AppTopNav({ navItems, activePath, onNavigate }: AppTopNavProps) {
const { t } = useTranslation(); const { t } = useTranslation();
@@ -36,6 +39,7 @@ export function AppTopNav({ navItems, activePath, onNavigate }: AppTopNavProps)
wrap="nowrap" wrap="nowrap"
role="navigation" role="navigation"
aria-label={t('nav.primary', 'Primary')} aria-label={t('nav.primary', 'Primary')}
style={{ flex: 1, minWidth: 0, overflowX: 'auto', scrollbarWidth: 'none' }}
> >
{sections.map((section, index) => { {sections.map((section, index) => {
// An unlabelled leading block (Dashboard) is a plain link, not a menu. // An unlabelled leading block (Dashboard) is a plain link, not a menu.
@@ -148,40 +152,52 @@ interface TopNavButtonProps {
onClick?: () => void; onClick?: () => void;
} }
function TopNavButton({ /**
label, * `Menu.Target` positions its dropdown against the ref it passes to its child,
active, * so a plain function component here left every section menu anchored at the
badge, * top-left of the viewport, covering the header instead of opening under the
soon, * button that was clicked. The rest props carry Menu's own click and aria
withChevron, * handling onto the real button.
onClick, */
}: TopNavButtonProps) { const TopNavButton = forwardRef<HTMLButtonElement, TopNavButtonProps>(
return ( function TopNavButton(
<UnstyledButton { label, active, badge, soon, withChevron, onClick, ...others },
onClick={onClick} ref,
style={{ ) {
display: 'flex', return (
alignItems: 'center', <UnstyledButton
gap: rem(6), ref={ref}
padding: `0 ${rem(14)}`, onClick={onClick}
height: '100%', {...others}
borderBottom: '2px solid', style={{
borderBottomColor: active ? 'var(--mantine-color-blue-6)' : 'transparent', display: 'flex',
color: active ? 'var(--mantine-color-blue-6)' : 'var(--mantine-color-gray-6)', alignItems: 'center',
fontWeight: active ? 600 : 500, gap: rem(6),
fontSize: rem(14), padding: `0 ${rem(14)}`,
whiteSpace: 'nowrap', height: '100%',
opacity: soon ? 0.55 : 1, flexShrink: 0,
marginBottom: -1, borderBottom: '2px solid',
}} borderBottomColor: active
> ? 'var(--mantine-color-blue-6)'
<span>{label}</span> : 'transparent',
{badge !== null && badge !== undefined && ( color: active
<Badge size="xs" variant="filled" color="red" radius="sm"> ? 'var(--mantine-color-blue-6)'
{badge} : 'var(--mantine-color-gray-6)',
</Badge> fontWeight: active ? 600 : 500,
)} fontSize: rem(14),
{withChevron && <IconChevronDown size={14} stroke={2} />} whiteSpace: 'nowrap',
</UnstyledButton> opacity: soon ? 0.55 : 1,
); marginBottom: -1,
} }}
>
<span>{label}</span>
{badge !== null && badge !== undefined && (
<Badge size="xs" variant="filled" color="red" radius="sm">
{badge}
</Badge>
)}
{withChevron && <IconChevronDown size={14} stroke={2} />}
</UnstyledButton>
);
},
);