@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
@@ -1,1163 +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
- /** 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;
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;