@db-vincent/components 0.0.0-stage → 0.1.0

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 (62) hide show
  1. package/package.json +28 -4
  2. package/src/astro/Accordion.astro +37 -0
  3. package/src/astro/Alert.astro +73 -0
  4. package/src/astro/Badge.astro +23 -0
  5. package/src/astro/Breadcrumbs.astro +45 -0
  6. package/src/astro/Button.astro +125 -0
  7. package/src/astro/Card.astro +63 -0
  8. package/src/astro/Checkbox.astro +61 -0
  9. package/src/astro/Chip.astro +116 -0
  10. package/src/astro/CodeBlock.astro +17 -0
  11. package/src/astro/EmptyState.astro +45 -0
  12. package/src/astro/Example.astro +37 -0
  13. package/src/astro/Grid.astro +50 -0
  14. package/src/astro/GridItem.astro +23 -0
  15. package/src/astro/Icon.astro +52 -0
  16. package/src/astro/Modal.astro +117 -0
  17. package/src/astro/Navbar.astro +756 -0
  18. package/src/astro/PageHeader.astro +34 -0
  19. package/src/astro/Pagination.astro +162 -0
  20. package/src/astro/Radio.astro +58 -0
  21. package/src/astro/RadioGroup.astro +25 -0
  22. package/src/astro/Section.astro +25 -0
  23. package/src/astro/Select.astro +403 -0
  24. package/src/astro/Sidebar.astro +215 -0
  25. package/src/astro/Skeleton.astro +39 -0
  26. package/src/astro/Spinner.astro +19 -0
  27. package/src/astro/Table.astro +254 -0
  28. package/src/astro/TextField.astro +287 -0
  29. package/src/astro/ThemeToggle.astro +66 -0
  30. package/src/astro/Toast.astro +181 -0
  31. package/src/astro/TopBar.astro +29 -0
  32. package/src/data/icons.ts +104 -0
  33. package/src/scripts/toast.ts +531 -0
  34. package/src/styles/accordion.css +77 -0
  35. package/src/styles/alert.css +106 -0
  36. package/src/styles/badge.css +46 -0
  37. package/src/styles/base.css +203 -0
  38. package/src/styles/breadcrumbs.css +52 -0
  39. package/src/styles/button.css +163 -0
  40. package/src/styles/card.css +108 -0
  41. package/src/styles/chip.css +104 -0
  42. package/src/styles/choice.css +137 -0
  43. package/src/styles/code.css +42 -0
  44. package/src/styles/empty.css +57 -0
  45. package/src/styles/example.css +55 -0
  46. package/src/styles/grid.css +62 -0
  47. package/src/styles/input.css +198 -0
  48. package/src/styles/modal.css +143 -0
  49. package/src/styles/motion.css +96 -0
  50. package/src/styles/nav.css +230 -0
  51. package/src/styles/navbar.css +656 -0
  52. package/src/styles/page-header.css +59 -0
  53. package/src/styles/pagination.css +77 -0
  54. package/src/styles/section.css +36 -0
  55. package/src/styles/select.css +235 -0
  56. package/src/styles/skeleton.css +64 -0
  57. package/src/styles/spinner.css +42 -0
  58. package/src/styles/table.css +175 -0
  59. package/src/styles/theme-toggle.css +29 -0
  60. package/src/styles/toast.css +207 -0
  61. package/src/styles/topbar.css +30 -0
  62. package/README.md +0 -3
