@gtivr4/a1-design-system-react 0.1.0 → 0.2.3

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 (108) hide show
  1. package/package.json +1 -1
  2. package/src/color-scheme.css +586 -24
  3. package/src/components/accordion/Accordion.jsx +80 -0
  4. package/src/components/accordion/accordion.css +118 -0
  5. package/src/components/banner/Banner.jsx +66 -0
  6. package/src/components/banner/banner.css +205 -0
  7. package/src/components/bleed/Bleed.jsx +27 -0
  8. package/src/components/bleed/bleed.css +5 -0
  9. package/src/components/blockquote/Blockquote.jsx +40 -0
  10. package/src/components/blockquote/blockquote.css +166 -0
  11. package/src/components/breadcrumb/Breadcrumb.jsx +82 -0
  12. package/src/components/breadcrumb/breadcrumb.css +133 -0
  13. package/src/components/button/button.css +42 -12
  14. package/src/components/button-container/ButtonContainer.jsx +20 -1
  15. package/src/components/button-container/button-container.css +19 -1
  16. package/src/components/calendar/Calendar.jsx +383 -0
  17. package/src/components/calendar/calendar.css +225 -0
  18. package/src/components/card/Card.jsx +50 -12
  19. package/src/components/card/card.css +178 -14
  20. package/src/components/checkbox-group/CheckboxGroup.jsx +120 -0
  21. package/src/components/checkbox-group/checkbox-group.css +304 -0
  22. package/src/components/cluster/Cluster.jsx +52 -0
  23. package/src/components/cluster/cluster.css +9 -0
  24. package/src/components/code/Code.jsx +135 -0
  25. package/src/components/code/code.css +60 -0
  26. package/src/components/data-table/DataTable.jsx +721 -0
  27. package/src/components/data-table/DataTableFilters.jsx +339 -0
  28. package/src/components/data-table/data-table-filters.css +259 -0
  29. package/src/components/data-table/data-table.css +425 -0
  30. package/src/components/dialog/Dialog.jsx +45 -2
  31. package/src/components/dialog/dialog.css +13 -4
  32. package/src/components/divider/Divider.jsx +64 -0
  33. package/src/components/divider/divider.css +170 -0
  34. package/src/components/field/CreditCardField.jsx +131 -0
  35. package/src/components/field/DateField.jsx +11 -0
  36. package/src/components/field/NumberField.jsx +11 -0
  37. package/src/components/field/PhoneField.jsx +107 -0
  38. package/src/components/field/SelectField.jsx +86 -0
  39. package/src/components/field/TextField.jsx +83 -0
  40. package/src/components/field/TextareaField.jsx +147 -0
  41. package/src/components/field/TimeField.jsx +11 -0
  42. package/src/components/field/ZipField.jsx +114 -0
  43. package/src/components/field/credit-card.css +30 -0
  44. package/src/components/field/field.css +380 -0
  45. package/src/components/field/textarea-field.css +185 -0
  46. package/src/components/field-row/FieldRow.jsx +23 -0
  47. package/src/components/field-row/field-row.css +51 -0
  48. package/src/components/fieldset/Fieldset.jsx +49 -0
  49. package/src/components/fieldset/fieldset.css +75 -0
  50. package/src/components/figure/Figure.jsx +63 -0
  51. package/src/components/figure/figure.css +97 -0
  52. package/src/components/grid/Grid.jsx +36 -2
  53. package/src/components/grid/grid.css +129 -4
  54. package/src/components/heading/Heading.jsx +41 -1
  55. package/src/components/heading/heading.css +65 -4
  56. package/src/components/icon/icon.css +1 -0
  57. package/src/components/icon-button/icon-button.css +1 -0
  58. package/src/components/inline/inline.css +51 -0
  59. package/src/components/inline-editable/InlineEditable.jsx +77 -0
  60. package/src/components/inline-editable/inline-editable.css +47 -0
  61. package/src/components/inset/Inset.jsx +27 -0
  62. package/src/components/inset/inset.css +6 -0
  63. package/src/components/labels/Labels.jsx +5 -5
  64. package/src/components/link/Link.jsx +2 -3
  65. package/src/components/link/link.css +30 -1
  66. package/src/components/list/List.jsx +92 -0
  67. package/src/components/list/list.css +178 -0
  68. package/src/components/menu/Menu.jsx +243 -10
  69. package/src/components/menu/menu.css +157 -17
  70. package/src/components/message/Message.jsx +25 -50
  71. package/src/components/message/message.css +50 -33
  72. package/src/components/notification/Notification.jsx +1 -1
  73. package/src/components/page-layout/PageLayout.jsx +16 -1
  74. package/src/components/page-layout/page-layout.css +97 -4
  75. package/src/components/page-nav/PageNav.jsx +110 -0
  76. package/src/components/page-nav/page-nav.css +167 -0
  77. package/src/components/paragraph/Paragraph.jsx +35 -2
  78. package/src/components/paragraph/paragraph.css +38 -1
  79. package/src/components/radio-group/RadioGroup.jsx +121 -0
  80. package/src/components/radio-group/radio-group.css +268 -0
  81. package/src/components/section/Section.jsx +108 -0
  82. package/src/components/section/section.css +280 -0
  83. package/src/components/segmented-control/SegmentedControl.jsx +4 -0
  84. package/src/components/segmented-control/segmented.css +13 -0
  85. package/src/components/side-nav/SideNav.jsx +29 -9
  86. package/src/components/side-nav/scrim.css +1 -1
  87. package/src/components/side-nav/side-nav.css +70 -32
  88. package/src/components/snackbar/Snackbar.jsx +56 -0
  89. package/src/components/snackbar/snackbar.css +113 -0
  90. package/src/components/spacer/Spacer.jsx +36 -0
  91. package/src/components/spacer/spacer.css +44 -0
  92. package/src/components/stack/Stack.jsx +100 -0
  93. package/src/components/stack/stack.css +37 -0
  94. package/src/components/switch/Switch.jsx +114 -0
  95. package/src/components/switch/switch.css +276 -0
  96. package/src/components/system-banner/SystemBanner.jsx +57 -0
  97. package/src/components/system-banner/system-banner.css +118 -0
  98. package/src/components/tabs/Tabs.jsx +96 -28
  99. package/src/components/tabs/tabs.css +352 -15
  100. package/src/components/token-select/TokenSelect.jsx +159 -0
  101. package/src/components/token-select/token-select.css +110 -0
  102. package/src/components/top-header/TopHeader.jsx +641 -0
  103. package/src/components/top-header/top-header.css +337 -0
  104. package/src/illustrations/ComponentThumbnails.jsx +227 -0
  105. package/src/index.js +41 -5
  106. package/src/themes.css +256 -5
  107. package/src/utilities/spacing.css +8 -0
  108. package/src/utilities/sr-only.css +16 -0
