@charcuterie/ui 3.22.0 → 3.24.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (76) hide show
  1. package/README.md +11 -0
  2. package/dist/Board/Board.d.ts +6 -3
  3. package/dist/Board/Board.d.ts.map +1 -1
  4. package/dist/Board/Board.js +19 -1
  5. package/dist/Board/Board.js.map +1 -1
  6. package/dist/Board/BoardCard.d.ts +42 -7
  7. package/dist/Board/BoardCard.d.ts.map +1 -1
  8. package/dist/Board/BoardCard.js +54 -8
  9. package/dist/Board/BoardCard.js.map +1 -1
  10. package/dist/Board/useBoardDrag.d.ts +15 -1
  11. package/dist/Board/useBoardDrag.d.ts.map +1 -1
  12. package/dist/Board/useBoardDrag.js +3 -3
  13. package/dist/Board/useBoardDrag.js.map +1 -1
  14. package/dist/Nav/Nav.d.ts +167 -0
  15. package/dist/Nav/Nav.d.ts.map +1 -0
  16. package/dist/Nav/Nav.js +165 -0
  17. package/dist/Nav/Nav.js.map +1 -0
  18. package/dist/Nav/NavLink.d.ts +43 -0
  19. package/dist/Nav/NavLink.d.ts.map +1 -0
  20. package/dist/Nav/NavLink.js +111 -0
  21. package/dist/Nav/NavLink.js.map +1 -0
  22. package/dist/Nav/navItems.d.ts +67 -0
  23. package/dist/Nav/navItems.d.ts.map +1 -0
  24. package/dist/Nav/navItems.js +104 -0
  25. package/dist/Nav/navItems.js.map +1 -0
  26. package/dist/Nav/useNavLayout.d.ts +86 -0
  27. package/dist/Nav/useNavLayout.d.ts.map +1 -0
  28. package/dist/Nav/useNavLayout.js +114 -0
  29. package/dist/Nav/useNavLayout.js.map +1 -0
  30. package/dist/NavBar/NavBar.d.ts +37 -111
  31. package/dist/NavBar/NavBar.d.ts.map +1 -1
  32. package/dist/NavBar/NavBar.js +30 -110
  33. package/dist/NavBar/NavBar.js.map +1 -1
  34. package/dist/ReorderList/ReorderList.d.ts +131 -0
  35. package/dist/ReorderList/ReorderList.d.ts.map +1 -0
  36. package/dist/ReorderList/ReorderList.js +154 -0
  37. package/dist/ReorderList/ReorderList.js.map +1 -0
  38. package/dist/index.d.ts +8 -1
  39. package/dist/index.d.ts.map +1 -1
  40. package/dist/index.js +4 -1
  41. package/dist/index.js.map +1 -1
  42. package/package.json +1 -1
  43. package/src/Board/Board.mdx +25 -2
  44. package/src/Board/Board.stories.tsx +52 -1
  45. package/src/Board/Board.test.tsx +82 -0
  46. package/src/Board/Board.tsx +24 -4
  47. package/src/Board/BoardCard.tsx +103 -21
  48. package/src/Board/boardMoveHandleContainer.test.ts +54 -0
  49. package/src/Board/useBoardDrag.ts +17 -2
  50. package/src/Nav/Nav.mdx +169 -0
  51. package/src/Nav/Nav.stories.tsx +402 -0
  52. package/src/Nav/Nav.test.tsx +338 -0
  53. package/src/Nav/Nav.tsx +406 -0
  54. package/src/Nav/NavLink.tsx +220 -0
  55. package/src/Nav/navItems.test.ts +163 -0
  56. package/src/Nav/navItems.ts +175 -0
  57. package/src/Nav/useNavLayout.ts +197 -0
  58. package/src/NavBar/NavBar.mdx +26 -0
  59. package/src/NavBar/NavBar.tsx +54 -240
  60. package/src/ReorderList/ReorderList.mdx +83 -0
  61. package/src/ReorderList/ReorderList.stories.tsx +149 -0
  62. package/src/ReorderList/ReorderList.test.tsx +190 -0
  63. package/src/ReorderList/ReorderList.tsx +331 -0
  64. package/src/index.ts +26 -1
  65. package/src/sourceRules.test.ts +26 -1
  66. package/dist/NavBar/NavBarLink.d.ts +0 -43
  67. package/dist/NavBar/NavBarLink.d.ts.map +0 -1
  68. package/dist/NavBar/NavBarLink.js +0 -67
  69. package/dist/NavBar/NavBarLink.js.map +0 -1
  70. package/dist/NavBar/navBarCurrent.d.ts +0 -37
  71. package/dist/NavBar/navBarCurrent.d.ts.map +0 -1
  72. package/dist/NavBar/navBarCurrent.js +0 -48
  73. package/dist/NavBar/navBarCurrent.js.map +0 -1
  74. package/src/NavBar/NavBarLink.tsx +0 -139
  75. package/src/NavBar/navBarCurrent.test.ts +0 -81
  76. package/src/NavBar/navBarCurrent.ts +0 -64