@@ -0,0 +1,756 @@
1
+ ---
2
+ import Icon from "./Icon.astro";
3
+ import "../styles/navbar.css";
4
+
5
+ /**
6
+ * Navigation bar for the top of an app view, built on the navigation-menu
7
+ * model: a row of triggers and links, one shared viewport that resizes and
8
+ * slides between panels, and rich rows that pair a title with a one-line
9
+ * description.
10
+ *
11
+ * - An item with `children` or `groups` is a trigger that opens a panel.
12
+ * `layout` picks the panel shape: "list" (one column), "grid" (two
13
+ * columns) or "featured" (a highlight card next to a list).
14
+ * - An item without them is a plain link.
15
+ * - The bar responds to its own width (a container query), not the window,
16
+ * so it collapses correctly inside any layout, including a demo frame.
17
+ * When the full row no longer fits, it becomes a menu button that opens
18
+ * a modal sheet (without script: below 720px of bar width):
19
+ * focus is trapped, the page is inert and cannot scroll, Escape and the
20
+ * backdrop close it, and focus returns to the button.
21
+ * - Slots: `start` (context such as breadcrumbs), `actions` (utilities
22
+ * such as a theme toggle) and `sheet-footer` (extra content at the end of
23
+ * the mobile sheet).
24
+ */
25
+
26
+ interface NavLink {
27
+ label: string;
28
+ href: string;
29
+ /** One-line summary under the label. */
30
+ description?: string;
31
+ /** Icon name from the functional icon set. */
32
+ icon?: string;
33
+ active?: boolean;
34
+ }
35
+
36
+ interface NavGroup {
37
+ /** Overline for the cluster; omit for an unlabelled cluster. */
38
+ label?: string;
39
+ items: NavLink[];
40
+ }
41
+
42
+ interface NavFeature {
43
+ title: string;
44
+ href: string;
45
+ description?: string;
46
+ /** Small overline above the title. */
47
+ eyebrow?: string;
48
+ icon?: string;
49
+ }
50
+
51
+ interface NavItem {
52
+ label: string;
53
+ /** Destination for a plain link. Menu items also expose it as an
54
+ * "Overview" row so the section itself stays reachable. */
55
+ href?: string;
56
+ icon?: string;
57
+ active?: boolean;
58
+ /** Flat list of destinations. */
59
+ children?: NavLink[];
60
+ /** Labelled clusters of destinations, one column each. */
61
+ groups?: NavGroup[];
62
+ layout?: "list" | "grid" | "featured";
63
+ /** Highlight card for the "featured" layout. */
64
+ feature?: NavFeature;
65
+ }
66
+
67
+ interface FooterLink {
68
+ label: string;
69
+ href: string;
70
+ }
71
+
72
+ interface Props {
73
+ /** Accessible label for the nav landmark. */
74
+ label?: string;
75
+ items?: NavItem[];
76
+ brand?: string;
77
+ brandHref?: string;
78
+ /** Pin the bar to the top; a blurred tint appears once content scrolls under it. */
79
+ sticky?: boolean;
80
+ /** Secondary links (about, support, legal) shown in two columns at the
81
+ * foot of the mobile menu. */
82
+ footerLinks?: FooterLink[];
83
+ navId?: string;
84
+ class?: string;
85
+ }
86
+
87
+ const {
88
+ label = "Main",
89
+ items = [],
90
+ brand,
91
+ brandHref = "/",
92
+ sticky = false,
93
+ footerLinks = [],
94
+ navId = `navbar-${Math.random().toString(36).slice(2, 9)}`,
95
+ class: className,
96
+ } = Astro.props;
97
+
98
+ const sheetId = `${navId}-sheet`;
99
+ const panelId = (index: number) => `${navId}-panel-${index}`;
100
+ const hasMenu = (item: NavItem) =>
101
+ Boolean(item.children?.length || item.groups?.length || item.feature);
102
+ const linksOf = (item: NavItem): NavLink[] =>
103
+ item.groups?.length
104
+ ? item.groups.flatMap((group) => group.items)
105
+ : (item.children ?? []);
106
+ const isCurrent = (item: NavItem) =>
107
+ Boolean(item.active || linksOf(item).some((child) => child.active));
108
+ const layoutOf = (item: NavItem) =>
109
+ item.groups?.length
110
+ ? "groups"
111
+ : item.feature
112
+ ? "featured"
113
+ : (item.layout ?? "list");
114
+ const hasSheetFooter = Astro.slots.has("sheet-footer");
115
+ ---
116
+
117
+ <header
118
+ class:list={["ds-navbar", sticky && "ds-navbar--sticky", className]}
119
+ data-navbar
120
+ >
121
+ <div class="ds-navbar__inner">
122
+ {brand && (
123
+ <a class="ds-navbar__brand" href={brandHref}>
124
+ <span class="ds-navbar__mark" aria-hidden="true">
125
+ <Icon name="terminal" size={16} />
126
+ </span>
127
+ <span>{brand}</span>
128
+ </a>
129
+ )}
130
+ <div class="ds-navbar__start">
131
+ <slot name="start" />
132
+ </div>
133
+
134
+ {items.length > 0 && (
135
+ <nav id={navId} class="ds-navbar__nav" aria-label={label}>
136
+ <ul class="ds-navbar__list">
137
+ {items.map((item, index) => (
138
+ <li class="ds-navbar__item">
139
+ {hasMenu(item) ? (
140
+ <button
141
+ class:list={[
142
+ "ds-navbar__trigger",
143
+ isCurrent(item) && "is-current",
144
+ ]}
145
+ type="button"
146
+ aria-expanded="false"
147
+ aria-controls={panelId(index)}
148
+ data-navbar-trigger={panelId(index)}
149
+ >
150
+ {item.icon && <Icon name={item.icon} size={16} />}
151
+ <span>{item.label}</span>
152
+ <Icon
153
+ name="chevron-down"
154
+ size={14}
155
+ class="ds-navbar__chevron"
156
+ />
157
+ </button>
158
+ ) : (
159
+ <a
160
+ class="ds-navbar__trigger"
161
+ href={item.href ?? "#"}
162
+ aria-current={item.active ? "page" : undefined}
163
+ data-navbar-link
164
+ >
165
+ {item.icon && <Icon name={item.icon} size={16} />}
166
+ <span>{item.label}</span>
167
+ </a>
168
+ )}
169
+ </li>
170
+ ))}
171
+ </ul>
172
+
173
+ <div class="ds-navbar__viewport" data-navbar-viewport hidden>
174
+ {items.map(
175
+ (item, index) =>
176
+ hasMenu(item) && (
177
+ <div
178
+ class:list={[
179
+ "ds-navbar__panel",
180
+ `ds-navbar__panel--${layoutOf(item)}`,
181
+ ]}
182
+ id={panelId(index)}
183
+ data-navbar-panel
184
+ hidden
185
+ >
186
+ {item.feature && (
187
+ <a
188
+ class="ds-navbar__feature"
189
+ href={item.feature.href}
190
+ data-navbar-row
191
+ >
192
+ {item.feature.icon && (
193
+ <span
194
+ class="ds-navbar__feature-icon"
195
+ aria-hidden="true"
196
+ >
197
+ <Icon name={item.feature.icon} size={20} />
198
+ </span>
199
+ )}
200
+ {item.feature.eyebrow && (
201
+ <span class="ds-navbar__eyebrow">
202
+ {item.feature.eyebrow}
203
+ </span>
204
+ )}
205
+ <span class="ds-navbar__feature-title">
206
+ {item.feature.title}
207
+ </span>
208
+ {item.feature.description && (
209
+ <span class="ds-navbar__row-desc">
210
+ {item.feature.description}
211
+ </span>
212
+ )}
213
+ </a>
214
+ )}
215
+ {(item.groups?.length
216
+ ? item.groups
217
+ : [{ items: item.children ?? [] }]
218
+ ).map((group: NavGroup) => (
219
+ <div class="ds-navbar__group">
220
+ {group.label && (
221
+ <p class="ds-navbar__eyebrow">{group.label}</p>
222
+ )}
223
+ <ul class="ds-navbar__rows">
224
+ {group.items.map((child) => (
225
+ <li>
226
+ <a
227
+ class="ds-navbar__row"
228
+ href={child.href}
229
+ aria-current={child.active ? "page" : undefined}
230
+ data-navbar-row
231
+ >
232
+ {child.icon && (
233
+ <span
234
+ class="ds-navbar__row-icon"
235
+ aria-hidden="true"
236
+ >
237
+ <Icon name={child.icon} size={16} />
238
+ </span>
239
+ )}
240
+ <span class="ds-navbar__row-body">
241
+ <span class="ds-navbar__row-title">
242
+ {child.label}
243
+ </span>
244
+ {child.description && (
245
+ <span class="ds-navbar__row-desc">
246
+ {child.description}
247
+ </span>
248
+ )}
249
+ </span>
250
+ </a>
251
+ </li>
252
+ ))}
253
+ </ul>
254
+ </div>
255
+ ))}
256
+ </div>
257
+ ),
258
+ )}
259
+ </div>
260
+ </nav>
261
+ )}
262
+
263
+ <div class="ds-navbar__actions">
264
+ <slot name="actions" />
265
+ </div>
266
+
267
+ {items.length > 0 && (
268
+ <button
269
+ class="ds-navbar__toggle"
270
+ type="button"
271
+ aria-haspopup="dialog"
272
+ aria-expanded="false"
273
+ aria-controls={sheetId}
274
+ aria-label="Open navigation menu"
275
+ data-navbar-toggle
276
+ >
277
+ <Icon name="menu" size={20} />
278
+ </button>
279
+ )}
280
+ </div>
281
+
282
+ {items.length > 0 && (
283
+ <dialog
284
+ class="ds-navbar__sheet"
285
+ id={sheetId}
286
+ aria-label={`${label} navigation`}
287
+ data-navbar-sheet
288
+ >
289
+ <div class="ds-navbar__sheet-head">
290
+ {brand ? (
291
+ <a class="ds-navbar__brand" href={brandHref}>
292
+ <span class="ds-navbar__mark" aria-hidden="true">
293
+ <Icon name="terminal" size={16} />
294
+ </span>
295
+ <span>{brand}</span>
296
+ </a>
297
+ ) : (
298
+ <span class="ds-navbar__sheet-title">{label}</span>
299
+ )}
300
+ <form method="dialog">
301
+ <button
302
+ class="ds-navbar__toggle ds-navbar__sheet-close"
303
+ type="submit"
304
+ aria-label="Close navigation menu"
305
+ >
306
+ <Icon name="x" size={20} />
307
+ </button>
308
+ </form>
309
+ </div>
310
+
311
+ <nav class="ds-navbar__sheet-nav" aria-label={label}>
312
+ <ul class="ds-navbar__sheet-list">
313
+ {items.map((item) =>
314
+ hasMenu(item) ? (
315
+ <li>
316
+ <details
317
+ class="ds-navbar__disclosure"
318
+ open={isCurrent(item) ? true : undefined}
319
+ >
320
+ <summary
321
+ class:list={[
322
+ "ds-navbar__sheet-link",
323
+ isCurrent(item) && "is-current",
324
+ ]}
325
+ >
326
+ {item.icon && <Icon name={item.icon} size={20} />}
327
+ <span>{item.label}</span>
328
+ <Icon
329
+ name="chevron-down"
330
+ size={16}
331
+ class="ds-navbar__chevron"
332
+ />
333
+ </summary>
334
+ <div class="ds-navbar__sheet-panel">
335
+ {item.feature && (
336
+ <a
337
+ class="ds-navbar__sheet-sublink"
338
+ href={item.feature.href}
339
+ >
340
+ <span class="ds-navbar__row-title">
341
+ {item.feature.title}
342
+ </span>
343
+ {item.feature.description && (
344
+ <span class="ds-navbar__row-desc">
345
+ {item.feature.description}
346
+ </span>
347
+ )}
348
+ </a>
349
+ )}
350
+ {(item.groups?.length
351
+ ? item.groups
352
+ : [{ items: item.children ?? [] }]
353
+ ).map((group: NavGroup) => (
354
+ <div class="ds-navbar__sheet-group">
355
+ {group.label && (
356
+ <p class="ds-navbar__eyebrow">{group.label}</p>
357
+ )}
358
+ <ul>
359
+ {group.items.map((child) => (
360
+ <li>
361
+ <a
362
+ class="ds-navbar__sheet-sublink"
363
+ href={child.href}
364
+ aria-current={child.active ? "page" : undefined}
365
+ >
366
+ <span class="ds-navbar__row-title">
367
+ {child.label}
368
+ </span>
369
+ {child.description && (
370
+ <span class="ds-navbar__row-desc">
371
+ {child.description}
372
+ </span>
373
+ )}
374
+ </a>
375
+ </li>
376
+ ))}
377
+ </ul>
378
+ </div>
379
+ ))}
380
+ </div>
381
+ </details>
382
+ </li>
383
+ ) : (
384
+ <li>
385
+ <a
386
+ class="ds-navbar__sheet-link"
387
+ href={item.href ?? "#"}
388
+ aria-current={item.active ? "page" : undefined}
389
+ >
390
+ {item.icon && <Icon name={item.icon} size={20} />}
391
+ <span>{item.label}</span>
392
+ </a>
393
+ </li>
394
+ ),
395
+ )}
396
+ </ul>
397
+ </nav>
398
+
399
+ {(footerLinks.length > 0 || hasSheetFooter) && (
400
+ <div class="ds-navbar__sheet-footer">
401
+ {footerLinks.length > 0 && (
402
+ <ul class="ds-navbar__sheet-meta">
403
+ {footerLinks.map((link) => (
404
+ <li>
405
+ <a href={link.href}>{link.label}</a>
406
+ </li>
407
+ ))}
408
+ </ul>
409
+ )}
410
+ {hasSheetFooter && (
411
+ <div class="ds-navbar__sheet-actions">
412
+ <slot name="sheet-footer" />
413
+ </div>
414
+ )}
415
+ </div>
416
+ )}
417
+ </dialog>
418
+ )}
419
+ </header>
420
+
421
+ <script>
422
+ const OPEN_DELAY = 120;
423
+ const CLOSE_DELAY = 260;
424
+ const canHover = () =>
425
+ window.matchMedia("(hover: hover) and (pointer: fine)").matches;
426
+
427
+ const bindNavbar = (navbar: HTMLElement) => {
428
+ if (navbar.dataset.navbarBound) return;
429
+ navbar.dataset.navbarBound = "true";
430
+
431
+ const nav = navbar.querySelector<HTMLElement>(".ds-navbar__nav");
432
+ const viewport = navbar.querySelector<HTMLElement>(
433
+ "[data-navbar-viewport]",
434
+ );
435
+ const triggers = Array.from(
436
+ navbar.querySelectorAll<HTMLButtonElement>("[data-navbar-trigger]"),
437
+ );
438
+ const topLevel = Array.from(
439
+ navbar.querySelectorAll<HTMLElement>(".ds-navbar__list > li > *"),
440
+ );
441
+ const toggle = navbar.querySelector<HTMLButtonElement>(
442
+ "[data-navbar-toggle]",
443
+ );
444
+ const sheet = navbar.querySelector<HTMLDialogElement>(
445
+ "[data-navbar-sheet]",
446
+ );
447
+
448
+ let current: HTMLButtonElement | null = null;
449
+ let openTimer = 0;
450
+ let closeTimer = 0;
451
+
452
+ const panelFor = (trigger: HTMLButtonElement) =>
453
+ document.getElementById(trigger.dataset.navbarTrigger ?? "");
454
+ const rowsOf = (panel: HTMLElement | null) =>
455
+ panel
456
+ ? Array.from(panel.querySelectorAll<HTMLElement>("[data-navbar-row]"))
457
+ : [];
458
+
459
+ // The viewport is one surface. It is placed under the open trigger,
460
+ // clamped inside the bar, and sized to the panel it shows, so moving
461
+ // between triggers slides and resizes it instead of swapping boxes.
462
+ const place = (trigger: HTMLButtonElement, panel: HTMLElement) => {
463
+ if (!viewport || !nav) return;
464
+ const navBox = nav.getBoundingClientRect();
465
+ const barBox = navbar.getBoundingClientRect();
466
+ const triggerBox = trigger.getBoundingClientRect();
467
+ const width = panel.offsetWidth;
468
+ const height = panel.offsetHeight;
469
+ const gutter = 8;
470
+ const minLeft = barBox.left - navBox.left + gutter;
471
+ const maxLeft = barBox.right - navBox.left - width - gutter;
472
+ const left = Math.max(
473
+ minLeft,
474
+ Math.min(triggerBox.left - navBox.left, maxLeft),
475
+ );
476
+ viewport.style.setProperty("--ds-navbar-viewport-x", `${left}px`);
477
+ viewport.style.setProperty("--ds-navbar-viewport-w", `${width}px`);
478
+ viewport.style.setProperty("--ds-navbar-viewport-h", `${height}px`);
479
+ };
480
+
481
+ const open = (
482
+ trigger: HTMLButtonElement,
483
+ focusRow: "first" | "last" | null = null,
484
+ ) => {
485
+ window.clearTimeout(closeTimer);
486
+ const panel = panelFor(trigger);
487
+ if (!viewport || !panel) return;
488
+ const wasOpen = current !== null;
489
+ triggers.forEach((other) => {
490
+ const active = other === trigger;
491
+ other.setAttribute("aria-expanded", String(active));
492
+ const otherPanel = panelFor(other);
493
+ if (otherPanel) otherPanel.hidden = !active;
494
+ });
495
+ viewport.hidden = false;
496
+ viewport.dataset.instant = wasOpen ? "false" : "true";
497
+ current = trigger;
498
+ place(trigger, panel);
499
+ if (focusRow) {
500
+ const rows = rowsOf(panel);
501
+ (focusRow === "first" ? rows[0] : rows[rows.length - 1])?.focus();
502
+ }
503
+ };
504
+
505
+ const close = (returnFocus = false) => {
506
+ window.clearTimeout(openTimer);
507
+ window.clearTimeout(closeTimer);
508
+ if (!current || !viewport) return;
509
+ const trigger = current;
510
+ current = null;
511
+ triggers.forEach((other) => other.setAttribute("aria-expanded", "false"));
512
+ viewport.hidden = true;
513
+ viewport
514
+ .querySelectorAll<HTMLElement>("[data-navbar-panel]")
515
+ .forEach((panel) => (panel.hidden = true));
516
+ if (returnFocus) trigger.focus();
517
+ };
518
+
519
+ const focusTopLevel = (from: HTMLElement, step: 1 | -1) => {
520
+ const index = topLevel.indexOf(from);
521
+ if (index < 0) return;
522
+ const next = topLevel[(index + step + topLevel.length) % topLevel.length];
523
+ next.focus();
524
+ if (
525
+ current &&
526
+ next instanceof HTMLButtonElement &&
527
+ next.dataset.navbarTrigger
528
+ ) {
529
+ open(next);
530
+ } else if (current) {
531
+ close();
532
+ }
533
+ };
534
+
535
+ triggers.forEach((trigger) => {
536
+ trigger.addEventListener("click", () => {
537
+ if (current === trigger) close();
538
+ else open(trigger);
539
+ });
540
+
541
+ trigger.addEventListener("keydown", (event) => {
542
+ if (event.key === "ArrowDown") {
543
+ event.preventDefault();
544
+ open(trigger, "first");
545
+ } else if (event.key === "ArrowUp") {
546
+ event.preventDefault();
547
+ open(trigger, "last");
548
+ }
549
+ });
550
+
551
+ trigger.addEventListener("pointerenter", (event) => {
552
+ if (event.pointerType !== "mouse" || !canHover()) return;
553
+ window.clearTimeout(closeTimer);
554
+ window.clearTimeout(openTimer);
555
+ if (current) open(trigger);
556
+ else openTimer = window.setTimeout(() => open(trigger), OPEN_DELAY);
557
+ });
558
+ });
559
+
560
+ topLevel.forEach((item) => {
561
+ item.addEventListener("keydown", (event) => {
562
+ if (event.key === "ArrowRight") {
563
+ event.preventDefault();
564
+ focusTopLevel(item, 1);
565
+ } else if (event.key === "ArrowLeft") {
566
+ event.preventDefault();
567
+ focusTopLevel(item, -1);
568
+ }
569
+ });
570
+ // Hovering a plain link while a panel is open closes the panel, as in
571
+ // a menubar: the pointer has moved on.
572
+ if (!(item instanceof HTMLButtonElement)) {
573
+ item.addEventListener("pointerenter", (event) => {
574
+ if (event.pointerType === "mouse" && current) {
575
+ closeTimer = window.setTimeout(() => close(), CLOSE_DELAY / 2);
576
+ }
577
+ });
578
+ }
579
+ });
580
+
581
+ if (nav && viewport) {
582
+ nav.addEventListener("pointerleave", (event) => {
583
+ if (event.pointerType !== "mouse" || !canHover()) return;
584
+ window.clearTimeout(openTimer);
585
+ closeTimer = window.setTimeout(() => close(), CLOSE_DELAY);
586
+ });
587
+ viewport.addEventListener("pointerenter", () =>
588
+ window.clearTimeout(closeTimer),
589
+ );
590
+
591
+ viewport.addEventListener("keydown", (event) => {
592
+ if (!current) return;
593
+ const rows = rowsOf(panelFor(current));
594
+ const index = rows.indexOf(document.activeElement as HTMLElement);
595
+ if (event.key === "ArrowDown") {
596
+ event.preventDefault();
597
+ rows[Math.min(rows.length - 1, index + 1)]?.focus();
598
+ } else if (event.key === "ArrowUp") {
599
+ event.preventDefault();
600
+ if (index <= 0) close(true);
601
+ else rows[index - 1]?.focus();
602
+ } else if (event.key === "Home") {
603
+ event.preventDefault();
604
+ rows[0]?.focus();
605
+ } else if (event.key === "End") {
606
+ event.preventDefault();
607
+ rows[rows.length - 1]?.focus();
608
+ } else if (event.key === "Tab") {
609
+ // The viewport sits after the row in the DOM; Tab continues to the
610
+ // next top-level item instead of skipping it.
611
+ const trigger = current;
612
+ if (!event.shiftKey && index === rows.length - 1) {
613
+ event.preventDefault();
614
+ close();
615
+ const next = topLevel[topLevel.indexOf(trigger) + 1];
616
+ if (next) next.focus();
617
+ else trigger.focus();
618
+ } else if (event.shiftKey && index === 0) {
619
+ event.preventDefault();
620
+ close(true);
621
+ }
622
+ }
623
+ });
624
+
625
+ viewport.addEventListener("click", (event) => {
626
+ if ((event.target as Element).closest("a[href]")) close();
627
+ });
628
+ }
629
+
630
+ navbar.addEventListener("keydown", (event) => {
631
+ if (event.key === "Escape" && current) {
632
+ event.preventDefault();
633
+ close(true);
634
+ }
635
+ });
636
+
637
+ navbar.addEventListener("focusout", (event) => {
638
+ const next = event.relatedTarget as Node | null;
639
+ if (current && next && !nav?.contains(next)) close();
640
+ });
641
+
642
+ document.addEventListener("pointerdown", (event) => {
643
+ if (current && !nav?.contains(event.target as Node)) close();
644
+ });
645
+
646
+ window.addEventListener("resize", () => {
647
+ if (current) {
648
+ const panel = panelFor(current);
649
+ if (panel) place(current, panel);
650
+ }
651
+ });
652
+
653
+ // Mobile sheet: a modal dialog, so the browser supplies the focus trap,
654
+ // the inert page and Escape. Scroll lock is CSS (:has(dialog[open])).
655
+ if (toggle && sheet) {
656
+ toggle.addEventListener("click", () => {
657
+ close();
658
+ sheet.showModal();
659
+ toggle.setAttribute("aria-expanded", "true");
660
+ sheet
661
+ .querySelector<HTMLElement>(
662
+ ".ds-navbar__sheet-nav [aria-current='page'], .ds-navbar__sheet-nav a, .ds-navbar__sheet-nav summary",
663
+ )
664
+ ?.focus();
665
+ });
666
+ sheet.addEventListener("close", () => {
667
+ toggle.setAttribute("aria-expanded", "false");
668
+ toggle.focus();
669
+ });
670
+ sheet.addEventListener("click", (event) => {
671
+ if (event.target === sheet) {
672
+ sheet.close();
673
+ return;
674
+ }
675
+ if ((event.target as Element).closest("a[href]")) sheet.close();
676
+ });
677
+ }
678
+
679
+ // Fit-based collapse: measure the full row once (expanded), then collapse
680
+ // whenever the bar is narrower than that. Re-measure when fonts load.
681
+ const inner = navbar.querySelector<HTMLElement>(".ds-navbar__inner");
682
+ let needed = 0;
683
+ const measure = () => {
684
+ if (!inner || !nav) return;
685
+ navbar.dataset.collapsed = "false";
686
+ const style = getComputedStyle(inner);
687
+ const gap = Number.parseFloat(style.columnGap) || 0;
688
+ const children = Array.from(inner.children).filter(
689
+ (child) =>
690
+ child !== toggle && getComputedStyle(child).display !== "none",
691
+ ) as HTMLElement[];
692
+ needed =
693
+ children.reduce((sum, child) => sum + child.scrollWidth, 0) +
694
+ gap * Math.max(0, children.length - 1) +
695
+ (Number.parseFloat(style.paddingInlineStart) || 0) +
696
+ (Number.parseFloat(style.paddingInlineEnd) || 0);
697
+ };
698
+ const fit = () => {
699
+ if (!inner || !nav) return;
700
+ const collapsed = navbar.clientWidth < needed;
701
+ navbar.dataset.collapsed = String(collapsed);
702
+ if (collapsed) close();
703
+ else if (sheet?.open) sheet.close();
704
+ };
705
+ if (nav) {
706
+ measure();
707
+ fit();
708
+ new ResizeObserver(fit).observe(navbar);
709
+ document.fonts?.ready.then(() => {
710
+ measure();
711
+ fit();
712
+ });
713
+ }
714
+
715
+ // Scrolled state: only a sticky bar has content passing under it. It
716
+ // listens to its own scroll container (the window, or the nearest
717
+ // scrolling ancestor), so it also works inside a scrolling panel.
718
+ if (navbar.classList.contains("ds-navbar--sticky")) {
719
+ const scrollParent = (() => {
720
+ for (let el = navbar.parentElement; el; el = el.parentElement) {
721
+ const { overflowY } = getComputedStyle(el);
722
+ if (
723
+ /(auto|scroll|overlay)/.test(overflowY) &&
724
+ el.scrollHeight > el.clientHeight
725
+ ) {
726
+ return el;
727
+ }
728
+ }
729
+ return null;
730
+ })();
731
+ const offset = () =>
732
+ scrollParent ? scrollParent.scrollTop : window.scrollY;
733
+ let ticking = false;
734
+ const syncScrolled = () => {
735
+ navbar.dataset.scrolled = offset() > 4 ? "true" : "false";
736
+ ticking = false;
737
+ };
738
+ (scrollParent ?? window).addEventListener(
739
+ "scroll",
740
+ () => {
741
+ if (!ticking) {
742
+ ticking = true;
743
+ requestAnimationFrame(syncScrolled);
744
+ }
745
+ },
746
+ { passive: true },
747
+ );
748
+ syncScrolled();
749
+ }
750
+ };
751
+
752
+ const bindAll = () =>
753
+ document.querySelectorAll<HTMLElement>("[data-navbar]").forEach(bindNavbar);
754
+ bindAll();
755
+ document.addEventListener("astro:page-load", bindAll);
756
+ </script>