@camunda/design-system 0.61.2 → 0.63.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 (81) hide show
  1. package/dist/src/components/ui/alert-dialog.d.ts +1 -1
  2. package/dist/src/components/ui/alert-dialog.d.ts.map +1 -1
  3. package/dist/src/components/ui/alert-dialog.js +3 -1
  4. package/dist/src/components/ui/alert-dialog.js.map +1 -1
  5. package/dist/src/components/ui/app-sidebar.d.ts +16 -15
  6. package/dist/src/components/ui/app-sidebar.d.ts.map +1 -1
  7. package/dist/src/components/ui/app-sidebar.js +395 -71
  8. package/dist/src/components/ui/app-sidebar.js.map +1 -1
  9. package/dist/src/components/ui/collapsible.d.ts +1 -1
  10. package/dist/src/components/ui/collapsible.d.ts.map +1 -1
  11. package/dist/src/components/ui/collapsible.js +3 -1
  12. package/dist/src/components/ui/collapsible.js.map +1 -1
  13. package/dist/src/components/ui/dialog.d.ts +1 -1
  14. package/dist/src/components/ui/dialog.d.ts.map +1 -1
  15. package/dist/src/components/ui/dialog.js +3 -1
  16. package/dist/src/components/ui/dialog.js.map +1 -1
  17. package/dist/src/components/ui/dropdown.d.ts +3 -3
  18. package/dist/src/components/ui/dropdown.d.ts.map +1 -1
  19. package/dist/src/components/ui/dropdown.js +7 -2
  20. package/dist/src/components/ui/dropdown.js.map +1 -1
  21. package/dist/src/components/ui/input.d.ts +3 -1
  22. package/dist/src/components/ui/input.d.ts.map +1 -1
  23. package/dist/src/components/ui/input.js +2 -2
  24. package/dist/src/components/ui/input.js.map +1 -1
  25. package/dist/src/components/ui/label.d.ts +3 -1
  26. package/dist/src/components/ui/label.d.ts.map +1 -1
  27. package/dist/src/components/ui/label.js +3 -3
  28. package/dist/src/components/ui/label.js.map +1 -1
  29. package/dist/src/components/ui/login-screen.d.ts +24 -0
  30. package/dist/src/components/ui/login-screen.d.ts.map +1 -0
  31. package/dist/src/components/ui/login-screen.js +59 -0
  32. package/dist/src/components/ui/login-screen.js.map +1 -0
  33. package/dist/src/components/ui/nav-icon.d.ts +8 -2
  34. package/dist/src/components/ui/nav-icon.d.ts.map +1 -1
  35. package/dist/src/components/ui/nav-icon.js +3 -1
  36. package/dist/src/components/ui/nav-icon.js.map +1 -1
  37. package/dist/src/components/ui/popover.d.ts +1 -1
  38. package/dist/src/components/ui/popover.d.ts.map +1 -1
  39. package/dist/src/components/ui/popover.js +8 -1
  40. package/dist/src/components/ui/popover.js.map +1 -1
  41. package/dist/src/components/ui/select.d.ts +1 -1
  42. package/dist/src/components/ui/select.d.ts.map +1 -1
  43. package/dist/src/components/ui/select.js +7 -3
  44. package/dist/src/components/ui/select.js.map +1 -1
  45. package/dist/src/components/ui/sheet.d.ts +1 -1
  46. package/dist/src/components/ui/sheet.d.ts.map +1 -1
  47. package/dist/src/components/ui/sheet.js +3 -1
  48. package/dist/src/components/ui/sheet.js.map +1 -1
  49. package/dist/src/components/ui/sidebar-provider.d.ts +7 -1
  50. package/dist/src/components/ui/sidebar-provider.d.ts.map +1 -1
  51. package/dist/src/components/ui/sidebar-provider.js +10 -6
  52. package/dist/src/components/ui/sidebar-provider.js.map +1 -1
  53. package/dist/src/components/ui/tabs.d.ts +1 -1
  54. package/dist/src/components/ui/tabs.d.ts.map +1 -1
  55. package/dist/src/components/ui/tabs.js +2 -1
  56. package/dist/src/components/ui/tabs.js.map +1 -1
  57. package/dist/src/components/ui/tooltip.d.ts +1 -1
  58. package/dist/src/components/ui/tooltip.d.ts.map +1 -1
  59. package/dist/src/components/ui/tooltip.js +5 -1
  60. package/dist/src/components/ui/tooltip.js.map +1 -1
  61. package/dist/src/icons/camunda-app-icons.js +2 -2
  62. package/dist/src/icons/camunda-app-icons.js.map +1 -1
  63. package/dist/src/icons/index.d.ts +1 -1
  64. package/dist/src/icons/index.d.ts.map +1 -1
  65. package/dist/src/icons/index.js +1 -1
  66. package/dist/src/icons/index.js.map +1 -1
  67. package/dist/src/icons/registry.d.ts +1 -0
  68. package/dist/src/icons/registry.d.ts.map +1 -1
  69. package/dist/src/icons/registry.js +3 -1
  70. package/dist/src/icons/registry.js.map +1 -1
  71. package/dist/src/icons/registry.json +12 -4
  72. package/dist/src/index.d.ts +2 -1
  73. package/dist/src/index.d.ts.map +1 -1
  74. package/dist/src/index.js +2 -1
  75. package/dist/src/index.js.map +1 -1
  76. package/dist/src/lib/i18n/dictionary.d.ts +8 -1
  77. package/dist/src/lib/i18n/dictionary.d.ts.map +1 -1
  78. package/dist/src/lib/i18n/dictionary.js +8 -1
  79. package/dist/src/lib/i18n/dictionary.js.map +1 -1
  80. package/dist/styles.css +72 -34
  81. package/package.json +2 -2
@@ -1,11 +1,11 @@
1
1
  import { jsx as _jsx, jsxs as _jsxs, Fragment as _Fragment } from "react/jsx-runtime";
2
2
  import * as React from "react";
3
3
  import { cva } from "class-variance-authority";
4
- import { ChevronDown, ChevronRight, ChevronLeft, PanelLeftClose, PanelLeft, X, } from "../../icons/lucide.js";
4
+ import { ChevronDown, ChevronRight, ChevronLeft, X } from "../../icons/lucide.js";
5
5
  import { cn } from "../../lib/utils.js";
6
6
  import { useC4Dictionary } from "../../lib/i18n/context.js";
7
7
  import { Text } from "./typography.js";
8
- import { Popover, PopoverContent, PopoverTrigger } from "./popover.js";
8
+ import { Popover, PopoverContent, PopoverHeader, PopoverTitle, PopoverTrigger, } from "./popover.js";
9
9
  import { Tooltip, TooltipContent, TooltipTrigger } from "./tooltip.js";
10
10
  import { Sheet, SheetClose, SheetContent, SheetTitle } from "./sheet.js";
11
11
  import { useSidebar } from "./sidebar-provider.js";
@@ -14,6 +14,12 @@ import { useIsTruncated } from "../../hooks/use-is-truncated.js";
14
14
  import { linkDestination, resolveActive, resolveLinkProps, } from "./nav-data.js";
