@mindlogic-ai/logician-ui 5.0.0-alpha.12 → 5.0.0-alpha.13

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 (77) hide show
  1. package/README.md +1 -0
  2. package/dist/components/NavList/NavList.d.ts +34 -0
  3. package/dist/components/NavList/NavList.d.ts.map +1 -0
  4. package/dist/components/NavList/NavList.js +113 -0
  5. package/dist/components/NavList/NavList.js.map +1 -0
  6. package/dist/components/NavList/NavList.mjs +111 -0
  7. package/dist/components/NavList/NavList.mjs.map +1 -0
  8. package/dist/components/NavList/NavList.styles.d.ts +90 -0
  9. package/dist/components/NavList/NavList.styles.d.ts.map +1 -0
  10. package/dist/components/NavList/NavList.styles.js +104 -0
  11. package/dist/components/NavList/NavList.styles.js.map +1 -0
  12. package/dist/components/NavList/NavList.styles.mjs +97 -0
  13. package/dist/components/NavList/NavList.styles.mjs.map +1 -0
  14. package/dist/components/NavList/NavList.translations.json.js +19 -0
  15. package/dist/components/NavList/NavList.translations.json.js.map +1 -0
  16. package/dist/components/NavList/NavList.translations.json.mjs +14 -0
  17. package/dist/components/NavList/NavList.translations.json.mjs.map +1 -0
  18. package/dist/components/NavList/NavList.types.d.ts +131 -0
  19. package/dist/components/NavList/NavList.types.d.ts.map +1 -0
  20. package/dist/components/NavList/NavListContext.d.ts +30 -0
  21. package/dist/components/NavList/NavListContext.d.ts.map +1 -0
  22. package/dist/components/NavList/NavListContext.js +16 -0
  23. package/dist/components/NavList/NavListContext.js.map +1 -0
  24. package/dist/components/NavList/NavListContext.mjs +13 -0
  25. package/dist/components/NavList/NavListContext.mjs.map +1 -0
  26. package/dist/components/NavList/NavListGroup.d.ts +31 -0
  27. package/dist/components/NavList/NavListGroup.d.ts.map +1 -0
  28. package/dist/components/NavList/NavListGroup.js +115 -0
  29. package/dist/components/NavList/NavListGroup.js.map +1 -0
  30. package/dist/components/NavList/NavListGroup.mjs +113 -0
  31. package/dist/components/NavList/NavListGroup.mjs.map +1 -0
  32. package/dist/components/NavList/NavListItem.d.ts +15 -0
  33. package/dist/components/NavList/NavListItem.d.ts.map +1 -0
  34. package/dist/components/NavList/NavListItem.js +269 -0
  35. package/dist/components/NavList/NavListItem.js.map +1 -0
  36. package/dist/components/NavList/NavListItem.mjs +267 -0
  37. package/dist/components/NavList/NavListItem.mjs.map +1 -0
  38. package/dist/components/NavList/NavListSkeleton.d.ts +17 -0
  39. package/dist/components/NavList/NavListSkeleton.d.ts.map +1 -0
  40. package/dist/components/NavList/NavListSkeleton.js +33 -0
  41. package/dist/components/NavList/NavListSkeleton.js.map +1 -0
  42. package/dist/components/NavList/NavListSkeleton.mjs +31 -0
  43. package/dist/components/NavList/NavListSkeleton.mjs.map +1 -0
  44. package/dist/components/NavList/index.d.ts +7 -0
  45. package/dist/components/NavList/index.d.ts.map +1 -0
  46. package/dist/index.d.ts +2 -1
  47. package/dist/index.d.ts.map +1 -1
  48. package/dist/index.js +12 -0
  49. package/dist/index.js.map +1 -1
  50. package/dist/index.mjs +6 -1
  51. package/dist/index.mjs.map +1 -1
  52. package/dist/utils/focusRing.d.ts +22 -0
  53. package/dist/utils/focusRing.d.ts.map +1 -1
  54. package/dist/utils/focusRing.js +22 -0
  55. package/dist/utils/focusRing.js.map +1 -1
  56. package/dist/utils/focusRing.mjs +21 -1
  57. package/dist/utils/focusRing.mjs.map +1 -1
  58. package/package.json +2 -1
  59. package/src/components/Menu/Menu.usage.mdx +8 -0
  60. package/src/components/NavList/NavList.stories.tsx +895 -0
  61. package/src/components/NavList/NavList.styles.ts +114 -0
  62. package/src/components/NavList/NavList.test.tsx +1254 -0
  63. package/src/components/NavList/NavList.translations.json +9 -0
  64. package/src/components/NavList/NavList.tsx +160 -0
  65. package/src/components/NavList/NavList.types.ts +145 -0
  66. package/src/components/NavList/NavList.usage.mdx +540 -0
  67. package/src/components/NavList/NavListContext.ts +25 -0
  68. package/src/components/NavList/NavListGroup.tsx +243 -0
  69. package/src/components/NavList/NavListItem.tsx +525 -0
  70. package/src/components/NavList/NavListSkeleton.tsx +57 -0
  71. package/src/components/NavList/index.ts +14 -0
  72. package/src/components/Tabs/Tabs.usage.mdx +58 -14
  73. package/src/components/Tooltip/Tooltip.usage.mdx +8 -0
  74. package/src/components/a11y.kwcag.test.tsx +66 -0
  75. package/src/components/claude.md +1 -1
  76. package/src/index.ts +7 -1
  77. package/src/utils/focusRing.ts +23 -0