@@ -0,0 +1,165 @@
1
+ import { jsx as _jsx } from "react/jsx-runtime";
2
+ import { useVisibility } from "@charcuterie/logic";
3
+ import { Button } from "../Button/Button.js";
4
+ import { IconButton } from "../IconButton/IconButton.js";
5
+ import { usePanelItemSize } from "../Overlay/usePanelItemSize.js";
6
+ import { Popover } from "../Popover/Popover.js";
7
+ import { useToolbarOverflow } from "../Toolbar/useToolbarOverflow.js";
8
+ import { toClassName } from "../toClassName.js";
9
+ import { NavLink } from "./NavLink.js";
10
+ import { getNavItemKey, resolveActiveKey, } from "./navItems.js";
11
+ const LIST_CLASS = {
12
+ bar: "flex min-w-0 flex-row items-center gap-1",
13
+ // `auto-cols-fr` with `grid-flow-col`: equal columns whose count
14
+ // comes from the item list, so four destinations divide the width
15
+ // and five still do, with no per-app count to keep in step.
16
+ bottom: "grid auto-cols-fr grid-flow-col gap-0",
17
+ menu: "flex min-w-48 flex-col gap-0.5",
18
+ rail: "flex min-w-0 flex-col gap-1",
19
+ railIcons: "flex flex-col items-center gap-1",
20
+ };
21
+ const NAV_CLASS = {
22
+ bar: "flex min-w-0 flex-1 items-center gap-1",
23
+ bottom: "border-border-subtle border-t bg-surface-raised",
24
+ menu: "min-w-0",
25
+ rail: "min-w-0",
26
+ railIcons: "min-w-0",
27
+ };
28
+ /**
29
+ * The app's destinations — a header row, a side rail, a collapsed
30
+ * rail, a strip along the foot of the Narrow View, or a panel behind
31
+ * one control. The same list, drawn where it fits.
32
+ *
33
+ * ## What was actually missing
34
+ *
35
+ * `Shell` / `Header` / `Rail` / `Main` shipped the page frame on
36
+ * 2026-08-10 and left the **contents of the nav rail empty**. Twelve
37
+ * UI repos, and exactly two of them had a `<nav>` at all: Docket
38
+ * hand-rolled a row of `NavLink`s with the collapse rule in the
39
+ * app's own stylesheet, Folio hand-rolled another, and the other ten
40
+ * had no destinations anywhere. The two that existed already
41
+ * disagreed — Docket *wraps* below its breakpoint and stands three
42
+ * lines tall on a phone, which is not a decision anybody made, it is
43
+ * what `flex-wrap` does when nobody writes the other rule.
44
+ *
45
+ * ## Every item is a real link
46
+ *
47
+ * `<a href>`, through the injected `RouterLinkProvider` — see
48
+ * `NavLink`, which is where that argument is written down.
49
+ *
50
+ * ## `aria-current="page"`, and exactly one of them
51
+ *
52
+ * `resolveActiveKey` takes the **deepest** matching path, so an app
53
+ * holding both `/settings` and `/settings/labels` marks the page the
54
+ * reader is actually on rather than both.
55
+ *
56
+ * ## `bar` folds WHOLE, and it is measured
57
+ *
58
+ * There is no `collapseAt` and no media query on this axis. The bar
59
+ * reads the width its links actually have and folds when their sum
60
+ * stops fitting, so a longer label, a tenth destination, a
61
+ * `data-density="kiosk"` or a reader at 175% browser zoom each move
62
+ * the fold on their own. mux-magic collapses at a hardcoded `480px`
63
+ * and plex-channels at `760px`, and neither number has a reason.
64
+ *
65
+ * It folds *whole* rather than progressively, unlike `Toolbar`:
66
+ * `Toolbar`'s items are ranked, and a nav's order is the shape of
67
+ * the product (Docket's reads as the pipeline: Triage → Backlog →
68
+ * Phases → Board → Archives). Half of that behind a button is a rule
69
+ * no reader can learn.
70
+ *
71
+ * ⚠️ **A `bar` must not be sized by its own contents.** It measures
72
+ * its own box to decide what to draw in it, so a container that
73
+ * shrink-wraps makes the answer its own input: put it in a
74
+ * `minmax(0, 1fr)` grid track or a `flex-1 min-w-0` row. The root
75
+ * carries `min-w-0 flex-1` for the flex case; a grid track is the
76
+ * caller's to declare.
77
+ *
78
+ * ## `menu` is the rail's narrow state, and the app owns it
79
+ *
80
+ * `bar` builds its own trigger because it knows when it folded.
81
+ * `menu` takes one, because the rail's narrow state puts that
82
+ * control in the `Header` — a different corner of `Shell`'s grid
83
+ * from a side rail, and no media query moves an element across a
84
+ * grid. `useNavLayout` is what tells the app which of the two it is
85
+ * rendering.
86
+ *
87
+ * ## Mounted exactly once
88
+ *
89
+ * A destination is in the bar or in the panel, never both. The
90
+ * fleet's habit is to render the whole nav twice and hide one copy
91
+ * with `hidden md:flex` — mux-magic's `PageHeader` across two
92
+ * ~55-line blocks, mail-sifter's `TriageQueue` again — which puts
93
+ * every link in the DOM at every width, so an agent driving the page
94
+ * finds two of each and cannot tell which one a human can see.
95
+ */
96
+ export const Nav = (props) => {
97
+ const { activeHref, className, items, label = "Main", } = props;
98
+ const layout = props.layout ?? "bar";
99
+ const activeKey = resolveActiveKey(items, activeHref);
100
+ const itemKeys = items.map(getNavItemKey);
101
+ const { containerRef, trackItem, visibleCount } = useToolbarOverflow({ itemKeys });
102
+ // The bar-only knobs. `menu` is the one variant that does not
103
+ // carry them, and it is the one variant the app branches on
104
+ // anyway.
105
+ const placedProps = props.layout === "menu" ? null : props;
106
+ const { hide, isVisible, toggle } = useVisibility({
107
+ isVisible: placedProps?.isMenuVisible ?? false,
108
+ });
109
+ const panelItemSize = usePanelItemSize("lg");
110
+ /**
111
+ * All or nothing. `visibleCount` is the progressive answer and
112
+ * this is the only thing read off it: the moment one link stops
113
+ * fitting, every link goes into the menu together.
114
+ */
115
+ const isFolded = layout === "bar" && visibleCount < items.length;
116
+ const size = placedProps?.size ?? "sm";
117
+ const renderList = (placement, listLayout) => (_jsx("ul", { className: toClassName("list-none p-0", LIST_CLASS[listLayout]), children: items.map((item) => {
118
+ const key = getNavItemKey(item);
119
+ return (_jsx("li", { className: "min-w-0", children: _jsx(NavLink, { isCurrent: key === activeKey, item: item, onNavigate: placement === "panel" ? hide : undefined, placement: placement, size: placement === "panel" ? panelItemSize : size, trackElement: placement === "bar"
120
+ ? (element) => {
121
+ trackItem(key, element);
122
+ }
123
+ : undefined }) }, key));
124
+ }) }));
125
+ if (props.layout === "menu") {
126
+ /**
127
+ * A `Popover` rather than a `Menu`. `Menu`'s items are
128
+ * `onSelect` callbacks on `<button>`s, which is right for the
129
+ * things a menu usually holds — undo, redo, a scheme cycle — and
130
+ * wrong for a destination, for every reason `NavLink` gives.
131
+ *
132
+ * `modal={false}` inside it, so the caret is not trapped: the
133
+ * panel is a disclosure, not a dialog somebody has to answer.
134
+ */
135
+ return (_jsx(Popover
136
+ // The default panel is `max-w-xs p-3` — sized for a
137
+ // paragraph of prose. This one holds full-bleed rows that
138
+ // bring their own inset, so the padding shrinks to the
139
+ // gutter around them and the width is left to the list.
140
+ , {
141
+ // The default panel is `max-w-xs p-3` — sized for a
142
+ // paragraph of prose. This one holds full-bleed rows that
143
+ // bring their own inset, so the padding shrinks to the
144
+ // gutter around them and the width is left to the list.
145
+ className: "max-w-none p-1", heading: label, isVisible: props.isVisible, onDismiss: props.onDismiss, placement: props.placement ?? "bottom-start", trigger: props.trigger, children: _jsx("nav", { "aria-label": label, children: renderList("panel", "menu") }) }));
146
+ }
147
+ if (layout !== "bar") {
148
+ return (_jsx("nav", { "aria-label": label, className: toClassName(NAV_CLASS[layout], className), children: renderList(layout === "rail"
149
+ ? "rail"
150
+ : layout === "railIcons"
151
+ ? "railIcons"
152
+ : "bottom", layout) }));
153
+ }
154
+ const menuLabel = placedProps?.menuLabel ?? "Menu";
155
+ const menuIcon = placedProps?.menuIcon;
156
+ const trigger = menuIcon === undefined ? (_jsx(Button, { appearance: "ghost", intent: "neutral", onClick: toggle, size: size, children: menuLabel })) : (_jsx(IconButton, { appearance: "ghost", intent: "neutral", label: menuLabel, onClick: toggle, size: size, children: menuIcon }));
157
+ return (_jsx("nav", { "aria-label": label, className: toClassName(NAV_CLASS.bar,
158
+ // Two written-out entries rather than one interpolated
159
+ // string: Tailwind's scanner cannot see a class that exists
160
+ // only at runtime.
161
+ isFolded && placedProps?.menuAlign === "end"
162
+ ? "justify-end"
163
+ : "justify-start", className), ref: containerRef, children: isFolded ? (_jsx(Popover, { className: "max-w-none p-1", heading: menuLabel, isVisible: isVisible, onDismiss: hide, placement: placedProps?.menuPlacement ?? "bottom-start", trigger: trigger, children: renderList("panel", "menu") })) : (renderList("bar", "bar")) }));
164
+ };
165
+ //# sourceMappingURL=Nav.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"Nav.js","sourceRoot":"","sources":["../../src/Nav/Nav.tsx"],"names":[],"mappings":"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"}
@@ -0,0 +1,43 @@
1
+ import type { ControlSize } from "@charcuterie/tokens";
2
+ import type { ReactNode } from "react";
3
+ import type { NavItem } from "./navItems.ts";
4
+ /** Where the row is drawn. Each paints differently; all are links. */
5
+ export type NavLinkPlacement = "bar" | "bottom" | "panel" | "rail" | "railIcons";
6
+ export type NavLinkProps = {
7
+ isCurrent: boolean;
8
+ item: NavItem;
9
+ /**
10
+ * Fires when the reader activates this link **in a way that keeps
11
+ * them on the page** — a plain left click, Enter, Space. It is
12
+ * what closes the panel behind them.
13
+ *
14
+ * A modified click (ctrl, meta, shift, alt) opens a new tab and
15
+ * leaves this document exactly where it was, so it deliberately
16
+ * does not fire: shutting the menu the reader is still working
17
+ * through is the behaviour that makes people stop ctrl-clicking.
18
+ */
19
+ onNavigate?: () => void;
20
+ placement: NavLinkPlacement;
21
+ size: ControlSize;
22
+ /** Hands the bar row's box to the measuring hook. Bar only. */
23
+ trackElement?: (element: HTMLElement | null) => void;
24
+ };
25
+ /**
26
+ * One destination — and it is a real `<a href>` in every placement.
27
+ *
28
+ * That is the whole reason this exists rather than the `Menu` a
29
+ * collapsed `Toolbar` opens: `MenuItem` is `{ label, onSelect }`, so
30
+ * a nav folded into one becomes a row of `<button>`s. It looks
31
+ * identical in a screenshot and it loses middle-click, ctrl-click,
32
+ * "open in a new tab", "copy link address", the status bar, and the
33
+ * browser's own visited styling. `BuildingAnApp.mdx` names
34
+ * `<button onClick={navigate}>` as the fleet's most common
35
+ * navigation defect; a collapse mechanism that introduced it at
36
+ * narrow widths would be that defect on a timer.
37
+ *
38
+ * The router seam is `ButtonLink`'s, unchanged: an external
39
+ * destination or a fragment falls back to a plain anchor rather than
40
+ * being pushed onto a history stack that has no route for it.
41
+ */
42
+ export declare const NavLink: ({ isCurrent, item, onNavigate, placement, size, trackElement, }: NavLinkProps) => ReactNode;
43
+ //# sourceMappingURL=NavLink.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"NavLink.d.ts","sourceRoot":"","sources":["../../src/Nav/NavLink.tsx"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,WAAW,EAAE,MAAM,qBAAqB,CAAA;AACtD,OAAO,KAAK,EAAc,SAAS,EAAE,MAAM,OAAO,CAAA;AAWlD,OAAO,KAAK,EAAE,OAAO,EAAE,MAAM,eAAe,CAAA;AAE5C,sEAAsE;AACtE,MAAM,MAAM,gBAAgB,GACxB,KAAK,GACL,QAAQ,GACR,OAAO,GACP,MAAM,GACN,WAAW,CAAA;AAEf,MAAM,MAAM,YAAY,GAAG;IACzB,SAAS,EAAE,OAAO,CAAA;IAClB,IAAI,EAAE,OAAO,CAAA;IACb;;;;;;;;;OASG;IACH,UAAU,CAAC,EAAE,MAAM,IAAI,CAAA;IACvB,SAAS,EAAE,gBAAgB,CAAA;IAC3B,IAAI,EAAE,WAAW,CAAA;IACjB,+DAA+D;IAC/D,YAAY,CAAC,EAAE,CAAC,OAAO,EAAE,WAAW,GAAG,IAAI,KAAK,IAAI,CAAA;CACrD,CAAA;AAiDD;;;;;;;;;;;;;;;;GAgBG;AACH,eAAO,MAAM,OAAO,GAAI,iEAOrB,YAAY,KAAG,SA0GjB,CAAA"}
@@ -0,0 +1,111 @@
1
+ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
+ import { CONTROL_SIZE_CLASS, PANEL_ITEM_SIZE_CLASS, } from "../controlStyles.js";
3
+ import { FOCUS_RING_CLASS } from "../intentStyles.js";
4
+ import { useRouterLink } from "../RouterLink/RouterLinkProvider.js";
5
+ import { getIsRoutedHref } from "../RouterLink/routerLink.js";
6
+ import { toClassName } from "../toClassName.js";
7
+ import { VisuallyHidden } from "../VisuallyHidden/VisuallyHidden.js";
8
+ const PLACEMENT_CLASS = {
9
+ bar: "inline-flex shrink-0 items-center rounded-md border border-transparent whitespace-nowrap",
10
+ // A foot-of-the-screen target is a thumb target, so it takes the
11
+ // 44px WCAG 2.5.5 floor rather than a control height. It is a
12
+ // genuinely standalone tap target and shares a row with nothing,
13
+ // which is the case
14
+ // `2026-08-05-controls-share-one-height-no-per-component-touch-floor`
15
+ // reserves `MIN_TOUCH_TARGET_CLASS` for.
16
+ bottom: "flex min-h-(--control-min-touch-target) flex-col items-center justify-center gap-1 rounded-none px-1 py-2 text-xs",
17
+ panel: "flex w-full items-center rounded-sm text-start",
18
+ rail: "flex w-full items-center rounded-md",
19
+ // Square, and sized on both axes from the height token — the same
20
+ // rule `ICON_CONTROL_SIZE_CLASS` states, so a collapsed rail item
21
+ // and an `IconButton` beside it measure the same.
22
+ railIcons: "flex size-(--control-height-lg) items-center justify-center rounded-md",
23
+ };
24
+ /**
25
+ * `-hover` in the panel, not the plain surface: the panel is drawn
26
+ * on `surface-overlay`, which every dark scheme paints *lighter*
27
+ * than the base tint, so the plain one reads as no change at all.
28
+ * Same correction `MenuAction` carries.
29
+ */
30
+ const CURRENT_CLASS = {
31
+ bar: "bg-intent-neutral-surface font-medium text-content-primary",
32
+ bottom: "bg-intent-neutral-surface font-medium text-content-primary",
33
+ panel: "bg-intent-neutral-surface-hover font-medium text-content-primary",
34
+ rail: "bg-intent-neutral-surface font-medium text-content-primary",
35
+ railIcons: "bg-intent-neutral-surface font-medium text-content-primary",
36
+ };
37
+ const RESTING_CLASS = {
38
+ bar: "text-content-secondary hover:bg-intent-neutral-surface hover:text-content-primary",
39
+ bottom: "text-content-secondary hover:bg-intent-neutral-surface hover:text-content-primary",
40
+ panel: "text-content-primary hover:bg-intent-neutral-surface-hover",
41
+ rail: "text-content-secondary hover:bg-intent-neutral-surface hover:text-content-primary",
42
+ railIcons: "text-content-secondary hover:bg-intent-neutral-surface hover:text-content-primary",
43
+ };
44
+ /**
45
+ * One destination — and it is a real `<a href>` in every placement.
46
+ *
47
+ * That is the whole reason this exists rather than the `Menu` a
48
+ * collapsed `Toolbar` opens: `MenuItem` is `{ label, onSelect }`, so
49
+ * a nav folded into one becomes a row of `<button>`s. It looks
50
+ * identical in a screenshot and it loses middle-click, ctrl-click,
51
+ * "open in a new tab", "copy link address", the status bar, and the
52
+ * browser's own visited styling. `BuildingAnApp.mdx` names
53
+ * `<button onClick={navigate}>` as the fleet's most common
54
+ * navigation defect; a collapse mechanism that introduced it at
55
+ * narrow widths would be that defect on a timer.
56
+ *
57
+ * The router seam is `ButtonLink`'s, unchanged: an external
58
+ * destination or a fragment falls back to a plain anchor rather than
59
+ * being pushed onto a history stack that has no route for it.
60
+ */
61
+ export const NavLink = ({ isCurrent, item, onNavigate, placement, size, trackElement, }) => {
62
+ const RouterLink = useRouterLink();
63
+ const isExternal = item.isExternal === true;
64
+ const LinkElement = isExternal || !getIsRoutedHref(item.href)
65
+ ? "a"
66
+ : RouterLink;
67
+ const isIconOnly = placement === "railIcons";
68
+ const sizeClass = placement === "panel"
69
+ ? PANEL_ITEM_SIZE_CLASS[size]
70
+ : placement === "bar"
71
+ ? CONTROL_SIZE_CLASS[size]
72
+ : placement === "rail"
73
+ ? "min-h-(--control-height-md) gap-(--control-gap-md) px-(--control-padding-inline-md) py-1.5 text-md"
74
+ : "";
75
+ return (_jsxs(LinkElement
76
+ // The platform's own "you are here", and the reason the paint
77
+ // is never the only signal: a highlight is invisible to a
78
+ // screen reader and to anyone who cannot tell the two surfaces
79
+ // apart. A `font-weight` swap alone is the same WCAG 1.4.1
80
+ // failure.
81
+ , { "aria-current": isCurrent ? "page" : undefined, className: toClassName("no-underline transition-colors duration-(--duration-fast) ease-standard", PLACEMENT_CLASS[placement], sizeClass,
82
+ // Two written-out entries rather than one interpolated
83
+ // string, the same rule `SegmentedOption` follows:
84
+ // Tailwind's scanner cannot see a class that exists only at
85
+ // runtime, and `tailwindCandidates.test.ts` rejects a
86
+ // template literal in a className outright.
87
+ isCurrent
88
+ ? CURRENT_CLASS[placement]
89
+ : RESTING_CLASS[placement], FOCUS_RING_CLASS), href: item.href, onClick: (event) => {
90
+ if (event.defaultPrevented ||
91
+ event.button !== 0 ||
92
+ event.altKey ||
93
+ event.ctrlKey ||
94
+ event.metaKey ||
95
+ event.shiftKey) {
96
+ return;
97
+ }
98
+ onNavigate?.();
99
+ }, ref: (element) => {
100
+ trackElement?.(element);
101
+ },
102
+ // `noreferrer` beside `noopener` on purpose: one closes the
103
+ // `window.opener` hole, the other stops this app's address
104
+ // leaking as the referrer to a product it does not own.
105
+ rel: isExternal ? "noopener noreferrer" : undefined, target: isExternal ? "_blank" : undefined,
106
+ // A pointer can recover the name a collapsed rail has hidden.
107
+ // It is not the accessible name — see the `VisuallyHidden`
108
+ // below — because `title` alone is not reliably announced.
109
+ title: isIconOnly ? item.label : undefined, children: [item.icon === undefined ? null : (_jsx("span", { "aria-hidden": "true", className: "flex shrink-0 text-content-secondary", children: item.icon })), isIconOnly ? (_jsx(VisuallyHidden, { children: item.label })) : (_jsx("span", { className: placement === "bar" ? undefined : "truncate", children: item.label })), isExternal ? (_jsx(VisuallyHidden, { children: "(opens in a new tab)" })) : null] }));
110
+ };
111
+ //# sourceMappingURL=NavLink.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"NavLink.js","sourceRoot":"","sources":["../../src/Nav/NavLink.tsx"],"names":[],"mappings":"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"}
@@ -0,0 +1,67 @@
1
+ import type { ReactNode } from "react";
2
+ export type NavItem = {
3
+ /**
4
+ * Where it goes. Routed through the injected `RouterLink` when it
5
+ * is this app's own path, and left to the platform when it is not.
6
+ */
7
+ href: string;
8
+ /** Decoration beside the label. Never the accessible name. */
9
+ icon?: ReactNode;
10
+ /**
11
+ * A destination outside this app. It renders a plain anchor with
12
+ * `target="_blank"` and `rel="noopener noreferrer"`, and it is
13
+ * never current — pushing `https://…` onto a router's history
14
+ * navigates the app to a route it does not have.
15
+ */
16
+ isExternal?: boolean;
17
+ /** Defaults to `href`, which is already unique in a nav. */
18
+ key?: string;
19
+ /** The visible text, and the accessible name. */
20
+ label: string;
21
+ };
22
+ /**
23
+ * What the rail layouts take. `icon` stops being optional, because
24
+ * `railIcons` takes the **label** away: an item with no glyph is a
25
+ * blank square that still navigates, and there is no visual defect
26
+ * to notice because the row reads as padding.
27
+ *
28
+ * Optional would move that failure out of the type checker and into
29
+ * the first time somebody drags a window narrow. The library still
30
+ * ships no icons — a glyph in a default renders as nothing where the
31
+ * font lacks it, so the app brings its own.
32
+ */
33
+ export type NavRailItem = NavItem & {
34
+ icon: ReactNode;
35
+ };
36
+ export declare const getNavItemKey: (item: NavItem) => string;
37
+ export declare const getIsCurrentHref: ({ currentHref, href, }: {
38
+ /** Where the reader is. `undefined` means "nothing is current". */
39
+ currentHref: string | undefined;
40
+ /** Where this destination goes. */
41
+ href: string;
42
+ }) => boolean;
43
+ /**
44
+ * The current destination's key, or `null` — the whole-list answer,
45
+ * and the one `Nav` marks with `aria-current="page"`.
46
+ *
47
+ * ## Exactly one, because the deepest match wins
48
+ *
49
+ * `getIsCurrentHref` is asked per item, so a nav holding both
50
+ * `/settings` and `/settings/labels` gets `true` twice while the
51
+ * reader is on the deeper one. Two current items is a nav that has
52
+ * stopped saying anything — the same thing react-router's `<NavLink
53
+ * to="/">` footgun produces — so the longest matching path wins and
54
+ * the shallower section stands down.
55
+ *
56
+ * Longest by **path**, not by array position: an app must be able to
57
+ * list its destinations in the order the product reads (Docket's is
58
+ * the pipeline) without that order deciding which one lights up.
59
+ *
60
+ * ## An external item is never current
61
+ *
62
+ * It is not a place inside this app, so no address the app is at can
63
+ * be it. Without this, an item pointing at `https://example.com/board`
64
+ * matches the app's own `/board` on a plain string compare.
65
+ */
66
+ export declare const resolveActiveKey: (items: readonly NavItem[], activeHref?: string) => string | null;
67
+ //# sourceMappingURL=navItems.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"navItems.d.ts","sourceRoot":"","sources":["../../src/Nav/navItems.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,SAAS,EAAE,MAAM,OAAO,CAAA;AAEtC,MAAM,MAAM,OAAO,GAAG;IACpB;;;OAGG;IACH,IAAI,EAAE,MAAM,CAAA;IACZ,8DAA8D;IAC9D,IAAI,CAAC,EAAE,SAAS,CAAA;IAChB;;;;;OAKG;IACH,UAAU,CAAC,EAAE,OAAO,CAAA;IACpB,4DAA4D;IAC5D,GAAG,CAAC,EAAE,MAAM,CAAA;IACZ,iDAAiD;IACjD,KAAK,EAAE,MAAM,CAAA;CACd,CAAA;AAED;;;;;;;;;;GAUG;AACH,MAAM,MAAM,WAAW,GAAG,OAAO,GAAG;IAClC,IAAI,EAAE,SAAS,CAAA;CAChB,CAAA;AAED,eAAO,MAAM,aAAa,GAAI,MAAM,OAAO,KAAG,MACvB,CAAA;AA8CvB,eAAO,MAAM,gBAAgB,GAAI,wBAG9B;IACD,mEAAmE;IACnE,WAAW,EAAE,MAAM,GAAG,SAAS,CAAA;IAC/B,mCAAmC;IACnC,IAAI,EAAE,MAAM,CAAA;CACb,KAAG,OAgBH,CAAA;AAED;;;;;;;;;;;;;;;;;;;;;;GAsBG;AACH,eAAO,MAAM,gBAAgB,GAC3B,OAAO,SAAS,OAAO,EAAE,EACzB,aAAa,MAAM,KAClB,MAAM,GAAG,IAqCX,CAAA"}
@@ -0,0 +1,104 @@
1
+ export const getNavItemKey = (item) => item.key ?? item.href;
2
+ /**
3
+ * Which destination the reader is on — the rule behind
4
+ * `aria-current="page"`, kept pure so it can be checked in Node.
5
+ *
6
+ * The library is router-agnostic (see `RouterLinkProvider`), so it
7
+ * cannot ask a router which link is active. The app hands it one
8
+ * string — `useLocation().pathname`, `window.location.pathname`,
9
+ * whatever it has — and this decides the rest.
10
+ *
11
+ * ## A parent path is current for its children
12
+ *
13
+ * `/tasks` is current while the reader is on `/tasks/41`, because a
14
+ * nav item names a **section** and a section does not stop being the
15
+ * one you are in when you open something inside it. The match is on
16
+ * whole segments: `/task` is *not* current for `/tasks`, which a bare
17
+ * `startsWith` would get wrong.
18
+ *
19
+ * ## `/` is exact, and that is deliberately not react-router
20
+ *
21
+ * react-router's `<NavLink to="/">` is active on **every** route
22
+ * unless the caller remembers `end`, which is the footgun behind
23
+ * every "why is Home always highlighted" bug in the fleet. The root
24
+ * matches only itself here, and there is no prop to change it: a nav
25
+ * with two current items is a nav that has stopped saying anything.
26
+ *
27
+ * ## The query string and the fragment are not part of the answer
28
+ *
29
+ * `/backlog?focus=search` is still the Backlog. Sorting a list does
30
+ * not move the reader to a different destination.
31
+ *
32
+ * ⚠️ **This answers for ONE item, so it cannot keep the promise in
33
+ * the paragraph above by itself.** Given `/settings` and
34
+ * `/settings/labels`, it says `true` for both on the deeper path.
35
+ * `resolveActiveKey` is the whole-list answer, and it is what `Nav`
36
+ * uses; reach for this one only when there is a single link to test.
37
+ */
38
+ const toComparablePath = (href) => {
39
+ const path = href.split(/[#?]/u)[0] ?? "";
40
+ // A trailing slash is the same place, so it must not change the
41
+ // answer — but `/` itself has to survive being trimmed.
42
+ return path.length > 1 ? path.replace(/\/+$/u, "") : path;
43
+ };
44
+ export const getIsCurrentHref = ({ currentHref, href, }) => {
45
+ if (currentHref === undefined) {
46
+ return false;
47
+ }
48
+ const current = toComparablePath(currentHref);
49
+ const target = toComparablePath(href);
50
+ if (target === "" || target === "/") {
51
+ return current === target;
52
+ }
53
+ return (current === target || current.startsWith(`${target}/`));
54
+ };
55
+ /**
56
+ * The current destination's key, or `null` — the whole-list answer,
57
+ * and the one `Nav` marks with `aria-current="page"`.
58
+ *
59
+ * ## Exactly one, because the deepest match wins
60
+ *
61
+ * `getIsCurrentHref` is asked per item, so a nav holding both
62
+ * `/settings` and `/settings/labels` gets `true` twice while the
63
+ * reader is on the deeper one. Two current items is a nav that has
64
+ * stopped saying anything — the same thing react-router's `<NavLink
65
+ * to="/">` footgun produces — so the longest matching path wins and
66
+ * the shallower section stands down.
67
+ *
68
+ * Longest by **path**, not by array position: an app must be able to
69
+ * list its destinations in the order the product reads (Docket's is
70
+ * the pipeline) without that order deciding which one lights up.
71
+ *
72
+ * ## An external item is never current
73
+ *
74
+ * It is not a place inside this app, so no address the app is at can
75
+ * be it. Without this, an item pointing at `https://example.com/board`
76
+ * matches the app's own `/board` on a plain string compare.
77
+ */
78
+ export const resolveActiveKey = (items, activeHref) => {
79
+ if (activeHref === undefined) {
80
+ return null;
81
+ }
82
+ let matchedItem = null;
83
+ let matchedLength = -1;
84
+ for (const item of items) {
85
+ if (item.isExternal === true) {
86
+ continue;
87
+ }
88
+ if (!getIsCurrentHref({
89
+ currentHref: activeHref,
90
+ href: item.href,
91
+ })) {
92
+ continue;
93
+ }
94
+ const candidateLength = toComparablePath(item.href).length;
95
+ if (candidateLength > matchedLength) {
96
+ matchedItem = item;
97
+ matchedLength = candidateLength;
98
+ }
99
+ }
100
+ return matchedItem === null
101
+ ? null
102
+ : getNavItemKey(matchedItem);
103
+ };
104
+ //# sourceMappingURL=navItems.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"navItems.js","sourceRoot":"","sources":["../../src/Nav/navItems.ts"],"names":[],"mappings":"AAsCA,MAAM,CAAC,MAAM,aAAa,GAAG,CAAC,IAAa,EAAU,EAAE,CACrD,IAAI,CAAC,GAAG,IAAI,IAAI,CAAC,IAAI,CAAA;AAEvB;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GAmCG;AACH,MAAM,gBAAgB,GAAG,CAAC,IAAY,EAAU,EAAE;IAChD,MAAM,IAAI,GAAG,IAAI,CAAC,KAAK,CAAC,OAAO,CAAC,CAAC,CAAC,CAAC,IAAI,EAAE,CAAA;IAEzC,gEAAgE;IAChE,wDAAwD;IACxD,OAAO,IAAI,CAAC,MAAM,GAAG,CAAC,CAAC,CAAC,CAAC,IAAI,CAAC,OAAO,CAAC,OAAO,EAAE,EAAE,CAAC,CAAC,CAAC,CAAC,IAAI,CAAA;AAC3D,CAAC,CAAA;AAED,MAAM,CAAC,MAAM,gBAAgB,GAAG,CAAC,EAC/B,WAAW,EACX,IAAI,GAML,EAAW,EAAE;IACZ,IAAI,WAAW,KAAK,SAAS,EAAE,CAAC;QAC9B,OAAO,KAAK,CAAA;IACd,CAAC;IAED,MAAM,OAAO,GAAG,gBAAgB,CAAC,WAAW,CAAC,CAAA;IAE7C,MAAM,MAAM,GAAG,gBAAgB,CAAC,IAAI,CAAC,CAAA;IAErC,IAAI,MAAM,KAAK,EAAE,IAAI,MAAM,KAAK,GAAG,EAAE,CAAC;QACpC,OAAO,OAAO,KAAK,MAAM,CAAA;IAC3B,CAAC;IAED,OAAO,CACL,OAAO,KAAK,MAAM,IAAI,OAAO,CAAC,UAAU,CAAC,GAAG,MAAM,GAAG,CAAC,CACvD,CAAA;AACH,CAAC,CAAA;AAED;;;;;;;;;;;;;;;;;;;;;;GAsBG;AACH,MAAM,CAAC,MAAM,gBAAgB,GAAG,CAC9B,KAAyB,EACzB,UAAmB,EACJ,EAAE;IACjB,IAAI,UAAU,KAAK,SAAS,EAAE,CAAC;QAC7B,OAAO,IAAI,CAAA;IACb,CAAC;IAED,IAAI,WAAW,GAAmB,IAAI,CAAA;IAEtC,IAAI,aAAa,GAAG,CAAC,CAAC,CAAA;IAEtB,KAAK,MAAM,IAAI,IAAI,KAAK,EAAE,CAAC;QACzB,IAAI,IAAI,CAAC,UAAU,KAAK,IAAI,EAAE,CAAC;YAC7B,SAAQ;QACV,CAAC;QAED,IACE,CAAC,gBAAgB,CAAC;YAChB,WAAW,EAAE,UAAU;YACvB,IAAI,EAAE,IAAI,CAAC,IAAI;SAChB,CAAC,EACF,CAAC;YACD,SAAQ;QACV,CAAC;QAED,MAAM,eAAe,GAAG,gBAAgB,CACtC,IAAI,CAAC,IAAI,CACV,CAAC,MAAM,CAAA;QAER,IAAI,eAAe,GAAG,aAAa,EAAE,CAAC;YACpC,WAAW,GAAG,IAAI,CAAA;YAElB,aAAa,GAAG,eAAe,CAAA;QACjC,CAAC;IACH,CAAC;IAED,OAAO,WAAW,KAAK,IAAI;QACzB,CAAC,CAAC,IAAI;QACN,CAAC,CAAC,aAAa,CAAC,WAAW,CAAC,CAAA;AAChC,CAAC,CAAA"}
@@ -0,0 +1,86 @@
1
+ /**
2
+ * What the reader asked for, which is not the same as what they get.
3
+ *
4
+ * `auto` lets the width decide. The other two are a deliberate
5
+ * override and outrank the width — but only where a rail fits at
6
+ * all; see `useNavLayout`.
7
+ */
8
+ export type NavLayoutMode = "auto" | "collapsed" | "expanded";
9
+ /** The three forms a rail-shaped nav actually takes on screen. */
10
+ export type ResolvedNavLayout = "menu" | "rail" | "railIcons";
11
+ export type UseNavLayoutOptions = {
12
+ /**
13
+ * Below this width the rail drops its labels. Default `64rem`,
14
+ * which is `screen.lg`.
15
+ */
16
+ iconsBelow?: string;
17
+ /**
18
+ * Below this width there is no rail at all. Default `48rem`, which
19
+ * is `screen.md` — and the same step `Rail` itself collapses at,
20
+ * so the two cannot disagree.
21
+ */
22
+ menuBelow?: string;
23
+ /**
24
+ * Where the manual choice is remembered. Omit and the override
25
+ * lasts until the tab is closed.
26
+ *
27
+ * Name it for the app (`"queuepilot-nav"`), not for this library:
28
+ * two apps on the same origin share `localStorage`, and this
29
+ * fleet's dev servers all sit on `localhost`.
30
+ */
31
+ storageKey?: string;
32
+ };
33
+ export type NavLayoutState = {
34
+ /** `true` in `railIcons`. The rail's own collapse control reads it. */
35
+ isCollapsed: boolean;
36
+ /** What to pass to `Nav`'s `layout`. */
37
+ layout: ResolvedNavLayout;
38
+ mode: NavLayoutMode;
39
+ setMode: (mode: NavLayoutMode) => void;
40
+ /**
41
+ * Expanded ⇄ collapsed. From `auto` it goes to whichever of the two
42
+ * is **not** on screen now, so the first press always visibly does
43
+ * something — which "toggle" has to mean or the control reads as
44
+ * broken.
45
+ */
46
+ toggle: () => void;
47
+ };
48
+ /**
49
+ * The width rule for a rail-shaped navigation, written once.
50
+ *
51
+ * Three states, and the app chooses none of them:
52
+ *
53
+ * | Width | Layout | What the app renders |
54
+ * | --- | --- | --- |
55
+ * | `>= iconsBelow` | `rail` | a `Rail` holding a `Nav` |
56
+ * | `>= menuBelow` | `railIcons` | the same `Rail`, narrower |
57
+ * | below that | `menu` | a trigger in the `Header` |
58
+ *
59
+ * ### Why the app still places it
60
+ *
61
+ * Because the placement genuinely differs and the rule does not. The
62
+ * `menu` state puts a control in the header — a different corner of
63
+ * the page from a side rail — and no amount of restyling moves an
64
+ * element across `Shell`'s grid. So the library owns *which* state
65
+ * is right, at what width, with what override and where the override
66
+ * is remembered; the app owns two `if`s. That is the half that was
67
+ * being rewritten per app, and rewritten differently: Docket's nav
68
+ * wraps to three lines on a phone, mail-sifter collapses at `40rem`,
69
+ * and QueuePilot at `760px`, all of them arrived at alone.
70
+ *
71
+ * ### The override does not reach the narrow state
72
+ *
73
+ * `expanded` on a 390px screen would be a 212px rail beside 178px of
74
+ * content. The width wins there, and the remembered choice is *kept*
75
+ * rather than cleared — rotating a tablet back to landscape restores
76
+ * the rail the reader asked for, instead of silently forgetting it.
77
+ *
78
+ * ### It is a hook, not CSS
79
+ *
80
+ * The same reason `usePanelItemSize` is: a media query can restyle a
81
+ * rail, but it cannot move a control into the header, and it cannot
82
+ * tell the trigger whether to render at all. A resize re-renders;
83
+ * a scheme or density change still does not.
84
+ */
85
+ export declare const useNavLayout: ({ iconsBelow, menuBelow, storageKey, }?: UseNavLayoutOptions) => NavLayoutState;
86
+ //# sourceMappingURL=useNavLayout.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"useNavLayout.d.ts","sourceRoot":"","sources":["../../src/Nav/useNavLayout.ts"],"names":[],"mappings":"AAIA;;;;;;GAMG;AACH,MAAM,MAAM,aAAa,GACrB,MAAM,GACN,WAAW,GACX,UAAU,CAAA;AAEd,kEAAkE;AAClE,MAAM,MAAM,iBAAiB,GACzB,MAAM,GACN,MAAM,GACN,WAAW,CAAA;AAEf,MAAM,MAAM,mBAAmB,GAAG;IAChC;;;OAGG;IACH,UAAU,CAAC,EAAE,MAAM,CAAA;IACnB;;;;OAIG;IACH,SAAS,CAAC,EAAE,MAAM,CAAA;IAClB;;;;;;;OAOG;IACH,UAAU,CAAC,EAAE,MAAM,CAAA;CACpB,CAAA;AAED,MAAM,MAAM,cAAc,GAAG;IAC3B,uEAAuE;IACvE,WAAW,EAAE,OAAO,CAAA;IACpB,wCAAwC;IACxC,MAAM,EAAE,iBAAiB,CAAA;IACzB,IAAI,EAAE,aAAa,CAAA;IACnB,OAAO,EAAE,CAAC,IAAI,EAAE,aAAa,KAAK,IAAI,CAAA;IACtC;;;;;OAKG;IACH,MAAM,EAAE,MAAM,IAAI,CAAA;CACnB,CAAA;AAgDD;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GAoCG;AACH,eAAO,MAAM,YAAY,GAAI,yCAI1B,mBAAwB,KAAG,cAgD7B,CAAA"}