@justin_evo/evo-ui 1.1.0 → 1.2.1

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 (80) hide show
  1. package/README.md +3 -3
  2. package/dist/TopNav/TopNav.d.ts +19 -0
  3. package/dist/declarations.d.ts +6 -6
  4. package/dist/evo-ui.css +1 -1
  5. package/dist/index.cjs.js +1 -1
  6. package/dist/index.es.js +3301 -3197
  7. package/package.json +52 -52
  8. package/src/Alert/Alert.tsx +49 -49
  9. package/src/AutoComplete/AutoComplete.tsx +810 -810
  10. package/src/Badge/Badge.tsx +53 -53
  11. package/src/Breadcrumb/Breadcrumb.tsx +53 -53
  12. package/src/Button/Button.tsx +125 -125
  13. package/src/Card/Card.tsx +257 -257
  14. package/src/Checkbox/Checkbox.tsx +59 -59
  15. package/src/CommandPalette/CommandPalette.tsx +185 -185
  16. package/src/Container/Container.tsx +31 -31
  17. package/src/Divider/Divider.tsx +31 -31
  18. package/src/Form/Form.tsx +185 -185
  19. package/src/Grid/Grid.tsx +66 -66
  20. package/src/ImageCropper/ImageCropper.tsx +911 -911
  21. package/src/Input/Input.tsx +74 -74
  22. package/src/Modal/Modal.tsx +77 -77
  23. package/src/Nav/Nav.tsx +708 -708
  24. package/src/Notification/Notification.tsx +1503 -1503
  25. package/src/Pagination/Pagination.tsx +76 -76
  26. package/src/Radio/Radio.tsx +69 -69
  27. package/src/RichTextArea/RichTextArea.tsx +886 -869
  28. package/src/Select/Select.tsx +515 -515
  29. package/src/Skeleton/Skeleton.tsx +70 -70
  30. package/src/Stack/Stack.tsx +52 -52
  31. package/src/Table/Table.tsx +335 -335
  32. package/src/Tabs/Tabs.tsx +90 -90
  33. package/src/Theme/ThemeProvider.tsx +253 -253
  34. package/src/Theme/ThemeToggle.tsx +79 -79
  35. package/src/Toggle/Toggle.tsx +48 -48
  36. package/src/Tooltip/Tooltip.tsx +38 -38
  37. package/src/TopNav/TopNav.tsx +1163 -994
  38. package/src/TreeSelect/TreeSelect.tsx +825 -825
  39. package/src/css/alert.module.scss +93 -93
  40. package/src/css/autocomplete.module.scss +416 -416
  41. package/src/css/badge.module.scss +82 -82
  42. package/src/css/base/_color.scss +159 -159
  43. package/src/css/base/_theme.scss +237 -237
  44. package/src/css/base/_variables.scss +161 -161
  45. package/src/css/breadcrumb.module.scss +50 -50
  46. package/src/css/button.module.scss +385 -385
  47. package/src/css/card.module.scss +217 -217
  48. package/src/css/checkbox.module.scss +123 -120
  49. package/src/css/commandpalette.module.scss +211 -211
  50. package/src/css/container.module.scss +18 -18
  51. package/src/css/divider.module.scss +41 -41
  52. package/src/css/form.module.scss +245 -245
  53. package/src/css/imagecropper.module.scss +397 -397
  54. package/src/css/input.module.scss +89 -89
  55. package/src/css/modal.module.scss +105 -105
  56. package/src/css/nav.module.scss +494 -494
  57. package/src/css/notification.module.scss +691 -691
  58. package/src/css/pagination.module.scss +63 -63
  59. package/src/css/radio.module.scss +89 -89
  60. package/src/css/richtextarea.module.scss +307 -307
  61. package/src/css/select.module.scss +525 -525
  62. package/src/css/skeleton.module.scss +30 -30
  63. package/src/css/table.module.scss +386 -386
  64. package/src/css/tabs.module.scss +63 -63
  65. package/src/css/theme-toggle.module.scss +83 -83
  66. package/src/css/toggle.module.scss +54 -54
  67. package/src/css/tooltip.module.scss +97 -97
  68. package/src/css/topnav.module.scss +568 -396
  69. package/src/css/treeselect.module.scss +558 -558
  70. package/src/css/utilities/_borders.scss +111 -111
  71. package/src/css/utilities/_colors.scss +66 -66
  72. package/src/css/utilities/_effects.scss +216 -216
  73. package/src/css/utilities/_layout.scss +181 -181
  74. package/src/css/utilities/_position.scss +75 -75
  75. package/src/css/utilities/_sizing.scss +138 -138
  76. package/src/css/utilities/_spacing.scss +99 -99
  77. package/src/css/utilities/_typography.scss +121 -121
  78. package/src/css/utilities/index.scss +24 -24
  79. package/src/declarations.d.ts +6 -6
  80. package/src/index.ts +60 -60
