{ "$schema": "https://ui.shadcn.com/schema/registry-item.json", "name": "push-menu", "title": "Push Menu", "description": "Multi-level drill-down navigation menu with slide transitions, focus management and inert parked levels, for mobile drawers.", "dependencies": [ "clsx", "tailwind-merge" ], "registryDependencies": [ "https://ui.digital.nsw.gov.au/registry/r/theme.json", "https://ui.digital.nsw.gov.au/registry/r/button.json", "https://ui.digital.nsw.gov.au/registry/r/link.json", "https://ui.digital.nsw.gov.au/registry/r/icons.json" ], "files": [ { "path": "src/components/push-menu.tsx", "content": "'use client'\n\nimport React from 'react'\n\nimport { cn } from '@/lib/utils'\n\nimport { Button } from '@/components/button'\nimport { Link } from '@/components/link'\nimport { IconChevronRight } from '@/icons/chevron-right'\nimport { IconClose } from '@/icons/close'\nimport { IconWest } from '@/icons/west'\n\n/**\n * Default slide duration for level transitions, in milliseconds — the default\n * of the `durationMs` prop.\n *\n * COUPLING: `durationMs` drives BOTH the `--push-menu-duration` custom\n * property (set inline on the root, read by the CSS transition) and the state\n * machine's `setTimeout`s (which settle the animation state, pop levels, and\n * schedule focus restoration). Do not override the custom property directly —\n * that changes only the visual duration while the state machine still settles\n * on `durationMs`, desyncing the two; pass `durationMs` instead. Under\n * `prefers-reduced-motion` the slide is skipped (the transition is applied\n * under `motion-safe:` only) but the state machine still waits the full\n * duration; the menu is simply \"settled early\" for those users, never broken.\n */\nconst PUSH_MENU_DURATION_MS = 300\n\n/** A node in the menu tree. Items with `links` drill in; items with `href` navigate. */\ntype PushMenuItem = {\n /** Unique across the whole tree — level ids and focus restoration track items by id. */\n id: string\n /** Visible label. */\n title: string\n /** Navigation target for leaf items. Ignored for drilling when `links` is non-empty. */\n href?: string\n /** Child items. A non-empty array makes this item a drill-in button, not a link. */\n links?: PushMenuItem[]\n}\n\n/** One entry in the level stack — the root level plus one per drill-in. */\ntype PushMenuLevel = {\n id: string\n title: string\n /** 1 for the root level, incrementing per drill-in. */\n depth: number\n items: PushMenuItem[]\n /** The item whose activation opened this level. Absent on the root level. */\n parentItem?: PushMenuItem\n}\n\ntype PushMenuAnimationState = 'idle' | 'sliding-forward' | 'sliding-backward'\n\nfunction buildLevelId(path: string[]): string {\n return `level-${path.length > 0 ? path.join('--') : 'root'}`\n}\n\n/**\n * Truncates to at most `maxLength` UTF-16 units, ellipsis included in the\n * budget.\n *\n * Iterates CODE POINTS (`for…of` over a string) rather than slicing by index:\n * a plain `slice` can cut between the two halves of a surrogate pair, leaving\n * a lone surrogate that renders as U+FFFD (an emoji in a level title was\n * enough to reproduce it). Accumulating whole code points and checking the\n * running UTF-16 length keeps both properties at once — never a split pair,\n * and never over budget.\n *\n * Code points, not grapheme clusters: a base character can still be separated\n * from a following combining mark. That degrades to an odd-looking glyph\n * rather than the U+FFFD this fixes, and `Intl.Segmenter` is too recent to\n * hand to registry consumers (who copy this source) without a fallback.\n */\nfunction truncateWithEllipsis(value: string, maxLength: number): string {\n if (maxLength < 1) {\n return ''\n }\n const budget = maxLength - 1\n let out = ''\n for (const codePoint of value) {\n if (out.length + codePoint.length > budget) {\n break\n }\n out += codePoint\n }\n return `${out}…`\n}\n\n/**\n * Collapses a trail of level titles into a single \"A › B › C\" string, keeping\n * the ends and eliding the middle once it outgrows `maxLength`. Exported so an\n * app can render the same trail outside the menu (e.g. in a sheet header).\n * Prefixed with \"PushMenu\" to avoid colliding with a future breadcrumb\n * component in the package barrel.\n *\n * `maxLength` is a HARD bound on the returned string, measured in UTF-16 units\n * (`String.length`) — the one exception is a single level, whose title is\n * returned verbatim because there is nothing to collapse. Callers rendering the\n * trail in a fixed-width slot can rely on that. Middle-elision is attempted\n * first because it reads better; when it does not fit, the full trail is\n * truncated from the end.\n */\nfunction generatePushMenuBreadcrumb(levels: { title: string }[], maxLength = 50): string {\n const first = levels[0]\n if (levels.length <= 1) {\n return first?.title ?? 'Menu'\n }\n\n const full = levels.map((level) => level.title).join(' › ')\n if (full.length <= maxLength) {\n return full\n }\n\n // Only worth eliding if it actually gets under the budget. With exactly four\n // levels it replaces ONE title with \"…\", which saves nothing when that title\n // is short — \"A › B › C › D\" and \"A › … › C › D\" are the same length — and\n // with long titles the elided form can still run well over. Returning it\n // unchecked is what broke the bound.\n const last = levels.at(-1)\n const secondLast = levels.at(-2)\n if (levels.length > 3 && first && last && secondLast) {\n const elided = `${first.title} › … › ${secondLast.title} › ${last.title}`\n if (elided.length <= maxLength) {\n return elided\n }\n }\n\n return truncateWithEllipsis(full, maxLength)\n}\n\n/**\n * Dev-only guard, mirroring `warnIfIconButtonUnlabelled` in button.tsx. It\n * replaces the nswds-app source's rendered \"Navigation Error\" fallback panel:\n * malformed navigation data is a programming error, not a runtime state a user\n * should ever see, so the design system surfaces it to the developer console\n * and renders what it can instead of shipping error chrome. No-op in\n * production.\n */\nfunction warnIfNavigationMalformed(navigation: PushMenuItem[]) {\n if (process.env.NODE_ENV === 'production') {\n return\n }\n if (!Array.isArray(navigation) || navigation.length === 0) {\n console.warn(\n '[nswds/ui] PushMenu received no navigation items — it renders the `emptyMessage` state. If that is expected (unpublished content, a permission-filtered menu), this warning is safe to ignore; it is compiled out in production.',\n )\n return\n }\n const seen = new Set()\n const visit = (items: PushMenuItem[]) => {\n for (const item of items) {\n if (!item?.id || !item.title) {\n console.warn('[nswds/ui] PushMenu navigation item is missing an `id` or `title`:', item)\n continue\n }\n if (seen.has(item.id)) {\n console.warn(\n `[nswds/ui] PushMenu navigation contains duplicate id \"${item.id}\" — level ids and focus restoration track items by id, so ids must be unique across the whole tree.`,\n )\n }\n seen.add(item.id)\n if (!item.href && !item.links?.length) {\n console.warn(\n `[nswds/ui] PushMenu item \"${item.id}\" has neither an href nor children — it renders as a button that only fires onItemClick.`,\n )\n }\n if (item.links?.length) {\n visit(item.links)\n }\n }\n }\n visit(navigation)\n}\n\n// One treatment for every row — drill-in buttons and leaf links alike — so the\n// two read as a single list. Tokenized from the nswds-app source's\n// border-l + primary-800 active pattern, with dark-mode equivalents the app\n// lacked (its grey-800-on-white text disappeared in dark mode).\nconst itemBaseClassName = [\n // min-h-11 guarantees the 44px minimum target size (WCAG 2.2, 2.5.8 Target\n // Size) even for single-line rows with compact padding overrides.\n 'relative flex min-h-11 w-full cursor-pointer items-center justify-between gap-x-6 border-l p-4 text-left text-base',\n 'motion-safe:transition-colors',\n 'hover:border-primary-800 hover:bg-primary-800/10 dark:hover:border-primary-200 dark:hover:bg-primary-200/10',\n // Inset outline (negative offset): the levels scroll inside an\n // overflow-hidden root, so an outward offset would be clipped on the first\n // and last rows (WCAG 2.2, 2.4.13 Focus Appearance).\n 'focus-visible:outline focus-visible:outline-2 focus-visible:-outline-offset-2 focus-visible:outline-primary-800 dark:focus-visible:outline-primary-200',\n].join(' ')\n\nconst itemActiveClassName =\n 'border-primary-800 bg-primary-800/10 font-semibold text-primary-800 dark:border-primary-200 dark:bg-primary-200/10 dark:text-primary-200'\n\nconst itemInactiveClassName = 'border-transparent text-popover-foreground'\n\ntype PushMenuProps = Omit, 'children' | 'title'> & {\n /** The menu tree. Item ids must be unique across the whole tree. */\n navigation: PushMenuItem[]\n /**\n * The app's current pathname. Leaf links whose `href` matches get\n * `aria-current=\"page\"` and the active treatment. Replaces the nswds-app\n * source's `usePathname()` — the design system is framework-free, so the\n * router value is passed in rather than read from next/navigation.\n */\n currentHref?: string\n /** Root level title, shown in the header row. Also the default `aria-label`. */\n title?: string\n /** Fired when a leaf item (link or child-less button) is activated. */\n onItemClick?: (item: PushMenuItem) => void\n /** Fired after a forward/back slide settles on a level. */\n onNavigate?: (level: PushMenuLevel, history: PushMenuLevel[]) => void\n /**\n * Renders a close button in the header row when provided. Unlike the\n * nswds-app source — which only offered close on sub-levels, leaving the\n * root level uncloseable — the button renders on every level.\n */\n onClose?: () => void\n /** Show the \"A › B › C\" trail under the header on sub-levels. Defaults to `true`. */\n showBreadcrumbs?: boolean\n /**\n * Slide duration in milliseconds, defaulting to `PUSH_MENU_DURATION_MS`\n * (300). Drives both the `--push-menu-duration` custom property and the\n * state machine's timeouts — tune the slide here, never by overriding the\n * custom property (see the coupling note on the constant).\n */\n durationMs?: number\n /** Label for the Back button on sub-levels. Defaults to `'Back'`. */\n backLabel?: string\n /** Accessible label for the close button. Defaults to `'Close menu'`. */\n closeLabel?: string\n /**\n * Heading level for the per-level title, following `FooterNavColumn`'s\n * precedent (the nswds-app source used a `Heading` component this package\n * does not ship). Defaults to `2`; `1` is excluded because a menu panel\n * title is never the page's own title.\n */\n headingLevel?: 2 | 3 | 4 | 5 | 6\n /**\n * Visually-hidden suffix appended to the accessible name of a row that\n * drills into a submenu. Defaults to `'submenu'`.\n *\n * The chevron is `aria-hidden`, so without this a drill-in row and a leaf\n * link are indistinguishable to a screen reader: both announce as\n * \", button\"/\"link\" with no hint that one replaces the panel and the\n * other leaves the page. Pass `null` to suppress it.\n */\n submenuLabel?: React.ReactNode\n /**\n * Shown in place of the row list when a level has no items. Defaults to\n * \"No navigation items available.\"; pass `null` to render an empty level.\n *\n * An empty `navigation` array is a legitimate runtime state — unpublished\n * content, a permission-filtered menu, a failed fetch — as distinct from the\n * malformed data `warnIfNavigationMalformed` reports to the console, which\n * is compiled out in production. Without a message the drawer opens onto a\n * blank panel whose only affordance is the close button.\n */\n emptyMessage?: React.ReactNode\n /**\n * When the menu is below its root level, let Escape pop one level instead of\n * bubbling to an enclosing dialog. Defaults to `true`.\n *\n * A drill-down nested in a `Sheet` inherits the dialog's Escape-to-dismiss,\n * so a reader three levels deep loses both their position AND the drawer\n * from one keypress — and `navigateBack` is the only route back up (the\n * breadcrumb is decorative, and there is no swipe gesture). Popping one\n * level matches the back-out affordance the component actually offers.\n * Escape at the ROOT level always bubbles, so the drawer still closes the\n * way a dialog should. Set `false` for plain dialog semantics.\n */\n escapeGoesBack?: boolean\n ref?: React.Ref<HTMLElement>\n}\n\n/**\n * Multi-level slide-in-place drill-down menu (\"push menu\") for mobile\n * navigation, ported from nswds-app's `MultiLevelPushMenu`. Items with\n * children render as buttons that slide a new level in from the right; leaf\n * items render as links through `Link`, so apps can inject their framework\n * link component via `LinkProvider`. Fill height from its container — compose\n * it inside `SheetContent side=\"left\"` for the classic mobile drawer.\n *\n * Accessibility contract (all improvements over the nswds-app source, which\n * got several of these wrong):\n *\n * - The root is a `nav` landmark named by `title` (override with `aria-label`).\n * - Non-current levels carry the `inert` attribute, so a hidden level's links\n * are neither tabbable nor exposed to assistive tech. The app left every\n * mounted level in the tab order, letting keyboard users tab into invisible\n * history levels (WCAG 2.2, 2.4.3 Focus Order).\n * - Focus moves with the level change (2.4.3): drilling forward focuses the\n * new level's Back button; going back focuses the item that opened the\n * level just left, tracked by item id and restored after the slide settles.\n * Without this, `inert` on the old level would silently drop focus to\n * `<body>`. The Back button is the only POINTER route back (the breadcrumb\n * is decorative), so it always renders on sub-levels — hiding it would make\n * drill-down one-way.\n * - Escape below the root level pops ONE level rather than dismissing an\n * enclosing dialog (`escapeGoesBack`, default true). Nested in a `Sheet` the\n * inherited dialog behaviour discarded both the reader's position and the\n * drawer on a single keypress; Escape at the root still closes normally.\n * - Row labels WRAP rather than truncate. Rows are `min-h-11` with\n * `items-center`, so a long label grows its row instead of losing its tail —\n * in a `w-3/4` drawer on a small phone the budget is roughly 25 characters,\n * which real government labels (\"Births, deaths and marriages\") exceed. The\n * level heading is the one exception (fixed-height header row) and carries a\n * `title` attribute instead.\n * - Drill-in rows append a visually-hidden `submenuLabel` to their accessible\n * name, so they no longer sound identical to leaf links.\n * - A single visually-hidden `aria-live=\"polite\"` region at the root announces\n * the current level's title, suffixed with the level number below the root.\n * A live attribute on the per-level headings would not work: each level's\n * heading is freshly mounted, and newly-mounted live regions are not\n * reliably announced.\n * - Level lists are `<ul role=\"list\">` — `list-style: none` strips list\n * semantics in some screen reader/browser pairings (notably VoiceOver), and\n * the explicit role restores them.\n * - Every row is at least 44px tall (2.5.8 Target Size); Back/close buttons\n * inherit `Button`'s 44px touch target.\n * - During the slide, rows get `pointer-events-none` and the state machine\n * ignores re-entrant navigation, but nothing is ever `disabled` — disabling\n * the focused Back button mid-animation would eject keyboard focus.\n * - Slides are CSS transforms applied under `motion-safe:`, replacing the app's\n * inline transition strings (which ignored reduced motion).\n *\n * Departures from the nswds-app source, beyond the above:\n *\n * - The footer/stats block (Level N, item counts, progress dots, lucide\n * icons) is cut, along with its `showStats`/`showFooter` props: it was demo\n * chrome for the sandbox, not part of a navigation component's job. The\n * breadcrumb trail it hosted moves under the header row.\n * - The rendered \"Navigation Error\" fallback for MALFORMED data is replaced\n * by a dev-only console warning (`warnIfNavigationMalformed`), mirroring\n * button.tsx's `warnIfIconButtonUnlabelled`. An EMPTY level is a different\n * case and does render — see `emptyMessage`. Empty is a legitimate runtime\n * state rather than a programming error, and the console warning is compiled\n * out in production regardless.\n * - The breadcrumb trail is `aria-hidden`: it repeats what the heading and\n * live region already announce, and \"›\" separators read poorly in AT.\n *\n * The level stack is seeded from `navigation`/`title` on mount; pass a `key`\n * to remount (and reset to the root level) if either changes at runtime.\n */\nfunction PushMenu({\n navigation,\n currentHref,\n title = 'Menu',\n onItemClick,\n onNavigate,\n onClose,\n showBreadcrumbs = true,\n durationMs = PUSH_MENU_DURATION_MS,\n backLabel = 'Back',\n closeLabel = 'Close menu',\n headingLevel = 2,\n submenuLabel = 'submenu',\n emptyMessage = 'No navigation items available.',\n escapeGoesBack = true,\n className,\n style,\n 'aria-label': ariaLabel,\n ref,\n ...props\n}: PushMenuProps) {\n const [navigationHistory, setNavigationHistory] = React.useState<PushMenuLevel[]>(() => [\n { items: navigation, title, depth: 1, id: buildLevelId([]) },\n ])\n const [animationState, setAnimationState] = React.useState<PushMenuAnimationState>('idle')\n const [isAnimationStarted, setIsAnimationStarted] = React.useState(false)\n\n const containerRef = React.useRef<HTMLElement | null>(null)\n const timeoutRef = React.useRef<number | null>(null)\n // Focus target applied by the effect below, after React commits the level\n // change — the target element must exist and must no longer be inert.\n const pendingFocusRef = React.useRef<{ levelId: string; itemId?: string } | null>(null)\n\n warnIfNavigationMalformed(navigation)\n\n React.useEffect(() => {\n return () => {\n if (timeoutRef.current !== null) {\n window.clearTimeout(timeoutRef.current)\n }\n }\n }, [])\n\n // Layout effect, not a passive one: the same commit that queues a focus\n // move can also flip the previously-focused element inert (drilling turns\n // the old level inert; going back turns the departing level inert), and the\n // browser blurs an element the moment it becomes inert. Applying the new\n // focus before paint means AT never observes the intermediate\n // focus-on-body state, and a containing dialog's focus guards (Base UI\n // Sheet) see focus already inside the popup when their async handlers run.\n React.useLayoutEffect(() => {\n const pending = pendingFocusRef.current\n if (!pending) {\n return\n }\n pendingFocusRef.current = null\n const level = containerRef.current?.querySelector<HTMLElement>(\n `[data-level-id=\"${CSS.escape(pending.levelId)}\"]`,\n )\n if (!level) {\n return\n }\n const target = pending.itemId\n ? level.querySelector<HTMLElement>(`[data-item-id=\"${CSS.escape(pending.itemId)}\"]`)\n : (level.querySelector<HTMLElement>('[data-slot=\"push-menu-back-button\"]') ??\n level.querySelector<HTMLElement>(\n '[data-slot=\"push-menu-item\"], [data-slot=\"push-menu-link\"]',\n ))\n // preventScroll: a level mid-slide sits at translateX(100%) inside an\n // overflow-hidden root; letting focus scroll it into view would desync the\n // container's scroll position from the transform.\n target?.focus({ preventScroll: true })\n // animationState is a dependency because back-navigation queues its focus\n // move on the slide START (an animationState commit), not on the level\n // pop — see navigateBack.\n }, [navigationHistory, animationState])\n\n const breadcrumb = React.useMemo(\n // During a back slide the trail already excludes the departing level —\n // the breadcrumb tracks the level the user is arriving at (see\n // activeIndex below), same as the live region.\n () =>\n generatePushMenuBreadcrumb(\n animationState === 'sliding-backward' ? navigationHistory.slice(0, -1) : navigationHistory,\n ),\n [navigationHistory, animationState],\n )\n\n function navigateToSubmenu(item: PushMenuItem) {\n if (!item.links?.length || animationState !== 'idle') {\n return\n }\n const levelPath = [\n ...navigationHistory.flatMap((level) => (level.parentItem ? [level.parentItem.id] : [])),\n item.id,\n ]\n const currentDepth = navigationHistory.at(-1)?.depth ?? 1\n const newLevel: PushMenuLevel = {\n items: item.links,\n title: item.title,\n parentItem: item,\n depth: currentDepth + 1,\n id: buildLevelId(levelPath),\n }\n const newHistory = [...navigationHistory, newLevel]\n\n // Focus moves as soon as the new level commits (see the effect above):\n // waiting for the slide to finish would leave focus on the old level,\n // which turns inert in the same commit and would eject focus to <body>.\n pendingFocusRef.current = { levelId: newLevel.id }\n setNavigationHistory(newHistory)\n setAnimationState('sliding-forward')\n setIsAnimationStarted(false)\n\n // Two-step start: the new level mounts at translateX(100%) with no\n // transition, then flips to 0 with the transition on. The layout read in\n // between is LOAD-BEARING: without it, nothing forces the browser to\n // compute styles for the mounted-at-100% frame, and when both states land\n // in one style recalc there is no transition — the panel snaps into\n // place instead of sliding (an intermittent, timing-dependent jump).\n // Reading layout inside the rAF forces that recalc deterministically.\n requestAnimationFrame(() => {\n containerRef.current?.getBoundingClientRect()\n setIsAnimationStarted(true)\n })\n\n timeoutRef.current = window.setTimeout(() => {\n setAnimationState('idle')\n setIsAnimationStarted(false)\n onNavigate?.(newLevel, newHistory)\n }, durationMs)\n }\n\n function navigateBack() {\n if (navigationHistory.length <= 1 || animationState !== 'idle') {\n return\n }\n const popped = navigationHistory.at(-1)\n const revealed = navigationHistory.at(-2)\n if (!revealed) {\n return\n }\n\n // Focus moves at slide START, symmetric with drilling forward: the\n // 'sliding-backward' commit un-inerts the revealed level (see the\n // activeIndex logic below) and the effect above focuses the item that\n // opened the departing level. Restoring focus at the POP instead — while\n // focus still sat on the departing level's Back button — meant the\n // focused node was removed from a live dialog, and Base UI's focus\n // containment would re-grab focus to the dialog popup a frame after our\n // restoration, silently overriding it (observed inside Sheet).\n pendingFocusRef.current = { levelId: revealed.id, itemId: popped?.parentItem?.id }\n setAnimationState('sliding-backward')\n\n timeoutRef.current = window.setTimeout(() => {\n const newHistory = navigationHistory.slice(0, -1)\n setNavigationHistory(newHistory)\n setAnimationState('idle')\n onNavigate?.(revealed, newHistory)\n }, durationMs)\n }\n\n // Escape pops one level instead of dismissing an enclosing dialog.\n //\n // The listener is attached NATIVELY, in the CAPTURE phase, on the menu root.\n // Base UI's dismiss hook listens for Escape with a BUBBLE-phase listener on\n // `document`, which sits above both this element and React's own delegated\n // root container, so a React `onKeyDown` cannot reliably get in front of it\n // (and when the menu is portalled into a Sheet, React's root container may\n // not even be an ancestor of the event target). A capture listener on this\n // node runs before the event reaches the focused row and long before it\n // reaches `document`, so stopping propagation here is what actually keeps\n // the drawer open.\n //\n // It reads its state from a ref rather than closing over it, and that ref is\n // written in a LAYOUT effect. React commits DOM mutations — including the\n // `data-animating` attribute below — before it flushes passive effects, so a\n // listener re-attached inside a `useEffect` lags the rendered state by one\n // flush. Escape pressed in that window would be judged against the previous\n // animation state and silently do nothing, which is exactly the moment a\n // reader is most likely to press it: right as a slide finishes. Layout\n // effects run before paint, so by the time anyone can see the settled level\n // the ref already describes it.\n const escapeStateRef = React.useRef({\n depth: navigationHistory.length,\n animationState,\n navigateBack,\n })\n React.useLayoutEffect(() => {\n escapeStateRef.current = { depth: navigationHistory.length, animationState, navigateBack }\n })\n\n React.useEffect(() => {\n const element = containerRef.current\n if (!element || !escapeGoesBack) {\n return\n }\n function handleEscape(event: KeyboardEvent) {\n if (event.key !== 'Escape') {\n return\n }\n const current = escapeStateRef.current\n // At the root the key is left alone, so an enclosing dialog still closes\n // as a dialog should.\n if (current.depth <= 1) {\n return\n }\n event.stopPropagation()\n event.preventDefault()\n // Mid-slide the key is swallowed rather than queued, matching the\n // component's existing policy of dropping re-entrant navigation. It is\n // never passed through: closing the whole drawer because a keypress\n // landed during a 300ms animation would be the worst of both outcomes.\n if (current.animationState === 'idle') {\n current.navigateBack()\n }\n }\n element.addEventListener('keydown', handleEscape, true)\n return () => element.removeEventListener('keydown', handleEscape, true)\n }, [escapeGoesBack])\n\n const currentLevel = navigationHistory.at(-1)\n if (!currentLevel) {\n return null\n }\n\n const isAnimating = animationState !== 'idle'\n // The level the user is arriving AT — the stack top, except during a back\n // slide, where the user's intent has already committed to the level being\n // revealed underneath. data-current, inert, the live region and the\n // breadcrumb all follow this index so focus can land on the revealed level\n // at slide start and AT hears the destination, not the departing level.\n const activeIndex =\n animationState === 'sliding-backward'\n ? navigationHistory.length - 2\n : navigationHistory.length - 1\n const activeLevel = navigationHistory[activeIndex] ?? currentLevel\n const HeadingTag = `h${headingLevel}` as const\n\n return (\n <nav\n data-slot='push-menu'\n aria-label={ariaLabel ?? title}\n // Present while a slide is running (matches header's present-or-absent\n // data-scrolled convention). Consumers can style the transition off it;\n // tests use it to wait for the state machine to go idle — clicks during\n // a slide are dropped by design, and pointer-events-none does not stop\n // a programmatic .click().\n data-animating={isAnimating || undefined}\n {...props}\n className={cn(\n 'relative isolate h-full overflow-hidden bg-popover text-popover-foreground',\n className,\n )}\n // The slide duration custom property is set from durationMs so the CSS\n // transition and the setTimeout state machine can never disagree — see\n // the coupling note on PUSH_MENU_DURATION_MS.\n style={{ ...style, '--push-menu-duration': `${durationMs}ms` } as React.CSSProperties}\n ref={(node) => {\n containerRef.current = node\n if (typeof ref === 'function') {\n ref(node)\n } else if (ref) {\n ;(ref as React.RefObject<HTMLElement | null>).current = node\n }\n }}\n >\n {/* Persistent live region: announces level changes to AT without\n stealing focus from the managed focus moves. The level number is\n appended below the root because live regions only re-announce when\n their content actually changes — navigating between identically-\n titled levels would otherwise announce nothing. Consecutive levels\n always differ in depth, so consecutive announcements always differ. */}\n <div data-slot='push-menu-live-region' aria-live='polite' className='sr-only'>\n {activeLevel.depth > 1\n ? `${activeLevel.title}, level ${activeLevel.depth}`\n : activeLevel.title}\n </div>\n\n {navigationHistory.map((level, index) => {\n // The stack top is the level that MOVES (slides in forward, slides\n // out backward); the active level is the one the user is arriving at\n // — they differ only during a back slide.\n const isTopLevel = index === navigationHistory.length - 1\n const isActiveLevel = index === activeIndex\n // Always shown below the root: the Back button is the ONLY route back\n // (the breadcrumb is aria-hidden and there is no keyboard binding or\n // imperative API), so making it optional would strand users.\n const shouldShowBackButton = level.depth > 1\n\n let translateX = 0\n if (animationState === 'sliding-forward') {\n translateX = isTopLevel ? (isAnimationStarted ? 0 : 100) : 0\n } else if (animationState === 'sliding-backward') {\n translateX = isTopLevel ? 100 : 0\n }\n\n // Only the moving (top) level transitions; forward slides wait for\n // the second frame so the mount position is applied untransitioned.\n const isSliding =\n isTopLevel &&\n (animationState === 'sliding-backward' ||\n (animationState === 'sliding-forward' && isAnimationStarted))\n\n return (\n <div\n key={level.id}\n data-slot='push-menu-level'\n data-level-id={level.id}\n data-current={isActiveLevel || undefined}\n // Non-active levels must be invisible to keyboard and AT, not\n // just to the eye. Keying this on the ACTIVE level (not the stack\n // top) un-inerts the revealed level at back-slide start, so focus\n // can move there immediately — and the focused Back button is\n // never removed from a live dialog while still focused.\n inert={!isActiveLevel}\n className={cn(\n 'absolute inset-0 flex h-full w-full flex-col bg-popover will-change-transform',\n isSliding\n ? 'duration-(--push-menu-duration) ease-out motion-safe:transition-transform'\n : 'transition-none',\n )}\n style={{ transform: `translateX(${translateX}%)`, zIndex: index + 1 }}\n >\n <div\n data-slot='push-menu-header'\n className='flex min-h-11 items-center gap-1 border-b border-border px-2 py-2'\n >\n {shouldShowBackButton && (\n <Button\n data-slot='push-menu-back-button'\n variant='ghost'\n color='primary'\n size='sm'\n leadingVisual={IconWest}\n onClick={navigateBack}\n className={cn(isAnimating && 'pointer-events-none')}\n >\n {backLabel}\n </Button>\n )}\n {/* Still truncated: this sits in a fixed-height header row\n between the back and close buttons, so it cannot wrap. The\n title attribute makes the full string recoverable on hover,\n and the live region below announces it in full regardless. */}\n <HeadingTag\n data-slot='push-menu-title'\n title={level.title}\n className='min-w-0 flex-1 truncate px-2 text-base font-semibold'\n >\n {level.title}\n </HeadingTag>\n {onClose && (\n <Button\n data-slot='push-menu-close-button'\n variant='ghost'\n color='grey'\n size='icon'\n aria-label={closeLabel}\n leadingVisual={IconClose}\n onClick={onClose}\n />\n )}\n </div>\n\n {showBreadcrumbs && isActiveLevel && level.depth > 1 && (\n // aria-hidden: the trail repeats what the heading and live\n // region already announce, and \"›\" separators read poorly in AT.\n <p\n data-slot='push-menu-breadcrumb'\n aria-hidden='true'\n className='truncate border-b border-border px-4 py-2 text-xs text-muted-foreground'\n >\n {breadcrumb}\n </p>\n )}\n\n <div data-slot='push-menu-items' className='flex-1 overflow-y-auto'>\n {level.items.length === 0 && emptyMessage != null && (\n <p data-slot='push-menu-empty' className='p-4 text-base text-muted-foreground'>\n {emptyMessage}\n </p>\n )}\n {/* role='list' restores list semantics stripped by list-none. */}\n <ul role='list' className='m-0 list-none divide-y divide-border p-0'>\n {level.items.map((item) => {\n const hasChildren = Boolean(item.links?.length)\n const isActive = item.href != null && item.href === currentHref\n\n return (\n <li key={item.id}>\n {hasChildren ? (\n <button\n type='button'\n data-slot='push-menu-item'\n data-item-id={item.id}\n onClick={() => navigateToSubmenu(item)}\n className={cn(\n itemBaseClassName,\n isActive ? itemActiveClassName : itemInactiveClassName,\n isAnimating && 'pointer-events-none',\n )}\n >\n <span className='min-w-0 flex-1'>\n {item.title}\n {/* The chevron is aria-hidden, so without this a\n drill-in row and a leaf link sound identical. */}\n {submenuLabel != null && (\n <span className='sr-only'> {submenuLabel}</span>\n )}\n </span>\n <IconChevronRight aria-hidden='true' className='size-5 shrink-0' />\n </button>\n ) : item.href ? (\n <Link\n variant='unstyled'\n data-slot='push-menu-link'\n data-item-id={item.id}\n href={item.href}\n aria-current={isActive ? 'page' : undefined}\n onClick={() => onItemClick?.(item)}\n className={cn(\n itemBaseClassName,\n isActive ? itemActiveClassName : itemInactiveClassName,\n isAnimating && 'pointer-events-none',\n )}\n >\n <span className='min-w-0 flex-1'>{item.title}</span>\n </Link>\n ) : (\n <button\n type='button'\n data-slot='push-menu-item'\n data-item-id={item.id}\n onClick={() => onItemClick?.(item)}\n className={cn(\n itemBaseClassName,\n itemInactiveClassName,\n isAnimating && 'pointer-events-none',\n )}\n >\n <span className='min-w-0 flex-1'>{item.title}</span>\n </button>\n )}\n </li>\n )\n })}\n </ul>\n </div>\n </div>\n )\n })}\n </nav>\n )\n}\n\nexport { generatePushMenuBreadcrumb, PUSH_MENU_DURATION_MS, PushMenu }\nexport type { PushMenuItem, PushMenuLevel, PushMenuProps }\n", "type": "registry:ui", "target": "components/push-menu.tsx" }, { "path": "src/lib/utils.ts", "content": "import { clsx, type ClassValue } from 'clsx'\nimport { twMerge } from 'tailwind-merge'\n\nexport function cn(...inputs: ClassValue[]) {\n return twMerge(clsx(inputs))\n}\n", "type": "registry:lib", "target": "lib/utils.ts" } ], "type": "registry:ui", "meta": { "nswdsVersion": "5.1.0" } }