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