import * as React from 'react'; import { Button } from '@/components/button'; import { Input } from '@/components/input'; import { Sheet, SheetContent, SheetDescription, SheetTitle } from '@/components/sheet'; import { SearchIcon } from '@/icons'; import { CbarLogo } from '~/brand/logo'; import { useGroupLabel, useLang, useMessages } from '~/i18n'; import { groupedEntries } from '~/registry'; import { NAV_ROUTES } from '~/routes'; import { Link, useRoute } from '~/router'; import { useScrolled } from '../hooks/use-scrolled'; /** The key that focuses the search field, and the glyph printed in the hint. */ const SEARCH_KEY = '/'; /** * The static rail's id, so the header's toggle can point `aria-controls` at the * thing it collapses. The drawer below `lg` gets no such id on purpose: Radix * keeps `SheetContent` unmounted while it is closed, and an `aria-controls` * naming an element that is not in the document is worse than none. */ export const RAIL_ID = 'rail'; /** * One rail item. * * The kit's own Button with `asChild`, so the anchor inherits its focus ring, * its height ladder and its icon handling; `.cbar-rail-link` then repaints * hover and the active state for the navy shell — including the turquoise bar * it draws in `::before`, which is why the class also sets `position:relative`. * Those rules are unlayered in `showcase.css` and Tailwind's utilities are not, * so they win over `ghost`'s `hover:bg-accent` without a single `!important`. * * `aria-current` rather than `data-active` alone: the attribute the styling * hangs off says nothing to a screen reader, and "which of these forty links am * I on" is the question the rail exists to answer. */ function NavLink({ to, active, children }: { to: string; active: boolean; children: React.ReactNode }) { return ( ); } /** * The rail's contents, drawn once and mounted in two places: as the static * column from `lg` up, and inside the Sheet below it. Only one is ever on * screen — Radix keeps the sheet's copy unmounted while it is closed — so the * forty-odd links are not in the document twice. * * Two boxes, not one: a head that stays and a list that scrolls. The rail used * to be a single scrolling column, which meant the lockup and the search field * left the viewport exactly when the list got long enough to need them. */ function Rail({ query, onQueryChange, onNavigate, }: { query: string; onQueryChange: (next: string) => void; onNavigate?: () => void; }) { const { path } = useRoute(); const m = useMessages(); const [lang] = useLang(); const groupLabel = useGroupLabel(); const search = React.useRef(null); /* The head's hairline, drawn only once there is a link scrolled under it. */ const [listScrolled, listRef] = useScrolled(); const needle = query.trim().toLowerCase(); const groups = needle ? groupedEntries .map( ([group, items]) => [group, items.filter((e) => e.name.toLowerCase().includes(needle))] as const ) .filter(([, items]) => items.length > 0) : groupedEntries; const matches = groups.reduce((sum, [, items]) => sum + items.length, 0); /* * `/` focuses the search — the shortcut every documentation site has, and the * one that makes a forty-link rail navigable without the mouse. * * On `document` rather than on the rail, because the point is to reach it from * anywhere on the page. Three guards: a modifier means the user is asking the * browser for something (`Ctrl`+`/`, and on some layouts `/` itself needs * `Shift`), a composing IME must be left alone, and typing a slash into any * field — including this one — has to type a slash. * * Below `lg` the static rail is `display:none` and `focus()` on a hidden input * does nothing, which is the correct outcome: there is no rail on screen to * search. The drawer's own copy takes the shortcut while it is open. * * A collapsed desktop rail needs the second half of that test. It is laid out * — clipped inside a zero-width track, not `display:none` — so `offsetParent` * is a real element and the guard above lets the key through to a field * nobody can see. `[inert]` is what the collapsed rail actually carries, and * asking for it is the same question the browser asks before focusing. */ React.useEffect(() => { const onKeyDown = (e: KeyboardEvent) => { if (e.key !== SEARCH_KEY || e.ctrlKey || e.metaKey || e.altKey || e.isComposing) return; const target = e.target as HTMLElement | null; if (target?.closest('input, textarea, select, [contenteditable]')) return; const field = search.current; if (!field || field.offsetParent === null || field.closest('[inert]')) return; e.preventDefault(); field.focus(); field.select(); }; document.addEventListener('keydown', onKeyDown); return () => document.removeEventListener('keydown', onKeyDown); }, []); return (
{ if ((e.target as HTMLElement).closest('a')) onNavigate?.(); }} className="flex min-h-0 flex-1 flex-col" >
{/* The lockup already reads "Mərkəzi Bank", so the line under it names the artefact rather than repeating the institution. */} {m.nav.brandCaption}
onQueryChange(e.target.value)} placeholder={m.nav.searchPlaceholder} aria-label={m.nav.searchPlaceholder} startElement={} /* The hint goes in the trailing slot, which stays interactive — the `` is not, but this is the edge it belongs on. `aria-hidden` because the sentence version is already on the element as a title: a screen reader should hear "press slash to search", not the character. */ endElement={ } /* With a slot filled the box is the wrapper, so the shell's field treatment goes on `className` and the bare field keeps only its own reset — the same split the kit documents on `InputProps.classNames`. */ className="cbar-rail-field" classNames={{ input: 'bg-transparent dark:bg-transparent' }} /> {/* Only while filtering. A count of everything is noise; a count of what is left after typing three letters is the thing the list is too long to tell you at a glance. `aria-live` so it is announced without moving focus off the field. */} {needle ? (

{m.nav.matchCount(matches)}

) : null}
{/* The fixed block, straight from `routes.tsx` — declaration order is the order drawn, so a new page appears here by existing. */}
{NAV_ROUTES.map((route) => { const to = `/${route.slug}`; return ( {route.label(m)} ); })}
{groups.map(([group, items]) => (

{groupLabel(group)}

{items.map((entry) => ( {entry.name} ))}
))} {groups.length === 0 ? (

{m.nav.noMatch}

) : null}
); } /** * The rail. * * Below `lg` there is no room for a 17rem column beside the content, so it * becomes the kit's own `Sheet` that the header's button slides in — which is * where the overlay, the Escape key, the focus trap and the scroll lock come * from now, instead of the hand-rolled `fixed inset-0` div and keydown listener * the shell used to carry. * * `open` is owned by the shell rather than by the URL: the rail being out is a * gesture, not a view worth putting in a link someone else opens on a desktop. * * From `lg` up the column can be collapsed away — `visible`, owned by the shell * too because the grid track it occupies is the shell's. The track animates * (`.cbar-shell` in `showcase.css`), so the rail stays in the document at every * width the transition passes through, including zero. * * `inert` is what makes that safe, and it is the whole reason the collapse used * to be a `display:none` snap instead: a rail clipped to nothing still holds * forty tabbable links and a search field, so Tab would walk through a column * that is not on screen. One attribute takes the subtree out of the tab order * *and* out of the accessibility tree, which is both halves of the problem the * two older answers — `visibility` mid-transition, or a delayed unmount — each * only solved one of. * * The filter text is owned here rather than inside `Rail`, so switching between * the drawer and the column at a breakpoint does not silently reset it. */ export function Sidebar({ open = false, visible = true, onClose, }: { open?: boolean; /** Is the static column open from `lg` up? The drawer ignores this. */ visible?: boolean; onClose?: () => void; }) { const m = useMessages(); const [query, setQuery] = React.useState(''); /* * Close it when the viewport crosses into `lg`. * * Not tidiness — without it the page locks. `SheetContent` is `lg:hidden`, * but the overlay Radix renders beside it is not, and there is no prop to * reach it: widen a window with the drawer out and you get a dimmed page, * scroll locked, with nothing on screen to dismiss. The old hand-rolled * overlay carried its own `lg:hidden`, which is what this replaces. * * `matchMedia` rather than a resize listener: it fires once on the crossing * instead of on every intermediate pixel. The query is `--breakpoint-lg` * spelled out, because Tailwind's breakpoints are CSS-side only. */ React.useEffect(() => { if (!open) return; const desktop = window.matchMedia('(min-width: 64rem)'); if (desktop.matches) { onClose?.(); return; } const onChange = (e: MediaQueryListEvent) => { if (e.matches) onClose?.(); }; desktop.addEventListener('change', onChange); return () => desktop.removeEventListener('change', onChange); }, [open, onClose]); return ( <> {/* Collapsed means inert and clipped, not unmounted — see the note above. Rendering `null` would drop the filter text with it, and re-expanding to a rail that has forgotten what you typed is the worse trade even before the animation needs something to animate. The class list no longer branches on `visible`: the width is the shell's track and the tab order is `inert`, so nothing here has to change at the moment of the press. */} (next ? undefined : onClose?.())}> {/* Radix requires both, and neither belongs on screen: the lockup inside already titles the drawer. */} {m.nav.rail} {m.nav.railDescription} ); }