@@ -0,0 +1,641 @@
1
+ import { useEffect, useLayoutEffect, useRef, useState } from "react";
2
+ import { Button } from "../button/Button.jsx";
3
+ import { Icon } from "../icon/Icon.jsx";
4
+ import { IconButton } from "../icon-button/IconButton.jsx";
5
+ import { Menu, MenuSection, MenuItem } from "../menu/Menu.jsx";
6
+ import { SideNav, SideNavGroup, SideNavItem } from "../side-nav/SideNav.jsx";
7
+ import "./top-header.css";
8
+
9
+ // ── Helpers ────────────────────────────────────────────────────────────────────
10
+
11
+ // Split a flat items array into sections separated by { divider: true } markers.
12
+ function splitIntoSections(items) {
13
+ const sections = [];
14
+ let current = [];
15
+ for (const item of items) {
16
+ if (item.divider) {
17
+ sections.push(current);
18
+ current = [];
19
+ } else {
20
+ current.push(item);
21
+ }
22
+ }
23
+ if (current.length > 0) sections.push(current);
24
+ return sections;
25
+ }
26
+
27
+ const menuFocusableSelector = [
28
+ "a[href]",
29
+ "button:not([disabled])",
30
+ "input:not([disabled])",
31
+ "select:not([disabled])",
32
+ "textarea:not([disabled])",
33
+ "[tabindex]:not([tabindex='-1'])",
34
+ ].join(",");
35
+
36
+ function getMenuFocusableElements(container) {
37
+ return [...container.querySelectorAll(menuFocusableSelector)].filter((element) => {
38
+ if (element.getAttribute("aria-disabled") === "true") return false;
39
+ return element.getClientRects().length > 0;
40
+ });
41
+ }
42
+
43
+ function NavMenuItem({ item, onClose }) {
44
+ const [open, setOpen] = useState(false);
45
+ const [flyoutPlacement, setFlyoutPlacement] = useState("end");
46
+ const [flyoutMaxHeight, setFlyoutMaxHeight] = useState(undefined);
47
+ const flyoutRef = useRef(null);
48
+ const hasFlyout = item.items?.length > 0;
49
+ const sections = hasFlyout ? splitIntoSections(item.items) : [];
50
+ const Trigger = item.href ? "a" : "button";
51
+
52
+ const triggerProps = item.href
53
+ ? {
54
+ href: item.href,
55
+ onClick: (event) => { item.onClick?.(event); onClose?.(); },
56
+ }
57
+ : {
58
+ type: "button",
59
+ onClick: () => setOpen((next) => !next),
60
+ };
61
+
62
+ const focusFirstFlyoutItem = () => {
63
+ requestAnimationFrame(() => {
64
+ flyoutRef.current?.querySelector("a, button")?.focus();
65
+ });
66
+ };
67
+
68
+ const openFlyoutFromKeyboard = () => {
69
+ setOpen(true);
70
+ focusFirstFlyoutItem();
71
+ };
72
+
73
+ useLayoutEffect(() => {
74
+ if (!hasFlyout || !open) return undefined;
75
+
76
+ const updateFlyoutLayout = () => {
77
+ const flyout = flyoutRef.current;
78
+ const trigger = flyout
79
+ ?.closest(".a1-top-header__flyout-wrap")
80
+ ?.querySelector(".a1-top-header__flyout-trigger");
81
+ if (!flyout || !trigger) return;
82
+
83
+ const viewportMargin = 8;
84
+ const triggerRect = trigger.getBoundingClientRect();
85
+ const flyoutRect = flyout.getBoundingClientRect();
86
+ const rightSpace = window.innerWidth - triggerRect.right - viewportMargin;
87
+ const leftSpace = triggerRect.left - viewportMargin;
88
+ const nextPlacement = rightSpace >= flyoutRect.width || rightSpace >= leftSpace
89
+ ? "end"
90
+ : "start";
91
+ const maxHeight = Math.max(1, window.innerHeight - flyoutRect.top - viewportMargin);
92
+
93
+ setFlyoutPlacement(nextPlacement);
94
+ setFlyoutMaxHeight(`${Math.floor(maxHeight)}px`);
95
+ };
96
+
97
+ updateFlyoutLayout();
98
+ window.addEventListener("resize", updateFlyoutLayout);
99
+ window.addEventListener("scroll", updateFlyoutLayout, true);
100
+ return () => {
101
+ window.removeEventListener("resize", updateFlyoutLayout);
102
+ window.removeEventListener("scroll", updateFlyoutLayout, true);
103
+ };
104
+ }, [hasFlyout, open]);
105
+
106
+ if (!hasFlyout) {
107
+ return (
108
+ <MenuItem
109
+ icon={item.icon}
110
+ href={item.href}
111
+ active={!!item.active}
112
+ onClick={(event) => { item.onClick?.(event); onClose?.(); }}
113
+ >
114
+ {item.label}
115
+ </MenuItem>
116
+ );
117
+ }
118
+
119
+ return (
120
+ <div
121
+ className="a1-top-header__flyout-wrap"
122
+ onMouseEnter={() => setOpen(true)}
123
+ onMouseLeave={() => setOpen(false)}
124
+ >
125
+ <Trigger
126
+ className={["a1-menu-item", "a1-top-header__flyout-trigger", item.active && "a1-menu-item--active"].filter(Boolean).join(" ")}
127
+ aria-expanded={open}
128
+ aria-current={item.active ? "page" : undefined}
129
+ aria-haspopup="menu"
130
+ onKeyDown={(event) => {
131
+ if (event.key === "Enter" || event.key === " ") {
132
+ event.preventDefault();
133
+ openFlyoutFromKeyboard();
134
+ } else if (event.key === "ArrowRight") {
135
+ event.preventDefault();
136
+ openFlyoutFromKeyboard();
137
+ }
138
+ }}
139
+ {...triggerProps}
140
+ >
141
+ {item.icon && (
142
+ <Icon
143
+ name={item.icon}
144
+ className="a1-menu-item__icon a1-top-header__flyout-icon"
145
+ aria-hidden="true"
146
+ />
147
+ )}
148
+ <span className="a1-menu-item__label a1-top-header__flyout-label">
149
+ {item.label}
150
+ </span>
151
+ <Icon
152
+ name="chevron_right"
153
+ className="a1-top-header__flyout-chevron"
154
+ aria-hidden="true"
155
+ />
156
+ </Trigger>
157
+
158
+ {open && (
159
+ <div
160
+ ref={flyoutRef}
161
+ className="a1-top-header__flyout-menu"
162
+ data-placement={flyoutPlacement}
163
+ style={{ "--a1-top-header-flyout-max-block-size": flyoutMaxHeight }}
164
+ role="menu"
165
+ aria-label={`${item.label} submenu`}
166
+ onKeyDown={(event) => {
167
+ if (event.key === "Tab") {
168
+ const focusableElements = getMenuFocusableElements(event.currentTarget);
169
+ if (focusableElements.length === 0) {
170
+ event.preventDefault();
171
+ return;
172
+ }
173
+
174
+ const firstElement = focusableElements[0];
175
+ const lastElement = focusableElements[focusableElements.length - 1];
176
+
177
+ if (event.shiftKey && document.activeElement === firstElement) {
178
+ event.preventDefault();
179
+ lastElement.focus();
180
+ } else if (!event.shiftKey && document.activeElement === lastElement) {
181
+ event.preventDefault();
182
+ firstElement.focus();
183
+ }
184
+ event.stopPropagation();
185
+ return;
186
+ }
187
+
188
+ if (event.key === "ArrowLeft" || event.key === "Escape") {
189
+ event.preventDefault();
190
+ event.stopPropagation();
191
+ setOpen(false);
192
+ event.currentTarget
193
+ .closest(".a1-top-header__flyout-wrap")
194
+ ?.querySelector(".a1-top-header__flyout-trigger")
195
+ ?.focus();
196
+ }
197
+ }}
198
+ >
199
+ {sections.map((section, i) => (
200
+ <MenuSection key={i}>
201
+ {section.map((sub) => (
202
+ <NavMenuItem key={sub.label} item={sub} onClose={onClose} />
203
+ ))}
204
+ </MenuSection>
205
+ ))}
206
+ </div>
207
+ )}
208
+ </div>
209
+ );
210
+ }
211
+
212
+ // ── NavItem (desktop) ──────────────────────────────────────────────────────────
213
+
214
+ function NavItem({ item, openId, onOpen }) {
215
+ const triggerRef = useRef(null);
216
+ const hasSubmenu = item.items?.length > 0;
217
+ const hasRoute = !!item.href;
218
+ const isOpen = hasSubmenu && openId === item.id;
219
+ const isIconOnly = !!item.iconOnly;
220
+ const sections = hasSubmenu ? splitIntoSections(item.items) : [];
221
+
222
+ const itemClass = [
223
+ "a1-top-header__nav-item",
224
+ hasSubmenu && hasRoute && "a1-top-header__nav-item--split",
225
+ ]
226
+ .filter(Boolean)
227
+ .join(" ");
228
+
229
+ const linkClass = [
230
+ "a1-top-header__nav-link",
231
+ item.active && "a1-top-header__nav-link--active",
232
+ isIconOnly && "a1-top-header__nav-link--icon-only",
233
+ ]
234
+ .filter(Boolean)
235
+ .join(" ");
236
+
237
+ const linkContent = (
238
+ <>
239
+ {item.icon && (
240
+ <Icon
241
+ name={item.icon}
242
+ className="a1-top-header__nav-link-icon"
243
+ aria-hidden="true"
244
+ />
245
+ )}
246
+ {!isIconOnly && <span>{item.label}</span>}
247
+ </>
248
+ );
249
+
250
+ const submenuChevron = (
251
+ hasSubmenu && (
252
+ <Icon
253
+ name="expand_more"
254
+ className="a1-top-header__nav-chevron"
255
+ aria-hidden="true"
256
+ />
257
+ )
258
+ );
259
+
260
+ const submenuButtonContent = (
261
+ <>
262
+ {linkContent}
263
+ {submenuChevron}
264
+ </>
265
+ );
266
+
267
+ return (
268
+ <li className={itemClass}>
269
+ {hasSubmenu && hasRoute ? (
270
+ <>
271
+ <a
272
+ href={item.href}
273
+ className={linkClass}
274
+ aria-current={item.active ? "page" : undefined}
275
+ aria-label={isIconOnly ? item.label : undefined}
276
+ onClick={item.onClick}
277
+ >
278
+ {linkContent}
279
+ </a>
280
+ <button
281
+ ref={triggerRef}
282
+ type="button"
283
+ className="a1-top-header__nav-link a1-top-header__nav-submenu-trigger"
284
+ aria-expanded={isOpen}
285
+ aria-haspopup="menu"
286
+ aria-label={`${item.label} submenu`}
287
+ onClick={() => onOpen(isOpen ? null : item.id)}
288
+ >
289
+ {submenuChevron}
290
+ </button>
291
+ <Menu
292
+ open={isOpen}
293
+ onClose={() => onOpen(null)}
294
+ anchorRef={triggerRef}
295
+ aria-label={`${item.label} submenu`}
296
+ className="a1-menu--with-flyouts"
297
+ >
298
+ {sections.map((section, i) => (
299
+ <MenuSection key={i}>
300
+ {section.map((sub) => (
301
+ <NavMenuItem
302
+ key={sub.label}
303
+ item={sub}
304
+ onClose={() => onOpen(null)}
305
+ />
306
+ ))}
307
+ </MenuSection>
308
+ ))}
309
+ </Menu>
310
+ </>
311
+ ) : hasSubmenu ? (
312
+ <>
313
+ <button
314
+ ref={triggerRef}
315
+ type="button"
316
+ className={linkClass}
317
+ aria-expanded={isOpen}
318
+ aria-haspopup="menu"
319
+ aria-current={item.active ? "page" : undefined}
320
+ aria-label={isIconOnly ? item.label : undefined}
321
+ onClick={() => onOpen(isOpen ? null : item.id)}
322
+ >
323
+ {submenuButtonContent}
324
+ </button>
325
+ <Menu
326
+ open={isOpen}
327
+ onClose={() => onOpen(null)}
328
+ anchorRef={triggerRef}
329
+ aria-label={`${item.label} submenu`}
330
+ className="a1-menu--with-flyouts"
331
+ >
332
+ {sections.map((section, i) => (
333
+ <MenuSection key={i}>
334
+ {section.map((sub) => (
335
+ <NavMenuItem
336
+ key={sub.label}
337
+ item={sub}
338
+ onClose={() => onOpen(null)}
339
+ />
340
+ ))}
341
+ </MenuSection>
342
+ ))}
343
+ </Menu>
344
+ </>
345
+ ) : item.href ? (
346
+ <a
347
+ href={item.href}
348
+ className={linkClass}
349
+ aria-current={item.active ? "page" : undefined}
350
+ aria-label={isIconOnly ? item.label : undefined}
351
+ onClick={item.onClick}
352
+ >
353
+ {linkContent}
354
+ </a>
355
+ ) : (
356
+ <button
357
+ type="button"
358
+ className={linkClass}
359
+ aria-current={item.active ? "page" : undefined}
360
+ aria-label={isIconOnly ? item.label : undefined}
361
+ onClick={item.onClick}
362
+ >
363
+ {linkContent}
364
+ </button>
365
+ )}
366
+ </li>
367
+ );
368
+ }
369
+
370
+ /*
371
+ * Mobile nav renders submenu parents as groups. When a parent also has a route,
372
+ * the first child links to that parent page so the route remains reachable.
373
+ */
374
+ function getMobileSubItems(item) {
375
+ if (!item.href) return (item.items ?? []).filter((sub) => !sub.divider);
376
+
377
+ return [
378
+ {
379
+ icon: item.icon,
380
+ label: item.label,
381
+ href: item.href,
382
+ onClick: item.onClick,
383
+ },
384
+ ...(item.items ?? []),
385
+ ].filter((sub) => !sub.divider);
386
+ }
387
+
388
+ function MobileDrawerItem({ item, onClose }) {
389
+ const children = (item.items ?? []).filter((sub) => !sub.divider);
390
+
391
+ if (children.length > 0) {
392
+ return (
393
+ <SideNavGroup icon={item.icon} label={item.label}>
394
+ {item.href && (
395
+ <SideNavItem
396
+ as="a"
397
+ href={item.href}
398
+ icon={item.icon}
399
+ label="Overview"
400
+ onClick={(event) => { item.onClick?.(event); onClose(); }}
401
+ />
402
+ )}
403
+ {children.map((sub) => (
404
+ <MobileDrawerItem key={sub.label} item={sub} onClose={onClose} />
405
+ ))}
406
+ </SideNavGroup>
407
+ );
408
+ }
409
+
410
+ return (
411
+ <SideNavItem
412
+ as={item.href ? "a" : "button"}
413
+ href={item.href}
414
+ icon={item.icon}
415
+ label={item.label}
416
+ onClick={(event) => { item.onClick?.(event); onClose(); }}
417
+ />
418
+ );
419
+ }
420
+
421
+ // ── ActionMenu ─────────────────────────────────────────────────────────────────
422
+
423
+ function ActionMenu({ action, isOpen, onToggle }) {
424
+ const btnRef = useRef(null);
425
+ const hasItems = (action.items?.length ?? 0) > 0;
426
+ const sections = hasItems ? splitIntoSections(action.items) : [];
427
+
428
+ return (
429
+ <div className="a1-top-header__action">
430
+ <div
431
+ ref={btnRef}
432
+ className={action.badge ? "a1-top-header__action-badge-wrap" : undefined}
433
+ data-count={action.badge || undefined}
434
+ >
435
+ <IconButton
436
+ icon={action.icon}
437
+ label={action.label}
438
+ aria-expanded={hasItems ? isOpen : undefined}
439
+ aria-haspopup={hasItems ? "menu" : undefined}
440
+ onClick={hasItems ? onToggle : action.onClick}
441
+ />
442
+ </div>
443
+
444
+ {hasItems && (
445
+ <Menu
446
+ open={isOpen}
447
+ onClose={onToggle}
448
+ anchorRef={btnRef}
449
+ aria-label={action.label}
450
+ >
451
+ {sections.map((section, i) => (
452
+ <MenuSection key={i}>
453
+ {section.map((item, j) => {
454
+ if (item.isHeader) {
455
+ return (
456
+ <div key={j} className="a1-top-header__menu-identity">
457
+ <span className="a1-top-header__menu-identity-name">
458
+ {item.label}
459
+ </span>
460
+ {item.description && (
461
+ <span className="a1-top-header__menu-identity-desc">
462
+ {item.description}
463
+ </span>
464
+ )}
465
+ </div>
466
+ );
467
+ }
468
+ return (
469
+ <MenuItem
470
+ key={j}
471
+ icon={item.icon}
472
+ href={item.href}
473
+ variant={item.danger ? "destructive" : "default"}
474
+ onClick={item.onClick}
475
+ >
476
+ {item.label}
477
+ </MenuItem>
478
+ );
479
+ })}
480
+ </MenuSection>
481
+ ))}
482
+ </Menu>
483
+ )}
484
+ </div>
485
+ );
486
+ }
487
+
488
+ // ── MobileDrawer ───────────────────────────────────────────────────────────────
489
+
490
+ function MobileDrawer({ navItems, onClose }) {
491
+ const [openSubId, setOpenSubId] = useState(null);
492
+
493
+ useEffect(() => {
494
+ const prev = document.body.style.overflow;
495
+ document.body.style.overflow = "hidden";
496
+ return () => { document.body.style.overflow = prev; };
497
+ }, []);
498
+
499
+ return (
500
+ <SideNav open onClose={onClose}>
501
+ {navItems.map((item) => {
502
+ if (item.items?.length > 0) {
503
+ return (
504
+ <SideNavGroup
505
+ key={item.id}
506
+ icon={item.icon}
507
+ label={item.label}
508
+ open={openSubId === item.id}
509
+ onOpenChange={(next) => setOpenSubId(next ? item.id : null)}
510
+ >
511
+ {getMobileSubItems(item).map((sub) => (
512
+ <MobileDrawerItem key={sub.label} item={sub} onClose={onClose} />
513
+ ))}
514
+ </SideNavGroup>
515
+ );
516
+ }
517
+
518
+ return (
519
+ <SideNavItem
520
+ key={item.id}
521
+ as={item.href ? "a" : "button"}
522
+ href={item.href}
523
+ icon={item.icon}
524
+ label={item.label}
525
+ active={item.active}
526
+ onClick={(event) => { item.onClick?.(event); onClose(); }}
527
+ />
528
+ );
529
+ })}
530
+ </SideNav>
531
+ );
532
+ }
533
+
534
+ // ── TopHeader ──────────────────────────────────────────────────────────────────
535
+
536
+ export function TopHeader({
537
+ logo,
538
+ logoText,
539
+ logoHref = "/",
540
+ navItems = [],
541
+ actions = [],
542
+ loginButton,
543
+ className = "",
544
+ }) {
545
+ const [openSubmenu, setOpenSubmenu] = useState(null);
546
+ const [openAction, setOpenAction] = useState(null);
547
+ const [mobileNavOpen, setMobileNavOpen] = useState(false);
548
+ // SideNav handles its own Escape key; Menu handles its own Escape + outside click.
549
+
550
+ useEffect(() => {
551
+ if (!mobileNavOpen || typeof window === "undefined" || !window.matchMedia) return undefined;
552
+
553
+ const desktopQuery = window.matchMedia("(min-width: 769px)");
554
+ const closeAtDesktop = (event) => {
555
+ if (event.matches) setMobileNavOpen(false);
556
+ };
557
+
558
+ if (desktopQuery.matches) {
559
+ setMobileNavOpen(false);
560
+ return undefined;
561
+ }
562
+
563
+ if (desktopQuery.addEventListener) {
564
+ desktopQuery.addEventListener("change", closeAtDesktop);
565
+ return () => desktopQuery.removeEventListener("change", closeAtDesktop);
566
+ }
567
+
568
+ desktopQuery.addListener(closeAtDesktop);
569
+ return () => desktopQuery.removeListener(closeAtDesktop);
570
+ }, [mobileNavOpen]);
571
+
572
+ return (
573
+ <>
574
+ <header
575
+ className={["a1-top-header", className].filter(Boolean).join(" ")}
576
+ >
577
+ <button
578
+ type="button"
579
+ className="a1-top-header__hamburger"
580
+ aria-label="Open navigation menu"
581
+ aria-expanded={mobileNavOpen}
582
+ onClick={() => setMobileNavOpen(true)}
583
+ >
584
+ <Icon name="menu" aria-hidden="true" />
585
+ </button>
586
+
587
+ {(logo ?? logoText) && (
588
+ <a href={logoHref} className="a1-top-header__logo">
589
+ {logo ?? logoText}
590
+ </a>
591
+ )}
592
+
593
+ {navItems.length > 0 && (
594
+ <nav className="a1-top-header__nav" aria-label="Main navigation">
595
+ <ul className="a1-top-header__nav-list" role="list">
596
+ {navItems.map((item) => (
597
+ <NavItem
598
+ key={item.id}
599
+ item={item}
600
+ openId={openSubmenu}
601
+ onOpen={setOpenSubmenu}
602
+ />
603
+ ))}
604
+ </ul>
605
+ </nav>
606
+ )}
607
+
608
+ <div className="a1-top-header__end">
609
+ {actions.map((action) => (
610
+ <ActionMenu
611
+ key={action.id}
612
+ action={action}
613
+ isOpen={openAction === action.id}
614
+ onToggle={() =>
615
+ setOpenAction(openAction === action.id ? null : action.id)
616
+ }
617
+ />
618
+ ))}
619
+ {loginButton && (
620
+ <div className="a1-top-header__login">
621
+ <Button
622
+ variant="primary"
623
+ size="sm"
624
+ onClick={loginButton.onClick}
625
+ >
626
+ {loginButton.label ?? "Log in"}
627
+ </Button>
628
+ </div>
629
+ )}
630
+ </div>
631
+ </header>
632
+
633
+ {mobileNavOpen && (
634
+ <MobileDrawer
635
+ navItems={navItems}
636
+ onClose={() => setMobileNavOpen(false)}
637
+ />
638
+ )}
639
+ </>
640
+ );
641
+ }