package/src/Nav/Nav.tsx CHANGED
@@ -1,708 +1,708 @@
1
- // EvoNav — compose-based sidebar navigation.
2
- //
3
- // API decisions (see Radix Navigation Menu, Mantine NavLink, shadcn Sidebar):
4
- // - Compose over config: nesting is always children, never an `items` array.
5
- // - Disclosure state is controlled (`open` + `onOpenChange`) or uncontrolled
6
- // (`defaultOpen`), matching the Evo naming rule from CLAUDE.md §0.1.
7
- // - `active` keeps its name (React community convention) but now always
8
- // forwards `aria-current="page"` — fixes the a11y gap called out in §9.
9
- // - Rows render as `<a href>` when `href` is set, `<button type="button">`
10
- // otherwise. This preserves right-click / middle-click / drag semantics
11
- // without forcing every consumer onto a router.
12
- // - At viewport widths below `breakpoint` (default 768px), the nav collapses
13
- // to an off-canvas drawer with a built-in hamburger trigger. Trigger can
14
- // be lifted with `hideTrigger` + controlled `drawerOpen`.
15
- // - Keyboard model is a "disclosure tree" (Mantine-style), not Radix's
16
- // roving-tabindex menu: every row is in the natural tab order, arrow keys
17
- // move focus within the nav, ←/→ collapse/expand.
18
-
19
- import {
20
- createContext,
21
- forwardRef,
22
- isValidElement,
23
- useCallback,
24
- useContext,
25
- useEffect,
26
- useId,
27
- useMemo,
28
- useRef,
29
- useState,
30
- type AnchorHTMLAttributes,
31
- type ButtonHTMLAttributes,
32
- type CSSProperties,
33
- type HTMLAttributes,
34
- type KeyboardEvent as ReactKeyboardEvent,
35
- type MouseEvent as ReactMouseEvent,
36
- type ReactNode,
37
- type RefObject,
38
- } from 'react';
39
- import styles from '../css/nav.module.scss';
40
-
41
- // ─── Public types ──────────────────────────────────────────────────────────
42
-
43
- export interface EvoNavProps extends Omit<HTMLAttributes<HTMLElement>, 'children'> {
44
- children: ReactNode;
45
- /** Below this viewport width (px), nav collapses to a drawer. @default 768 */
46
- breakpoint?: number;
47
- /** Controlled drawer open state (mobile only). */
48
- drawerOpen?: boolean;
49
- /** Uncontrolled initial drawer state. @default false */
50
- defaultDrawerOpen?: boolean;
51
- /** Called when the drawer opens or closes. */
52
- onDrawerOpenChange?: (open: boolean) => void;
53
- /** Hide the built-in hamburger trigger (use when wiring a trigger yourself). */
54
- hideTrigger?: boolean;
55
- /** Collapse to an icon-only rail: labels hide, icons center, and each row
56
- * shows a native tooltip from its `tooltip` prop. @default false */
57
- collapsed?: boolean;
58
- /** Accessible label for the <nav> landmark. @default 'Main navigation' */
59
- 'aria-label'?: string;
60
- }
61
-
62
- export interface EvoNavGroupProps {
63
- label: string;
64
- children: ReactNode;
65
- className?: string;
66
- /** Render the heading as a disclosure that expands/collapses the group. */
67
- collapsible?: boolean;
68
- /** Uncontrolled initial open state (collapsible only). @default true */
69
- defaultOpen?: boolean;
70
- /** Controlled open state (collapsible only). */
71
- open?: boolean;
72
- /** Called when the group expands or collapses. */
73
- onOpenChange?: (open: boolean) => void;
74
- /** Small count chip shown after the label. */
75
- count?: number;
76
- }
77
-
78
- interface EvoNavRowProps {
79
- children: ReactNode;
80
- icon?: ReactNode;
81
- /** Marks this row as the current page (sets aria-current="page"). */
82
- active?: boolean;
83
- /** Render as <a href> instead of <button>. */
84
- href?: string;
85
- onClick?: (event: ReactMouseEvent | ReactKeyboardEvent) => void;
86
- /** Tooltip text shown (via native title) when the nav is collapsed to a rail. */
87
- tooltip?: string;
88
- /** Controlled expand state (only when row has SubItem children). */
89
- open?: boolean;
90
- /** Uncontrolled initial expand state. */
91
- defaultOpen?: boolean;
92
- onOpenChange?: (open: boolean) => void;
93
- disabled?: boolean;
94
- className?: string;
95
- }
96
-
97
- export interface EvoNavItemProps extends EvoNavRowProps {}
98
- export interface EvoNavSubItemProps extends EvoNavRowProps {}
99
-
100
- export interface EvoNavSkeletonProps {
101
- /** @default 4 */
102
- count?: number;
103
- }
104
-
105
- export interface EvoNavQuickActionProps
106
- extends ButtonHTMLAttributes<HTMLButtonElement> {
107
- /** @default 'Create New' */
108
- label?: string;
109
- icon?: ReactNode;
110
- }
111
-
112
- // ─── Internal context ──────────────────────────────────────────────────────
113
-
114
- interface NavRootContextValue {
115
- /** Whether the viewport is below `breakpoint`. */
116
- isMobile: boolean;
117
- /** Drawer open state (only meaningful when isMobile). */
118
- drawerOpen: boolean;
119
- setDrawerOpen: (open: boolean) => void;
120
- /** Closes the drawer; safe to call regardless of mobile state. */
121
- closeDrawer: () => void;
122
- /** Root id used by the hamburger button's aria-controls. */
123
- rootId: string;
124
- /** Icon-only rail mode — labels hide, rows surface a native tooltip. */
125
- collapsed: boolean;
126
- }
127
-
128
- const NavRootContext = createContext<NavRootContextValue | null>(null);
129
-
130
- interface NavDepthContextValue {
131
- depth: number;
132
- }
133
-
134
- const NavDepthContext = createContext<NavDepthContextValue>({ depth: 0 });
135
-
136
- // ─── Hooks ────────────────────────────────────────────────────────────────
137
-
138
- function useIsBelowWidth(maxWidth: number): boolean {
139
- const [isBelow, setIsBelow] = useState(false);
140
-
141
- useEffect(() => {
142
- if (typeof window === 'undefined' || !window.matchMedia) return;
143
- const mql = window.matchMedia(`(max-width: ${maxWidth - 1}px)`);
144
- const update = () => setIsBelow(mql.matches);
145
- update();
146
- mql.addEventListener('change', update);
147
- return () => mql.removeEventListener('change', update);
148
- }, [maxWidth]);
149
-
150
- return isBelow;
151
- }
152
-
153
- /** Resolve controlled vs. uncontrolled state. */
154
- function useControllableState<T>(
155
- controlled: T | undefined,
156
- defaultValue: T,
157
- onChange?: (value: T) => void,
158
- ): [T, (value: T) => void] {
159
- const [uncontrolled, setUncontrolled] = useState(defaultValue);
160
- const isControlled = controlled !== undefined;
161
- const value = isControlled ? (controlled as T) : uncontrolled;
162
- const setValue = useCallback(
163
- (next: T) => {
164
- if (!isControlled) setUncontrolled(next);
165
- onChange?.(next);
166
- },
167
- [isControlled, onChange],
168
- );
169
- return [value, setValue];
170
- }
171
-
172
- // ─── Keyboard navigation ──────────────────────────────────────────────────
173
-
174
- const ROW_ATTR = 'data-evo-nav-row';
175
-
176
- function focusableRows(root: HTMLElement | null): HTMLElement[] {
177
- if (!root) return [];
178
- return Array.from(
179
- root.querySelectorAll<HTMLElement>(`[${ROW_ATTR}]:not([data-disabled="true"])`),
180
- ).filter((el) => el.offsetParent !== null && el.closest('[inert]') === null);
181
- }
182
-
183
- function moveFocus(root: HTMLElement | null, from: HTMLElement, delta: 1 | -1 | 'first' | 'last') {
184
- const rows = focusableRows(root);
185
- if (rows.length === 0) return;
186
- const idx = rows.indexOf(from);
187
- let nextIdx: number;
188
- if (delta === 'first') nextIdx = 0;
189
- else if (delta === 'last') nextIdx = rows.length - 1;
190
- else nextIdx = Math.max(0, Math.min(rows.length - 1, idx + delta));
191
- rows[nextIdx]?.focus();
192
- }
193
-
194
- // ─── Chevron / plus icons ─────────────────────────────────────────────────
195
-
196
- const ChevronIcon = ({ open }: { open: boolean }) => (
197
- <svg
198
- viewBox="0 0 16 16"
199
- fill="none"
200
- width="12"
201
- height="12"
202
- aria-hidden="true"
203
- className={[styles.chevron, open ? styles.chevronOpen : ''].filter(Boolean).join(' ')}
204
- >
205
- <path d="M6 4l4 4-4 4" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round" />
206
- </svg>
207
- );
208
-
209
- const PlusIcon = () => (
210
- <svg viewBox="0 0 16 16" fill="none" width="14" height="14" aria-hidden="true">
211
- <path d="M8 3v10M3 8h10" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" />
212
- </svg>
213
- );
214
-
215
- const HamburgerIcon = () => (
216
- <svg viewBox="0 0 20 20" fill="none" width="18" height="18" aria-hidden="true">
217
- <path d="M3 5h14M3 10h14M3 15h14" stroke="currentColor" strokeWidth="1.75" strokeLinecap="round" />
218
- </svg>
219
- );
220
-
221
- const CloseIcon = () => (
222
- <svg viewBox="0 0 20 20" fill="none" width="18" height="18" aria-hidden="true">
223
- <path d="M5 5l10 10M15 5L5 15" stroke="currentColor" strokeWidth="1.75" strokeLinecap="round" />
224
- </svg>
225
- );
226
-
227
- // ─── Row (Item / SubItem shared implementation) ───────────────────────────
228
-
229
- interface RowInternalProps extends EvoNavRowProps {
230
- /** Visual tier — affects font weight and density. */
231
- tier: 'item' | 'subitem';
232
- }
233
-
234
- function splitSubItemChildren(children: ReactNode): { label: ReactNode[]; subs: ReactNode[] } {
235
- const label: ReactNode[] = [];
236
- const subs: ReactNode[] = [];
237
- const walk = (node: ReactNode) => {
238
- if (Array.isArray(node)) {
239
- node.forEach(walk);
240
- return;
241
- }
242
- if (isValidElement(node)) {
243
- const type = node.type as { displayName?: string } | string;
244
- if (typeof type !== 'string' && type?.displayName === 'EvoNavSubItem') {
245
- subs.push(node);
246
- return;
247
- }
248
- }
249
- label.push(node);
250
- };
251
- walk(children);
252
- return { label, subs };
253
- }
254
-
255
- const NavRow = forwardRef<HTMLLIElement, RowInternalProps>(function NavRow(
256
- {
257
- children,
258
- icon,
259
- active = false,
260
- href,
261
- onClick,
262
- tooltip,
263
- open: openProp,
264
- defaultOpen = false,
265
- onOpenChange,
266
- disabled = false,
267
- className,
268
- tier,
269
- },
270
- liRef,
271
- ) {
272
- const rootCtx = useContext(NavRootContext);
273
- const collapsed = rootCtx?.collapsed ?? false;
274
- const { depth } = useContext(NavDepthContext);
275
- const buttonRef = useRef<HTMLButtonElement | HTMLAnchorElement>(null);
276
- const rowId = useId();
277
- const subListId = `${rowId}-sub`;
278
-
279
- const { label, subs } = useMemo(() => splitSubItemChildren(children), [children]);
280
- const expandable = subs.length > 0;
281
- const [open, setOpen] = useControllableState(
282
- expandable ? openProp : false,
283
- expandable ? defaultOpen : false,
284
- onOpenChange,
285
- );
286
-
287
- const toggle = useCallback(() => {
288
- if (!expandable) return;
289
- setOpen(!open);
290
- }, [expandable, open, setOpen]);
291
-
292
- const handleActivate = useCallback(
293
- (event: ReactMouseEvent | ReactKeyboardEvent) => {
294
- if (disabled) return;
295
- if (expandable && !href) {
296
- // No real navigation target — primary click toggles disclosure.
297
- toggle();
298
- }
299
- onClick?.(event);
300
- // On mobile, navigating closes the drawer so the user lands on the page.
301
- if (rootCtx?.isMobile && href && !expandable) {
302
- rootCtx.closeDrawer();
303
- }
304
- },
305
- [disabled, expandable, href, onClick, rootCtx, toggle],
306
- );
307
-
308
- const handleKeyDown = (event: ReactKeyboardEvent) => {
309
- if (disabled) return;
310
- const self = event.currentTarget as HTMLElement;
311
- const navRoot = self.closest<HTMLElement>(`.${styles.navContainer}`);
312
- switch (event.key) {
313
- case 'ArrowDown':
314
- event.preventDefault();
315
- moveFocus(navRoot, self, 1);
316
- break;
317
- case 'ArrowUp':
318
- event.preventDefault();
319
- moveFocus(navRoot, self, -1);
320
- break;
321
- case 'ArrowRight':
322
- if (expandable) {
323
- event.preventDefault();
324
- if (!open) {
325
- setOpen(true);
326
- } else {
327
- // Focus first child row.
328
- const li = self.closest('li');
329
- const firstChild = li?.querySelector<HTMLElement>(`ul [${ROW_ATTR}]`);
330
- firstChild?.focus();
331
- }
332
- }
333
- break;
334
- case 'ArrowLeft':
335
- event.preventDefault();
336
- if (expandable && open) {
337
- setOpen(false);
338
- } else if (depth > 0) {
339
- // Walk up to the nearest ancestor row.
340
- const parentLi = self.closest('li')?.parentElement?.closest('li');
341
- parentLi?.querySelector<HTMLElement>(`[${ROW_ATTR}]`)?.focus();
342
- }
343
- break;
344
- case 'Home':
345
- event.preventDefault();
346
- moveFocus(navRoot, self, 'first');
347
- break;
348
- case 'End':
349
- event.preventDefault();
350
- moveFocus(navRoot, self, 'last');
351
- break;
352
- case 'Enter':
353
- case ' ': {
354
- // <a> already activates on Enter; let it through but capture Space.
355
- if (event.key === ' ' || href === undefined) {
356
- event.preventDefault();
357
- handleActivate(event);
358
- }
359
- break;
360
- }
361
- default:
362
- break;
363
- }
364
- };
365
-
366
- const rowClasses = [
367
- styles.navRow,
368
- tier === 'subitem' ? styles.navRowSub : styles.navRowTop,
369
- active ? styles.active : '',
370
- disabled ? styles.disabled : '',
371
- className,
372
- ]
373
- .filter(Boolean)
374
- .join(' ');
375
-
376
- const rowStyle =
377
- tier === 'subitem' && depth > 0
378
- ? ({ ['--evo-nav-indent' as string]: `${depth * 0.875}rem` } as CSSProperties)
379
- : undefined;
380
-
381
- const commonRowProps = {
382
- 'data-evo-nav-row': '',
383
- id: rowId,
384
- className: rowClasses,
385
- style: rowStyle,
386
- title: collapsed && tooltip ? tooltip : undefined,
387
- 'aria-current': active ? ('page' as const) : undefined,
388
- 'aria-expanded': expandable ? open : undefined,
389
- 'aria-controls': expandable ? subListId : undefined,
390
- 'aria-disabled': disabled || undefined,
391
- 'data-disabled': disabled ? 'true' : undefined,
392
- 'data-active': active ? 'true' : undefined,
393
- tabIndex: disabled ? -1 : 0,
394
- onKeyDown: handleKeyDown,
395
- };
396
-
397
- const rowInner = (
398
- <>
399
- {icon && <span className={styles.navIcon}>{icon}</span>}
400
- <span className={styles.navLabel}>{label}</span>
401
- {expandable && <ChevronIcon open={open} />}
402
- </>
403
- );
404
-
405
- let rowEl: ReactNode;
406
- if (href && !disabled) {
407
- const anchorRest: AnchorHTMLAttributes<HTMLAnchorElement> = {
408
- href,
409
- onClick: handleActivate as unknown as AnchorHTMLAttributes<HTMLAnchorElement>['onClick'],
410
- };
411
- rowEl = (
412
- <a
413
- ref={buttonRef as RefObject<HTMLAnchorElement>}
414
- {...commonRowProps}
415
- {...anchorRest}
416
- >
417
- {rowInner}
418
- </a>
419
- );
420
- } else {
421
- rowEl = (
422
- <button
423
- ref={buttonRef as RefObject<HTMLButtonElement>}
424
- type="button"
425
- disabled={disabled}
426
- onClick={handleActivate}
427
- {...commonRowProps}
428
- >
429
- {rowInner}
430
- </button>
431
- );
432
- }
433
-
434
- return (
435
- <li ref={liRef} className={styles.navLi}>
436
- {rowEl}
437
- {expandable && (
438
- <NavDepthContext.Provider value={{ depth: depth + 1 }}>
439
- <ul
440
- id={subListId}
441
- role="group"
442
- aria-labelledby={rowId}
443
- hidden={!open}
444
- className={styles.navSubList}
445
- >
446
- {subs}
447
- </ul>
448
- </NavDepthContext.Provider>
449
- )}
450
- </li>
451
- );
452
- });
453
-
454
- // ─── Public sub-components ────────────────────────────────────────────────
455
-
456
- export const EvoNavItem = forwardRef<HTMLLIElement, EvoNavItemProps>(function EvoNavItem(
457
- props,
458
- ref,
459
- ) {
460
- return <NavRow ref={ref} tier="item" {...props} />;
461
- });
462
- EvoNavItem.displayName = 'EvoNavItem';
463
-
464
- export const EvoNavSubItem = forwardRef<HTMLLIElement, EvoNavSubItemProps>(function EvoNavSubItem(
465
- props,
466
- ref,
467
- ) {
468
- return <NavRow ref={ref} tier="subitem" {...props} />;
469
- });
470
- EvoNavSubItem.displayName = 'EvoNavSubItem';
471
-
472
- export const EvoNavGroup = forwardRef<HTMLLIElement, EvoNavGroupProps>(function EvoNavGroup(
473
- { label, children, className, collapsible = false, defaultOpen = true, open: openProp, onOpenChange, count },
474
- ref,
475
- ) {
476
- const headingId = useId();
477
- const panelId = `${headingId}-panel`;
478
- const collapsed = useContext(NavRootContext)?.collapsed ?? false;
479
- // The disclosure is interactive only when collapsible AND not in icon-rail
480
- // mode. In the rail the accordion is meaningless, so we render a static
481
- // heading and show the items — avoiding a focusable, label-less toggle that
482
- // would silently mutate the (forced-open) state.
483
- const interactive = collapsible && !collapsed;
484
- const [open, setOpen] = useControllableState(
485
- interactive ? openProp : true,
486
- interactive ? defaultOpen : true,
487
- onOpenChange,
488
- );
489
- const effectiveOpen = collapsed ? true : open;
490
-
491
- // Toggle `inert` imperatively rather than through a prop: `inert` is only a
492
- // managed React attribute as of React 19, but the peer range allows >=17.
493
- // The DOM API works on every version and keeps the console warning-free.
494
- const panelRef = useRef<HTMLDivElement>(null);
495
- useEffect(() => {
496
- const el = panelRef.current;
497
- if (!el) return;
498
- if (effectiveOpen) el.removeAttribute('inert');
499
- else el.setAttribute('inert', '');
500
- }, [effectiveOpen]);
501
-
502
- const countChip =
503
- count != null ? (
504
- <span className={styles.navGroupCount} aria-hidden="true">
505
- {count}
506
- </span>
507
- ) : null;
508
-
509
- const list = (
510
- <ul role="group" aria-labelledby={headingId} className={styles.navList}>
511
- {children}
512
- </ul>
513
- );
514
-
515
- return (
516
- <li ref={ref} className={[styles.navGroup, className].filter(Boolean).join(' ')}>
517
- {interactive ? (
518
- <button
519
- type="button"
520
- id={headingId}
521
- className={[styles.navGroupLabel, styles.navGroupToggle].join(' ')}
522
- aria-expanded={effectiveOpen}
523
- aria-controls={panelId}
524
- onClick={() => setOpen(!open)}
525
- >
526
- <span className={styles.navGroupLabelText}>{label}</span>
527
- {countChip}
528
- <ChevronIcon open={effectiveOpen} />
529
- </button>
530
- ) : (
531
- <div id={headingId} role="heading" aria-level={3} className={styles.navGroupLabel}>
532
- <span className={styles.navGroupLabelText}>{label}</span>
533
- {countChip}
534
- </div>
535
- )}
536
- {interactive ? (
537
- <div
538
- ref={panelRef}
539
- id={panelId}
540
- className={styles.navGroupPanel}
541
- data-open={effectiveOpen ? 'true' : 'false'}
542
- >
543
- {list}
544
- </div>
545
- ) : (
546
- list
547
- )}
548
- </li>
549
- );
550
- });
551
- EvoNavGroup.displayName = 'EvoNavGroup';
552
-
553
- export const EvoNavSkeleton = ({ count = 4 }: EvoNavSkeletonProps) => (
554
- <>
555
- {Array.from({ length: count }).map((_, i) => (
556
- <li
557
- key={i}
558
- aria-hidden="true"
559
- className={styles.navSkeletonItem}
560
- >
561
- <span className={styles.navSkeletonIcon} />
562
- <span
563
- className={styles.navSkeletonText}
564
- style={{ width: `${45 + (i % 4) * 12}%` }}
565
- />
566
- </li>
567
- ))}
568
- </>
569
- );
570
-
571
- export const EvoNavQuickAction = forwardRef<HTMLButtonElement, EvoNavQuickActionProps>(
572
- function EvoNavQuickAction({ label = 'Create New', icon, className, type = 'button', ...rest }, ref) {
573
- return (
574
- <li className={styles.navLi}>
575
- <button
576
- ref={ref}
577
- type={type}
578
- className={[styles.navQuickAction, className].filter(Boolean).join(' ')}
579
- {...rest}
580
- >
581
- <span className={styles.navIcon}>{icon ?? <PlusIcon />}</span>
582
- <span className={styles.navLabel}>{label}</span>
583
- </button>
584
- </li>
585
- );
586
- },
587
- );
588
- EvoNavQuickAction.displayName = 'EvoNavQuickAction';
589
-
590
- // ─── Root ─────────────────────────────────────────────────────────────────
591
-
592
- const EvoNavRoot = forwardRef<HTMLElement, EvoNavProps>(function EvoNav(
593
- {
594
- children,
595
- breakpoint = 768,
596
- drawerOpen: drawerOpenProp,
597
- defaultDrawerOpen = false,
598
- onDrawerOpenChange,
599
- hideTrigger = false,
600
- collapsed = false,
601
- className,
602
- 'aria-label': ariaLabel = 'Main navigation',
603
- ...rest
604
- },
605
- ref,
606
- ) {
607
- const isMobile = useIsBelowWidth(breakpoint);
608
- const [drawerOpen, setDrawerOpen] = useControllableState(
609
- drawerOpenProp,
610
- defaultDrawerOpen,
611
- onDrawerOpenChange,
612
- );
613
- const closeDrawer = useCallback(() => setDrawerOpen(false), [setDrawerOpen]);
614
- const rootId = useId();
615
- const navRef = useRef<HTMLElement>(null);
616
-
617
- // Close drawer on Escape when on mobile.
618
- useEffect(() => {
619
- if (!isMobile || !drawerOpen) return;
620
- const handler = (e: globalThis.KeyboardEvent) => {
621
- if (e.key === 'Escape') closeDrawer();
622
- };
623
- document.addEventListener('keydown', handler);
624
- return () => document.removeEventListener('keydown', handler);
625
- }, [isMobile, drawerOpen, closeDrawer]);
626
-
627
- // Lock body scroll when drawer is open.
628
- useEffect(() => {
629
- if (!isMobile || !drawerOpen) return;
630
- const prev = document.body.style.overflow;
631
- document.body.style.overflow = 'hidden';
632
- return () => {
633
- document.body.style.overflow = prev;
634
- };
635
- }, [isMobile, drawerOpen]);
636
-
637
- // When transitioning back to desktop width, ensure drawer is closed so
638
- // subsequent shrinks reopen the sidebar cleanly.
639
- useEffect(() => {
640
- if (!isMobile && drawerOpen) closeDrawer();
641
- }, [isMobile, drawerOpen, closeDrawer]);
642
-
643
- const ctxValue = useMemo<NavRootContextValue>(
644
- () => ({ isMobile, drawerOpen, setDrawerOpen, closeDrawer, rootId, collapsed }),
645
- [isMobile, drawerOpen, setDrawerOpen, closeDrawer, rootId, collapsed],
646
- );
647
-
648
- const navClasses = [
649
- styles.navContainer,
650
- isMobile ? styles.navMobile : styles.navDesktop,
651
- isMobile && drawerOpen ? styles.navDrawerOpen : '',
652
- collapsed ? styles.navCollapsed : '',
653
- className,
654
- ]
655
- .filter(Boolean)
656
- .join(' ');
657
-
658
- const setRefs = (el: HTMLElement | null) => {
659
- (navRef as RefObject<HTMLElement | null>).current = el;
660
- if (typeof ref === 'function') ref(el);
661
- else if (ref) (ref as RefObject<HTMLElement | null>).current = el;
662
- };
663
-
664
- return (
665
- <NavRootContext.Provider value={ctxValue}>
666
- {isMobile && !hideTrigger && (
667
- <button
668
- type="button"
669
- className={styles.navTrigger}
670
- aria-expanded={drawerOpen}
671
- aria-controls={rootId}
672
- aria-label={drawerOpen ? 'Close navigation' : 'Open navigation'}
673
- onClick={() => setDrawerOpen(!drawerOpen)}
674
- >
675
- {drawerOpen ? <CloseIcon /> : <HamburgerIcon />}
676
- </button>
677
- )}
678
- {isMobile && drawerOpen && (
679
- <div
680
- className={styles.navBackdrop}
681
- aria-hidden="true"
682
- onClick={closeDrawer}
683
- />
684
- )}
685
- <nav
686
- ref={setRefs}
687
- id={rootId}
688
- role="navigation"
689
- aria-label={ariaLabel}
690
- aria-hidden={isMobile && !drawerOpen ? true : undefined}
691
- className={navClasses}
692
- {...rest}
693
- >
694
- <ul className={styles.navList}>{children}</ul>
695
- </nav>
696
- </NavRootContext.Provider>
697
- );
698
- });
699
-
700
- EvoNavRoot.displayName = 'EvoNav';
701
-
702
- export const EvoNav = Object.assign(EvoNavRoot, {
703
- Group: EvoNavGroup,
704
- Item: EvoNavItem,
705
- SubItem: EvoNavSubItem,
706
- Skeleton: EvoNavSkeleton,
707
- QuickAction: EvoNavQuickAction,
708
- });
1
+ // EvoNav — compose-based sidebar navigation.
2
+ //
3
+ // API decisions (see Radix Navigation Menu, Mantine NavLink, shadcn Sidebar):
4
+ // - Compose over config: nesting is always children, never an `items` array.
5
+ // - Disclosure state is controlled (`open` + `onOpenChange`) or uncontrolled
6
+ // (`defaultOpen`), matching the Evo naming rule from CLAUDE.md §0.1.
7
+ // - `active` keeps its name (React community convention) but now always
8
+ // forwards `aria-current="page"` — fixes the a11y gap called out in §9.
9
+ // - Rows render as `<a href>` when `href` is set, `<button type="button">`
10
+ // otherwise. This preserves right-click / middle-click / drag semantics
11
+ // without forcing every consumer onto a router.
12
+ // - At viewport widths below `breakpoint` (default 768px), the nav collapses
13
+ // to an off-canvas drawer with a built-in hamburger trigger. Trigger can
14
+ // be lifted with `hideTrigger` + controlled `drawerOpen`.
15
+ // - Keyboard model is a "disclosure tree" (Mantine-style), not Radix's
16
+ // roving-tabindex menu: every row is in the natural tab order, arrow keys
17
+ // move focus within the nav, ←/→ collapse/expand.
18
+
19
+ import {
20
+ createContext,
21
+ forwardRef,
22
+ isValidElement,
23
+ useCallback,
24
+ useContext,
25
+ useEffect,
26
+ useId,
27
+ useMemo,
28
+ useRef,
29
+ useState,
30
+ type AnchorHTMLAttributes,
31
+ type ButtonHTMLAttributes,
32
+ type CSSProperties,
33
+ type HTMLAttributes,
34
+ type KeyboardEvent as ReactKeyboardEvent,
35
+ type MouseEvent as ReactMouseEvent,
36
+ type ReactNode,
37
+ type RefObject,
38
+ } from 'react';
39
+ import styles from '../css/nav.module.scss';
40
+
41
+ // ─── Public types ──────────────────────────────────────────────────────────
42
+
43
+ export interface EvoNavProps extends Omit<HTMLAttributes<HTMLElement>, 'children'> {
44
+ children: ReactNode;
45
+ /** Below this viewport width (px), nav collapses to a drawer. @default 768 */
46
+ breakpoint?: number;
47
+ /** Controlled drawer open state (mobile only). */
48
+ drawerOpen?: boolean;
49
+ /** Uncontrolled initial drawer state. @default false */
50
+ defaultDrawerOpen?: boolean;
51
+ /** Called when the drawer opens or closes. */
52
+ onDrawerOpenChange?: (open: boolean) => void;
53
+ /** Hide the built-in hamburger trigger (use when wiring a trigger yourself). */
54
+ hideTrigger?: boolean;
55
+ /** Collapse to an icon-only rail: labels hide, icons center, and each row
56
+ * shows a native tooltip from its `tooltip` prop. @default false */
57
+ collapsed?: boolean;
58
+ /** Accessible label for the <nav> landmark. @default 'Main navigation' */
59
+ 'aria-label'?: string;
60
+ }
61
+
62
+ export interface EvoNavGroupProps {
63
+ label: string;
64
+ children: ReactNode;
65
+ className?: string;
66
+ /** Render the heading as a disclosure that expands/collapses the group. */
67
+ collapsible?: boolean;
68
+ /** Uncontrolled initial open state (collapsible only). @default true */
69
+ defaultOpen?: boolean;
70
+ /** Controlled open state (collapsible only). */
71
+ open?: boolean;
72
+ /** Called when the group expands or collapses. */
73
+ onOpenChange?: (open: boolean) => void;
74
+ /** Small count chip shown after the label. */
75
+ count?: number;
76
+ }
77
+
78
+ interface EvoNavRowProps {
79
+ children: ReactNode;
80
+ icon?: ReactNode;
81
+ /** Marks this row as the current page (sets aria-current="page"). */
82
+ active?: boolean;
83
+ /** Render as <a href> instead of <button>. */
84
+ href?: string;
85
+ onClick?: (event: ReactMouseEvent | ReactKeyboardEvent) => void;
86
+ /** Tooltip text shown (via native title) when the nav is collapsed to a rail. */
87
+ tooltip?: string;
88
+ /** Controlled expand state (only when row has SubItem children). */
89
+ open?: boolean;
90
+ /** Uncontrolled initial expand state. */
91
+ defaultOpen?: boolean;
92
+ onOpenChange?: (open: boolean) => void;
93
+ disabled?: boolean;
94
+ className?: string;
95
+ }
96
+
97
+ export interface EvoNavItemProps extends EvoNavRowProps {}
98
+ export interface EvoNavSubItemProps extends EvoNavRowProps {}
99
+
100
+ export interface EvoNavSkeletonProps {
101
+ /** @default 4 */
102
+ count?: number;
103
+ }
104
+
105
+ export interface EvoNavQuickActionProps
106
+ extends ButtonHTMLAttributes<HTMLButtonElement> {
107
+ /** @default 'Create New' */
108
+ label?: string;
109
+ icon?: ReactNode;
110
+ }
111
+
112
+ // ─── Internal context ──────────────────────────────────────────────────────
113
+
114
+ interface NavRootContextValue {
115
+ /** Whether the viewport is below `breakpoint`. */
116
+ isMobile: boolean;
117
+ /** Drawer open state (only meaningful when isMobile). */
118
+ drawerOpen: boolean;
119
+ setDrawerOpen: (open: boolean) => void;
120
+ /** Closes the drawer; safe to call regardless of mobile state. */
121
+ closeDrawer: () => void;
122
+ /** Root id used by the hamburger button's aria-controls. */
123
+ rootId: string;
124
+ /** Icon-only rail mode — labels hide, rows surface a native tooltip. */
125
+ collapsed: boolean;
126
+ }
127
+
128
+ const NavRootContext = createContext<NavRootContextValue | null>(null);
129
+
130
+ interface NavDepthContextValue {
131
+ depth: number;
132
+ }
133
+
134
+ const NavDepthContext = createContext<NavDepthContextValue>({ depth: 0 });
135
+
136
+ // ─── Hooks ────────────────────────────────────────────────────────────────
137
+
138
+ function useIsBelowWidth(maxWidth: number): boolean {
139
+ const [isBelow, setIsBelow] = useState(false);
140
+
141
+ useEffect(() => {
142
+ if (typeof window === 'undefined' || !window.matchMedia) return;
143
+ const mql = window.matchMedia(`(max-width: ${maxWidth - 1}px)`);
144
+ const update = () => setIsBelow(mql.matches);
145
+ update();
146
+ mql.addEventListener('change', update);
147
+ return () => mql.removeEventListener('change', update);
148
+ }, [maxWidth]);
149
+
150
+ return isBelow;
151
+ }
152
+
153
+ /** Resolve controlled vs. uncontrolled state. */
154
+ function useControllableState<T>(
155
+ controlled: T | undefined,
156
+ defaultValue: T,
157
+ onChange?: (value: T) => void,
158
+ ): [T, (value: T) => void] {
159
+ const [uncontrolled, setUncontrolled] = useState(defaultValue);
160
+ const isControlled = controlled !== undefined;
161
+ const value = isControlled ? (controlled as T) : uncontrolled;
162
+ const setValue = useCallback(
163
+ (next: T) => {
164
+ if (!isControlled) setUncontrolled(next);
165
+ onChange?.(next);
166
+ },
167
+ [isControlled, onChange],
168
+ );
169
+ return [value, setValue];
170
+ }
171
+
172
+ // ─── Keyboard navigation ──────────────────────────────────────────────────
173
+
174
+ const ROW_ATTR = 'data-evo-nav-row';
175
+
176
+ function focusableRows(root: HTMLElement | null): HTMLElement[] {
177
+ if (!root) return [];
178
+ return Array.from(
179
+ root.querySelectorAll<HTMLElement>(`[${ROW_ATTR}]:not([data-disabled="true"])`),
180
+ ).filter((el) => el.offsetParent !== null && el.closest('[inert]') === null);
181
+ }
182
+
183
+ function moveFocus(root: HTMLElement | null, from: HTMLElement, delta: 1 | -1 | 'first' | 'last') {
184
+ const rows = focusableRows(root);
185
+ if (rows.length === 0) return;
186
+ const idx = rows.indexOf(from);
187
+ let nextIdx: number;
188
+ if (delta === 'first') nextIdx = 0;
189
+ else if (delta === 'last') nextIdx = rows.length - 1;
190
+ else nextIdx = Math.max(0, Math.min(rows.length - 1, idx + delta));
191
+ rows[nextIdx]?.focus();
192
+ }
193
+
194
+ // ─── Chevron / plus icons ─────────────────────────────────────────────────
195
+
196
+ const ChevronIcon = ({ open }: { open: boolean }) => (
197
+ <svg
198
+ viewBox="0 0 16 16"
199
+ fill="none"
200
+ width="12"
201
+ height="12"
202
+ aria-hidden="true"
203
+ className={[styles.chevron, open ? styles.chevronOpen : ''].filter(Boolean).join(' ')}
204
+ >
205
+ <path d="M6 4l4 4-4 4" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round" />
206
+ </svg>
207
+ );
208
+
209
+ const PlusIcon = () => (
210
+ <svg viewBox="0 0 16 16" fill="none" width="14" height="14" aria-hidden="true">
211
+ <path d="M8 3v10M3 8h10" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" />
212
+ </svg>
213
+ );
214
+
215
+ const HamburgerIcon = () => (
216
+ <svg viewBox="0 0 20 20" fill="none" width="18" height="18" aria-hidden="true">
217
+ <path d="M3 5h14M3 10h14M3 15h14" stroke="currentColor" strokeWidth="1.75" strokeLinecap="round" />
218
+ </svg>
219
+ );
220
+
221
+ const CloseIcon = () => (
222
+ <svg viewBox="0 0 20 20" fill="none" width="18" height="18" aria-hidden="true">
223
+ <path d="M5 5l10 10M15 5L5 15" stroke="currentColor" strokeWidth="1.75" strokeLinecap="round" />
224
+ </svg>
225
+ );
226
+
227
+ // ─── Row (Item / SubItem shared implementation) ───────────────────────────
228
+
229
+ interface RowInternalProps extends EvoNavRowProps {
230
+ /** Visual tier — affects font weight and density. */
231
+ tier: 'item' | 'subitem';
232
+ }
233
+
234
+ function splitSubItemChildren(children: ReactNode): { label: ReactNode[]; subs: ReactNode[] } {
235
+ const label: ReactNode[] = [];
236
+ const subs: ReactNode[] = [];
237
+ const walk = (node: ReactNode) => {
238
+ if (Array.isArray(node)) {
239
+ node.forEach(walk);
240
+ return;
241
+ }
242
+ if (isValidElement(node)) {
243
+ const type = node.type as { displayName?: string } | string;
244
+ if (typeof type !== 'string' && type?.displayName === 'EvoNavSubItem') {
245
+ subs.push(node);
246
+ return;
247
+ }
248
+ }
249
+ label.push(node);
250
+ };
251
+ walk(children);
252
+ return { label, subs };
253
+ }
254
+
255
+ const NavRow = forwardRef<HTMLLIElement, RowInternalProps>(function NavRow(
256
+ {
257
+ children,
258
+ icon,
259
+ active = false,
260
+ href,
261
+ onClick,
262
+ tooltip,
263
+ open: openProp,
264
+ defaultOpen = false,
265
+ onOpenChange,
266
+ disabled = false,
267
+ className,
268
+ tier,
269
+ },
270
+ liRef,
271
+ ) {
272
+ const rootCtx = useContext(NavRootContext);
273
+ const collapsed = rootCtx?.collapsed ?? false;
274
+ const { depth } = useContext(NavDepthContext);
275
+ const buttonRef = useRef<HTMLButtonElement | HTMLAnchorElement>(null);
276
+ const rowId = useId();
277
+ const subListId = `${rowId}-sub`;
278
+
279
+ const { label, subs } = useMemo(() => splitSubItemChildren(children), [children]);
280
+ const expandable = subs.length > 0;
281
+ const [open, setOpen] = useControllableState(
282
+ expandable ? openProp : false,
283
+ expandable ? defaultOpen : false,
284
+ onOpenChange,
285
+ );
286
+
287
+ const toggle = useCallback(() => {
288
+ if (!expandable) return;
289
+ setOpen(!open);
290
+ }, [expandable, open, setOpen]);
291
+
292
+ const handleActivate = useCallback(
293
+ (event: ReactMouseEvent | ReactKeyboardEvent) => {
294
+ if (disabled) return;
295
+ if (expandable && !href) {
296
+ // No real navigation target — primary click toggles disclosure.
297
+ toggle();
298
+ }
299
+ onClick?.(event);
300
+ // On mobile, navigating closes the drawer so the user lands on the page.
301
+ if (rootCtx?.isMobile && href && !expandable) {
302
+ rootCtx.closeDrawer();
303
+ }
304
+ },
305
+ [disabled, expandable, href, onClick, rootCtx, toggle],
306
+ );
307
+
308
+ const handleKeyDown = (event: ReactKeyboardEvent) => {
309
+ if (disabled) return;
310
+ const self = event.currentTarget as HTMLElement;
311
+ const navRoot = self.closest<HTMLElement>(`.${styles.navContainer}`);
312
+ switch (event.key) {
313
+ case 'ArrowDown':
314
+ event.preventDefault();
315
+ moveFocus(navRoot, self, 1);
316
+ break;
317
+ case 'ArrowUp':
318
+ event.preventDefault();
319
+ moveFocus(navRoot, self, -1);
320
+ break;
321
+ case 'ArrowRight':
322
+ if (expandable) {
323
+ event.preventDefault();
324
+ if (!open) {
325
+ setOpen(true);
326
+ } else {
327
+ // Focus first child row.
328
+ const li = self.closest('li');
329
+ const firstChild = li?.querySelector<HTMLElement>(`ul [${ROW_ATTR}]`);
330
+ firstChild?.focus();
331
+ }
332
+ }
333
+ break;
334
+ case 'ArrowLeft':
335
+ event.preventDefault();
336
+ if (expandable && open) {
337
+ setOpen(false);
338
+ } else if (depth > 0) {
339
+ // Walk up to the nearest ancestor row.
340
+ const parentLi = self.closest('li')?.parentElement?.closest('li');
341
+ parentLi?.querySelector<HTMLElement>(`[${ROW_ATTR}]`)?.focus();
342
+ }
343
+ break;
344
+ case 'Home':
345
+ event.preventDefault();
346
+ moveFocus(navRoot, self, 'first');
347
+ break;
348
+ case 'End':
349
+ event.preventDefault();
350
+ moveFocus(navRoot, self, 'last');
351
+ break;
352
+ case 'Enter':
353
+ case ' ': {
354
+ // <a> already activates on Enter; let it through but capture Space.
355
+ if (event.key === ' ' || href === undefined) {
356
+ event.preventDefault();
357
+ handleActivate(event);
358
+ }
359
+ break;
360
+ }
361
+ default:
362
+ break;
363
+ }
364
+ };
365
+
366
+ const rowClasses = [
367
+ styles.navRow,
368
+ tier === 'subitem' ? styles.navRowSub : styles.navRowTop,
369
+ active ? styles.active : '',
370
+ disabled ? styles.disabled : '',
371
+ className,
372
+ ]
373
+ .filter(Boolean)
374
+ .join(' ');
375
+
376
+ const rowStyle =
377
+ tier === 'subitem' && depth > 0
378
+ ? ({ ['--evo-nav-indent' as string]: `${depth * 0.875}rem` } as CSSProperties)
379
+ : undefined;
380
+
381
+ const commonRowProps = {
382
+ 'data-evo-nav-row': '',
383
+ id: rowId,
384
+ className: rowClasses,
385
+ style: rowStyle,
386
+ title: collapsed && tooltip ? tooltip : undefined,
387
+ 'aria-current': active ? ('page' as const) : undefined,
388
+ 'aria-expanded': expandable ? open : undefined,
389
+ 'aria-controls': expandable ? subListId : undefined,
390
+ 'aria-disabled': disabled || undefined,
391
+ 'data-disabled': disabled ? 'true' : undefined,
392
+ 'data-active': active ? 'true' : undefined,
393
+ tabIndex: disabled ? -1 : 0,
394
+ onKeyDown: handleKeyDown,
395
+ };
396
+
397
+ const rowInner = (
398
+ <>
399
+ {icon && <span className={styles.navIcon}>{icon}</span>}
400
+ <span className={styles.navLabel}>{label}</span>
401
+ {expandable && <ChevronIcon open={open} />}
402
+ </>
403
+ );
404
+
405
+ let rowEl: ReactNode;
406
+ if (href && !disabled) {
407
+ const anchorRest: AnchorHTMLAttributes<HTMLAnchorElement> = {
408
+ href,
409
+ onClick: handleActivate as unknown as AnchorHTMLAttributes<HTMLAnchorElement>['onClick'],
410
+ };
411
+ rowEl = (
412
+ <a
413
+ ref={buttonRef as RefObject<HTMLAnchorElement>}
414
+ {...commonRowProps}
415
+ {...anchorRest}
416
+ >
417
+ {rowInner}
418
+ </a>
419
+ );
420
+ } else {
421
+ rowEl = (
422
+ <button
423
+ ref={buttonRef as RefObject<HTMLButtonElement>}
424
+ type="button"
425
+ disabled={disabled}
426
+ onClick={handleActivate}
427
+ {...commonRowProps}
428
+ >
429
+ {rowInner}
430
+ </button>
431
+ );
432
+ }
433
+
434
+ return (
435
+ <li ref={liRef} className={styles.navLi}>
436
+ {rowEl}
437
+ {expandable && (
438
+ <NavDepthContext.Provider value={{ depth: depth + 1 }}>
439
+ <ul
440
+ id={subListId}
441
+ role="group"
442
+ aria-labelledby={rowId}
443
+ hidden={!open}
444
+ className={styles.navSubList}
445
+ >
446
+ {subs}
447
+ </ul>
448
+ </NavDepthContext.Provider>
449
+ )}
450
+ </li>
451
+ );
452
+ });
453
+
454
+ // ─── Public sub-components ────────────────────────────────────────────────
455
+
456
+ export const EvoNavItem = forwardRef<HTMLLIElement, EvoNavItemProps>(function EvoNavItem(
457
+ props,
458
+ ref,
459
+ ) {
460
+ return <NavRow ref={ref} tier="item" {...props} />;
461
+ });
462
+ EvoNavItem.displayName = 'EvoNavItem';
463
+
464
+ export const EvoNavSubItem = forwardRef<HTMLLIElement, EvoNavSubItemProps>(function EvoNavSubItem(
465
+ props,
466
+ ref,
467
+ ) {
468
+ return <NavRow ref={ref} tier="subitem" {...props} />;
469
+ });
470
+ EvoNavSubItem.displayName = 'EvoNavSubItem';
471
+
472
+ export const EvoNavGroup = forwardRef<HTMLLIElement, EvoNavGroupProps>(function EvoNavGroup(
473
+ { label, children, className, collapsible = false, defaultOpen = true, open: openProp, onOpenChange, count },
474
+ ref,
475
+ ) {
476
+ const headingId = useId();
477
+ const panelId = `${headingId}-panel`;
478
+ const collapsed = useContext(NavRootContext)?.collapsed ?? false;
479
+ // The disclosure is interactive only when collapsible AND not in icon-rail
480
+ // mode. In the rail the accordion is meaningless, so we render a static
481
+ // heading and show the items — avoiding a focusable, label-less toggle that
482
+ // would silently mutate the (forced-open) state.
483
+ const interactive = collapsible && !collapsed;
484
+ const [open, setOpen] = useControllableState(
485
+ interactive ? openProp : true,
486
+ interactive ? defaultOpen : true,
487
+ onOpenChange,
488
+ );
489
+ const effectiveOpen = collapsed ? true : open;
490
+
491
+ // Toggle `inert` imperatively rather than through a prop: `inert` is only a
492
+ // managed React attribute as of React 19, but the peer range allows >=17.
493
+ // The DOM API works on every version and keeps the console warning-free.
494
+ const panelRef = useRef<HTMLDivElement>(null);
495
+ useEffect(() => {
496
+ const el = panelRef.current;
497
+ if (!el) return;
498
+ if (effectiveOpen) el.removeAttribute('inert');
499
+ else el.setAttribute('inert', '');
500
+ }, [effectiveOpen]);
501
+
502
+ const countChip =
503
+ count != null ? (
504
+ <span className={styles.navGroupCount} aria-hidden="true">
505
+ {count}
506
+ </span>
507
+ ) : null;
508
+
509
+ const list = (
510
+ <ul role="group" aria-labelledby={headingId} className={styles.navList}>
511
+ {children}
512
+ </ul>
513
+ );
514
+
515
+ return (
516
+ <li ref={ref} className={[styles.navGroup, className].filter(Boolean).join(' ')}>
517
+ {interactive ? (
518
+ <button
519
+ type="button"
520
+ id={headingId}
521
+ className={[styles.navGroupLabel, styles.navGroupToggle].join(' ')}
522
+ aria-expanded={effectiveOpen}
523
+ aria-controls={panelId}
524
+ onClick={() => setOpen(!open)}
525
+ >
526
+ <span className={styles.navGroupLabelText}>{label}</span>
527
+ {countChip}
528
+ <ChevronIcon open={effectiveOpen} />
529
+ </button>
530
+ ) : (
531
+ <div id={headingId} role="heading" aria-level={3} className={styles.navGroupLabel}>
532
+ <span className={styles.navGroupLabelText}>{label}</span>
533
+ {countChip}
534
+ </div>
535
+ )}
536
+ {interactive ? (
537
+ <div
538
+ ref={panelRef}
539
+ id={panelId}
540
+ className={styles.navGroupPanel}
541
+ data-open={effectiveOpen ? 'true' : 'false'}
542
+ >
543
+ {list}
544
+ </div>
545
+ ) : (
546
+ list
547
+ )}
548
+ </li>
549
+ );
550
+ });
551
+ EvoNavGroup.displayName = 'EvoNavGroup';
552
+
553
+ export const EvoNavSkeleton = ({ count = 4 }: EvoNavSkeletonProps) => (
554
+ <>
555
+ {Array.from({ length: count }).map((_, i) => (
556
+ <li
557
+ key={i}
558
+ aria-hidden="true"
559
+ className={styles.navSkeletonItem}
560
+ >
561
+ <span className={styles.navSkeletonIcon} />
562
+ <span
563
+ className={styles.navSkeletonText}
564
+ style={{ width: `${45 + (i % 4) * 12}%` }}
565
+ />
566
+ </li>
567
+ ))}
568
+ </>
569
+ );
570
+
571
+ export const EvoNavQuickAction = forwardRef<HTMLButtonElement, EvoNavQuickActionProps>(
572
+ function EvoNavQuickAction({ label = 'Create New', icon, className, type = 'button', ...rest }, ref) {
573
+ return (
574
+ <li className={styles.navLi}>
575
+ <button
576
+ ref={ref}
577
+ type={type}
578
+ className={[styles.navQuickAction, className].filter(Boolean).join(' ')}
579
+ {...rest}
580
+ >
581
+ <span className={styles.navIcon}>{icon ?? <PlusIcon />}</span>
582
+ <span className={styles.navLabel}>{label}</span>
583
+ </button>
584
+ </li>
585
+ );
586
+ },
587
+ );
588
+ EvoNavQuickAction.displayName = 'EvoNavQuickAction';
589
+
590
+ // ─── Root ─────────────────────────────────────────────────────────────────
591
+
592
+ const EvoNavRoot = forwardRef<HTMLElement, EvoNavProps>(function EvoNav(
593
+ {
594
+ children,
595
+ breakpoint = 768,
596
+ drawerOpen: drawerOpenProp,
597
+ defaultDrawerOpen = false,
598
+ onDrawerOpenChange,
599
+ hideTrigger = false,
600
+ collapsed = false,
601
+ className,
602
+ 'aria-label': ariaLabel = 'Main navigation',
603
+ ...rest
604
+ },
605
+ ref,
606
+ ) {
607
+ const isMobile = useIsBelowWidth(breakpoint);
608
+ const [drawerOpen, setDrawerOpen] = useControllableState(
609
+ drawerOpenProp,
610
+ defaultDrawerOpen,
611
+ onDrawerOpenChange,
612
+ );
613
+ const closeDrawer = useCallback(() => setDrawerOpen(false), [setDrawerOpen]);
614
+ const rootId = useId();
615
+ const navRef = useRef<HTMLElement>(null);
616
+
617
+ // Close drawer on Escape when on mobile.
618
+ useEffect(() => {
619
+ if (!isMobile || !drawerOpen) return;
620
+ const handler = (e: globalThis.KeyboardEvent) => {
621
+ if (e.key === 'Escape') closeDrawer();
622
+ };
623
+ document.addEventListener('keydown', handler);
624
+ return () => document.removeEventListener('keydown', handler);
625
+ }, [isMobile, drawerOpen, closeDrawer]);
626
+
627
+ // Lock body scroll when drawer is open.
628
+ useEffect(() => {
629
+ if (!isMobile || !drawerOpen) return;
630
+ const prev = document.body.style.overflow;
631
+ document.body.style.overflow = 'hidden';
632
+ return () => {
633
+ document.body.style.overflow = prev;
634
+ };
635
+ }, [isMobile, drawerOpen]);
636
+
637
+ // When transitioning back to desktop width, ensure drawer is closed so
638
+ // subsequent shrinks reopen the sidebar cleanly.
639
+ useEffect(() => {
640
+ if (!isMobile && drawerOpen) closeDrawer();
641
+ }, [isMobile, drawerOpen, closeDrawer]);
642
+
643
+ const ctxValue = useMemo<NavRootContextValue>(
644
+ () => ({ isMobile, drawerOpen, setDrawerOpen, closeDrawer, rootId, collapsed }),
645
+ [isMobile, drawerOpen, setDrawerOpen, closeDrawer, rootId, collapsed],
646
+ );
647
+
648
+ const navClasses = [
649
+ styles.navContainer,
650
+ isMobile ? styles.navMobile : styles.navDesktop,
651
+ isMobile && drawerOpen ? styles.navDrawerOpen : '',
652
+ collapsed ? styles.navCollapsed : '',
653
+ className,
654
+ ]
655
+ .filter(Boolean)
656
+ .join(' ');
657
+
658
+ const setRefs = (el: HTMLElement | null) => {
659
+ (navRef as RefObject<HTMLElement | null>).current = el;
660
+ if (typeof ref === 'function') ref(el);
661
+ else if (ref) (ref as RefObject<HTMLElement | null>).current = el;
662
+ };
663
+
664
+ return (
665
+ <NavRootContext.Provider value={ctxValue}>
666
+ {isMobile && !hideTrigger && (
667
+ <button
668
+ type="button"
669
+ className={styles.navTrigger}
670
+ aria-expanded={drawerOpen}
671
+ aria-controls={rootId}
672
+ aria-label={drawerOpen ? 'Close navigation' : 'Open navigation'}
673
+ onClick={() => setDrawerOpen(!drawerOpen)}
674
+ >
675
+ {drawerOpen ? <CloseIcon /> : <HamburgerIcon />}
676
+ </button>
677
+ )}
678
+ {isMobile && drawerOpen && (
679
+ <div
680
+ className={styles.navBackdrop}
681
+ aria-hidden="true"
682
+ onClick={closeDrawer}
683
+ />
684
+ )}
685
+ <nav
686
+ ref={setRefs}
687
+ id={rootId}
688
+ role="navigation"
689
+ aria-label={ariaLabel}
690
+ aria-hidden={isMobile && !drawerOpen ? true : undefined}
691
+ className={navClasses}
692
+ {...rest}
693
+ >
694
+ <ul className={styles.navList}>{children}</ul>
695
+ </nav>
696
+ </NavRootContext.Provider>
697
+ );
698
+ });
699
+
700
+ EvoNavRoot.displayName = 'EvoNav';
701
+
702
+ export const EvoNav = Object.assign(EvoNavRoot, {
703
+ Group: EvoNavGroup,
704
+ Item: EvoNavItem,
705
+ SubItem: EvoNavSubItem,
706
+ Skeleton: EvoNavSkeleton,
707
+ QuickAction: EvoNavQuickAction,
708
+ });