@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
@@ -1,994 +1,1163 @@
1
- // EvoTopNav — compose-based top navigation with a mobile drawer, polymorphic
2
- // items, and dropdown sub-menus.
3
- //
4
- // Research notes (CLAUDE.md §2):
5
- // - Radix Navigation Menu — adopted the compose + `asChild` slot pattern.
6
- // - Mantine AppShell.Header — kept Burger / drawer as a separate sub-component
7
- // so the header itself stays a thin layout primitive.
8
- // - shadcn Navigation Menu — confirmed the same compose shape works without
9
- // any runtime dependency.
10
-
11
- import React, {
12
- forwardRef,
13
- useCallback,
14
- useEffect,
15
- useId,
16
- useLayoutEffect,
17
- useMemo,
18
- useRef,
19
- useState,
20
- } from 'react';
21
- import styles from '../css/topnav.module.scss';
22
-
23
- // ============================================================================
24
- // Types
25
- // ============================================================================
26
-
27
- export interface EvoTopNavProps
28
- extends Omit<React.HTMLAttributes<HTMLElement>, 'onChange'> {
29
- children: React.ReactNode;
30
- open?: boolean;
31
- defaultOpen?: boolean;
32
- onOpenChange?: (open: boolean) => void;
33
- /** Width in px below which Menu collapses into the drawer. @default 768 */
34
- collapseBelow?: number;
35
- className?: string;
36
- }
37
-
38
- export interface EvoTopNavBrandProps
39
- extends React.HTMLAttributes<HTMLDivElement> {
40
- children: React.ReactNode;
41
- className?: string;
42
- }
43
-
44
- export interface EvoTopNavMenuProps
45
- extends React.HTMLAttributes<HTMLUListElement> {
46
- children: React.ReactNode;
47
- className?: string;
48
- }
49
-
50
- export interface EvoTopNavItemProps {
51
- children: React.ReactNode;
52
- active?: boolean;
53
- icon?: React.ReactNode;
54
- href?: string;
55
- target?: '_self' | '_blank' | '_parent' | '_top';
56
- rel?: string;
57
- onClick?: (e: React.MouseEvent) => void;
58
- asChild?: boolean;
59
- className?: string;
60
- }
61
-
62
- export interface EvoTopNavActionsProps
63
- extends React.HTMLAttributes<HTMLDivElement> {
64
- children: React.ReactNode;
65
- className?: string;
66
- }
67
-
68
- export interface EvoTopNavToggleProps
69
- extends Omit<
70
- React.ButtonHTMLAttributes<HTMLButtonElement>,
71
- 'aria-expanded' | 'aria-controls'
72
- > {
73
- icon?: React.ReactNode;
74
- className?: string;
75
- }
76
-
77
- export interface EvoTopNavDropdownProps {
78
- label: React.ReactNode;
79
- icon?: React.ReactNode;
80
- active?: boolean;
81
- hoverable?: boolean;
82
- open?: boolean;
83
- defaultOpen?: boolean;
84
- onOpenChange?: (open: boolean) => void;
85
- children: React.ReactNode;
86
- className?: string;
87
- }
88
-
89
- export interface EvoTopNavDropdownItemProps {
90
- children: React.ReactNode;
91
- icon?: React.ReactNode;
92
- active?: boolean;
93
- href?: string;
94
- target?: '_self' | '_blank' | '_parent' | '_top';
95
- rel?: string;
96
- onClick?: (e: React.MouseEvent) => void;
97
- asChild?: boolean;
98
- className?: string;
99
- }
100
-
101
- // ============================================================================
102
- // Internal helpers
103
- // ============================================================================
104
-
105
- const cn = (...c: Array<string | false | null | undefined>) =>
106
- c.filter(Boolean).join(' ');
107
-
108
- function composeHandlers<E extends React.SyntheticEvent>(
109
- ours?: (e: E) => void,
110
- theirs?: (e: E) => void,
111
- ) {
112
- return (e: E) => {
113
- ours?.(e);
114
- if (!e.defaultPrevented) theirs?.(e);
115
- };
116
- }
117
-
118
- function useControllableState<T>(opts: {
119
- value?: T;
120
- defaultValue: T;
121
- onChange?: (v: T) => void;
122
- }) {
123
- const { value, defaultValue, onChange } = opts;
124
- const [internal, setInternal] = useState<T>(defaultValue);
125
- const isControlled = value !== undefined;
126
- const current = isControlled ? (value as T) : internal;
127
- const setValue = useCallback(
128
- (next: T) => {
129
- if (!isControlled) setInternal(next);
130
- onChange?.(next);
131
- },
132
- [isControlled, onChange],
133
- );
134
- return [current, setValue] as const;
135
- }
136
-
137
- const useIsoLayoutEffect =
138
- typeof window !== 'undefined' ? useLayoutEffect : useEffect;
139
-
140
- function useMediaQuery(query: string, fallback = false) {
141
- const [matches, setMatches] = useState(fallback);
142
- useIsoLayoutEffect(() => {
143
- if (typeof window === 'undefined' || !window.matchMedia) return;
144
- const mql = window.matchMedia(query);
145
- const update = () => setMatches(mql.matches);
146
- update();
147
- mql.addEventListener?.('change', update);
148
- return () => mql.removeEventListener?.('change', update);
149
- }, [query]);
150
- return matches;
151
- }
152
-
153
- const useIsCollapsed = (breakpoint: number) =>
154
- useMediaQuery(`(max-width: ${breakpoint - 1}px)`);
155
-
156
- const useHoverCapable = () =>
157
- useMediaQuery('(hover: hover) and (pointer: fine)', true);
158
-
159
- const usePrefersReducedMotion = () =>
160
- useMediaQuery('(prefers-reduced-motion: reduce)');
161
-
162
- function getFocusable(root: HTMLElement | null) {
163
- if (!root) return [] as HTMLElement[];
164
- return Array.from(
165
- root.querySelectorAll<HTMLElement>(
166
- 'a[href], button:not([disabled]), [tabindex]:not([tabindex="-1"]), input:not([disabled]), select:not([disabled]), textarea:not([disabled])',
167
- ),
168
- ).filter(
169
- (el) =>
170
- !el.hasAttribute('aria-hidden') &&
171
- el.offsetParent !== null,
172
- );
173
- }
174
-
175
- // ============================================================================
176
- // Slot minimal Radix-style asChild implementation (single child)
177
- // ============================================================================
178
-
179
- interface SlotProps extends React.HTMLAttributes<HTMLElement> {
180
- children: React.ReactNode;
181
- }
182
-
183
- const Slot = forwardRef<HTMLElement, SlotProps>(function Slot(
184
- { children, ...slotProps },
185
- _ref,
186
- ) {
187
- const child = React.Children.only(children) as React.ReactElement<
188
- Record<string, unknown>
189
- >;
190
- const childProps = (child.props ?? {}) as Record<string, unknown>;
191
-
192
- const merged: Record<string, unknown> = { ...slotProps, ...childProps };
193
-
194
- // className: parent first, child appended
195
- merged.className = cn(
196
- slotProps.className as string | undefined,
197
- childProps.className as string | undefined,
198
- );
199
-
200
- // style: shallow merge child wins on key collisions
201
- if (slotProps.style || childProps.style) {
202
- merged.style = {
203
- ...(slotProps.style as React.CSSProperties),
204
- ...(childProps.style as React.CSSProperties),
205
- };
206
- }
207
-
208
- // Compose event handlers (every on* prop on the slot)
209
- for (const key of Object.keys(slotProps)) {
210
- if (
211
- key.startsWith('on') &&
212
- typeof (slotProps as Record<string, unknown>)[key] === 'function'
213
- ) {
214
- const ours = (slotProps as Record<string, unknown>)[key] as (
215
- e: React.SyntheticEvent,
216
- ) => void;
217
- const theirs = childProps[key] as
218
- | ((e: React.SyntheticEvent) => void)
219
- | undefined;
220
- merged[key] = composeHandlers(ours, theirs);
221
- }
222
- }
223
-
224
- return React.cloneElement(child, merged);
225
- });
226
-
227
- // ============================================================================
228
- // Context
229
- // ============================================================================
230
-
231
- interface TopNavContextValue {
232
- drawerOpen: boolean;
233
- setDrawerOpen: (open: boolean) => void;
234
- isCollapsed: boolean;
235
- menuId: string;
236
- toggleId: string;
237
- registerToggle: () => () => void;
238
- toggleCount: number;
239
- menuRef: React.RefObject<HTMLUListElement | null>;
240
- toggleRef: React.RefObject<HTMLButtonElement | null>;
241
- }
242
-
243
- const TopNavContext = React.createContext<TopNavContextValue | null>(null);
244
-
245
- function useTopNavContext(component: string) {
246
- const ctx = React.useContext(TopNavContext);
247
- if (!ctx) {
248
- throw new Error(
249
- `EvoTopNav.${component} must be rendered inside <EvoTopNav>.`,
250
- );
251
- }
252
- return ctx;
253
- }
254
-
255
- interface DropdownContextValue {
256
- open: boolean;
257
- setOpen: (open: boolean) => void;
258
- triggerRef: React.RefObject<HTMLButtonElement | null>;
259
- contentRef: React.RefObject<HTMLUListElement | null>;
260
- contentId: string;
261
- focusItem: (delta: 1 | -1 | 'first' | 'last') => void;
262
- inDrawer: boolean;
263
- }
264
-
265
- const DropdownContext = React.createContext<DropdownContextValue | null>(null);
266
-
267
- // ============================================================================
268
- // Default icons
269
- // ============================================================================
270
-
271
- const HamburgerIcon = ({ open }: { open: boolean }) => (
272
- <svg
273
- width="18"
274
- height="18"
275
- viewBox="0 0 18 18"
276
- fill="none"
277
- aria-hidden="true"
278
- className={styles.toggleIcon}
279
- >
280
- {open ? (
281
- <>
282
- <path
283
- d="M4 4l10 10M14 4L4 14"
284
- stroke="currentColor"
285
- strokeWidth="1.75"
286
- strokeLinecap="round"
287
- />
288
- </>
289
- ) : (
290
- <>
291
- <path
292
- d="M3 5h12M3 9h12M3 13h12"
293
- stroke="currentColor"
294
- strokeWidth="1.75"
295
- strokeLinecap="round"
296
- />
297
- </>
298
- )}
299
- </svg>
300
- );
301
-
302
- const ChevronIcon = ({ open }: { open: boolean }) => (
303
- <svg
304
- viewBox="0 0 12 12"
305
- width="10"
306
- height="10"
307
- fill="none"
308
- aria-hidden="true"
309
- className={cn(styles.dropdownChevron, open && styles.dropdownChevronOpen)}
310
- >
311
- <path
312
- d="M3 4.5l3 3 3-3"
313
- stroke="currentColor"
314
- strokeWidth="1.5"
315
- strokeLinecap="round"
316
- strokeLinejoin="round"
317
- />
318
- </svg>
319
- );
320
-
321
- // ============================================================================
322
- // EvoTopNav.Brand
323
- // ============================================================================
324
-
325
- const EvoTopNavBrand = forwardRef<HTMLDivElement, EvoTopNavBrandProps>(
326
- function EvoTopNavBrand({ children, className, ...rest }, ref) {
327
- return (
328
- <div
329
- ref={ref}
330
- className={cn(styles.topNavBrand, className)}
331
- {...rest}
332
- >
333
- {children}
334
- </div>
335
- );
336
- },
337
- );
338
-
339
- // ============================================================================
340
- // EvoTopNav.Menu
341
- // ============================================================================
342
-
343
- const EvoTopNavMenu = forwardRef<HTMLUListElement, EvoTopNavMenuProps>(
344
- function EvoTopNavMenu({ children, className, ...rest }, forwardedRef) {
345
- const ctx = useTopNavContext('Menu');
346
- const localRef = useRef<HTMLUListElement | null>(null);
347
-
348
- const setRef = (node: HTMLUListElement | null) => {
349
- localRef.current = node;
350
- ctx.menuRef.current = node;
351
- if (typeof forwardedRef === 'function') forwardedRef(node);
352
- else if (forwardedRef)
353
- (forwardedRef as React.RefObject<HTMLUListElement | null>).current = node;
354
- };
355
-
356
- const hasToggle = ctx.toggleCount > 0;
357
- const inDrawer = ctx.isCollapsed && hasToggle;
358
- const drawerClosed = inDrawer && !ctx.drawerOpen;
359
-
360
- return (
361
- <ul
362
- ref={setRef}
363
- id={ctx.menuId}
364
- className={cn(
365
- styles.topNavMenu,
366
- inDrawer && styles.topNavMenuDrawer,
367
- drawerClosed && styles.topNavMenuDrawerClosed,
368
- ctx.isCollapsed && !hasToggle && styles.topNavMenuScroll,
369
- className,
370
- )}
371
- data-state={inDrawer ? (ctx.drawerOpen ? 'open' : 'closed') : 'inline'}
372
- aria-hidden={drawerClosed || undefined}
373
- {...rest}
374
- >
375
- {children}
376
- </ul>
377
- );
378
- },
379
- );
380
-
381
- // ============================================================================
382
- // EvoTopNav.Item — polymorphic
383
- // ============================================================================
384
-
385
- const EvoTopNavItem = forwardRef<HTMLElement, EvoTopNavItemProps>(
386
- function EvoTopNavItem(
387
- { children, active, icon, href, target, rel, onClick, asChild, className },
388
- ref,
389
- ) {
390
- const ctx = React.useContext(TopNavContext);
391
- const inDrawerCtx = !!ctx && ctx.isCollapsed && ctx.drawerOpen;
392
-
393
- // Auto-close drawer on activation (SPA route nav heuristic).
394
- const handleActivation = (e: React.MouseEvent) => {
395
- onClick?.(e);
396
- if (!e.defaultPrevented && inDrawerCtx) ctx.setDrawerOpen(false);
397
- };
398
-
399
- const shared = {
400
- className: cn(styles.topNavItem, active && styles.topNavItemActive, className),
401
- 'aria-current': active ? ('page' as const) : undefined,
402
- 'data-active': active || undefined,
403
- };
404
-
405
- const content = (
406
- <>
407
- {icon && (
408
- <span className={styles.topNavIcon} aria-hidden="true">
409
- {icon}
410
- </span>
411
- )}
412
- <span className={styles.topNavItemLabel}>{children}</span>
413
- </>
414
- );
415
-
416
- let element: React.ReactElement;
417
-
418
- if (asChild) {
419
- element = (
420
- <Slot
421
- {...shared}
422
- onClick={handleActivation}
423
- ref={ref as React.Ref<HTMLElement>}
424
- >
425
- {children}
426
- </Slot>
427
- );
428
- } else if (href) {
429
- element = (
430
- <a
431
- ref={ref as React.Ref<HTMLAnchorElement>}
432
- href={href}
433
- target={target}
434
- rel={
435
- rel ?? (target === '_blank' ? 'noopener noreferrer' : undefined)
436
- }
437
- onClick={handleActivation}
438
- {...shared}
439
- >
440
- {content}
441
- </a>
442
- );
443
- } else {
444
- element = (
445
- <button
446
- ref={ref as React.Ref<HTMLButtonElement>}
447
- type="button"
448
- onClick={handleActivation}
449
- {...shared}
450
- >
451
- {content}
452
- </button>
453
- );
454
- }
455
-
456
- return <li className={styles.topNavItemRow}>{element}</li>;
457
- },
458
- );
459
-
460
- // ============================================================================
461
- // EvoTopNav.Actions
462
- // ============================================================================
463
-
464
- const EvoTopNavActions = forwardRef<HTMLDivElement, EvoTopNavActionsProps>(
465
- function EvoTopNavActions({ children, className, ...rest }, ref) {
466
- return (
467
- <div
468
- ref={ref}
469
- className={cn(styles.topNavActions, className)}
470
- {...rest}
471
- >
472
- {children}
473
- </div>
474
- );
475
- },
476
- );
477
-
478
- // ============================================================================
479
- // EvoTopNav.Toggle hamburger
480
- // ============================================================================
481
-
482
- const EvoTopNavToggle = forwardRef<HTMLButtonElement, EvoTopNavToggleProps>(
483
- function EvoTopNavToggle(
484
- { icon, className, onClick, 'aria-label': ariaLabel, ...rest },
485
- forwardedRef,
486
- ) {
487
- const ctx = useTopNavContext('Toggle');
488
- const localRef = useRef<HTMLButtonElement | null>(null);
489
-
490
- useEffect(() => ctx.registerToggle(), [ctx]);
491
-
492
- const setRef = (node: HTMLButtonElement | null) => {
493
- localRef.current = node;
494
- ctx.toggleRef.current = node;
495
- if (typeof forwardedRef === 'function') forwardedRef(node);
496
- else if (forwardedRef)
497
- (forwardedRef as React.RefObject<HTMLButtonElement | null>).current = node;
498
- };
499
-
500
- const handleClick = (e: React.MouseEvent<HTMLButtonElement>) => {
501
- onClick?.(e);
502
- if (!e.defaultPrevented) ctx.setDrawerOpen(!ctx.drawerOpen);
503
- };
504
-
505
- return (
506
- <button
507
- ref={setRef}
508
- id={ctx.toggleId}
509
- type="button"
510
- className={cn(styles.topNavToggle, className)}
511
- aria-expanded={ctx.drawerOpen}
512
- aria-controls={ctx.menuId}
513
- aria-label={
514
- ariaLabel ?? (ctx.drawerOpen ? 'Close menu' : 'Open menu')
515
- }
516
- onClick={handleClick}
517
- {...rest}
518
- >
519
- {icon ?? <HamburgerIcon open={ctx.drawerOpen} />}
520
- </button>
521
- );
522
- },
523
- );
524
-
525
- // ============================================================================
526
- // EvoTopNav.Dropdown
527
- // ============================================================================
528
-
529
- const EvoTopNavDropdown: React.FC<EvoTopNavDropdownProps> = ({
530
- label,
531
- icon,
532
- active,
533
- hoverable = true,
534
- open,
535
- defaultOpen = false,
536
- onOpenChange,
537
- children,
538
- className,
539
- }) => {
540
- const ctx = React.useContext(TopNavContext);
541
- const inDrawer = !!ctx && ctx.isCollapsed && ctx.toggleCount > 0;
542
-
543
- const [isOpen, setOpen] = useControllableState({
544
- value: open,
545
- defaultValue: defaultOpen,
546
- onChange: onOpenChange,
547
- });
548
-
549
- const hoverCapable = useHoverCapable();
550
- const allowHover = hoverable && hoverCapable && !inDrawer;
551
-
552
- const triggerRef = useRef<HTMLButtonElement | null>(null);
553
- const contentRef = useRef<HTMLUListElement | null>(null);
554
- const rootRef = useRef<HTMLLIElement | null>(null);
555
- const contentId = useId();
556
- const hoverTimer = useRef<ReturnType<typeof setTimeout> | null>(null);
557
-
558
- const closeAndRestore = useCallback(() => {
559
- setOpen(false);
560
- triggerRef.current?.focus();
561
- }, [setOpen]);
562
-
563
- // Click outside
564
- useEffect(() => {
565
- if (!isOpen || inDrawer) return;
566
- const onDocDown = (e: MouseEvent) => {
567
- const root = rootRef.current;
568
- if (root && e.target instanceof Node && !root.contains(e.target)) {
569
- setOpen(false);
570
- }
571
- };
572
- document.addEventListener('mousedown', onDocDown);
573
- return () => document.removeEventListener('mousedown', onDocDown);
574
- }, [isOpen, inDrawer, setOpen]);
575
-
576
- // Escape to close (only desktop dropdown — drawer handles its own Esc)
577
- useEffect(() => {
578
- if (!isOpen || inDrawer) return;
579
- const onKey = (e: KeyboardEvent) => {
580
- if (e.key === 'Escape') {
581
- e.stopPropagation();
582
- closeAndRestore();
583
- }
584
- };
585
- document.addEventListener('keydown', onKey);
586
- return () => document.removeEventListener('keydown', onKey);
587
- }, [isOpen, inDrawer, closeAndRestore]);
588
-
589
- const focusItem = useCallback(
590
- (delta: 1 | -1 | 'first' | 'last') => {
591
- const items = getFocusable(contentRef.current);
592
- if (items.length === 0) return;
593
- const activeEl = document.activeElement as HTMLElement | null;
594
- const idx = activeEl ? items.indexOf(activeEl) : -1;
595
- let next = 0;
596
- if (delta === 'first') next = 0;
597
- else if (delta === 'last') next = items.length - 1;
598
- else if (delta === 1) next = idx < 0 ? 0 : (idx + 1) % items.length;
599
- else next = idx < 0 ? items.length - 1 : (idx - 1 + items.length) % items.length;
600
- items[next]?.focus();
601
- },
602
- [],
603
- );
604
-
605
- const handleTriggerKey = (e: React.KeyboardEvent<HTMLButtonElement>) => {
606
- if (e.key === 'ArrowDown' || e.key === 'Enter' || e.key === ' ') {
607
- e.preventDefault();
608
- setOpen(true);
609
- requestAnimationFrame(() => focusItem('first'));
610
- } else if (e.key === 'ArrowUp') {
611
- e.preventDefault();
612
- setOpen(true);
613
- requestAnimationFrame(() => focusItem('last'));
614
- }
615
- };
616
-
617
- const handleContentKey = (e: React.KeyboardEvent<HTMLUListElement>) => {
618
- if (e.key === 'ArrowDown') {
619
- e.preventDefault();
620
- focusItem(1);
621
- } else if (e.key === 'ArrowUp') {
622
- e.preventDefault();
623
- focusItem(-1);
624
- } else if (e.key === 'Home') {
625
- e.preventDefault();
626
- focusItem('first');
627
- } else if (e.key === 'End') {
628
- e.preventDefault();
629
- focusItem('last');
630
- } else if (e.key === 'Tab') {
631
- // Allow Tab to leave the dropdown; close it.
632
- setOpen(false);
633
- }
634
- };
635
-
636
- const onMouseEnter = () => {
637
- if (!allowHover) return;
638
- if (hoverTimer.current) clearTimeout(hoverTimer.current);
639
- setOpen(true);
640
- };
641
- const onMouseLeave = () => {
642
- if (!allowHover) return;
643
- if (hoverTimer.current) clearTimeout(hoverTimer.current);
644
- hoverTimer.current = setTimeout(() => setOpen(false), 120);
645
- };
646
-
647
- const dropdownCtx = useMemo<DropdownContextValue>(
648
- () => ({
649
- open: isOpen,
650
- setOpen,
651
- triggerRef,
652
- contentRef,
653
- contentId,
654
- focusItem,
655
- inDrawer,
656
- }),
657
- [isOpen, setOpen, contentId, focusItem, inDrawer],
658
- );
659
-
660
- return (
661
- <DropdownContext.Provider value={dropdownCtx}>
662
- <li
663
- ref={rootRef}
664
- className={cn(
665
- styles.topNavDropdown,
666
- inDrawer && styles.topNavDropdownInDrawer,
667
- isOpen && styles.topNavDropdownOpen,
668
- className,
669
- )}
670
- onMouseEnter={onMouseEnter}
671
- onMouseLeave={onMouseLeave}
672
- >
673
- <button
674
- ref={triggerRef}
675
- type="button"
676
- className={cn(
677
- styles.topNavItem,
678
- styles.topNavDropdownTrigger,
679
- active && styles.topNavItemActive,
680
- )}
681
- aria-haspopup="menu"
682
- aria-expanded={isOpen}
683
- aria-controls={contentId}
684
- aria-current={active ? 'page' : undefined}
685
- onClick={() => setOpen(!isOpen)}
686
- onKeyDown={handleTriggerKey}
687
- >
688
- {icon && (
689
- <span className={styles.topNavIcon} aria-hidden="true">
690
- {icon}
691
- </span>
692
- )}
693
- <span className={styles.topNavItemLabel}>{label}</span>
694
- <ChevronIcon open={isOpen} />
695
- </button>
696
- <ul
697
- ref={contentRef}
698
- id={contentId}
699
- role="menu"
700
- className={cn(
701
- styles.topNavDropdownContent,
702
- isOpen && styles.topNavDropdownContentOpen,
703
- )}
704
- // Note: visibility is driven by the .topNavDropdownContentOpen class
705
- // (display: none → flex). Avoid the `hidden` attribute because
706
- // `[hidden] { display: none }` is overridden by class-level `display`
707
- // rules — the two-source-of-truth setup leaked an empty panel onto
708
- // the page in v1.
709
- aria-hidden={!isOpen || undefined}
710
- onKeyDown={handleContentKey}
711
- >
712
- {children}
713
- </ul>
714
- </li>
715
- </DropdownContext.Provider>
716
- );
717
- };
718
-
719
- // ============================================================================
720
- // EvoTopNav.DropdownItem
721
- // ============================================================================
722
-
723
- const EvoTopNavDropdownItem = forwardRef<HTMLElement, EvoTopNavDropdownItemProps>(
724
- function EvoTopNavDropdownItem(
725
- { children, icon, active, href, target, rel, onClick, asChild, className },
726
- ref,
727
- ) {
728
- const dropdown = React.useContext(DropdownContext);
729
- const top = React.useContext(TopNavContext);
730
-
731
- const handleActivation = (e: React.MouseEvent) => {
732
- onClick?.(e);
733
- if (!e.defaultPrevented) {
734
- dropdown?.setOpen(false);
735
- // Also close the drawer if we're inside it.
736
- if (top && top.isCollapsed && top.drawerOpen) top.setDrawerOpen(false);
737
- }
738
- };
739
-
740
- const shared = {
741
- className: cn(
742
- styles.topNavDropdownItem,
743
- active && styles.topNavItemActive,
744
- className,
745
- ),
746
- role: 'menuitem' as const,
747
- 'aria-current': active ? ('page' as const) : undefined,
748
- };
749
-
750
- const content = (
751
- <>
752
- {icon && (
753
- <span className={styles.topNavIcon} aria-hidden="true">
754
- {icon}
755
- </span>
756
- )}
757
- <span className={styles.topNavItemLabel}>{children}</span>
758
- </>
759
- );
760
-
761
- let element: React.ReactElement;
762
-
763
- if (asChild) {
764
- element = (
765
- <Slot
766
- {...shared}
767
- onClick={handleActivation}
768
- ref={ref as React.Ref<HTMLElement>}
769
- >
770
- {children}
771
- </Slot>
772
- );
773
- } else if (href) {
774
- element = (
775
- <a
776
- ref={ref as React.Ref<HTMLAnchorElement>}
777
- href={href}
778
- target={target}
779
- rel={rel ?? (target === '_blank' ? 'noopener noreferrer' : undefined)}
780
- onClick={handleActivation}
781
- {...shared}
782
- >
783
- {content}
784
- </a>
785
- );
786
- } else {
787
- element = (
788
- <button
789
- ref={ref as React.Ref<HTMLButtonElement>}
790
- type="button"
791
- onClick={handleActivation}
792
- {...shared}
793
- >
794
- {content}
795
- </button>
796
- );
797
- }
798
-
799
- return <li>{element}</li>;
800
- },
801
- );
802
-
803
- // ============================================================================
804
- // EvoTopNav — root
805
- // ============================================================================
806
-
807
- export const EvoTopNav = forwardRef<HTMLElement, EvoTopNavProps>(
808
- function EvoTopNav(
809
- {
810
- children,
811
- open,
812
- defaultOpen = false,
813
- onOpenChange,
814
- collapseBelow = 768,
815
- className,
816
- ...rest
817
- },
818
- ref,
819
- ) {
820
- const [drawerOpen, setDrawerOpen] = useControllableState({
821
- value: open,
822
- defaultValue: defaultOpen,
823
- onChange: onOpenChange,
824
- });
825
-
826
- const isCollapsed = useIsCollapsed(collapseBelow);
827
- const reducedMotion = usePrefersReducedMotion();
828
-
829
- const [toggleCount, setToggleCount] = useState(0);
830
- const registerToggle = useCallback(() => {
831
- setToggleCount((c) => c + 1);
832
- return () => setToggleCount((c) => Math.max(0, c - 1));
833
- }, []);
834
-
835
- const menuId = useId();
836
- const toggleId = useId();
837
- const menuRef = useRef<HTMLUListElement | null>(null);
838
- const toggleRef = useRef<HTMLButtonElement | null>(null);
839
- const restoreFocusRef = useRef<HTMLElement | null>(null);
840
-
841
- const drawerActive = isCollapsed && drawerOpen && toggleCount > 0;
842
-
843
- // Close drawer when leaving the collapsed breakpoint.
844
- useEffect(() => {
845
- if (!isCollapsed && drawerOpen) setDrawerOpen(false);
846
- }, [isCollapsed, drawerOpen, setDrawerOpen]);
847
-
848
- // Body scroll lock while drawer is open.
849
- useEffect(() => {
850
- if (!drawerActive) return;
851
- const prev = document.body.style.overflow;
852
- document.body.style.overflow = 'hidden';
853
- return () => {
854
- document.body.style.overflow = prev;
855
- };
856
- }, [drawerActive]);
857
-
858
- // Esc to close + focus trap inside Menu while drawer is open.
859
- useEffect(() => {
860
- if (!drawerActive) return;
861
- restoreFocusRef.current =
862
- (document.activeElement as HTMLElement | null) ?? null;
863
-
864
- const focusFirst = () => {
865
- const items = getFocusable(menuRef.current);
866
- items[0]?.focus();
867
- };
868
- const id = requestAnimationFrame(focusFirst);
869
-
870
- const onKey = (e: KeyboardEvent) => {
871
- if (e.key === 'Escape') {
872
- e.preventDefault();
873
- setDrawerOpen(false);
874
- return;
875
- }
876
- if (e.key !== 'Tab' || !menuRef.current) return;
877
- const items = getFocusable(menuRef.current);
878
- if (items.length === 0) return;
879
- const first = items[0];
880
- const last = items[items.length - 1];
881
- const activeEl = document.activeElement as HTMLElement | null;
882
- if (e.shiftKey && activeEl === first) {
883
- e.preventDefault();
884
- last.focus();
885
- } else if (!e.shiftKey && activeEl === last) {
886
- e.preventDefault();
887
- first.focus();
888
- } else if (activeEl && !menuRef.current.contains(activeEl)) {
889
- e.preventDefault();
890
- first.focus();
891
- }
892
- };
893
- document.addEventListener('keydown', onKey);
894
- return () => {
895
- cancelAnimationFrame(id);
896
- document.removeEventListener('keydown', onKey);
897
- };
898
- }, [drawerActive, setDrawerOpen]);
899
-
900
- // Restore focus when drawer closes.
901
- useEffect(() => {
902
- if (drawerActive) return;
903
- const target = restoreFocusRef.current;
904
- if (target && typeof target.focus === 'function') {
905
- // Only restore if focus is currently inside the menu (or nowhere).
906
- const active = document.activeElement;
907
- if (
908
- !active ||
909
- active === document.body ||
910
- (menuRef.current && menuRef.current.contains(active))
911
- ) {
912
- target.focus();
913
- }
914
- restoreFocusRef.current = null;
915
- }
916
- }, [drawerActive]);
917
-
918
- const ctxValue = useMemo<TopNavContextValue>(
919
- () => ({
920
- drawerOpen,
921
- setDrawerOpen,
922
- isCollapsed,
923
- menuId,
924
- toggleId,
925
- registerToggle,
926
- toggleCount,
927
- menuRef,
928
- toggleRef,
929
- }),
930
- [
931
- drawerOpen,
932
- setDrawerOpen,
933
- isCollapsed,
934
- menuId,
935
- toggleId,
936
- registerToggle,
937
- toggleCount,
938
- ],
939
- );
940
-
941
- return (
942
- <TopNavContext.Provider value={ctxValue}>
943
- <nav
944
- ref={ref}
945
- className={cn(
946
- styles.topNav,
947
- drawerActive && styles.topNavDrawerOpen,
948
- reducedMotion && styles.topNavReducedMotion,
949
- className,
950
- )}
951
- data-collapsed={isCollapsed || undefined}
952
- data-drawer-open={drawerActive || undefined}
953
- {...rest}
954
- >
955
- <div className={styles.topNavInner}>{children}</div>
956
- {drawerActive && (
957
- <div
958
- className={styles.topNavBackdrop}
959
- onClick={() => setDrawerOpen(false)}
960
- aria-hidden="true"
961
- />
962
- )}
963
- </nav>
964
- </TopNavContext.Provider>
965
- );
966
- },
967
- ) as React.ForwardRefExoticComponent<
968
- EvoTopNavProps & React.RefAttributes<HTMLElement>
969
- > & {
970
- Brand: typeof EvoTopNavBrand;
971
- Menu: typeof EvoTopNavMenu;
972
- Item: typeof EvoTopNavItem;
973
- Actions: typeof EvoTopNavActions;
974
- Toggle: typeof EvoTopNavToggle;
975
- Dropdown: typeof EvoTopNavDropdown;
976
- DropdownItem: typeof EvoTopNavDropdownItem;
977
- };
978
-
979
- EvoTopNavBrand.displayName = 'EvoTopNav.Brand';
980
- EvoTopNavMenu.displayName = 'EvoTopNav.Menu';
981
- EvoTopNavItem.displayName = 'EvoTopNav.Item';
982
- EvoTopNavActions.displayName = 'EvoTopNav.Actions';
983
- EvoTopNavToggle.displayName = 'EvoTopNav.Toggle';
984
- EvoTopNavDropdown.displayName = 'EvoTopNav.Dropdown';
985
- EvoTopNavDropdownItem.displayName = 'EvoTopNav.DropdownItem';
986
- (EvoTopNav as { displayName?: string }).displayName = 'EvoTopNav';
987
-
988
- EvoTopNav.Brand = EvoTopNavBrand;
989
- EvoTopNav.Menu = EvoTopNavMenu;
990
- EvoTopNav.Item = EvoTopNavItem;
991
- EvoTopNav.Actions = EvoTopNavActions;
992
- EvoTopNav.Toggle = EvoTopNavToggle;
993
- EvoTopNav.Dropdown = EvoTopNavDropdown;
994
- EvoTopNav.DropdownItem = EvoTopNavDropdownItem;
1
+ // EvoTopNav — compose-based top navigation with a mobile drawer, polymorphic
2
+ // items, and dropdown sub-menus.
3
+ //
4
+ // Research notes (CLAUDE.md §2):
5
+ // - Radix Navigation Menu — adopted the compose + `asChild` slot pattern.
6
+ // - Mantine AppShell.Header — kept Burger / drawer as a separate sub-component
7
+ // so the header itself stays a thin layout primitive.
8
+ // - shadcn Navigation Menu — confirmed the same compose shape works without
9
+ // any runtime dependency.
10
+
11
+ import React, {
12
+ forwardRef,
13
+ useCallback,
14
+ useEffect,
15
+ useId,
16
+ useLayoutEffect,
17
+ useMemo,
18
+ useRef,
19
+ useState,
20
+ } from 'react';
21
+ import styles from '../css/topnav.module.scss';
22
+
23
+ // ============================================================================
24
+ // Types
25
+ // ============================================================================
26
+
27
+ export interface EvoTopNavProps
28
+ extends Omit<React.HTMLAttributes<HTMLElement>, 'onChange'> {
29
+ children: React.ReactNode;
30
+ open?: boolean;
31
+ defaultOpen?: boolean;
32
+ onOpenChange?: (open: boolean) => void;
33
+ /** Width in px below which Menu collapses into the drawer. @default 768 */
34
+ collapseBelow?: number;
35
+ /** Staggered mount animation for the bar's contents. @default 'none' */
36
+ entrance?: 'none' | 'rise' | 'fade';
37
+ /** Pin the bar with position: sticky; top: 0. @default false */
38
+ sticky?: boolean;
39
+ /** On-scroll treatment of a sticky bar. @default 'none' */
40
+ scrollBehavior?: 'none' | 'elevate' | 'shrink' | 'hide';
41
+ /** Render a thin scroll-progress accent line along the bottom edge. @default false */
42
+ showProgress?: boolean;
43
+ className?: string;
44
+ }
45
+
46
+ export interface EvoTopNavBrandProps
47
+ extends React.HTMLAttributes<HTMLDivElement> {
48
+ children: React.ReactNode;
49
+ className?: string;
50
+ }
51
+
52
+ export interface EvoTopNavMenuProps
53
+ extends React.HTMLAttributes<HTMLUListElement> {
54
+ children: React.ReactNode;
55
+ className?: string;
56
+ }
57
+
58
+ export interface EvoTopNavItemProps {
59
+ children: React.ReactNode;
60
+ active?: boolean;
61
+ icon?: React.ReactNode;
62
+ href?: string;
63
+ target?: '_self' | '_blank' | '_parent' | '_top';
64
+ rel?: string;
65
+ onClick?: (e: React.MouseEvent) => void;
66
+ asChild?: boolean;
67
+ className?: string;
68
+ }
69
+
70
+ export interface EvoTopNavActionsProps
71
+ extends React.HTMLAttributes<HTMLDivElement> {
72
+ children: React.ReactNode;
73
+ className?: string;
74
+ }
75
+
76
+ export interface EvoTopNavToggleProps
77
+ extends Omit<
78
+ React.ButtonHTMLAttributes<HTMLButtonElement>,
79
+ 'aria-expanded' | 'aria-controls'
80
+ > {
81
+ icon?: React.ReactNode;
82
+ className?: string;
83
+ }
84
+
85
+ export interface EvoTopNavSearchProps
86
+ extends Omit<React.ButtonHTMLAttributes<HTMLButtonElement>, 'children'> {
87
+ /** Placeholder text shown inside the trigger. @default 'Search…' */
88
+ placeholder?: string;
89
+ /** Opt-in global hotkey, e.g. 'mod+k' (mod = ⌘ on macOS, Ctrl elsewhere). Default: none. */
90
+ shortcut?: string;
91
+ /** Override the kbd hint. @default platform-aware ⌘K / Ctrl K */
92
+ shortcutHint?: React.ReactNode;
93
+ className?: string;
94
+ }
95
+
96
+ export interface EvoTopNavDropdownProps {
97
+ label: React.ReactNode;
98
+ icon?: React.ReactNode;
99
+ active?: boolean;
100
+ hoverable?: boolean;
101
+ open?: boolean;
102
+ defaultOpen?: boolean;
103
+ onOpenChange?: (open: boolean) => void;
104
+ children: React.ReactNode;
105
+ className?: string;
106
+ }
107
+
108
+ export interface EvoTopNavDropdownItemProps {
109
+ children: React.ReactNode;
110
+ icon?: React.ReactNode;
111
+ active?: boolean;
112
+ href?: string;
113
+ target?: '_self' | '_blank' | '_parent' | '_top';
114
+ rel?: string;
115
+ onClick?: (e: React.MouseEvent) => void;
116
+ asChild?: boolean;
117
+ className?: string;
118
+ }
119
+
120
+ // ============================================================================
121
+ // Internal helpers
122
+ // ============================================================================
123
+
124
+ const cn = (...c: Array<string | false | null | undefined>) =>
125
+ c.filter(Boolean).join(' ');
126
+
127
+ function composeHandlers<E extends React.SyntheticEvent>(
128
+ ours?: (e: E) => void,
129
+ theirs?: (e: E) => void,
130
+ ) {
131
+ return (e: E) => {
132
+ ours?.(e);
133
+ if (!e.defaultPrevented) theirs?.(e);
134
+ };
135
+ }
136
+
137
+ function useControllableState<T>(opts: {
138
+ value?: T;
139
+ defaultValue: T;
140
+ onChange?: (v: T) => void;
141
+ }) {
142
+ const { value, defaultValue, onChange } = opts;
143
+ const [internal, setInternal] = useState<T>(defaultValue);
144
+ const isControlled = value !== undefined;
145
+ const current = isControlled ? (value as T) : internal;
146
+ const setValue = useCallback(
147
+ (next: T) => {
148
+ if (!isControlled) setInternal(next);
149
+ onChange?.(next);
150
+ },
151
+ [isControlled, onChange],
152
+ );
153
+ return [current, setValue] as const;
154
+ }
155
+
156
+ const useIsoLayoutEffect =
157
+ typeof window !== 'undefined' ? useLayoutEffect : useEffect;
158
+
159
+ function useMediaQuery(query: string, fallback = false) {
160
+ const [matches, setMatches] = useState(fallback);
161
+ useIsoLayoutEffect(() => {
162
+ if (typeof window === 'undefined' || !window.matchMedia) return;
163
+ const mql = window.matchMedia(query);
164
+ const update = () => setMatches(mql.matches);
165
+ update();
166
+ mql.addEventListener?.('change', update);
167
+ return () => mql.removeEventListener?.('change', update);
168
+ }, [query]);
169
+ return matches;
170
+ }
171
+
172
+ const useIsCollapsed = (breakpoint: number) =>
173
+ useMediaQuery(`(max-width: ${breakpoint - 1}px)`);
174
+
175
+ const useHoverCapable = () =>
176
+ useMediaQuery('(hover: hover) and (pointer: fine)', true);
177
+
178
+ const usePrefersReducedMotion = () =>
179
+ useMediaQuery('(prefers-reduced-motion: reduce)');
180
+
181
+ function useScrollState(
182
+ enabled: boolean,
183
+ behavior: 'none' | 'elevate' | 'shrink' | 'hide',
184
+ wantProgress: boolean,
185
+ ) {
186
+ const [scrolled, setScrolled] = useState(false);
187
+ const [hidden, setHidden] = useState(false);
188
+ const [progress, setProgress] = useState(0);
189
+ const lastY = useRef(0);
190
+
191
+ useEffect(() => {
192
+ if (typeof window === 'undefined' || !enabled) {
193
+ setScrolled(false);
194
+ setHidden(false);
195
+ setProgress(0);
196
+ lastY.current = 0;
197
+ return;
198
+ }
199
+ let raf = 0;
200
+ const read = () => {
201
+ raf = 0;
202
+ const doc = document.documentElement;
203
+ const y = window.scrollY || doc.scrollTop || 0;
204
+ setScrolled(y > 8);
205
+ setHidden(behavior === 'hide' ? y > lastY.current && y > 64 : false);
206
+ if (wantProgress) {
207
+ const max = doc.scrollHeight - doc.clientHeight || 1;
208
+ setProgress(Math.min(1, Math.max(0, y / max)));
209
+ }
210
+ lastY.current = y;
211
+ };
212
+ const onScroll = () => {
213
+ if (!raf) raf = requestAnimationFrame(read);
214
+ };
215
+ read();
216
+ window.addEventListener('scroll', onScroll, { passive: true });
217
+ window.addEventListener('resize', onScroll, { passive: true });
218
+ return () => {
219
+ if (raf) cancelAnimationFrame(raf);
220
+ window.removeEventListener('scroll', onScroll);
221
+ window.removeEventListener('resize', onScroll);
222
+ };
223
+ }, [enabled, behavior, wantProgress]);
224
+
225
+ return { scrolled, hidden, progress };
226
+ }
227
+
228
+ function getFocusable(root: HTMLElement | null) {
229
+ if (!root) return [] as HTMLElement[];
230
+ return Array.from(
231
+ root.querySelectorAll<HTMLElement>(
232
+ 'a[href], button:not([disabled]), [tabindex]:not([tabindex="-1"]), input:not([disabled]), select:not([disabled]), textarea:not([disabled])',
233
+ ),
234
+ ).filter(
235
+ (el) =>
236
+ !el.hasAttribute('aria-hidden') &&
237
+ el.offsetParent !== null,
238
+ );
239
+ }
240
+
241
+ // ============================================================================
242
+ // Slot — minimal Radix-style asChild implementation (single child)
243
+ // ============================================================================
244
+
245
+ interface SlotProps extends React.HTMLAttributes<HTMLElement> {
246
+ children: React.ReactNode;
247
+ }
248
+
249
+ const Slot = forwardRef<HTMLElement, SlotProps>(function Slot(
250
+ { children, ...slotProps },
251
+ _ref,
252
+ ) {
253
+ const child = React.Children.only(children) as React.ReactElement<
254
+ Record<string, unknown>
255
+ >;
256
+ const childProps = (child.props ?? {}) as Record<string, unknown>;
257
+
258
+ const merged: Record<string, unknown> = { ...slotProps, ...childProps };
259
+
260
+ // className: parent first, child appended
261
+ merged.className = cn(
262
+ slotProps.className as string | undefined,
263
+ childProps.className as string | undefined,
264
+ );
265
+
266
+ // style: shallow merge — child wins on key collisions
267
+ if (slotProps.style || childProps.style) {
268
+ merged.style = {
269
+ ...(slotProps.style as React.CSSProperties),
270
+ ...(childProps.style as React.CSSProperties),
271
+ };
272
+ }
273
+
274
+ // Compose event handlers (every on* prop on the slot)
275
+ for (const key of Object.keys(slotProps)) {
276
+ if (
277
+ key.startsWith('on') &&
278
+ typeof (slotProps as Record<string, unknown>)[key] === 'function'
279
+ ) {
280
+ const ours = (slotProps as Record<string, unknown>)[key] as (
281
+ e: React.SyntheticEvent,
282
+ ) => void;
283
+ const theirs = childProps[key] as
284
+ | ((e: React.SyntheticEvent) => void)
285
+ | undefined;
286
+ merged[key] = composeHandlers(ours, theirs);
287
+ }
288
+ }
289
+
290
+ return React.cloneElement(child, merged);
291
+ });
292
+
293
+ // ============================================================================
294
+ // Context
295
+ // ============================================================================
296
+
297
+ interface TopNavContextValue {
298
+ drawerOpen: boolean;
299
+ setDrawerOpen: (open: boolean) => void;
300
+ isCollapsed: boolean;
301
+ menuId: string;
302
+ toggleId: string;
303
+ registerToggle: () => () => void;
304
+ toggleCount: number;
305
+ menuRef: React.RefObject<HTMLUListElement | null>;
306
+ toggleRef: React.RefObject<HTMLButtonElement | null>;
307
+ }
308
+
309
+ const TopNavContext = React.createContext<TopNavContextValue | null>(null);
310
+
311
+ function useTopNavContext(component: string) {
312
+ const ctx = React.useContext(TopNavContext);
313
+ if (!ctx) {
314
+ throw new Error(
315
+ `EvoTopNav.${component} must be rendered inside <EvoTopNav>.`,
316
+ );
317
+ }
318
+ return ctx;
319
+ }
320
+
321
+ interface DropdownContextValue {
322
+ open: boolean;
323
+ setOpen: (open: boolean) => void;
324
+ triggerRef: React.RefObject<HTMLButtonElement | null>;
325
+ contentRef: React.RefObject<HTMLUListElement | null>;
326
+ contentId: string;
327
+ focusItem: (delta: 1 | -1 | 'first' | 'last') => void;
328
+ inDrawer: boolean;
329
+ }
330
+
331
+ const DropdownContext = React.createContext<DropdownContextValue | null>(null);
332
+
333
+ // ============================================================================
334
+ // Default icons
335
+ // ============================================================================
336
+
337
+ const HamburgerIcon = ({ open }: { open: boolean }) => (
338
+ <svg
339
+ width="18"
340
+ height="18"
341
+ viewBox="0 0 18 18"
342
+ fill="none"
343
+ aria-hidden="true"
344
+ className={styles.toggleIcon}
345
+ >
346
+ {open ? (
347
+ <>
348
+ <path
349
+ d="M4 4l10 10M14 4L4 14"
350
+ stroke="currentColor"
351
+ strokeWidth="1.75"
352
+ strokeLinecap="round"
353
+ />
354
+ </>
355
+ ) : (
356
+ <>
357
+ <path
358
+ d="M3 5h12M3 9h12M3 13h12"
359
+ stroke="currentColor"
360
+ strokeWidth="1.75"
361
+ strokeLinecap="round"
362
+ />
363
+ </>
364
+ )}
365
+ </svg>
366
+ );
367
+
368
+ const ChevronIcon = ({ open }: { open: boolean }) => (
369
+ <svg
370
+ viewBox="0 0 12 12"
371
+ width="10"
372
+ height="10"
373
+ fill="none"
374
+ aria-hidden="true"
375
+ className={cn(styles.dropdownChevron, open && styles.dropdownChevronOpen)}
376
+ >
377
+ <path
378
+ d="M3 4.5l3 3 3-3"
379
+ stroke="currentColor"
380
+ strokeWidth="1.5"
381
+ strokeLinecap="round"
382
+ strokeLinejoin="round"
383
+ />
384
+ </svg>
385
+ );
386
+
387
+ const SearchGlyph = () => (
388
+ <svg
389
+ width="16"
390
+ height="16"
391
+ viewBox="0 0 24 24"
392
+ fill="none"
393
+ stroke="currentColor"
394
+ strokeWidth="2"
395
+ strokeLinecap="round"
396
+ strokeLinejoin="round"
397
+ aria-hidden="true"
398
+ >
399
+ <circle cx="11" cy="11" r="7" />
400
+ <path d="m20 20-3.2-3.2" />
401
+ </svg>
402
+ );
403
+
404
+ // ============================================================================
405
+ // EvoTopNav.Brand
406
+ // ============================================================================
407
+
408
+ const EvoTopNavBrand = forwardRef<HTMLDivElement, EvoTopNavBrandProps>(
409
+ function EvoTopNavBrand({ children, className, ...rest }, ref) {
410
+ return (
411
+ <div
412
+ ref={ref}
413
+ className={cn(styles.topNavBrand, className)}
414
+ {...rest}
415
+ >
416
+ {children}
417
+ </div>
418
+ );
419
+ },
420
+ );
421
+
422
+ // ============================================================================
423
+ // EvoTopNav.Menu
424
+ // ============================================================================
425
+
426
+ const EvoTopNavMenu = forwardRef<HTMLUListElement, EvoTopNavMenuProps>(
427
+ function EvoTopNavMenu({ children, className, ...rest }, forwardedRef) {
428
+ const ctx = useTopNavContext('Menu');
429
+ const localRef = useRef<HTMLUListElement | null>(null);
430
+
431
+ const setRef = (node: HTMLUListElement | null) => {
432
+ localRef.current = node;
433
+ ctx.menuRef.current = node;
434
+ if (typeof forwardedRef === 'function') forwardedRef(node);
435
+ else if (forwardedRef)
436
+ (forwardedRef as React.RefObject<HTMLUListElement | null>).current = node;
437
+ };
438
+
439
+ const hasToggle = ctx.toggleCount > 0;
440
+ const inDrawer = ctx.isCollapsed && hasToggle;
441
+ const drawerClosed = inDrawer && !ctx.drawerOpen;
442
+
443
+ return (
444
+ <ul
445
+ ref={setRef}
446
+ id={ctx.menuId}
447
+ className={cn(
448
+ styles.topNavMenu,
449
+ inDrawer && styles.topNavMenuDrawer,
450
+ drawerClosed && styles.topNavMenuDrawerClosed,
451
+ ctx.isCollapsed && !hasToggle && styles.topNavMenuScroll,
452
+ className,
453
+ )}
454
+ data-state={inDrawer ? (ctx.drawerOpen ? 'open' : 'closed') : 'inline'}
455
+ aria-hidden={drawerClosed || undefined}
456
+ {...rest}
457
+ >
458
+ {children}
459
+ </ul>
460
+ );
461
+ },
462
+ );
463
+
464
+ // ============================================================================
465
+ // EvoTopNav.Item polymorphic
466
+ // ============================================================================
467
+
468
+ const EvoTopNavItem = forwardRef<HTMLElement, EvoTopNavItemProps>(
469
+ function EvoTopNavItem(
470
+ { children, active, icon, href, target, rel, onClick, asChild, className },
471
+ ref,
472
+ ) {
473
+ const ctx = React.useContext(TopNavContext);
474
+ const inDrawerCtx = !!ctx && ctx.isCollapsed && ctx.drawerOpen;
475
+
476
+ // Auto-close drawer on activation (SPA route nav heuristic).
477
+ const handleActivation = (e: React.MouseEvent) => {
478
+ onClick?.(e);
479
+ if (!e.defaultPrevented && inDrawerCtx) ctx.setDrawerOpen(false);
480
+ };
481
+
482
+ const shared = {
483
+ className: cn(styles.topNavItem, active && styles.topNavItemActive, className),
484
+ 'aria-current': active ? ('page' as const) : undefined,
485
+ 'data-active': active || undefined,
486
+ };
487
+
488
+ const content = (
489
+ <>
490
+ {icon && (
491
+ <span className={styles.topNavIcon} aria-hidden="true">
492
+ {icon}
493
+ </span>
494
+ )}
495
+ <span className={styles.topNavItemLabel}>{children}</span>
496
+ </>
497
+ );
498
+
499
+ let element: React.ReactElement;
500
+
501
+ if (asChild) {
502
+ element = (
503
+ <Slot
504
+ {...shared}
505
+ onClick={handleActivation}
506
+ ref={ref as React.Ref<HTMLElement>}
507
+ >
508
+ {children}
509
+ </Slot>
510
+ );
511
+ } else if (href) {
512
+ element = (
513
+ <a
514
+ ref={ref as React.Ref<HTMLAnchorElement>}
515
+ href={href}
516
+ target={target}
517
+ rel={
518
+ rel ?? (target === '_blank' ? 'noopener noreferrer' : undefined)
519
+ }
520
+ onClick={handleActivation}
521
+ {...shared}
522
+ >
523
+ {content}
524
+ </a>
525
+ );
526
+ } else {
527
+ element = (
528
+ <button
529
+ ref={ref as React.Ref<HTMLButtonElement>}
530
+ type="button"
531
+ onClick={handleActivation}
532
+ {...shared}
533
+ >
534
+ {content}
535
+ </button>
536
+ );
537
+ }
538
+
539
+ return <li className={styles.topNavItemRow}>{element}</li>;
540
+ },
541
+ );
542
+
543
+ // ============================================================================
544
+ // EvoTopNav.Actions
545
+ // ============================================================================
546
+
547
+ const EvoTopNavActions = forwardRef<HTMLDivElement, EvoTopNavActionsProps>(
548
+ function EvoTopNavActions({ children, className, ...rest }, ref) {
549
+ return (
550
+ <div
551
+ ref={ref}
552
+ className={cn(styles.topNavActions, className)}
553
+ {...rest}
554
+ >
555
+ {children}
556
+ </div>
557
+ );
558
+ },
559
+ );
560
+
561
+ // ============================================================================
562
+ // EvoTopNav.Toggle — hamburger
563
+ // ============================================================================
564
+
565
+ const EvoTopNavToggle = forwardRef<HTMLButtonElement, EvoTopNavToggleProps>(
566
+ function EvoTopNavToggle(
567
+ { icon, className, onClick, 'aria-label': ariaLabel, ...rest },
568
+ forwardedRef,
569
+ ) {
570
+ const ctx = useTopNavContext('Toggle');
571
+ const localRef = useRef<HTMLButtonElement | null>(null);
572
+
573
+ useEffect(() => ctx.registerToggle(), [ctx]);
574
+
575
+ const setRef = (node: HTMLButtonElement | null) => {
576
+ localRef.current = node;
577
+ ctx.toggleRef.current = node;
578
+ if (typeof forwardedRef === 'function') forwardedRef(node);
579
+ else if (forwardedRef)
580
+ (forwardedRef as React.RefObject<HTMLButtonElement | null>).current = node;
581
+ };
582
+
583
+ const handleClick = (e: React.MouseEvent<HTMLButtonElement>) => {
584
+ onClick?.(e);
585
+ if (!e.defaultPrevented) ctx.setDrawerOpen(!ctx.drawerOpen);
586
+ };
587
+
588
+ return (
589
+ <button
590
+ ref={setRef}
591
+ id={ctx.toggleId}
592
+ type="button"
593
+ className={cn(styles.topNavToggle, className)}
594
+ aria-expanded={ctx.drawerOpen}
595
+ aria-controls={ctx.menuId}
596
+ aria-label={
597
+ ariaLabel ?? (ctx.drawerOpen ? 'Close menu' : 'Open menu')
598
+ }
599
+ onClick={handleClick}
600
+ {...rest}
601
+ >
602
+ {icon ?? <HamburgerIcon open={ctx.drawerOpen} />}
603
+ </button>
604
+ );
605
+ },
606
+ );
607
+
608
+ // ============================================================================
609
+ // EvoTopNav.Search — presentational ⌘K quick-search trigger
610
+ // ============================================================================
611
+
612
+ const EvoTopNavSearch = forwardRef<HTMLButtonElement, EvoTopNavSearchProps>(
613
+ function EvoTopNavSearch(
614
+ { placeholder = 'Search…', shortcut, shortcutHint, className, onClick, ...rest },
615
+ forwardedRef,
616
+ ) {
617
+ const localRef = useRef<HTMLButtonElement | null>(null);
618
+ const setRef = (node: HTMLButtonElement | null) => {
619
+ localRef.current = node;
620
+ if (typeof forwardedRef === 'function') forwardedRef(node);
621
+ else if (forwardedRef)
622
+ (forwardedRef as React.RefObject<HTMLButtonElement | null>).current = node;
623
+ };
624
+
625
+ // Platform-aware hint resolved after mount to avoid SSR hydration mismatch.
626
+ const [autoHint, setAutoHint] = useState<React.ReactNode>(null);
627
+ useEffect(() => {
628
+ if (shortcutHint !== undefined) return;
629
+ const platform =
630
+ (typeof navigator !== 'undefined' &&
631
+ (navigator.platform || navigator.userAgent)) || '';
632
+ setAutoHint(/Mac|iPhone|iPad|iPod/.test(platform) ? '⌘K' : 'Ctrl K');
633
+ }, [shortcutHint]);
634
+ const hint = shortcutHint !== undefined ? shortcutHint : autoHint;
635
+
636
+ // Opt-in global hotkey dispatch a real click so onClick fires naturally.
637
+ useEffect(() => {
638
+ if (!shortcut) return;
639
+ const parts = shortcut.toLowerCase().split('+').map((p) => p.trim());
640
+ const wantMod = parts.some((p) => ['mod', 'cmd', 'meta', 'ctrl', 'control'].includes(p));
641
+ const key = parts[parts.length - 1];
642
+ const handler = (e: KeyboardEvent) => {
643
+ const mod = e.metaKey || e.ctrlKey;
644
+ if ((!wantMod || mod) && e.key.toLowerCase() === key) {
645
+ e.preventDefault();
646
+ localRef.current?.click();
647
+ }
648
+ };
649
+ document.addEventListener('keydown', handler);
650
+ return () => document.removeEventListener('keydown', handler);
651
+ }, [shortcut]);
652
+
653
+ return (
654
+ <button
655
+ ref={setRef}
656
+ className={cn(styles.topNavSearch, className)}
657
+ onClick={onClick}
658
+ {...rest}
659
+ type="button"
660
+ >
661
+ <span className={styles.topNavSearchIcon} aria-hidden="true">
662
+ <SearchGlyph />
663
+ </span>
664
+ <span className={styles.topNavSearchText}>{placeholder}</span>
665
+ {hint != null && <kbd className={styles.topNavSearchKbd}>{hint}</kbd>}
666
+ </button>
667
+ );
668
+ },
669
+ );
670
+
671
+ // ============================================================================
672
+ // EvoTopNav.Dropdown
673
+ // ============================================================================
674
+
675
+ const EvoTopNavDropdown: React.FC<EvoTopNavDropdownProps> = ({
676
+ label,
677
+ icon,
678
+ active,
679
+ hoverable = true,
680
+ open,
681
+ defaultOpen = false,
682
+ onOpenChange,
683
+ children,
684
+ className,
685
+ }) => {
686
+ const ctx = React.useContext(TopNavContext);
687
+ const inDrawer = !!ctx && ctx.isCollapsed && ctx.toggleCount > 0;
688
+
689
+ const [isOpen, setOpen] = useControllableState({
690
+ value: open,
691
+ defaultValue: defaultOpen,
692
+ onChange: onOpenChange,
693
+ });
694
+
695
+ const hoverCapable = useHoverCapable();
696
+ const allowHover = hoverable && hoverCapable && !inDrawer;
697
+
698
+ const triggerRef = useRef<HTMLButtonElement | null>(null);
699
+ const contentRef = useRef<HTMLUListElement | null>(null);
700
+ const rootRef = useRef<HTMLLIElement | null>(null);
701
+ const contentId = useId();
702
+ const hoverTimer = useRef<ReturnType<typeof setTimeout> | null>(null);
703
+
704
+ const closeAndRestore = useCallback(() => {
705
+ setOpen(false);
706
+ triggerRef.current?.focus();
707
+ }, [setOpen]);
708
+
709
+ // Click outside
710
+ useEffect(() => {
711
+ if (!isOpen || inDrawer) return;
712
+ const onDocDown = (e: MouseEvent) => {
713
+ const root = rootRef.current;
714
+ if (root && e.target instanceof Node && !root.contains(e.target)) {
715
+ setOpen(false);
716
+ }
717
+ };
718
+ document.addEventListener('mousedown', onDocDown);
719
+ return () => document.removeEventListener('mousedown', onDocDown);
720
+ }, [isOpen, inDrawer, setOpen]);
721
+
722
+ // Escape to close (only desktop dropdown — drawer handles its own Esc)
723
+ useEffect(() => {
724
+ if (!isOpen || inDrawer) return;
725
+ const onKey = (e: KeyboardEvent) => {
726
+ if (e.key === 'Escape') {
727
+ e.stopPropagation();
728
+ closeAndRestore();
729
+ }
730
+ };
731
+ document.addEventListener('keydown', onKey);
732
+ return () => document.removeEventListener('keydown', onKey);
733
+ }, [isOpen, inDrawer, closeAndRestore]);
734
+
735
+ const focusItem = useCallback(
736
+ (delta: 1 | -1 | 'first' | 'last') => {
737
+ const items = getFocusable(contentRef.current);
738
+ if (items.length === 0) return;
739
+ const activeEl = document.activeElement as HTMLElement | null;
740
+ const idx = activeEl ? items.indexOf(activeEl) : -1;
741
+ let next = 0;
742
+ if (delta === 'first') next = 0;
743
+ else if (delta === 'last') next = items.length - 1;
744
+ else if (delta === 1) next = idx < 0 ? 0 : (idx + 1) % items.length;
745
+ else next = idx < 0 ? items.length - 1 : (idx - 1 + items.length) % items.length;
746
+ items[next]?.focus();
747
+ },
748
+ [],
749
+ );
750
+
751
+ const handleTriggerKey = (e: React.KeyboardEvent<HTMLButtonElement>) => {
752
+ if (e.key === 'ArrowDown' || e.key === 'Enter' || e.key === ' ') {
753
+ e.preventDefault();
754
+ setOpen(true);
755
+ requestAnimationFrame(() => focusItem('first'));
756
+ } else if (e.key === 'ArrowUp') {
757
+ e.preventDefault();
758
+ setOpen(true);
759
+ requestAnimationFrame(() => focusItem('last'));
760
+ }
761
+ };
762
+
763
+ const handleContentKey = (e: React.KeyboardEvent<HTMLUListElement>) => {
764
+ if (e.key === 'ArrowDown') {
765
+ e.preventDefault();
766
+ focusItem(1);
767
+ } else if (e.key === 'ArrowUp') {
768
+ e.preventDefault();
769
+ focusItem(-1);
770
+ } else if (e.key === 'Home') {
771
+ e.preventDefault();
772
+ focusItem('first');
773
+ } else if (e.key === 'End') {
774
+ e.preventDefault();
775
+ focusItem('last');
776
+ } else if (e.key === 'Tab') {
777
+ // Allow Tab to leave the dropdown; close it.
778
+ setOpen(false);
779
+ }
780
+ };
781
+
782
+ const onMouseEnter = () => {
783
+ if (!allowHover) return;
784
+ if (hoverTimer.current) clearTimeout(hoverTimer.current);
785
+ setOpen(true);
786
+ };
787
+ const onMouseLeave = () => {
788
+ if (!allowHover) return;
789
+ if (hoverTimer.current) clearTimeout(hoverTimer.current);
790
+ hoverTimer.current = setTimeout(() => setOpen(false), 120);
791
+ };
792
+
793
+ const dropdownCtx = useMemo<DropdownContextValue>(
794
+ () => ({
795
+ open: isOpen,
796
+ setOpen,
797
+ triggerRef,
798
+ contentRef,
799
+ contentId,
800
+ focusItem,
801
+ inDrawer,
802
+ }),
803
+ [isOpen, setOpen, contentId, focusItem, inDrawer],
804
+ );
805
+
806
+ return (
807
+ <DropdownContext.Provider value={dropdownCtx}>
808
+ <li
809
+ ref={rootRef}
810
+ className={cn(
811
+ styles.topNavDropdown,
812
+ inDrawer && styles.topNavDropdownInDrawer,
813
+ isOpen && styles.topNavDropdownOpen,
814
+ className,
815
+ )}
816
+ onMouseEnter={onMouseEnter}
817
+ onMouseLeave={onMouseLeave}
818
+ >
819
+ <button
820
+ ref={triggerRef}
821
+ type="button"
822
+ className={cn(
823
+ styles.topNavItem,
824
+ styles.topNavDropdownTrigger,
825
+ active && styles.topNavItemActive,
826
+ )}
827
+ aria-haspopup="menu"
828
+ aria-expanded={isOpen}
829
+ aria-controls={contentId}
830
+ aria-current={active ? 'page' : undefined}
831
+ onClick={() => setOpen(!isOpen)}
832
+ onKeyDown={handleTriggerKey}
833
+ >
834
+ {icon && (
835
+ <span className={styles.topNavIcon} aria-hidden="true">
836
+ {icon}
837
+ </span>
838
+ )}
839
+ <span className={styles.topNavItemLabel}>{label}</span>
840
+ <ChevronIcon open={isOpen} />
841
+ </button>
842
+ <ul
843
+ ref={contentRef}
844
+ id={contentId}
845
+ role="menu"
846
+ className={cn(
847
+ styles.topNavDropdownContent,
848
+ isOpen && styles.topNavDropdownContentOpen,
849
+ )}
850
+ // Note: visibility is driven by the .topNavDropdownContentOpen class
851
+ // (display: none → flex). Avoid the `hidden` attribute because
852
+ // `[hidden] { display: none }` is overridden by class-level `display`
853
+ // rules the two-source-of-truth setup leaked an empty panel onto
854
+ // the page in v1.
855
+ aria-hidden={!isOpen || undefined}
856
+ onKeyDown={handleContentKey}
857
+ >
858
+ {children}
859
+ </ul>
860
+ </li>
861
+ </DropdownContext.Provider>
862
+ );
863
+ };
864
+
865
+ // ============================================================================
866
+ // EvoTopNav.DropdownItem
867
+ // ============================================================================
868
+
869
+ const EvoTopNavDropdownItem = forwardRef<HTMLElement, EvoTopNavDropdownItemProps>(
870
+ function EvoTopNavDropdownItem(
871
+ { children, icon, active, href, target, rel, onClick, asChild, className },
872
+ ref,
873
+ ) {
874
+ const dropdown = React.useContext(DropdownContext);
875
+ const top = React.useContext(TopNavContext);
876
+
877
+ const handleActivation = (e: React.MouseEvent) => {
878
+ onClick?.(e);
879
+ if (!e.defaultPrevented) {
880
+ dropdown?.setOpen(false);
881
+ // Also close the drawer if we're inside it.
882
+ if (top && top.isCollapsed && top.drawerOpen) top.setDrawerOpen(false);
883
+ }
884
+ };
885
+
886
+ const shared = {
887
+ className: cn(
888
+ styles.topNavDropdownItem,
889
+ active && styles.topNavItemActive,
890
+ className,
891
+ ),
892
+ role: 'menuitem' as const,
893
+ 'aria-current': active ? ('page' as const) : undefined,
894
+ };
895
+
896
+ const content = (
897
+ <>
898
+ {icon && (
899
+ <span className={styles.topNavIcon} aria-hidden="true">
900
+ {icon}
901
+ </span>
902
+ )}
903
+ <span className={styles.topNavItemLabel}>{children}</span>
904
+ </>
905
+ );
906
+
907
+ let element: React.ReactElement;
908
+
909
+ if (asChild) {
910
+ element = (
911
+ <Slot
912
+ {...shared}
913
+ onClick={handleActivation}
914
+ ref={ref as React.Ref<HTMLElement>}
915
+ >
916
+ {children}
917
+ </Slot>
918
+ );
919
+ } else if (href) {
920
+ element = (
921
+ <a
922
+ ref={ref as React.Ref<HTMLAnchorElement>}
923
+ href={href}
924
+ target={target}
925
+ rel={rel ?? (target === '_blank' ? 'noopener noreferrer' : undefined)}
926
+ onClick={handleActivation}
927
+ {...shared}
928
+ >
929
+ {content}
930
+ </a>
931
+ );
932
+ } else {
933
+ element = (
934
+ <button
935
+ ref={ref as React.Ref<HTMLButtonElement>}
936
+ type="button"
937
+ onClick={handleActivation}
938
+ {...shared}
939
+ >
940
+ {content}
941
+ </button>
942
+ );
943
+ }
944
+
945
+ return <li>{element}</li>;
946
+ },
947
+ );
948
+
949
+ // ============================================================================
950
+ // EvoTopNav — root
951
+ // ============================================================================
952
+
953
+ export const EvoTopNav = forwardRef<HTMLElement, EvoTopNavProps>(
954
+ function EvoTopNav(
955
+ {
956
+ children,
957
+ open,
958
+ defaultOpen = false,
959
+ onOpenChange,
960
+ collapseBelow = 768,
961
+ entrance = 'none',
962
+ sticky = false,
963
+ scrollBehavior = 'none',
964
+ showProgress = false,
965
+ className,
966
+ style,
967
+ ...rest
968
+ },
969
+ ref,
970
+ ) {
971
+ const [drawerOpen, setDrawerOpen] = useControllableState({
972
+ value: open,
973
+ defaultValue: defaultOpen,
974
+ onChange: onOpenChange,
975
+ });
976
+
977
+ const isCollapsed = useIsCollapsed(collapseBelow);
978
+ const reducedMotion = usePrefersReducedMotion();
979
+ const scrollEnabled = scrollBehavior !== 'none' || showProgress;
980
+ const { scrolled, hidden, progress } = useScrollState(scrollEnabled, scrollBehavior, showProgress);
981
+ const animateEntrance = entrance !== 'none' && !reducedMotion;
982
+ const mergedStyle: React.CSSProperties = showProgress
983
+ ? ({ ...style, ['--evo-topnav-progress' as string]: progress } as React.CSSProperties)
984
+ : (style as React.CSSProperties);
985
+
986
+ const [toggleCount, setToggleCount] = useState(0);
987
+ const registerToggle = useCallback(() => {
988
+ setToggleCount((c) => c + 1);
989
+ return () => setToggleCount((c) => Math.max(0, c - 1));
990
+ }, []);
991
+
992
+ const menuId = useId();
993
+ const toggleId = useId();
994
+ const menuRef = useRef<HTMLUListElement | null>(null);
995
+ const toggleRef = useRef<HTMLButtonElement | null>(null);
996
+ const restoreFocusRef = useRef<HTMLElement | null>(null);
997
+
998
+ const drawerActive = isCollapsed && drawerOpen && toggleCount > 0;
999
+
1000
+ // Close drawer when leaving the collapsed breakpoint.
1001
+ useEffect(() => {
1002
+ if (!isCollapsed && drawerOpen) setDrawerOpen(false);
1003
+ }, [isCollapsed, drawerOpen, setDrawerOpen]);
1004
+
1005
+ // Body scroll lock while drawer is open.
1006
+ useEffect(() => {
1007
+ if (!drawerActive) return;
1008
+ const prev = document.body.style.overflow;
1009
+ document.body.style.overflow = 'hidden';
1010
+ return () => {
1011
+ document.body.style.overflow = prev;
1012
+ };
1013
+ }, [drawerActive]);
1014
+
1015
+ // Esc to close + focus trap inside Menu while drawer is open.
1016
+ useEffect(() => {
1017
+ if (!drawerActive) return;
1018
+ restoreFocusRef.current =
1019
+ (document.activeElement as HTMLElement | null) ?? null;
1020
+
1021
+ const focusFirst = () => {
1022
+ const items = getFocusable(menuRef.current);
1023
+ items[0]?.focus();
1024
+ };
1025
+ const id = requestAnimationFrame(focusFirst);
1026
+
1027
+ const onKey = (e: KeyboardEvent) => {
1028
+ if (e.key === 'Escape') {
1029
+ e.preventDefault();
1030
+ setDrawerOpen(false);
1031
+ return;
1032
+ }
1033
+ if (e.key !== 'Tab' || !menuRef.current) return;
1034
+ const items = getFocusable(menuRef.current);
1035
+ if (items.length === 0) return;
1036
+ const first = items[0];
1037
+ const last = items[items.length - 1];
1038
+ const activeEl = document.activeElement as HTMLElement | null;
1039
+ if (e.shiftKey && activeEl === first) {
1040
+ e.preventDefault();
1041
+ last.focus();
1042
+ } else if (!e.shiftKey && activeEl === last) {
1043
+ e.preventDefault();
1044
+ first.focus();
1045
+ } else if (activeEl && !menuRef.current.contains(activeEl)) {
1046
+ e.preventDefault();
1047
+ first.focus();
1048
+ }
1049
+ };
1050
+ document.addEventListener('keydown', onKey);
1051
+ return () => {
1052
+ cancelAnimationFrame(id);
1053
+ document.removeEventListener('keydown', onKey);
1054
+ };
1055
+ }, [drawerActive, setDrawerOpen]);
1056
+
1057
+ // Restore focus when drawer closes.
1058
+ useEffect(() => {
1059
+ if (drawerActive) return;
1060
+ const target = restoreFocusRef.current;
1061
+ if (target && typeof target.focus === 'function') {
1062
+ // Only restore if focus is currently inside the menu (or nowhere).
1063
+ const active = document.activeElement;
1064
+ if (
1065
+ !active ||
1066
+ active === document.body ||
1067
+ (menuRef.current && menuRef.current.contains(active))
1068
+ ) {
1069
+ target.focus();
1070
+ }
1071
+ restoreFocusRef.current = null;
1072
+ }
1073
+ }, [drawerActive]);
1074
+
1075
+ const ctxValue = useMemo<TopNavContextValue>(
1076
+ () => ({
1077
+ drawerOpen,
1078
+ setDrawerOpen,
1079
+ isCollapsed,
1080
+ menuId,
1081
+ toggleId,
1082
+ registerToggle,
1083
+ toggleCount,
1084
+ menuRef,
1085
+ toggleRef,
1086
+ }),
1087
+ [
1088
+ drawerOpen,
1089
+ setDrawerOpen,
1090
+ isCollapsed,
1091
+ menuId,
1092
+ toggleId,
1093
+ registerToggle,
1094
+ toggleCount,
1095
+ ],
1096
+ );
1097
+
1098
+ return (
1099
+ <TopNavContext.Provider value={ctxValue}>
1100
+ <nav
1101
+ ref={ref}
1102
+ className={cn(
1103
+ styles.topNav,
1104
+ sticky && styles.topNavSticky,
1105
+ drawerActive && styles.topNavDrawerOpen,
1106
+ reducedMotion && styles.topNavReducedMotion,
1107
+ className,
1108
+ )}
1109
+ style={mergedStyle}
1110
+ data-collapsed={isCollapsed || undefined}
1111
+ data-drawer-open={drawerActive || undefined}
1112
+ data-entrance={animateEntrance ? entrance : undefined}
1113
+ data-scroll={scrollBehavior !== 'none' ? scrollBehavior : undefined}
1114
+ data-scrolled={scrolled || undefined}
1115
+ data-hidden={hidden || undefined}
1116
+ {...rest}
1117
+ >
1118
+ <div className={styles.topNavInner}>{children}</div>
1119
+ {showProgress && (
1120
+ <span className={styles.topNavProgress} aria-hidden="true" />
1121
+ )}
1122
+ {drawerActive && (
1123
+ <div
1124
+ className={styles.topNavBackdrop}
1125
+ onClick={() => setDrawerOpen(false)}
1126
+ aria-hidden="true"
1127
+ />
1128
+ )}
1129
+ </nav>
1130
+ </TopNavContext.Provider>
1131
+ );
1132
+ },
1133
+ ) as React.ForwardRefExoticComponent<
1134
+ EvoTopNavProps & React.RefAttributes<HTMLElement>
1135
+ > & {
1136
+ Brand: typeof EvoTopNavBrand;
1137
+ Menu: typeof EvoTopNavMenu;
1138
+ Item: typeof EvoTopNavItem;
1139
+ Actions: typeof EvoTopNavActions;
1140
+ Toggle: typeof EvoTopNavToggle;
1141
+ Search: typeof EvoTopNavSearch;
1142
+ Dropdown: typeof EvoTopNavDropdown;
1143
+ DropdownItem: typeof EvoTopNavDropdownItem;
1144
+ };
1145
+
1146
+ EvoTopNavBrand.displayName = 'EvoTopNav.Brand';
1147
+ EvoTopNavMenu.displayName = 'EvoTopNav.Menu';
1148
+ EvoTopNavItem.displayName = 'EvoTopNav.Item';
1149
+ EvoTopNavActions.displayName = 'EvoTopNav.Actions';
1150
+ EvoTopNavToggle.displayName = 'EvoTopNav.Toggle';
1151
+ EvoTopNavSearch.displayName = 'EvoTopNav.Search';
1152
+ EvoTopNavDropdown.displayName = 'EvoTopNav.Dropdown';
1153
+ EvoTopNavDropdownItem.displayName = 'EvoTopNav.DropdownItem';
1154
+ (EvoTopNav as { displayName?: string }).displayName = 'EvoTopNav';
1155
+
1156
+ EvoTopNav.Brand = EvoTopNavBrand;
1157
+ EvoTopNav.Menu = EvoTopNavMenu;
1158
+ EvoTopNav.Item = EvoTopNavItem;
1159
+ EvoTopNav.Actions = EvoTopNavActions;
1160
+ EvoTopNav.Toggle = EvoTopNavToggle;
1161
+ EvoTopNav.Search = EvoTopNavSearch;
1162
+ EvoTopNav.Dropdown = EvoTopNavDropdown;
1163
+ EvoTopNav.DropdownItem = EvoTopNavDropdownItem;