@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.
- package/package.json +28 -4
- package/src/astro/Accordion.astro +37 -0
- package/src/astro/Alert.astro +73 -0
- package/src/astro/Badge.astro +23 -0
- package/src/astro/Breadcrumbs.astro +45 -0
- package/src/astro/Button.astro +125 -0
- package/src/astro/Card.astro +63 -0
- package/src/astro/Checkbox.astro +61 -0
- package/src/astro/Chip.astro +116 -0
- package/src/astro/CodeBlock.astro +17 -0
- package/src/astro/EmptyState.astro +45 -0
- package/src/astro/Example.astro +37 -0
- package/src/astro/Grid.astro +50 -0
- package/src/astro/GridItem.astro +23 -0
- package/src/astro/Icon.astro +52 -0
- package/src/astro/Modal.astro +117 -0
- package/src/astro/Navbar.astro +756 -0
- package/src/astro/PageHeader.astro +34 -0
- package/src/astro/Pagination.astro +162 -0
- package/src/astro/Radio.astro +58 -0
- package/src/astro/RadioGroup.astro +25 -0
- package/src/astro/Section.astro +25 -0
- package/src/astro/Select.astro +403 -0
- package/src/astro/Sidebar.astro +215 -0
- package/src/astro/Skeleton.astro +39 -0
- package/src/astro/Spinner.astro +19 -0
- package/src/astro/Table.astro +254 -0
- package/src/astro/TextField.astro +287 -0
- package/src/astro/ThemeToggle.astro +66 -0
- package/src/astro/Toast.astro +181 -0
- package/src/astro/TopBar.astro +29 -0
- package/src/data/icons.ts +104 -0
- package/src/scripts/toast.ts +531 -0
- package/src/styles/accordion.css +77 -0
- package/src/styles/alert.css +106 -0
- package/src/styles/badge.css +46 -0
- package/src/styles/base.css +203 -0
- package/src/styles/breadcrumbs.css +52 -0
- package/src/styles/button.css +163 -0
- package/src/styles/card.css +108 -0
- package/src/styles/chip.css +104 -0
- package/src/styles/choice.css +137 -0
- package/src/styles/code.css +42 -0
- package/src/styles/empty.css +57 -0
- package/src/styles/example.css +55 -0
- package/src/styles/grid.css +62 -0
- package/src/styles/input.css +198 -0
- package/src/styles/modal.css +143 -0
- package/src/styles/motion.css +96 -0
- package/src/styles/nav.css +230 -0
- package/src/styles/navbar.css +656 -0
- package/src/styles/page-header.css +59 -0
- package/src/styles/pagination.css +77 -0
- package/src/styles/section.css +36 -0
- package/src/styles/select.css +235 -0
- package/src/styles/skeleton.css +64 -0
- package/src/styles/spinner.css +42 -0
- package/src/styles/table.css +175 -0
- package/src/styles/theme-toggle.css +29 -0
- package/src/styles/toast.css +207 -0
- package/src/styles/topbar.css +30 -0
- 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>
|