/** * `` — the desktop catalogue panel: a rail of roots on the * left, and the chosen root's second and third levels on the right. * * ── One request, not one per branch ──────────────────────────────────────── * * The whole panel is `useCategoryTree(depth)`, i.e. `GET /tree/?depth=3`: one * call, one server-cached answer, four fields per node. Assembled the other * two ways available to this pair it is either one request per branch (roots * plus a `children` read per root, on the coldest page a storefront has) or * the whole catalogue table, which is 1.4 MB before the first name can be * drawn. A host that already holds the nodes passes {@link * CategoryMegaMenuProps.nodes} and this component asks for nothing. * * ── The guard is a guard, not a policy ───────────────────────────────────── * * Below `minWidth` (default `breakpoints.desktop`) this renders NOTHING. The storefront still * decides when to mount it — it opens from a button, and a phone gets the tile * grid instead, with no drawer. The guard only makes the two decisions * impossible to contradict: a menu that opened at 480px because somebody's * button forgot a media query would cover the page it was navigating. * * ── ARIA, and the one thing the seam cannot carry ────────────────────────── * * The RAIL is a `menu` and its roots are `menuitem`s: they own the keyboard * model (roving tabindex, arrows, Home/End, Escape), and each says it * discloses the pane with `aria-haspopup` / `aria-expanded` / * `aria-controls`. The pane is a list named by the root that opened it — it * sits beside the menu rather than inside it, so no element claims to be a * menu whose children are not menu items. * * The second- and third-level entries are LINKS, and they are not marked * `menuitem` — deliberately. They are drawn through core's `LinkComponent`, a * host's own router link, whose props contract carries no `role`. A component * that put `role="menuitem"` on the anchors it renders itself would announce * one thing on a plain-anchor host and another on a router host, for the same * screen. So the pane is a labelled group of ordinary links — which is what a * navigation panel is — and every keyboard affordance a menu owes is * implemented rather than merely declared. * * ── Where the third level stops ──────────────────────────────────────────── * * Five links per second-level column, then a "N more" link pointing at the * second-level node itself, whose own page lists the rest. A column that grew * to the length of its longest branch would set the height of the whole panel * from one crowded category. */ import { useCallback, useEffect, useMemo, useRef, useState } from "react"; import type { CSSProperties, KeyboardEvent as ReactKeyboardEvent, ReactElement, } from "react"; import { Skeleton } from "antd"; import { breakpoints, cssVar, fontWeight, radii, spacing } from "@stapel/tokens-antd"; import { loadStateFromQuery, useT } from "@stapel/core"; import { EmptyState, ErrorAlert, LoadList, SkinTheme, } from "@stapel/tokens-antd/skin"; import type { CategoryTreeNode } from "../api/types.js"; import { categoryIconSrc } from "../catalog/tiles.js"; import { CATEGORIES_I18N_KEYS } from "../i18n/keys.js"; import { DEFAULT_TREE_DEPTH, useCategoryTree } from "../model/queries.js"; import { CategoryLink } from "./CategoryLink.js"; import type { LinkComponentProp } from "./CategoryLink.js"; import type { ThemeModeProp } from "./types.js"; /** Third-level links a column shows before it hands the rest to the tail. */ const DEFAULT_MAX_LINKS = 5; /** * The narrowest viewport this panel may appear at, by default — the tokens' * own `desktop` rung. The phone door is the tile grid; a mega-menu on a phone * covers the page it navigates. * * IT WAS A HARD-CODED 1024, which is on no rung of `@stapel/tokens` and * happened to equal one deployment's private edge — so the fleet default * silently carried one storefront's composition to every host that named * nothing. A width that is a claim about every device belongs to the ladder or * to the caller, never to a literal in between: the rung is the default and * {@link CategoryMegaMenuProps.minWidth} is how a deployment says otherwise. * (Same treatment as `` and ``.) */ const DEFAULT_MIN_WIDTH: number = breakpoints.desktop; /** Root rows the loading arm reserves room for. */ const SKELETON_ROWS = [1, 2, 3, 4, 5, 6] as const; /** * The rail's narrowest width, in px — see {@link CategoryMegaMenuProps.railWidth}. * * The rail used to be `minmax(180px, 1fr)` beside a `3fr` pane: a quarter of * the panel at 1440 (338px of 1400) and 236px at a 1024 guard, where a * four-word root wraps onto two lines. The owner's read * of the stand (2026-09-14) was that the column is too narrow; the reference's * overlay could not be measured from this host (its edge refuses the address), * so the number is a floor a host can move, not a measurement: 360px never * wraps a two-word root at 16px, and above the floor the rail takes * 1/(1 + {@link MEGA_MENU_PANE_FRACTION}) of the panel. */ export const MEGA_MENU_RAIL_WIDTH = 360; /** The pane's share of the panel against the rail's `1fr`: 2.5 puts the rail * at two sevenths of the width (386px of 1400) — wider than the old quarter, * still the narrower of the two columns. */ export const MEGA_MENU_PANE_FRACTION = 2.5; /** * The panel's own height ceiling — see {@link CategoryMegaMenuProps.maxHeight}. * * `dvh`, not `vh`: on a phone-class browser with a collapsing address bar the * `vh` unit is the LARGEST viewport, and a panel sized by it would put its * last rows under the bar. `spacing[6]` (32px) is the room for whatever the * host stood the panel under — a header row typically — when it passes no * measure of its own. */ export const MEGA_MENU_MAX_HEIGHT: string = `calc(100dvh - ${String(spacing[6])}px)`; /** The class every rail root carries — the hover/focus fill hangs on it. */ export const MEGA_MENU_ROOT_CLASS = "stapel-mega-menu-root"; /** The modifier the DISCLOSED root carries: the row whose pane is showing. */ export const MEGA_MENU_ROOT_ACTIVE_CLASS = "stapel-mega-menu-root-active"; /** The `href` the hoisted mega-menu sheet is deduplicated by. */ export const MEGA_MENU_STYLE_HREF = "stapel-mega-menu"; /** * The rail row's rule set. * * A sheet rather than inline styles because `:hover` and `:focus-visible` * cannot be said in a style attribute — and the row's fill could not stay * inline either, since an inline `background` beats any sheet rule and the * hover would never paint. So the fill LEAVES the style attribute and every * state of it lives here, with `--stapel-*` custom properties so both themes * resolve at paint time. * * `surface-sunken` — the design system's neutral tertiary fill — for the * hovered, the focused and the disclosed row alike, and deliberately not * `brand-subtle` (which the disclosed row drew before 0.32.0). The reference's * overlay highlights its hovered root in light grey; a pointer resting on a row * is an answer about THAT row and a brand tint there reads as a selection the * person has not made. The three states share one fill because on this rail * they are one state — hovering or focusing a root discloses it — and the * disclosed row still says which it is by weight. * * `background-color`, never the shorthand: a host that put an image behind a * row of its own keeps it. */ export function megaMenuCss(): string { const root = `.${MEGA_MENU_ROOT_CLASS}`; const active = `.${MEGA_MENU_ROOT_ACTIVE_CLASS}`; return [ // A ` ))}
    {(activeRoot?.children ?? []).map((child) => ( ))}
); } export function CategoryMegaMenu( props: CategoryMegaMenuProps ): ReactElement | null { const t = useT(); const minWidth = props.minWidth ?? DEFAULT_MIN_WIDTH; const wide = useViewportAtLeast(minWidth); const basePath = props.basePath ?? "/c"; const builder = props.href; const href = useMemo( () => builder ?? ((node: CategoryTreeNode): string => `${basePath}/${node.slug}`), [builder, basePath] ); const override = props.nodes; // The read is skipped below the guard and under an override: a panel nobody // may see must not pay for the rows it would hide. const query = useCategoryTree(props.depth ?? DEFAULT_TREE_DEPTH, { enabled: wide && override === undefined, }); // The lock follows the mount, and the guard: a panel that renders nothing // holds nothing still. const lockScroll = props.lockScroll ?? true; useEffect(() => { if (!wide || !lockScroll) return; return lockPageScroll(); }, [wide, lockScroll]); if (!wide) return null; const maxHeight = props.maxHeight ?? MEGA_MENU_MAX_HEIGHT; const railWidth = props.railWidth ?? MEGA_MENU_RAIL_WIDTH; const panelProps = { href, maxLinks: props.maxLinksPerColumn ?? DEFAULT_MAX_LINKS, maxHeight, railWidth, ...(props.linkComponent !== undefined ? { linkComponent: props.linkComponent } : {}), ...(props.onClose !== undefined ? { onClose: props.onClose } : {}), ...(props.onSelect !== undefined ? { onSelect: props.onSelect } : {}), }; const empty = ( ); return ( {override !== undefined ? ( override.length === 0 ? ( empty ) : ( ) ) : ( { void query.refetch(); }} loading={
{SKELETON_ROWS.map((slot) => ( ))}
} failed={(error) => ( { void query.refetch(); }} /> )} empty={empty} > {(nodes: readonly CategoryTreeNode[]) => ( )} )} ); }