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={
{SEARCH_KEY}
}
/* 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. */}
);
}
/**
* 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}
>
);
}