15
15
  import { NavRowIcon } from "./nav-icon.js";
16
16
  const NAV_ROW_BASE = "group/navbtn flex w-full items-center overflow-hidden rounded-md transition-colors";
17
+ /**
18
+ * The type a section title reads at. Shared with the collapsed rail's flyout header,
19
+ * which titles a group the same way the expanded rail titles a section — one constant
20
+ * so the two cannot drift apart.
21
+ */
22
+ const SECTION_LABEL_TYPE = "text-xs leading-4 font-medium text-neutral-foreground-subtle";
17
23
  // Hides the scrollbar track; scroll itself still works.
18
24
  const HIDDEN_SCROLLBAR = "[scrollbar-width:none] [&::-webkit-scrollbar]:hidden";
19
25
  const navButton = cva(cn(NAV_ROW_BASE, "gap-3 min-h-10 text-sm text-left whitespace-nowrap focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-inset"), {
@@ -22,9 +28,15 @@ const navButton = cva(cn(NAV_ROW_BASE, "gap-3 min-h-10 text-sm text-left whitesp
22
28
  true: "bg-neutral-background-strong text-neutral-foreground-strong font-medium",
23
29
  false: "text-neutral-foreground-subtle hover:bg-neutral-background-medium hover:text-neutral-foreground-strong",
24
30
  },
31
+ // Both states left-align on the same 10px inset, so the icon holds its x
32
+ // position across a collapse and only the label arrives or leaves. With
33
+ // the list's own 8px padding that puts a 20px icon at x=18, which is also
34
+ // dead centre of the 56px collapsed rail — the one inset satisfying both.
35
+ // Centring the collapsed row instead lands half a pixel off, because the
36
+ // row's flex `gap` still counts the sr-only label.
25
37
  expanded: {
26
- true: "justify-start px-3 py-2",
27
- false: "justify-center px-2 py-2",
38
+ true: "justify-start px-2.5 py-2",
39
+ false: "justify-start px-2.5 py-2",
28
40
  },
29
41
  },
30
42
  defaultVariants: { active: false, expanded: true },
@@ -40,9 +52,13 @@ function useExpanded({ expanded: controlled, defaultExpanded, onExpandedChange,
40
52
  }, [isControlled, onExpandedChange]);
41
53
  return [expanded, setExpanded];
42
54
  }
43
- // Base offset mirrors c3-sidebar's icon column.
55
+ // Matches navButton's own inset at depth 0, so an indented row steps off the
56
+ // same left edge every other row starts from.
57
+ const ROW_INSET_REM = 0.625;
44
58
  function indentStyle(depth) {
45
- return depth > 0 ? { paddingLeft: `${0.75 + depth * 1.75}rem` } : undefined;
59
+ return depth > 0
60
+ ? { paddingLeft: `${ROW_INSET_REM + depth * 1.75}rem` }
61
+ : undefined;
46
62
  }
47
63
  function renderNode(node, ctx) {
48
64
  switch (node.type) {
@@ -55,7 +71,7 @@ function renderNode(node, ctx) {
55
71
  return _jsx(NavSection, { node: node, ...ctx }, node.key);
56
72
  }
57
73
  }
58
- function NavItemRow({ node, expanded, depth, LinkEl, isMobile, onNavigate, hasLinkComponent, activeItemKey, }) {
74
+ function NavItemRow({ node, expanded, depth, LinkEl, isMobile, onNavigate, hasLinkComponent, activeItemKey, hideIconFallback, }) {
59
75
  const isLink = linkDestination(node.linkProps) !== undefined;
60
76
  const Comp = isLink ? LinkEl : "button";
61
77
  const isActive = resolveActive(node.isActive, node.key, activeItemKey);
@@ -71,7 +87,7 @@ function NavItemRow({ node, expanded, depth, LinkEl, isMobile, onNavigate, hasLi
71
87
  : { type: "button" }), "data-slot": "app-sidebar-item", className: cn(navButton({ active: isActive, expanded })), style: indentStyle(depth), "aria-current": isActive ? "page" : undefined, onClick: () => {
72
88
  node.onClick?.();
73
89
  onNavigate?.();
74
- }, onPointerEnter: measure, onFocus: measure, children: [_jsx(NavRowIcon, { icon: node.icon, label: node.label }), _jsx("span", { ref: labelRef, className: cn("flex-1 truncate", !expanded && "sr-only"), children: node.label }), expanded && node.trailingElement] }));
90
+ }, onPointerEnter: measure, onFocus: measure, children: [_jsx(NavRowIcon, { icon: node.icon, label: node.label, hideFallback: hideIconFallback }), _jsx("span", { ref: labelRef, className: cn("flex-1 truncate", !expanded && "sr-only"), children: node.label }), expanded && node.trailingElement] }));
75
91
  // Collapsed icon rail: name the icon with a tooltip. Not on mobile (the Sheet
76
92
  // shows full labels) and not when expanded (the label is already visible).