@@ -0,0 +1,525 @@
1
+ import {
2
+ AriaAttributes,
3
+ Children,
4
+ cloneElement,
5
+ forwardRef,
6
+ isValidElement,
7
+ MouseEvent,
8
+ MouseEventHandler,
9
+ ReactElement,
10
+ ReactNode,
11
+ SyntheticEvent,
12
+ useEffect,
13
+ useId,
14
+ useRef,
15
+ useState,
16
+ } from 'react';
17
+ import {
18
+ Box,
19
+ chakra,
20
+ LocaleProvider,
21
+ useLocaleContext,
22
+ VisuallyHidden,
23
+ } from '@chakra-ui/react';
24
+
25
+ import { Tooltip } from '@/components/Tooltip';
26
+ import { useTranslate } from '@/hooks/useTranslate';
27
+ import { disabledText } from '@/utils/disabledControl';
28
+ import { focusRingInset } from '@/utils/focusRing';
29
+
30
+ import {
31
+ byOrientation,
32
+ inheritDirection,
33
+ localeFor,
34
+ NAV_LIST_RAMP,
35
+ pressedFill,
36
+ } from './NavList.styles';
37
+ import NavListTranslations from './NavList.translations.json';
38
+ import { NavListItemProps } from './NavList.types';
39
+ import { useNavListContext } from './NavListContext';
40
+
41
+ /**
42
+ * One row: a link (`href`, or `asChild` around a router link) or, with only
43
+ * `onClick`, a `<button type="button">`.
44
+ *
45
+ * Both are native, so activation is the platform's: Enter follows a link,
46
+ * Enter and Space press a button, and Tab moves between rows. There is no
47
+ * arrow-key handling on purpose — a rail of links is not a composite widget,
48
+ * and roving focus would hide every row but one from the tab order.
49
+ */
50
+ type RouterChildProps = {
51
+ children?: ReactNode;
52
+ onClick?: MouseEventHandler<HTMLElement>;
53
+ target?: string;
54
+ 'aria-current'?: AriaAttributes['aria-current'];
55
+ };
56
+
57
+ /** The label span is cut off by its ellipsis. */
58
+ // Every selector below names the row by this attribute, not by `data-part`:
59
+ // the tooltip trigger's own `data-part="trigger"` lands on the same element.
60
+ const ROW = '[data-nav-list-row]';
61
+ const ACTION = '[data-part="trailing-action"]';
62
+ /** Touch screens: no hover to reveal on, and a finger needs the space kept. */
63
+ const NO_HOVER = '@media (hover: none), (pointer: coarse)';
64
+ /** The row, not the action beside it, is being pressed. */
65
+ const PRESSED = `&:has(${ROW}:active)`;
66
+
67
+ const isTruncated = (el: HTMLElement | null) =>
68
+ el != null && el.scrollWidth > el.clientWidth + 1;
69
+
70
+ export const NavListItem = forwardRef<HTMLElement, NavListItemProps>(
71
+ (
72
+ {
73
+ children,
74
+ href,
75
+ asChild = false,
76
+ onClick,
77
+ isCurrent = false,
78
+ isDisabled = false,
79
+ leadingVisual,
80
+ trailingVisual,
81
+ trailingAction,
82
+ trailingActionVisibility = 'always',
83
+ tooltip,
84
+ ...rest
85
+ },
86
+ ref
87
+ ) => {
88
+ const { orientation, isCollapsed, tooltipDir, revealCurrent } =
89
+ useNavListContext();
90
+ const translate = useTranslate(NavListTranslations);
91
+ const labelRef = useRef<HTMLSpanElement>(null);
92
+ const descriptionId = useId();
93
+ const [isTooltipOpen, setIsTooltipOpen] = useState(false);
94
+
95
+ useEffect(() => {
96
+ if (isCurrent) revealCurrent();
97
+ }, [isCurrent, revealCurrent]);
98
+ const isLink = asChild || href != null;
99
+
100
+ const child = isLink && asChild ? Children.only(children) : null;
101
+ if (asChild && !isValidElement<RouterChildProps>(child)) {
102
+ throw new Error(
103
+ 'NavList.Item with asChild needs exactly one element child'
104
+ );
105
+ }
106
+ const routerChild = child as ReactElement<RouterChildProps> | null;
107
+ const label = routerChild ? routerChild.props.children : children;
108
+ const opensNewWindow =
109
+ !isDisabled &&
110
+ isLink &&
111
+ (routerChild?.props.target ?? (rest as { target?: string }).target) ===
112
+ '_blank';
113
+
114
+ const inherited = useLocaleContext();
115
+ const content = (
116
+ <LocaleProvider locale={inherited.locale}>
117
+ {leadingVisual != null && (
118
+ <chakra.span
119
+ aria-hidden
120
+ display="inline-flex"
121
+ alignItems="center"
122
+ flexShrink={0}
123
+ >
124
+ {leadingVisual}
125
+ </chakra.span>
126
+ )}
127
+ {/* One label element in both modes, so toggling the rail fades it
128
+ instead of swapping it: collapsed it is transparent and 1px wide —
129
+ the size VisuallyHidden uses, which every screen reader still
130
+ reads — and stays the row's accessible name. */}
131
+ <chakra.span
132
+ ref={labelRef}
133
+ data-part="label"
134
+ flex={byOrientation(orientation, '1 1 auto', '0 0 auto')}
135
+ minW={isCollapsed ? '1px' : 0}
136
+ overflow="hidden"
137
+ textOverflow="ellipsis"
138
+ whiteSpace="nowrap"
139
+ opacity={isCollapsed ? 0 : 1}
140
+ animationStyle="feedback"
141
+ transitionProperty="opacity"
142
+ >
143
+ {label}
144
+ </chakra.span>
145
+ {/* KWCAG 2.1 — a link that opens a new window says so in its name. */}
146
+ {opensNewWindow && (
147
+ <VisuallyHidden>{` (${translate('nav_list_opens_new_window')})`}</VisuallyHidden>
148
+ )}
149
+ {trailingVisual != null && (
150
+ <chakra.span
151
+ display="inline-flex"
152
+ alignItems="center"
153
+ flexShrink={0}
154
+ ms="auto"
155
+ color={isDisabled ? 'disabled.fg' : NAV_LIST_RAMP.trailing}
156
+ fontWeight="normal"
157
+ fontVariantNumeric="tabular-nums"
158
+ // Fades with the label. `visibility` flips at the END of the fade
159
+ // (it is discrete), which is when the count leaves the row's name.
160
+ opacity={isCollapsed ? 0 : 1}
161
+ visibility={isCollapsed ? 'hidden' : 'visible'}
162
+ animationStyle="feedback"
163
+ transitionProperty="opacity, visibility"
164
+ >
165
+ {trailingVisual}
166
+ </chakra.span>
167
+ )}
168
+ </LocaleProvider>
169
+ );
170
+
171
+ // One tooltip per row, three jobs. Collapsed: the label, beside the icon.
172
+ // `tooltip`: the caller's description. Otherwise: the label again, only
173
+ // while the ellipsis is actually cutting it off.
174
+ const tooltipKind = isCollapsed
175
+ ? 'label'
176
+ : tooltip != null
177
+ ? 'description'
178
+ : 'overflow';
179
+
180
+ // The overflow tooltip is gated at the trigger: zag skips opening when the
181
+ // event that would open it was `defaultPrevented`, and the row's own
182
+ // handlers run before zag's. A controlled `open` cannot do this — zag
183
+ // enters its open state first and only re-syncs when the prop CHANGES.
184
+ const restHandlers = rest as {
185
+ onPointerMove?: (e: SyntheticEvent) => void;
186
+ onPointerOver?: (e: SyntheticEvent) => void;
187
+ onFocus?: (e: SyntheticEvent) => void;
188
+ };
189
+ // A POINTER also waits for the label to finish fading when the rail is
190
+ // toggled, so no tooltip flashes over a row that is still changing shape:
191
+ // the label one only once the label is fully gone, the others only once
192
+ // it is fully back. The next pointermove after the fade opens it. Focus
193
+ // is never held back — a cancelled focus is not re-fired, so a row
194
+ // focused mid-fade would never show its label.
195
+ const isSettled = () => {
196
+ const el = labelRef.current;
197
+ if (!el) return true;
198
+ const opacity = parseFloat(getComputedStyle(el).opacity);
199
+ if (Number.isNaN(opacity)) return true;
200
+ return isCollapsed ? opacity === 0 : opacity === 1;
201
+ };
202
+ const gate =
203
+ (handler?: (e: SyntheticEvent) => void, waitForFade = true) =>
204
+ (event: SyntheticEvent) => {
205
+ if (
206
+ (waitForFade && !isSettled()) ||
207
+ (tooltipKind === 'overflow' && !isTruncated(labelRef.current))
208
+ )
209
+ event.preventDefault();
210
+ handler?.(event);
211
+ };
212
+ const tooltipGate = {
213
+ onPointerMove: gate(restHandlers.onPointerMove),
214
+ onPointerOver: gate(restHandlers.onPointerOver),
215
+ onFocus: gate(restHandlers.onFocus, false),
216
+ };
217
+
218
+ // The description is a visually hidden element of its own, referenced
219
+ // all the time — not the tooltip, which zag only references while it is
220
+ // open. A disabled row cannot take focus, so a description that existed
221
+ // only while open would never reach a keyboard or screen-reader user,
222
+ // which is exactly when "why is this disabled" matters. The tooltip
223
+ // itself is then never a description (`aria-label` on its root takes its
224
+ // id away), and while it is open the row's own value — the caller's ids,
225
+ // or an empty list — outranks the dangling id zag would leave behind.
226
+ const callerDescribedBy = (rest as { 'aria-describedby'?: string })[
227
+ 'aria-describedby'
228
+ ];
229
+ const describedBy =
230
+ [callerDescribedBy, tooltip != null ? descriptionId : undefined]
231
+ .filter(Boolean)
232
+ .join(' ') || (isTooltipOpen ? '' : undefined);
233
+ const describedByProps = { 'aria-describedby': describedBy };
234
+
235
+ const handleClick = (event: MouseEvent<HTMLElement>) => {
236
+ // Belt and braces: a disabled `<button>` fires no click and a disabled
237
+ // link has no `href`, so this only guards a caller who forces one.
238
+ if (isDisabled) {
239
+ event.preventDefault();
240
+ return;
241
+ }
242
+ onClick?.(event);
243
+ };
244
+
245
+ // `hover`: the action floats over the end of the label instead of keeping
246
+ // its own column, so a resting row spends its full width on the label —
247
+ // the app's HoverRevealedKebab. The row and the action share one grid
248
+ // cell's row; the row spans both columns at rest and gives the second one
249
+ // back to the action when it shows. Only the label's end moves: the row
250
+ // is as tall as it ever was, so nothing below it shifts. Vertical only —
251
+ // a horizontal row is as wide as its label, so there is nothing to give.
252
+ const overlaysAction =
253
+ trailingActionVisibility === 'hover' &&
254
+ trailingAction != null &&
255
+ !isCollapsed;
256
+
257
+ // The fill lives on the `<li>`, not the row, so a trailing action sits on
258
+ // the same surface: hovering the action keeps the row lit, and the current
259
+ // row's fill runs under its action instead of stopping short of it.
260
+ const rowStyles = {
261
+ display: 'flex',
262
+ alignItems: 'center',
263
+ gap: 2,
264
+ flex: byOrientation(orientation, '1 1 auto', '0 0 auto'),
265
+ minW: 0,
266
+ // The same inline padding and start alignment in both modes, so the
267
+ // icon does not move when the rail is toggled: at 2.5 a row is the
268
+ // 40px square around an `sm` icon. The `<li>` is what narrows.
269
+ minH: isCollapsed ? 10 : 9,
270
+ px: 2.5,
271
+ py: 2,
272
+ // Clips the label and count while the rail narrows around them. Only
273
+ // collapsed, so an expanded row never clips a badge or a dot that a
274
+ // visual draws past its own box.
275
+ overflow: isCollapsed ? 'hidden' : undefined,
276
+ borderRadius: 'md',
277
+ border: 'none',
278
+ bg: 'transparent',
279
+ textAlign: 'start',
280
+ textDecoration: 'none',
281
+ textStyle: 'p',
282
+ cursor: 'pointer',
283
+ color: isCurrent ? NAV_LIST_RAMP.currentLabel : NAV_LIST_RAMP.label,
284
+ fontWeight: isCurrent ? 'bold' : 'normal',
285
+ _hover: { textDecoration: 'none' },
286
+ _disabled: { ...disabledText, cursor: 'not-allowed' },
287
+ ...inheritDirection,
288
+ ...(overlaysAction && { gridRow: 1, gridColumn: '1 / -1' }),
289
+ // Inset: rows sit 2px apart and a horizontal rail scrolls, so the
290
+ // outward ring would land on the neighbour or be clipped.
291
+ ...focusRingInset,
292
+ } as const;
293
+
294
+ // Both state attributes go on the element that carries the role, and on
295
+ // the router child's own props when there is one (its props win the
296
+ // `asChild` merge). `data-current` is the hook for scroll-to-current.
297
+ const stateProps = {
298
+ 'aria-current': isCurrent ? (isLink ? 'page' : 'true') : undefined,
299
+ 'data-current': isCurrent ? '' : undefined,
300
+ 'data-nav-list-row': '',
301
+ } as const;
302
+
303
+ let row: ReactElement;
304
+ if (!isLink) {
305
+ row = (
306
+ <chakra.button
307
+ ref={ref as React.Ref<HTMLButtonElement>}
308
+ type="button"
309
+ disabled={isDisabled}
310
+ {...stateProps}
311
+ onClick={handleClick}
312
+ {...rowStyles}
313
+ // The prop type is the anchor's; the global attributes and style
314
+ // props it carries are the same on a button.
315
+ {...(rest as unknown as React.ComponentProps<typeof chakra.button>)}
316
+ {...describedByProps}
317
+ {...tooltipGate}
318
+ >
319
+ {content}
320
+ </chakra.button>
321
+ );
322
+ } else {
323
+ const linkProps = {
324
+ ref: ref as React.Ref<HTMLAnchorElement>,
325
+ ...rowStyles,
326
+ ...rest,
327
+ ...describedByProps,
328
+ ...tooltipGate,
329
+ };
330
+
331
+ if (isDisabled) {
332
+ // A disabled link has no destination, so it is not rendered as one —
333
+ // not even the router link, which would keep its href for middle-click,
334
+ // "open in new tab" and prefetch. Still announced as a link, with
335
+ // `aria-disabled`, the same way whether or not `asChild` was used. With
336
+ // no `href` it is out of the tab order.
337
+ row = (
338
+ <chakra.a
339
+ role="link"
340
+ aria-disabled="true"
341
+ {...stateProps}
342
+ {...linkProps}
343
+ target={undefined}
344
+ >
345
+ {content}
346
+ </chakra.a>
347
+ );
348
+ } else if (routerChild) {
349
+ // Our state goes on the CHILD's props, not on the slot: Chakra's
350
+ // `asChild` merge lets the child's own props win, so a router NavLink
351
+ // that sets its own `aria-current` would otherwise contradict
352
+ // `isCurrent`. The child's own click handler still runs, after ours.
353
+ row = (
354
+ <chakra.a asChild {...linkProps}>
355
+ {cloneElement(
356
+ routerChild,
357
+ {
358
+ ...stateProps,
359
+ ...describedByProps,
360
+ onClick: (event: MouseEvent<HTMLElement>) => {
361
+ handleClick(event);
362
+ routerChild.props.onClick?.(event);
363
+ },
364
+ },
365
+ content
366
+ )}
367
+ </chakra.a>
368
+ );
369
+ } else {
370
+ row = (
371
+ <chakra.a
372
+ href={href}
373
+ {...stateProps}
374
+ onClick={handleClick}
375
+ {...linkProps}
376
+ >
377
+ {content}
378
+ </chakra.a>
379
+ );
380
+ }
381
+ }
382
+
383
+ // The two label tooltips name the tooltip itself (`aria-label`), which
384
+ // takes it out of the trigger's description — the row is already called that.
385
+ const tooltipContent =
386
+ tooltipKind === 'description' ? (
387
+ tooltip
388
+ ) : isCollapsed && tooltip != null ? (
389
+ <>
390
+ {label}
391
+ <Box>{tooltip}</Box>
392
+ </>
393
+ ) : (
394
+ label
395
+ );
396
+ const labelText =
397
+ typeof label === 'string' || typeof label === 'number'
398
+ ? String(label)
399
+ : undefined;
400
+ const withTooltip = (
401
+ <Tooltip
402
+ content={tooltipContent}
403
+ // Beside the icon, towards the content: the rail is on the right of
404
+ // a right-to-left page. Popper placements are physical.
405
+ placement={
406
+ isCollapsed ? (tooltipDir === 'rtl' ? 'left' : 'right') : 'top'
407
+ }
408
+ onOpenChange={({ open }) => setIsTooltipOpen(open)}
409
+ // Zag only checks that this is set — it takes the content's id and
410
+ // role away, so the trigger is never described by the tooltip. Not
411
+ // rendered anywhere. The description, if any, is `descriptionId`.
412
+ aria-label={labelText ?? 'tooltip'}
413
+ contentProps={{ 'aria-hidden': true }}
414
+ >
415
+ {row}
416
+ </Tooltip>
417
+ );
418
+
419
+ const fills = isDisabled
420
+ ? undefined
421
+ : {
422
+ // On the `<li>`, like the hover fill, so the press covers the
423
+ // action's end of the row too — but only a press on the ROW: a
424
+ // press on the action is the action's, and lights its own button.
425
+ // No `>` inside `:has()`: jsdom's selector engine throws on a
426
+ // relative selector, which would break every consumer's tests.
427
+ [PRESSED]: pressedFill,
428
+ };
429
+ // Shown: hovered, focus anywhere in the row, or the action's menu open.
430
+ const shown = [`&:hover > ${ACTION}`, `&:focus-within > ${ACTION}`].join(
431
+ ', '
432
+ );
433
+ const revealOnHover =
434
+ trailingActionVisibility === 'hover'
435
+ ? {
436
+ [`& > ${ACTION}`]: { opacity: 0 },
437
+ [shown]: { opacity: 1 },
438
+ // Its own rule: `:has()` is not forgiving, and a browser without
439
+ // it would drop every selector listed beside it.
440
+ [`& > ${ACTION}:has([aria-expanded="true"], [data-state="open"])`]:
441
+ { opacity: 1 },
442
+ // Touch: always shown, and with its space kept.
443
+ [NO_HOVER]: {
444
+ [`& > ${ACTION}`]: { opacity: 1 },
445
+ ...(overlaysAction && { [`& > ${ROW}`]: { gridColumn: 1 } }),
446
+ },
447
+ ...(overlaysAction && {
448
+ // The row gives the action its column back exactly when the
449
+ // action shows, so the label ends before it.
450
+ [`&:hover > ${ROW}, &:focus-within > ${ROW}`]: { gridColumn: 1 },
451
+ [`&:has(${ACTION} [aria-expanded="true"], ${ACTION} [data-state="open"]) > ${ROW}`]:
452
+ { gridColumn: 1 },
453
+ }),
454
+ }
455
+ : undefined;
456
+
457
+ return (
458
+ <chakra.li
459
+ display={
460
+ overlaysAction ? byOrientation(orientation, 'grid', 'flex') : 'flex'
461
+ }
462
+ gridTemplateColumns={overlaysAction ? 'minmax(0, 1fr) auto' : undefined}
463
+ alignItems="center"
464
+ gap={0.5}
465
+ flexShrink={0}
466
+ minW={0}
467
+ // Narrows to the square when the rail collapses, on the label's clock,
468
+ // so the fill shrinks with the rail instead of snapping to 40px.
469
+ maxW={isCollapsed ? 10 : byOrientation(orientation, '100%', 'none')}
470
+ animationStyle="feedback"
471
+ transitionProperty="max-width"
472
+ borderRadius="md"
473
+ bg={isCurrent ? NAV_LIST_RAMP.currentFill : 'transparent'}
474
+ _hover={
475
+ isDisabled
476
+ ? undefined
477
+ : {
478
+ bg: NAV_LIST_RAMP.hoverFill,
479
+ // Again inside hover: Chakra's hover rule is as specific as the
480
+ // press rule and comes later, so a pointer press would lose.
481
+ [PRESSED]: pressedFill,
482
+ }
483
+ }
484
+ css={{ ...fills, ...revealOnHover }}
485
+ >
486
+ {/* Zag stamps `dir` on the portalled tooltip from Ark's locale (`ltr`
487
+ unless a LocaleProvider says otherwise), so a tooltip in an RTL
488
+ rail would set its text left to right. The list measures the
489
+ direction after mount, and a tooltip only opens after that. The
490
+ row lays out from CSS (`inheritDirection`), never from this; its
491
+ content gets the caller's own locale back. */}
492
+ <LocaleProvider locale={localeFor(inherited, tooltipDir)}>
493
+ {withTooltip}
494
+ </LocaleProvider>
495
+ {tooltip != null && (
496
+ // `hidden`, not visually hidden: a description reference still
497
+ // reads hidden text, and browse mode does not read it a second time.
498
+ <chakra.span id={descriptionId} hidden>
499
+ {tooltip}
500
+ </chakra.span>
501
+ )}
502
+ {trailingAction != null && !isCollapsed && (
503
+ <chakra.span
504
+ data-part="trailing-action"
505
+ display="inline-flex"
506
+ alignItems="center"
507
+ flexShrink={0}
508
+ pe={0.5}
509
+ // Over the row's end, above its fill and its focus ring.
510
+ {...(overlaysAction && {
511
+ gridRow: 1,
512
+ gridColumn: 2,
513
+ position: 'relative',
514
+ zIndex: 1,
515
+ })}
516
+ >
517
+ {trailingAction}
518
+ </chakra.span>
519
+ )}
520
+ </chakra.li>
521
+ );
522
+ }
523
+ );
524
+
525
+ NavListItem.displayName = 'NavList.Item';
@@ -0,0 +1,57 @@
1
+ import { forwardRef } from 'react';
2
+ import { chakra, Skeleton } from '@chakra-ui/react';
3
+
4
+ import { byOrientation, listStyles } from './NavList.styles';
5
+ import { NavListSkeletonProps } from './NavList.types';
6
+
7
+ /** Widths that vary row to row, so the placeholder does not read as a table. */
8
+ const WIDTHS = ['72%', '56%', '84%', '64%', '48%', '76%'];
9
+
10
+ /**
11
+ * Placeholder rows while a rail's entries load, with the same row height,
12
+ * radius and gap as the real list, so nothing moves when it swaps in.
13
+ *
14
+ * Render it INSTEAD of the `NavList`, not inside it. It is `aria-hidden`: a
15
+ * column of grey bars says nothing to a screen reader, and "loading" belongs
16
+ * to the region that is loading (`aria-busy`, or a status message) rather
17
+ * than to one of its placeholders.
18
+ *
19
+ * Counts that are still loading are not this — leave `trailingVisual` unset
20
+ * until they arrive, and the row keeps its label.
21
+ */
22
+ export const NavListSkeleton = forwardRef<
23
+ HTMLUListElement,
24
+ NavListSkeletonProps
25
+ >(({ count = 5, orientation = 'vertical', ...rest }, ref) => (
26
+ <chakra.ul ref={ref} aria-hidden {...listStyles(orientation)} {...rest}>
27
+ {Array.from({ length: count }, (_, i) => (
28
+ <chakra.li
29
+ key={i}
30
+ display="flex"
31
+ alignItems="center"
32
+ flexShrink={0}
33
+ // The row's own box: `minH`, padding and one line of `p` text, so
34
+ // the placeholder is exactly as tall as the row that replaces it.
35
+ minH={9}
36
+ px={2.5}
37
+ py={2}
38
+ textStyle="p"
39
+ w={byOrientation(orientation, 'auto', '96px')}
40
+ >
41
+ <Skeleton
42
+ h={4}
43
+ w={byOrientation(orientation, WIDTHS[i % WIDTHS.length], 'full')}
44
+ borderRadius="sm"
45
+ // A placeholder that stops pulsing is still a placeholder; there
46
+ // is no progress for the motion to carry.
47
+ _motionReduce={{ animation: 'none' }}
48
+ />
49
+ <chakra.span w={0} overflow="hidden">
50
+ {'\u00A0'}
51
+ </chakra.span>
52
+ </chakra.li>
53
+ ))}
54
+ </chakra.ul>
55
+ ));
56
+
57
+ NavListSkeleton.displayName = 'NavList.Skeleton';
@@ -0,0 +1,14 @@
1
+ export { NavList } from './NavList';
2
+ export { NAV_LIST_RAMP } from './NavList.styles';
3
+ export type {
4
+ NavListGroupProps,
5
+ NavListItemProps,
6
+ NavListOrientation,
7
+ NavListProps,
8
+ NavListResponsiveOrientation,
9
+ NavListSkeletonProps,
10
+ NavListTrailingActionVisibility,
11
+ } from './NavList.types';
12
+ export { NavListGroup } from './NavListGroup';
13
+ export { NavListItem } from './NavListItem';
14
+ export { NavListSkeleton } from './NavListSkeleton';