@justin_evo/evo-ui 1.2.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 (77) hide show
  1. package/LICENSE +21 -21
  2. package/README.md +70 -70
  3. package/dist/declarations.d.ts +6 -6
  4. package/package.json +52 -52
  5. package/src/Alert/Alert.tsx +49 -49
  6. package/src/AutoComplete/AutoComplete.tsx +810 -810
  7. package/src/Badge/Badge.tsx +53 -53
  8. package/src/Breadcrumb/Breadcrumb.tsx +53 -53
  9. package/src/Button/Button.tsx +125 -125
  10. package/src/Card/Card.tsx +257 -257
  11. package/src/Checkbox/Checkbox.tsx +59 -59
  12. package/src/CommandPalette/CommandPalette.tsx +185 -185
  13. package/src/Container/Container.tsx +31 -31
  14. package/src/Divider/Divider.tsx +31 -31
  15. package/src/Form/Form.tsx +185 -185
  16. package/src/Grid/Grid.tsx +66 -66
  17. package/src/ImageCropper/ImageCropper.tsx +911 -911
  18. package/src/Input/Input.tsx +74 -74
  19. package/src/Modal/Modal.tsx +77 -77
  20. package/src/Nav/Nav.tsx +708 -708
  21. package/src/Notification/Notification.tsx +1503 -1503
  22. package/src/Pagination/Pagination.tsx +76 -76
  23. package/src/Radio/Radio.tsx +69 -69
  24. package/src/RichTextArea/RichTextArea.tsx +886 -886
  25. package/src/Select/Select.tsx +515 -515
  26. package/src/Skeleton/Skeleton.tsx +70 -70
  27. package/src/Stack/Stack.tsx +52 -52
  28. package/src/Table/Table.tsx +335 -335
  29. package/src/Tabs/Tabs.tsx +90 -90
  30. package/src/Theme/ThemeProvider.tsx +253 -253
  31. package/src/Theme/ThemeToggle.tsx +79 -79
  32. package/src/Toggle/Toggle.tsx +48 -48
  33. package/src/Tooltip/Tooltip.tsx +38 -38
  34. package/src/TopNav/TopNav.tsx +1163 -1163
  35. package/src/TreeSelect/TreeSelect.tsx +825 -825
  36. package/src/css/alert.module.scss +93 -93
  37. package/src/css/autocomplete.module.scss +416 -416
  38. package/src/css/badge.module.scss +82 -82
  39. package/src/css/base/_color.scss +159 -159
  40. package/src/css/base/_theme.scss +237 -237
  41. package/src/css/base/_variables.scss +161 -161
  42. package/src/css/breadcrumb.module.scss +50 -50
  43. package/src/css/button.module.scss +385 -385
  44. package/src/css/card.module.scss +217 -217
  45. package/src/css/checkbox.module.scss +123 -123
  46. package/src/css/commandpalette.module.scss +211 -211
  47. package/src/css/container.module.scss +18 -18
  48. package/src/css/divider.module.scss +41 -41
  49. package/src/css/form.module.scss +245 -245
  50. package/src/css/imagecropper.module.scss +397 -397
  51. package/src/css/input.module.scss +89 -89
  52. package/src/css/modal.module.scss +105 -105
  53. package/src/css/nav.module.scss +494 -494
  54. package/src/css/notification.module.scss +691 -691
  55. package/src/css/pagination.module.scss +63 -63
  56. package/src/css/radio.module.scss +89 -89
  57. package/src/css/richtextarea.module.scss +307 -307
  58. package/src/css/select.module.scss +525 -525
  59. package/src/css/skeleton.module.scss +30 -30
  60. package/src/css/table.module.scss +386 -386
  61. package/src/css/tabs.module.scss +63 -63
  62. package/src/css/theme-toggle.module.scss +83 -83
  63. package/src/css/toggle.module.scss +54 -54
  64. package/src/css/tooltip.module.scss +97 -97
  65. package/src/css/topnav.module.scss +568 -568
  66. package/src/css/treeselect.module.scss +558 -558
  67. package/src/css/utilities/_borders.scss +111 -111
  68. package/src/css/utilities/_colors.scss +66 -66
  69. package/src/css/utilities/_effects.scss +216 -216
  70. package/src/css/utilities/_layout.scss +181 -181
  71. package/src/css/utilities/_position.scss +75 -75
  72. package/src/css/utilities/_sizing.scss +138 -138
  73. package/src/css/utilities/_spacing.scss +99 -99
  74. package/src/css/utilities/_typography.scss +121 -121
  75. package/src/css/utilities/index.scss +24 -24
  76. package/src/declarations.d.ts +6 -6
  77. 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
+ });