77
93
  if (!expanded && !isMobile) {
@@ -82,7 +98,53 @@ function NavItemRow({ node, expanded, depth, LinkEl, isMobile, onNavigate, hasLi
82
98
  // "Truncated Text" in `docs/specs/shadcn/patterns.md`.
83
99
  return isTruncated ? (_jsxs(Tooltip, { children: [_jsx(TooltipTrigger, { asChild: true, children: row }), _jsx(TooltipContent, { children: node.label })] })) : (row);
84
100
  }
85
- function NavGroupRow({ node, expanded, depth, LinkEl, hasLinkComponent, activeItemKey, isMobile, onNavigate, }) {
101
+ /**
102
+ * The collapsed rail's single flyout slot.
103
+ *
104
+ * Group flyouts share one slot, so opening one closes whichever was open —
105
+ * including a pinned or keyboard-opened flyout, which ignores hover-out and
106
+ * would otherwise stay on screen beside the new one. It lives above the rows
107
+ * because a row cannot close a sibling it has no reference to.
108
+ */
109
+ const FlyoutSlotContext = React.createContext(null);
110
+ function useFlyoutSlot(nodeKey) {
111
+ const slot = React.useContext(FlyoutSlotContext);
112
+ // A row rendered without the rail around it keeps a slot of its own.
113
+ const [localKey, setLocalKey] = React.useState(null);
114
+ const openKey = slot ? slot.openKey : localKey;
115
+ const setOpenKey = slot ? slot.setOpenKey : setLocalKey;
116
+ return {
117
+ flyoutOpen: openKey === nodeKey,
118
+ setFlyoutOpen: (next) => setOpenKey((prev) => (next ? nodeKey : prev === nodeKey ? null : prev)),
119
+ };
120
+ }
121
+ // Hover intent for the collapsed-rail flyout. The open delay keeps a pointer
122
+ // crossing the rail vertically from flashing every group's flyout in turn; the
123
+ // close delay covers the gap between the trigger and the flyout, which the
124
+ // pointer has to traverse to reach the children (WCAG 1.4.13, hoverable).
125
+ const FLYOUT_HOVER_OPEN_DELAY = 120;
126
+ const FLYOUT_HOVER_CLOSE_DELAY = 240;
127
+ /**
128
+ * Whether anything below this node resolves active.
129
+ *
130
+ * A pure `group` has no active state of its own — see `SidebarGroup`, and the C3 contract in
131
+ * `nav-c3-compat.specs.ts`, which both leave the field off deliberately. Expanded that costs
132
+ * nothing, because the active descendant is on screen carrying its own highlight. Collapsed it
133
+ * leaves the rail with no current-location signal at all: the descendant lives in a flyout
134
+ * nobody has opened yet.
135
+ */
136
+ function hasActiveDescendant(nodes, activeItemKey) {
137
+ return nodes.some((child) => {
138
+ if (child.type === "section" || child.type === "group") {
139
+ return hasActiveDescendant(child.children, activeItemKey);
140
+ }
141
+ if (resolveActive(child.isActive, child.key, activeItemKey))
142
+ return true;
143
+ return (child.type === "group-item" &&
144
+ hasActiveDescendant(child.children, activeItemKey));
145
+ });
146
+ }
147
+ function NavGroupRow({ node, expanded, depth, LinkEl, hasLinkComponent, activeItemKey, isMobile, onNavigate, hideIconFallback, }) {
86
148
  const isGroupControlled = node.isExpanded !== undefined;
87
149
  const [openInternal, setOpenInternal] = React.useState(!!node.isExpanded);
88
150
  const open = isGroupControlled ? !!node.isExpanded : openInternal;
@@ -95,11 +157,58 @@ function NavGroupRow({ node, expanded, depth, LinkEl, hasLinkComponent, activeIt
95
157
  // Collapsed-rail flyout visibility is its own concern — decoupled from group
96
158
  // expansion so collapsing the rail never auto-opens a flyout and opening a
97
159
  // flyout never mutates expansion state.
98
- const [flyoutOpen, setFlyoutOpen] = React.useState(false);
160
+ const { flyoutOpen, setFlyoutOpen } = useFlyoutSlot(node.key);
161
+ const hoverTimerRef = React.useRef(null);
162
+ // Which input opened the flyout. A pointer-driven open must not pull focus
163
+ // into the flyout: the pointer only crossed the rail, and Radix's default
164
+ // open-autofocus would yank focus off whatever the user was actually on.
165
+ const openedByPointerRef = React.useRef(false);
166
+ // The group trigger's tooltip is keyboard-only: on hover the flyout itself
167
+ // names the group, and with `delayDuration = 0` a hover tooltip would appear
168
+ // instantly, then be force-closed 120ms later when the flyout opens — a flash
169
+ // on every pass over the rail. Keyboard focus has no flyout until Enter, so it
170
+ // still needs the tooltip. Leaf rows keep their ordinary hover tooltip.
171
+ const [labelTooltipOpen, setLabelTooltipOpen] = React.useState(false);
172
+ const pointerInsideRef = React.useRef(false);
173
+ const triggerRef = React.useRef(null);
174
+ // Readable from the hover timer, which captured an older render's props.
175
+ const flyoutOpenRef = React.useRef(flyoutOpen);
176
+ React.useEffect(() => {
177
+ flyoutOpenRef.current = flyoutOpen;
178
+ }, [flyoutOpen]);
179
+ const cancelHoverTimer = () => {
180
+ if (hoverTimerRef.current !== null) {
181
+ window.clearTimeout(hoverTimerRef.current);
182
+ hoverTimerRef.current = null;
183
+ }
184
+ };
185
+ React.useEffect(() => cancelHoverTimer, []);
186
+ const scheduleFlyout = (next) => {
187
+ cancelHoverTimer();
188
+ hoverTimerRef.current = window.setTimeout(() => {
189
+ hoverTimerRef.current = null;
190
+ if (next) {
191
+ // Only an open this timer actually performs counts as pointer-opened.
192
+ // Re-entering the trigger of an already-pinned flyout also runs here,
193
+ // and marking that one as pointer-opened would silently un-pin it —
194
+ // the next hover-out would dismiss it.
195
+ if (!flyoutOpenRef.current)
196
+ openedByPointerRef.current = true;
197
+ setFlyoutOpen(true);
198
+ return;
199
+ }
200
+ // Only a hover-opened flyout closes on hover-out. A click- or
201
+ // keyboard-opened one holds focus inside itself, so a pointer merely
202
+ // crossing the rail must not close it and snap focus back to the
203
+ // trigger.
204
+ if (openedByPointerRef.current)
205
+ setFlyoutOpen(false);
206
+ }, next ? FLYOUT_HOVER_OPEN_DELAY : FLYOUT_HOVER_CLOSE_DELAY);
207
+ };
99
208
  const isGroupItem = node.type === "group-item";
100
209
  const isActive = isGroupItem
101
210
  ? resolveActive(node.isActive, node.key, activeItemKey)
102
- : false;
211
+ : !expanded && hasActiveDescendant(node.children, activeItemKey);
103
212
  const isLink = isGroupItem && linkDestination(node.linkProps) !== undefined;
104
213
  const LabelComp = isLink ? LinkEl : "button";
105
214
  // Truncation reveal — expanded state only (see the collapsed-rail flyout's
@@ -108,34 +217,104 @@ function NavGroupRow({ node, expanded, depth, LinkEl, hasLinkComponent, activeIt
108
217
  React.useLayoutEffect(() => {
109
218
  measure();
110
219
  });
111
- const label = (_jsxs(_Fragment, { children: [_jsx(NavRowIcon, { icon: node.icon, label: node.label }), _jsx("span", { ref: labelRef, className: cn("flex-1 truncate", !expanded && "sr-only"), children: node.label }), expanded && node.trailingElement] }));
220
+ const label = (_jsxs(_Fragment, { children: [_jsx(NavRowIcon, { icon: node.icon, label: node.label, hideFallback: hideIconFallback }), _jsx("span", { ref: labelRef, className: cn("flex-1 truncate", !expanded && "sr-only"), children: node.label }), expanded && node.trailingElement] }));
112
221
  if (!expanded) {
113
- return (_jsxs(Popover, { open: flyoutOpen, onOpenChange: setFlyoutOpen, children: [_jsxs(Tooltip, { open: flyoutOpen ? false : undefined, children: [_jsx(TooltipTrigger, { asChild: true, children: _jsx(PopoverTrigger, { asChild: true, children: _jsxs("button", { type: "button", "data-slot": "app-sidebar-group-trigger", "aria-label": node.label, "aria-expanded": flyoutOpen, className: cn(navButton({ active: isActive, expanded: false })), children: [_jsx(NavRowIcon, { icon: node.icon, label: node.label }), _jsx("span", { className: "sr-only", children: node.label })] }) }) }), _jsx(TooltipContent, { side: "right", children: node.label })] }), _jsx(PopoverContent, { side: "right", align: "start", className: "w-56 gap-0 p-1", children: node.children.map((child) => renderNode(child, {
114
- expanded: true,
115
- depth: 0,
116
- LinkEl,
117
- hasLinkComponent,
118
- activeItemKey,
119
- isMobile,
120
- onNavigate,
121
- })) })] }));
222
+ return (_jsxs(Popover, { open: flyoutOpen, onOpenChange: (next) => {
223
+ cancelHoverTimer();
224
+ setFlyoutOpen(next);
225
+ }, children: [_jsxs(Tooltip, { open: labelTooltipOpen && !flyoutOpen,
226
+ // Controlled, so Radix's own dismissal has to be accepted or Escape
227
+ // leaves the tooltip up until blur (WCAG 1.4.13, dismissible). Only
228
+ // the close request is honoured: an open request would reinstate the
229
+ // hover tooltip this trigger deliberately does not have.
230
+ onOpenChange: (next) => {
231
+ if (!next)
232
+ setLabelTooltipOpen(false);
233
+ }, children: [_jsx(TooltipTrigger, { asChild: true, children: _jsx(PopoverTrigger, { asChild: true, children: _jsxs("button", { ref: triggerRef, type: "button", "data-slot": "app-sidebar-group-trigger", "aria-label": node.label, "aria-expanded": flyoutOpen, className: cn(navButton({ active: isActive, expanded: false })), onMouseEnter: () => {
234
+ pointerInsideRef.current = true;
235
+ scheduleFlyout(true);
236
+ }, onMouseLeave: () => {
237
+ pointerInsideRef.current = false;
238
+ scheduleFlyout(false);
239
+ }, onFocus: () => {
240
+ if (!pointerInsideRef.current)
241
+ setLabelTooltipOpen(true);
242
+ }, onBlur: () => setLabelTooltipOpen(false), onClick: (event) => {
243
+ cancelHoverTimer();
244
+ // Hover has already opened it; Radix's trigger toggle would
245
+ // close it again, so a click on the icon the pointer is
246
+ // sitting on would dismiss the flyout it just opened. The
247
+ // toggle is composed behind this handler and skips on
248
+ // `defaultPrevented`.
249
+ if (flyoutOpen) {
250
+ event.preventDefault();
251
+ }
252
+ // Either way the open is now an explicit one: it stays until
253
+ // Escape, an outside pointer-down, or a child is selected.
254
+ openedByPointerRef.current = false;
255
+ }, children: [_jsx(NavRowIcon, { icon: node.icon, label: node.label }), _jsx("span", { className: "sr-only", children: node.label })] }) }) }), _jsx(TooltipContent, { side: "right", children: node.label })] }), _jsxs(PopoverContent, { side: "right", align: "start", className: "w-56 gap-0 p-1", "aria-label": node.label,
256
+ // The launcher Popover captures at open time is whatever was focused
257
+ // when the pointer crossed the rail, which is not this trigger. Name
258
+ // the trigger explicitly so an explicit close returns focus to it.
259
+ restoreFocusRef: triggerRef, onMouseEnter: cancelHoverTimer, onMouseLeave: () => scheduleFlyout(false), onOpenAutoFocus: (event) => {
260
+ if (openedByPointerRef.current)
261
+ event.preventDefault();
262
+ }, onFocusOutside: (event) => {
263
+ // A hover-opened flyout holds no focus, so every focus-outside is
264
+ // spurious for it — and one in particular is fatal: displacing a
265
+ // keyboard-opened sibling returns that sibling's focus to its
266
+ // trigger, which would dismiss the flyout the pointer just opened.
267
+ // Keyboard and click opens keep the default (tabbing out closes).
268
+ if (openedByPointerRef.current)
269
+ event.preventDefault();
270
+ }, onCloseAutoFocus: (event) => {
271
+ // A hover-opened flyout never held focus, so restoring would drag
272
+ // it off whatever the user is really on. One that was opened by
273
+ // click or keyboard does hold it, and still returns it to the
274
+ // trigger — including when a sibling flyout displaces this one,
275
+ // which would otherwise drop focus to <body>. (Asking the DOM
276
+ // here instead does not work: by the time this fires Radix has
277
+ // already detached the content, so `activeElement` is `<body>`.)
278
+ if (openedByPointerRef.current)
279
+ event.preventDefault();
280
+ // Cleared here rather than in `onOpenChange`, which Radix calls
281
+ // first on an outside pointer-down: clearing there would make this
282
+ // handler read a hover-opened flyout as a focus-holding one and
283
+ // pull focus to the trigger, off whatever was just clicked.
284
+ openedByPointerRef.current = false;
285
+ }, children: [_jsx(PopoverHeader, { className: "px-2 pt-1.5 pb-1", children: _jsx(PopoverTitle, { className: SECTION_LABEL_TYPE, children: node.label }) }), node.children.map((child) => renderNode(child, {
286
+ expanded: true,
287
+ depth: 0,
288
+ LinkEl,
289
+ hasLinkComponent,
290
+ activeItemKey,
291
+ isMobile,
292
+ // Selecting a child dismisses the flyout. Navigation alone does
293
+ // not: the rail survives an in-app route change, so the flyout
294
+ // would otherwise stay open over the destination.
295
+ onNavigate: () => {
296
+ setFlyoutOpen(false);
297
+ onNavigate?.();
298
+ },
299
+ }))] })] }));
122
300
  }
123
- const labelElement = (_jsx(LabelComp, { ...(isLink ? node.linkProps : { type: "button" }), className: cn("flex flex-1 min-w-0 items-center gap-3 min-h-10 py-2 text-sm text-left transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-inset", isActive
301
+ const chevron = open ? (_jsx(ChevronDown, { "aria-hidden": true, className: "size-4" })) : (_jsx(ChevronRight, { "aria-hidden": true, className: "size-4" }));
302
+ const labelElement = (_jsxs(LabelComp, { ...(isLink ? node.linkProps : { type: "button" }), className: cn("flex flex-1 min-w-0 items-center gap-3 min-h-10 py-2 text-sm text-left transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-inset", isActive
124
303
  ? "text-neutral-foreground-strong font-medium"
125
304
  : "text-neutral-foreground-subtle hover:text-neutral-foreground-strong"), style: depth > 0
126
- ? { paddingLeft: `${0.75 + depth * 1.75}rem` }
127
- : { paddingLeft: "0.75rem" }, "aria-current": isActive ? "page" : undefined, "aria-expanded": isGroupItem ? undefined : open, onClick: isGroupItem
305
+ ? { paddingLeft: `${ROW_INSET_REM + depth * 1.75}rem` }
306
+ : { paddingLeft: `${ROW_INSET_REM}rem` }, "aria-current": isActive ? "page" : undefined, "aria-expanded": isGroupItem ? undefined : open, onClick: isGroupItem
128
307
  ? () => {
129
308
  node.onClick?.();
130
309
  onNavigate?.();
131
310
  }
132
- : toggle, onPointerEnter: measure, onFocus: measure, children: label }));
311
+ : toggle, onPointerEnter: measure, onFocus: measure, children: [label, !isGroupItem && (_jsx("span", { className: "flex items-center mr-3", children: chevron }))] }));
133
312
  // Truncation reveal, mounted only when the label actually clips — see
134
313
  // "Truncated Text" in `docs/specs/shadcn/patterns.md`.
135
314
  const labelWithReveal = isTruncated ? (_jsxs(Tooltip, { children: [_jsx(TooltipTrigger, { asChild: true, children: labelElement }), _jsx(TooltipContent, { children: node.label })] })) : (labelElement);
136
315
  return (_jsxs("div", { "data-slot": "app-sidebar-group", className: cn("flex flex-col gap-0.5"), children: [_jsxs("div", { className: cn(NAV_ROW_BASE, isActive
137
316
  ? "bg-neutral-background-strong"
138
- : "hover:bg-neutral-background-medium"), children: [labelWithReveal, _jsx("button", { type: "button", "data-slot": "app-sidebar-group-toggle", "aria-label": `Toggle ${node.label}`, "aria-expanded": open, onClick: toggle, className: "flex items-center justify-center mr-3 ml-1 rounded p-0.5 text-neutral-foreground-subtle hover:bg-neutral-background-strong focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-inset", children: open ? (_jsx(ChevronDown, { "aria-hidden": true, className: "size-4" })) : (_jsx(ChevronRight, { "aria-hidden": true, className: "size-4" })) })] }), open &&
317
+ : "hover:bg-neutral-background-medium"), children: [labelWithReveal, isGroupItem && (_jsx("button", { type: "button", "data-slot": "app-sidebar-group-toggle", "aria-label": `Toggle ${node.label}`, "aria-expanded": open, onClick: toggle, className: "flex items-center justify-center mr-3 ml-1 rounded p-0.5 text-neutral-foreground-subtle hover:bg-neutral-background-strong focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-inset", children: chevron }))] }), open &&
139
318
  node.children.map((child) => renderNode(child, {
140
319
  expanded,
141
320
  depth: depth + 1,
@@ -144,10 +323,12 @@ function NavGroupRow({ node, expanded, depth, LinkEl, hasLinkComponent, activeIt
144
323
  activeItemKey,
145
324
  isMobile,
146
325
  onNavigate,
326
+ hideIconFallback,
147
327
  }))] }));
148
328
  }
149
- function NavSection({ node, expanded, depth, LinkEl, hasLinkComponent, activeItemKey, isMobile, onNavigate, }) {
150
- return (_jsxs("div", { "data-slot": "app-sidebar-section", className: cn(!node.compact && "pt-2"), children: [node.title && (_jsx("div", { className: cn("px-3 pt-4 pb-1 text-xs font-medium text-neutral-foreground-subtle", node.compact && "pt-0", !expanded && "sr-only"), children: node.title })), node.children.map((child) => renderNode(child, {
329
+ function NavSection({ node, expanded, depth, LinkEl, hasLinkComponent, activeItemKey, isMobile, onNavigate, isFirst, }) {
330
+ const isEntities = node.variant === "entities";
331
+ return (_jsxs("div", { "data-slot": "app-sidebar-section", "data-variant": node.variant, className: cn(!node.compact && !isFirst && "pt-2", isEntities && !expanded && "mt-2 border-t border-border pt-2"), children: [node.title && (_jsx("div", { className: cn("px-2.5 pt-4 pb-1", SECTION_LABEL_TYPE, node.compact && "pt-0", !expanded && "sr-only"), children: node.title })), node.children.map((child) => renderNode(child, {
151
332
  expanded,
152
333
  depth,
153
334
  LinkEl,
@@ -155,6 +336,7 @@ function NavSection({ node, expanded, depth, LinkEl, hasLinkComponent, activeIte
155
336
  activeItemKey,
156
337
  isMobile,
157
338
  onNavigate,
339
+ hideIconFallback: isEntities && expanded,
158
340
  }))] }));
159
341
  }
160
342
  function BackNavHeader({ backNav, expanded, LinkEl, }) {
@@ -177,33 +359,143 @@ function BackNavHeader({ backNav, expanded, LinkEl, }) {
177
359
  const titleElement = expanded && titleTruncated ? (_jsxs(Tooltip, { children: [_jsx(TooltipTrigger, { asChild: true, children: titleSpan }), _jsx(TooltipContent, { children: title })] })) : (titleSpan);
178
360
  return (_jsxs("div", { "data-slot": "app-sidebar-back-nav", className: cn("flex h-10 shrink-0 items-center gap-1 border-b border-border", expanded ? "pl-1 pr-3" : "justify-center px-2"), children: [_jsx(BackComp, { ...(isLink ? { href: backHref } : { type: "button", onClick: onBack }), "data-slot": "app-sidebar-back", "aria-label": resolvedBackLabel, className: "flex size-8 shrink-0 items-center justify-center rounded-md text-neutral-foreground-subtle transition-colors hover:bg-neutral-background-strong hover:text-neutral-foreground-strong focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-inset", children: _jsx(ChevronLeft, { "aria-hidden": true, className: "size-4" }) }), titleElement] }));
179
361
  }
180
- function remToPx(rem) {
181
- return parseFloat(rem) * 16;
362
+ // Widths cross this component as CSS lengths. Resting values are authored in
363
+ // rem, but a live drag publishes px so the rail can sit between rem steps, so
364
+ // both spellings have to parse — reading "180px" as rem would report 2880.
365
+ function toPx(width) {
366
+ return width.trimEnd().endsWith("px")
367
+ ? parseFloat(width)
368
+ : parseFloat(width) * 16;
182
369
  }
183
- function SidebarResizeHandle({ width, minWidth, maxWidth, onResize, resizeLabel, resizeValueTextLabel, }) {
370
+ /**
371
+ * Releasing a drag below this width closes the rail. It sits under the 14rem
372
+ * resting minimum so reaching it takes a deliberate push past the minimum
373
+ * rather than an ordinary narrow resize. Only used when the configured
374
+ * collapsed and minimum widths leave room for it — see `snapClosedBelowPx`.
375
+ */
376
+ const SNAP_CLOSED_BELOW_PX = 180;
377
+ /** Pointer travel that separates a resize drag from a click on the edge. */
378
+ const DRAG_THRESHOLD_PX = 3;
379
+ /** Keyboard resize increment. */
380
+ const RESIZE_STEP_PX = 16;
381
+ /**
382
+ * The rail's right edge as one gesture surface: clicking toggles, dragging
383
+ * resizes, and releasing a drag below `SNAP_CLOSED_BELOW_PX` snaps it closed.
384
+ * Invisible at rest — the col-resize cursor is the whole affordance, and
385
+ * `SidebarCollapseToggle` beside it is what makes the action findable.
386
+ *
387
+ * That split is deliberate: only the separator carries the resize value range,
388
+ * and only the toggle carries expanded/collapsed state, so neither ends up
389
+ * with ARIA that contradicts its role, and the two have distinct accessible
390
+ * names rather than one shared between adjacent tab stops.
391
+ */
392
+ function SidebarResizeSeparator({ expanded, setExpanded, restingWidth, collapsedWidth, minWidth, maxWidth, resizable, onResize, onPreviewWidth, resizeLabel, resizeValueTextLabel, }) {
184
393
  const { t } = useC4Dictionary();
185
- const minPx = remToPx(minWidth);
186
- const maxPx = remToPx(maxWidth);
187
- const nowPx = Math.round(remToPx(width));
188
- // The rail is fixed at left:0, so the pointer's clientX is the target width.
189
- const clampToRem = (px) => `${Math.round(Math.min(maxPx, Math.max(minPx, px)) / 16)}rem`;
394
+ const minPx = toPx(minWidth);
395
+ const maxPx = toPx(maxWidth);
396
+ const collapsedPx = toPx(collapsedWidth);
397
+ // Reads the live width mid-drag, since `restingWidth` carries the dragged
398
+ // value while one is in flight — so what is announced matches what is drawn.
399
+ const nowPx = Math.round(expanded ? toPx(restingWidth) : collapsedPx);
400
+ // Resting widths clamp to [min, max]. A drag travels further — down to the
401
+ // rail itself — because the snap-closed gesture needs to cross below min.
402
+ const toRestingRem = (px) => `${Math.round(Math.min(maxPx, Math.max(minPx, px)) / 16)}rem`;
403
+ // The designed threshold, but only while it genuinely sits between the rail
404
+ // and the resting minimum. A drag is floored at the rail, so a consumer
405
+ // raising `collapsedWidth` to 180px or beyond would put the threshold out of
406
+ // reach and silently cost them drag-to-collapse. Falling back to the midpoint
407
+ // leaves a usable closing zone at any configured pair of widths.
408
+ const snapClosedBelowPx = SNAP_CLOSED_BELOW_PX > collapsedPx && SNAP_CLOSED_BELOW_PX < minPx
409
+ ? SNAP_CLOSED_BELOW_PX
410
+ : (collapsedPx + minPx) / 2;
190
411
  const onPointerDown = (e) => {
191
412
  e.preventDefault();
192
- e.currentTarget.setPointerCapture(e.pointerId);
193
- const onMove = (ev) => onResize(clampToRem(ev.clientX));
194
- const onUp = () => {
413
+ // Best-effort: the drag is driven by window listeners, so capture only
414
+ // hardens it against the pointer leaving the strip. It throws when the id
415
+ // belongs to no live pointer — a synthetic pointer from a test, or one
416
+ // already released — and that must not take the gesture down with it.
417
+ try {
418
+ e.currentTarget.setPointerCapture(e.pointerId);
419
+ }
420
+ catch {
421
+ // no capture available; the window listeners still carry the drag
422
+ }
423
+ const startX = e.clientX;
424
+ // Captured before the drag mutates it, so aborting or snapping closed can
425
+ // hand the remembered width back untouched.
426
+ const widthBeforeDrag = restingWidth;
427
+ let moved = false;
428
+ let lastPx = nowPx;
429
+ const stopListening = () => {
195
430
  window.removeEventListener("pointermove", onMove);
196
431
  window.removeEventListener("pointerup", onUp);
432
+ window.removeEventListener("pointercancel", onCancel);
433
+ onPreviewWidth(null);
434
+ };
435
+ // The rail is fixed at left:0, so the pointer's clientX is the target width.
436
+ const onMove = (ev) => {
437
+ if (!moved) {
438
+ if (Math.abs(ev.clientX - startX) <= DRAG_THRESHOLD_PX)
439
+ return;
440
+ moved = true;
441
+ // Open on the first real movement so the edge tracks the pointer out of
442
+ // the collapsed state, instead of staying put and jumping at release.
443
+ if (!expanded)
444
+ setExpanded(true);
445
+ }
446
+ lastPx = Math.min(maxPx, Math.max(collapsedPx, ev.clientX));
447
+ const live = `${Math.round(lastPx)}px`;
448
+ onPreviewWidth(live);
449
+ // Published unclamped and live: a sibling <main> offset by
450
+ // --app-sidebar-width has to track the rail through the snap zone, and
451
+ // clamping here would leave it hanging at the resting minimum while the
452
+ // rail kept narrowing. The clamp belongs on the committed value only.
453
+ onResize(live);
454
+ };
455
+ const onUp = () => {
456
+ stopListening();
457
+ if (!moved) {
458
+ setExpanded(!expanded);
459
+ }
460
+ else if (lastPx < snapClosedBelowPx) {
461
+ onResize(widthBeforeDrag);
462
+ setExpanded(false);
463
+ }
464
+ else {
465
+ onResize(toRestingRem(lastPx));
466
+ setExpanded(true);
467
+ }
468
+ };
469
+ // A cancelled pointer (interrupted touch, browser gesture takeover) never
470
+ // fires pointerup. Without this the listeners outlive the gesture and the
471
+ // rail keeps resizing with nothing held down.
472
+ const onCancel = () => {
473
+ stopListening();
474
+ onResize(widthBeforeDrag);
197
475
  };
198
476
  window.addEventListener("pointermove", onMove);
199
477
  window.addEventListener("pointerup", onUp);
478
+ window.addEventListener("pointercancel", onCancel);
200
479
  };
201
- const STEP = 16;
202
480
  const onKeyDown = (e) => {
203
- if (e.key === "ArrowRight")
204
- onResize(clampToRem(nowPx + STEP));
205
- else if (e.key === "ArrowLeft")
206
- onResize(clampToRem(nowPx - STEP));
481
+ if (!expanded) {
482
+ // Collapsed, the only resize left is the one that reopens the rail.
483
+ if (e.key !== "ArrowRight" && e.key !== "Home" && e.key !== "End")
484
+ return;
485
+ setExpanded(true);
486
+ }
487
+ else if (e.key === "ArrowLeft") {
488
+ // Stepping left off the minimum closes the rail: the keyboard equivalent
489
+ // of dragging past the snap threshold, so the gesture is not drag-only
490
+ // (WCAG 2.5.1).
491
+ if (nowPx <= minPx)
492
+ setExpanded(false);
493
+ else
494
+ onResize(toRestingRem(nowPx - RESIZE_STEP_PX));
495
+ }
496
+ else if (e.key === "ArrowRight") {
497
+ onResize(toRestingRem(nowPx + RESIZE_STEP_PX));
498
+ }
207
499
  else if (e.key === "Home")
208
500
  onResize(minWidth);
209
501
  else if (e.key === "End")
@@ -212,10 +504,31 @@ function SidebarResizeHandle({ width, minWidth, maxWidth, onResize, resizeLabel,
212
504
  return;
213
505
  e.preventDefault();
214
506
  };
215
- return (_jsx("div", { "data-slot": "app-sidebar-resize-handle", role: "separator", "aria-orientation": "vertical", "aria-label": resizeLabel ?? t("appSidebar.resize"), "aria-valuemin": Math.round(minPx), "aria-valuemax": Math.round(maxPx), "aria-valuenow": nowPx, "aria-valuetext": resizeValueTextLabel ??
216
- t("appSidebar.resizeValueText", { pixels: nowPx }), tabIndex: 0, onPointerDown: onPointerDown, onKeyDown: onKeyDown, className: "absolute top-0 right-0 z-50 h-full w-1 cursor-col-resize select-none hover:bg-neutral-background-medium focus-visible:bg-ring focus-visible:outline-none" }));
507
+ if (!resizable)
508
+ return null;
509
+ return (_jsx("div", { "data-slot": "app-sidebar-resize-handle", role: "separator", "aria-orientation": "vertical", "aria-label": resizeLabel ?? t("appSidebar.resize"), "aria-valuemin": Math.round(collapsedPx), "aria-valuemax": Math.round(maxPx), "aria-valuenow": nowPx, "aria-valuetext": resizeValueTextLabel ??
510
+ t("appSidebar.resizeValueText", { pixels: nowPx }), tabIndex: 0, onPointerDown: onPointerDown, onKeyDown: onKeyDown,
511
+ // Straddles the edge for a bigger target than the 1px border. No resting
512
+ // fill — the col-resize cursor is the affordance, and the button beside
513
+ // it is what makes the action visible.
514
+ className: "absolute top-0 -right-1.5 z-50 h-full w-3 cursor-col-resize select-none focus-visible:bg-ring focus-visible:outline-none" }));
217
515
  }
218
- export function AppSidebar({ ariaLabel, items, backNav, expandedWidth = "16rem", collapsedWidth = "3.5rem", expandLabel, collapseLabel, collapseText, resizable = true, minWidth = "14rem", maxWidth = "24rem", resizeLabel, resizeValueTextLabel, footer, linkComponent, closeNavigationLabel, activeItemKey, className, ...expandProps }) {
516
+ /**
517
+ * The visible half of the edge: a circular toggle straddling the rail's border.
518
+ *
519
+ * Rendered as a sibling of the item list rather than of the whole rail, so it
520
+ * bottoms out at the top of the `footer` when there is one instead of sitting
521
+ * on top of it — the footer takes arbitrary consumer content, including
522
+ * right-aligned controls this would otherwise cover and intercept.
523
+ */
524
+ function SidebarCollapseToggle({ expanded, setExpanded, expandLabel, collapseLabel, }) {
525
+ const label = expanded ? collapseLabel : expandLabel;
526
+ return (_jsxs(Tooltip, { children: [_jsx(TooltipTrigger, { asChild: true, children: _jsx("button", { type: "button", "data-slot": "app-sidebar-collapse-toggle", "aria-label": label, "aria-expanded": expanded, onClick: () => setExpanded(!expanded),
527
+ // Above the separator strip so the pointer lands on the button rather
528
+ // than the resize cursor where the two overlap.
529
+ className: "absolute -right-4 bottom-3.5 z-[60] flex size-8 items-center justify-center rounded-full border border-border bg-background text-neutral-foreground-subtle shadow-xs transition-colors hover:bg-neutral-background-medium hover:text-neutral-foreground-strong focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring", children: _jsx(ChevronLeft, { "aria-hidden": true, className: cn("size-3.5 transition-transform duration-150", !expanded && "rotate-180") }) }) }), _jsx(TooltipContent, { side: "right", children: label })] }));
530
+ }
531
+ export function AppSidebar({ ariaLabel, items, backNav, expandedWidth = "16rem", collapsedWidth = "3.5rem", expandLabel, collapseLabel, resizable = true, minWidth = "14rem", maxWidth = "24rem", resizeLabel, resizeValueTextLabel, footer, linkComponent, closeNavigationLabel, activeItemKey, className, ...expandProps }) {
219
532
  const ctx = useSidebar();
220
533
  const standaloneIsMobile = useIsMobile();
221
534
  const hasWarnedNoProvider = React.useRef(false);
@@ -251,23 +564,32 @@ export function AppSidebar({ ariaLabel, items, backNav, expandedWidth = "16rem",
251
564
  const [localWidth, setLocalWidth] = React.useState(expandedWidth);
252
565
  const resizedWidth = ctx ? ctx.width : resizable ? localWidth : expandedWidth;
253
566
  const setResizedWidth = ctx ? ctx.setWidth : setLocalWidth;
567
+ // Live width while the edge is being dragged. Non-null takes over from the
568
+ // resting state so the rail can follow the pointer below its resting minimum,
569
+ // through the zone where releasing snaps it closed.
570
+ const [dragWidth, setDragWidth] = React.useState(null);
254
571
  const isMobile = ctx?.isMobile ?? false;
255
572
  const LinkEl = linkComponent ?? "a";
256
573
  const hasLinkComponent = linkComponent !== undefined;
257
- const toggleRef = React.useRef(null);
258
- // Truncation reveal for the collapse-toggle's own label — declared above
259
- // the mobile early return below (hooks can't follow a conditional return).
260
- // Only relevant once `expanded` (the collapsed state hides the label via
261
- // `sr-only`, and already gets its own, separate expand-action tooltip).
262
- const { ref: collapseTextRef, isTruncated: collapseTextTruncated, measure: measureCollapseText, } = useIsTruncated();
263
- React.useLayoutEffect(() => {
264
- measureCollapseText();
265
- });
574
+ const [openFlyoutKey, setOpenFlyoutKey] = React.useState(null);
575
+ const flyoutSlot = React.useMemo(() => ({ openKey: openFlyoutKey, setOpenKey: setOpenFlyoutKey }), [openFlyoutKey]);
576
+ // Expanding the rail unmounts the flyouts without clearing the slot, so a
577
+ // later collapse would remount the last one still open, with no hover behind
578
+ // it. Resetting in an effect (rather than on the toggle) is what also covers
579
+ // an expansion driven by the provider or by a controlled `expanded` prop.
580
+ React.useEffect(() => {
581
+ // Resetting one key on an expansion change: there is no external system to
582
+ // sync with, and no `key` to reset by that would not remount every row.
583
+ // eslint-disable-next-line react-hooks/set-state-in-effect
584
+ if (expanded)
585
+ setOpenFlyoutKey(null);
586
+ }, [expanded]);
266
587
  // Mobile: render the nav inside a left Sheet overlay (always full labels).
267
588
  if (isMobile) {
268
- return (_jsx(Sheet, { open: ctx?.mobileOpen ?? false, onOpenChange: (o) => ctx?.setMobileOpen(o), children: _jsxs(SheetContent, { side: "left", showCloseButton: false, className: "w-72 p-0", children: [_jsx(SheetTitle, { className: "sr-only", children: ariaLabel }), _jsxs("nav", { "data-slot": "app-sidebar", "aria-label": ariaLabel, className: "flex h-full flex-col bg-background", children: [_jsx("div", { className: "flex h-12 shrink-0 items-center justify-end px-2", children: _jsx(SheetClose, { asChild: true, children: _jsx("button", { type: "button", "aria-label": closeNavigationLabel ?? t("appSidebar.closeNavigation"), className: "flex size-8 items-center justify-center rounded-md text-neutral-foreground-subtle transition-colors hover:bg-neutral-background-strong hover:text-neutral-foreground-strong focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-inset", children: _jsx(X, { "aria-hidden": true, className: "size-4" }) }) }) }), backNav && (_jsx(BackNavHeader, { backNav: backNav, expanded: true, LinkEl: LinkEl })), _jsx("div", { className: cn("flex flex-1 flex-col gap-0.5 overflow-y-auto px-2 py-2", HIDDEN_SCROLLBAR), children: items.map((node) => renderNode(node, {
589
+ return (_jsx(Sheet, { open: ctx?.mobileOpen ?? false, onOpenChange: (o) => ctx?.setMobileOpen(o), children: _jsxs(SheetContent, { side: "left", showCloseButton: false, className: "w-72 p-0", children: [_jsx(SheetTitle, { className: "sr-only", children: ariaLabel }), _jsxs("nav", { "data-slot": "app-sidebar", "aria-label": ariaLabel, className: "flex h-full flex-col bg-background", children: [_jsx("div", { className: "flex h-12 shrink-0 items-center justify-end px-2", children: _jsx(SheetClose, { asChild: true, children: _jsx("button", { type: "button", "aria-label": closeNavigationLabel ?? t("appSidebar.closeNavigation"), className: "flex size-8 items-center justify-center rounded-md text-neutral-foreground-subtle transition-colors hover:bg-neutral-background-strong hover:text-neutral-foreground-strong focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-inset", children: _jsx(X, { "aria-hidden": true, className: "size-4" }) }) }) }), backNav && (_jsx(BackNavHeader, { backNav: backNav, expanded: true, LinkEl: LinkEl })), _jsx("div", { className: cn("flex flex-1 flex-col gap-0.5 overflow-y-auto px-2 py-2", HIDDEN_SCROLLBAR), children: items.map((node, index) => renderNode(node, {
269
590
  expanded: true,
270
591
  depth: 0,
592
+ isFirst: index === 0,
271
593
  LinkEl,
272
594
  hasLinkComponent,
273
595
  activeItemKey,
@@ -277,23 +599,25 @@ export function AppSidebar({ ariaLabel, items, backNav, expandedWidth = "16rem",
277
599
  })) }), footer && (_jsx("div", { className: "border-t border-border p-2", children: footer }))] })] }) }));
278
600
  }
279
601
  // Desktop: the fixed rail.
280
- const width = expanded ? resizedWidth : collapsedWidth;
602
+ const width = dragWidth ?? (expanded ? resizedWidth : collapsedWidth);
281
603
  // Shared with the tooltip below so the accessible name (aria-label) and the
282
604
  // visible tooltip text never drift apart under a dictionary override.
283
605
  const resolvedExpandLabel = expandLabel ?? t("appSidebar.expand");
284
606
  const resolvedCollapseLabel = collapseLabel ?? t("appSidebar.collapse");
285
- const resolvedCollapseText = collapseText ?? t("appSidebar.collapseText");
286
- const collapseToggle = (_jsxs("button", { ref: toggleRef, type: "button", "aria-label": expanded ? resolvedCollapseLabel : resolvedExpandLabel, "aria-expanded": expanded, onClick: () => {
287
- setExpanded(!expanded);
288
- toggleRef.current?.focus();
289
- }, onPointerEnter: measureCollapseText, onFocus: measureCollapseText, className: cn("flex min-h-10 items-center py-2 text-sm text-neutral-foreground-subtle hover:bg-neutral-background-strong hover:text-neutral-foreground-strong border-t border-border focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-inset", expanded ? "gap-3 px-3" : "justify-center px-2"), children: [expanded ? (_jsx(PanelLeftClose, { "aria-hidden": true, className: "size-4 shrink-0" })) : (_jsx(PanelLeft, { "aria-hidden": true, className: "size-4 shrink-0" })), _jsx("span", { ref: collapseTextRef, className: cn(expanded ? "flex-1 truncate text-left" : "sr-only"), children: resolvedCollapseText })] }));
290
- return (_jsxs("nav", { "data-slot": "app-sidebar", "aria-label": ariaLabel, style: { width, ["--app-sidebar-width"]: width }, className: cn("fixed left-0 top-12 bottom-0 z-40 flex flex-col bg-background border-r border-border transition-[width] duration-150 ease-out", className), children: [backNav && (_jsx(BackNavHeader, { backNav: backNav, expanded: expanded, LinkEl: LinkEl })), _jsx("div", { className: cn("flex-1 min-h-0 flex flex-col gap-0.5 px-2 py-2 overflow-y-auto overflow-x-hidden", HIDDEN_SCROLLBAR), children: items.map((node) => renderNode(node, {
291
- expanded,
292
- depth: 0,
293
- LinkEl,
294
- hasLinkComponent,
295
- activeItemKey,
296
- isMobile: false,
297
- })) }), footer && _jsx("div", { className: "border-t border-border p-2", children: footer }), expanded ? (collapseTextTruncated ? (_jsxs(Tooltip, { children: [_jsx(TooltipTrigger, { asChild: true, children: collapseToggle }), _jsx(TooltipContent, { children: resolvedCollapseText })] })) : (collapseToggle)) : (_jsxs(Tooltip, { children: [_jsx(TooltipTrigger, { asChild: true, children: collapseToggle }), _jsx(TooltipContent, { side: "right", children: resolvedExpandLabel })] })), resizable && expanded && (_jsx(SidebarResizeHandle, { width: resizedWidth, minWidth: minWidth, maxWidth: maxWidth, onResize: setResizedWidth, resizeLabel: resizeLabel, resizeValueTextLabel: resizeValueTextLabel }))] }));
607
+ return (
608
+ // The flyout slot is provided only here: the mobile Sheet renders every row
609
+ // expanded, so no flyout is reachable on that path.
610
+ _jsx(FlyoutSlotContext.Provider, { value: flyoutSlot, children: _jsxs("nav", { "data-slot": "app-sidebar", "aria-label": ariaLabel, style: { width, ["--app-sidebar-width"]: width }, className: cn("fixed left-0 top-12 bottom-0 z-40 flex flex-col bg-background border-r border-border transition-[width] duration-150 ease-out",
611
+ // An animated width can't keep up with the pointer it is supposed to
612
+ // be following.
613
+ dragWidth && "transition-none", className), children: [backNav && (_jsx(BackNavHeader, { backNav: backNav, expanded: expanded, LinkEl: LinkEl })), _jsxs("div", { className: "relative flex min-h-0 flex-1 flex-col", children: [_jsx("div", { className: cn("flex-1 min-h-0 flex flex-col gap-0.5 px-2 py-2 overflow-y-auto overflow-x-hidden", HIDDEN_SCROLLBAR), children: items.map((node, index) => renderNode(node, {
614
+ expanded,
615
+ depth: 0,
616
+ isFirst: index === 0,
617
+ LinkEl,
618
+ hasLinkComponent,
619
+ activeItemKey,
620
+ isMobile: false,
621
+ })) }), _jsx(SidebarCollapseToggle, { expanded: expanded, setExpanded: setExpanded, expandLabel: resolvedExpandLabel, collapseLabel: resolvedCollapseLabel })] }), footer && _jsx("div", { className: "border-t border-border p-2", children: footer }), _jsx(SidebarResizeSeparator, { expanded: expanded, setExpanded: setExpanded, restingWidth: resizedWidth, collapsedWidth: collapsedWidth, minWidth: minWidth, maxWidth: maxWidth, resizable: resizable, onResize: setResizedWidth, onPreviewWidth: setDragWidth, resizeLabel: resizeLabel, resizeValueTextLabel: resizeValueTextLabel })] }) }));
298
622
  }
299
623
  //# sourceMappingURL=app-sidebar.js.map