@vakwerk/navigation 0.0.0-stage → 0.0.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.
@@ -0,0 +1,461 @@
1
+ /*
2
+ * @vakwerk/navigation
3
+ *
4
+ * Public theming surface: the --ui-nav-* custom properties, set anywhere above or on the host.
5
+ * Each falls back to a semantic token of @vakwerk/tokens and then to a system colour, so the
6
+ * component follows a vakwerk theme and still works on its own. The --_nav-* properties are
7
+ * internal; set the public ones instead.
8
+ *
9
+ * Submenu visibility follows aria-expanded only, and every enhanced rule is gated on
10
+ * [data-enhanced], so without JavaScript the navigation is a plain nested list.
11
+ */
12
+ /* Registered as a length, so a unitless 0 parses as 0px and the 4px floor below still applies.
13
+ The var() fallback below covers browsers without @property. */
14
+ @property --ui-nav-toggle-gap {
15
+ syntax: '<length>';
16
+ inherits: true;
17
+ initial-value: 4px;
18
+ }
19
+
20
+ @layer ui.navigation {
21
+ :is(ui-navigation, .ui-nav) {
22
+ --_nav-color: var(--ui-nav-color, var(--color-text-high, CanvasText));
23
+ --_nav-bg: var(--ui-nav-bg, var(--color-bg-app, Canvas));
24
+ --_nav-border: var(--ui-nav-border, var(--color-border-subtle, GrayText));
25
+ --_nav-hover-bg: var(--ui-nav-hover-bg, var(--color-bg-ui-hover, transparent));
26
+ --_nav-current: var(--ui-nav-current, var(--color-link, LinkText));
27
+ --_nav-focus: var(--ui-nav-focus, var(--color-focus-ring, Highlight));
28
+ --_nav-backdrop: var(--ui-nav-backdrop, var(--color-overlay, color-mix(in srgb, CanvasText 50%, transparent)));
29
+ --_nav-font-size: var(--ui-nav-font-size, 1rem);
30
+ --_nav-panel-font-size: var(--ui-nav-panel-font-size, 1.125rem);
31
+ --_nav-item-gap: var(--ui-nav-item-gap, 0px);
32
+ --_nav-item-pad-block: var(--ui-nav-item-pad-block, 4px);
33
+ --_nav-item-pad-inline: var(--ui-nav-item-pad-inline, 8px);
34
+ --_nav-indent: var(--ui-nav-indent, 16px);
35
+ --_nav-dropdown-pad: var(--ui-nav-dropdown-pad, 4px);
36
+ --_nav-dropdown-item-pad-inline: var(--ui-nav-dropdown-item-pad-inline, 8px);
37
+ --_nav-button-pad-inline: var(--ui-nav-button-pad-inline, 12px);
38
+ --_nav-panel-pad: var(--ui-nav-panel-pad, 16px);
39
+ --_nav-panel-gap: var(--ui-nav-panel-gap, 8px);
40
+ --_nav-group-font-size: var(--ui-nav-group-font-size, 1em);
41
+ --_nav-group-color: var(--ui-nav-group-color, currentColor);
42
+ --_nav-group-text-transform: var(--ui-nav-group-text-transform, none);
43
+ --_nav-group-letter-spacing: var(--ui-nav-group-letter-spacing, normal);
44
+ --_nav-group-divider: var(--ui-nav-group-divider, none);
45
+ --_nav-group-pad-block-start: var(--ui-nav-group-pad-block-start, var(--_nav-item-pad-block));
46
+ /* A focus ring reaches 4px beyond a link or toggle (2px outline at 2px offset), so never less. */
47
+ --_nav-toggle-gap: max(4px, var(--ui-nav-toggle-gap, 4px));
48
+ /* Not themeable: the padding of the toggles, menu and close buttons; a label toggle's end padding matches it so chevrons line up. */
49
+ --_nav-control-pad: 4px;
50
+ --_nav-radius: var(--ui-nav-radius, var(--radius-md, 6px));
51
+ --_nav-dropdown-width: var(--ui-nav-dropdown-width, 16rem);
52
+ --_nav-panel-width: var(--ui-nav-panel-width, min(24rem, 100vw));
53
+ --_nav-gutter: var(--ui-nav-gutter, 16px);
54
+ --_nav-z: var(--ui-nav-z, 100);
55
+ --_nav-duration: var(--ui-nav-duration, var(--duration-normal, 200ms));
56
+
57
+ display: block;
58
+ color: var(--_nav-color);
59
+ font-size: var(--_nav-font-size);
60
+ }
61
+
62
+ /* Base: also the layout without JavaScript. */
63
+
64
+ :is(ui-navigation, .ui-nav) nav :is(ul, li) {
65
+ margin: 0;
66
+ padding: 0;
67
+ list-style: none;
68
+ }
69
+
70
+ :is(ui-navigation, .ui-nav) nav > ul {
71
+ display: flex;
72
+ flex-wrap: wrap;
73
+ align-items: flex-start;
74
+ gap: 4px var(--_nav-item-gap);
75
+ }
76
+
77
+ :is(ui-navigation, .ui-nav) nav ul ul {
78
+ padding-inline-start: var(--_nav-indent);
79
+ }
80
+
81
+ :is(ui-navigation, .ui-nav) nav a,
82
+ :is(ui-navigation, .ui-nav) nav li > span {
83
+ display: inline-flex;
84
+ align-items: center;
85
+ min-block-size: 24px;
86
+ padding-block: var(--_nav-item-pad-block);
87
+ padding-inline: var(--_nav-item-pad-inline);
88
+ color: inherit;
89
+ text-decoration-line: none;
90
+ }
91
+
92
+ :is(ui-navigation, .ui-nav) nav a:hover {
93
+ text-decoration-line: underline;
94
+ }
95
+
96
+ :is(ui-navigation, .ui-nav) nav [aria-current='page'] {
97
+ font-weight: 600;
98
+ color: var(--_nav-current);
99
+ text-decoration-line: underline;
100
+ text-underline-offset: 0.2em;
101
+ }
102
+
103
+ /* A parent of the current page: a dotted underline on its label. */
104
+ :is(ui-navigation, .ui-nav) nav li:has(ul [aria-current='page']) > :is(a, span, .ui-nav__toggle--label) {
105
+ text-decoration-line: underline;
106
+ text-decoration-style: dotted;
107
+ text-underline-offset: 0.2em;
108
+ }
109
+
110
+ :is(ui-navigation, .ui-nav) :is(a, button):focus-visible {
111
+ outline: 2px solid var(--_nav-focus);
112
+ outline-offset: 2px;
113
+ }
114
+
115
+ /* Below the default breakpoint the top level stacks. Hosts with another breakpoint override this. */
116
+ @media (max-width: 767.98px) {
117
+ :is(ui-navigation, .ui-nav):not([data-enhanced]) nav > ul {
118
+ flex-direction: column;
119
+ }
120
+ }
121
+
122
+ /*
123
+ * While JavaScript is on but the script has not enhanced the navigation yet, only the top level
124
+ * shows, so a slow first load does not flash the whole nested list. Without JavaScript the
125
+ * nested list stays. If the script fails to load, links under a label without a page stay hidden.
126
+ */
127
+ @media (scripting: enabled) {
128
+ :is(ui-navigation, .ui-nav):not([data-enhanced], [submenu-visibility='always'], [data-submenu-visibility='always']) nav ul ul {
129
+ display: none;
130
+ }
131
+ }
132
+
133
+ /*
134
+ * A group: a label without a page below the top level. Its heading is bold, and its links stay
135
+ * in line with the heading instead of indenting under it. --ui-nav-group-divider draws a line
136
+ * above each group that follows another item, so a submenu never starts with a double edge.
137
+ */
138
+ :is(ui-navigation, .ui-nav)[data-enhanced] nav ul ul li > span {
139
+ font-size: var(--_nav-group-font-size);
140
+ font-weight: 600;
141
+ color: var(--_nav-group-color);
142
+ text-transform: var(--_nav-group-text-transform);
143
+ letter-spacing: var(--_nav-group-letter-spacing);
144
+ /* The space beyond the item padding is a margin, so it counts once whatever the box sizing. */
145
+ margin-block-start: calc(var(--_nav-group-pad-block-start) - var(--_nav-item-pad-block));
146
+ }
147
+
148
+ :is(ui-navigation, .ui-nav)[data-enhanced] nav ul ul li + li:has(> span) {
149
+ border-block-start: var(--_nav-group-divider);
150
+ }
151
+
152
+ /* A heading is not a link, so it never takes the dotted marker of a parent of the current page. */
153
+ :is(ui-navigation, .ui-nav)[data-enhanced] nav ul ul li:has(ul [aria-current='page']) > span {
154
+ text-decoration-line: none;
155
+ }
156
+
157
+ :is(ui-navigation, .ui-nav)[data-enhanced] nav ul ul li:has(> span) > ul {
158
+ padding-inline-start: 0;
159
+ }
160
+
161
+ /* Enhanced: shared controls. */
162
+
163
+ :is(ui-navigation, .ui-nav)[data-enhanced] :is(.ui-nav__toggle, .ui-nav__menu-button, .ui-nav__close) {
164
+ display: inline-flex;
165
+ align-items: center;
166
+ justify-content: center;
167
+ gap: 4px;
168
+ min-inline-size: 24px;
169
+ min-block-size: 24px;
170
+ margin: 0;
171
+ padding: var(--_nav-control-pad);
172
+ /* Invisible normally; under forced colours it becomes the button's outline. */
173
+ border: 1px solid transparent;
174
+ border-radius: var(--_nav-radius);
175
+ background: none;
176
+ color: inherit;
177
+ font: inherit;
178
+ cursor: pointer;
179
+ }
180
+
181
+ :is(ui-navigation, .ui-nav)[data-enhanced] :is(.ui-nav__toggle, .ui-nav__menu-button, .ui-nav__close, a):hover {
182
+ background: var(--_nav-hover-bg);
183
+ }
184
+
185
+ /* A label toggle reads as a menu item: its text starts where the links' text does, also when it fills a row. */
186
+ :is(ui-navigation, .ui-nav)[data-enhanced] .ui-nav__toggle--label {
187
+ justify-content: flex-start;
188
+ padding-inline: var(--_nav-item-pad-inline) var(--_nav-control-pad);
189
+ }
190
+
191
+ /* When a label toggle fills a row, its chevron sits at the row's end, in line with a split toggle's (same end padding). */
192
+ :is(ui-navigation, .ui-nav)[data-enhanced] .ui-nav__toggle--label .ui-nav__chevron {
193
+ margin-inline-start: auto;
194
+ }
195
+
196
+ :is(ui-navigation, .ui-nav)[data-enhanced] .ui-nav__chevron {
197
+ inline-size: 1em;
198
+ block-size: 1em;
199
+ flex: none;
200
+ }
201
+
202
+ :is(ui-navigation, .ui-nav)[data-enhanced] .ui-nav__toggle[aria-expanded='true'] .ui-nav__chevron {
203
+ rotate: 180deg;
204
+ }
205
+
206
+ :is(ui-navigation, .ui-nav)[data-enhanced] .ui-nav__toggle[aria-expanded='false'] ~ ul {
207
+ display: none;
208
+ }
209
+
210
+ :is(ui-navigation, .ui-nav)[data-enhanced] .ui-nav__visually-hidden {
211
+ position: absolute;
212
+ inline-size: 1px;
213
+ block-size: 1px;
214
+ margin: -1px;
215
+ padding: 0;
216
+ overflow: hidden;
217
+ clip-path: inset(50%);
218
+ white-space: nowrap;
219
+ border: 0;
220
+ }
221
+
222
+ /* Enhanced inline. */
223
+
224
+ :is(ui-navigation, .ui-nav)[data-enhanced][data-mode='inline'] .ui-nav__menu-button {
225
+ display: none;
226
+ }
227
+
228
+ :is(ui-navigation, .ui-nav)[data-enhanced][data-mode='inline'] nav > ul {
229
+ flex-direction: row;
230
+ align-items: center;
231
+ }
232
+
233
+ :is(ui-navigation, .ui-nav)[data-enhanced][data-justify='center'] nav > ul {
234
+ justify-content: center;
235
+ }
236
+
237
+ :is(ui-navigation, .ui-nav)[data-enhanced][data-justify='end'] nav > ul {
238
+ justify-content: flex-end;
239
+ }
240
+
241
+ :is(ui-navigation, .ui-nav)[data-enhanced][data-justify='space-between'] nav > ul {
242
+ justify-content: space-between;
243
+ }
244
+
245
+ /* The column gap leaves room for a toggle's focus ring (2px at a 2px offset) next to its link. */
246
+ :is(ui-navigation, .ui-nav)[data-enhanced][data-mode='inline'] nav > ul > li {
247
+ position: relative;
248
+ display: flex;
249
+ align-items: center;
250
+ column-gap: var(--_nav-toggle-gap);
251
+ }
252
+
253
+ /*
254
+ * A parent with a toggle is one item in the bar: the item padding goes before its label and
255
+ * after its chevron, so the chevron sits by its own label and the next item keeps its distance.
256
+ */
257
+ :is(ui-navigation, .ui-nav)[data-enhanced][data-mode='inline'] nav > ul > li > a:has(+ .ui-nav__toggle) {
258
+ padding-inline-end: 0;
259
+ }
260
+
261
+ :is(ui-navigation, .ui-nav)[data-enhanced][data-mode='inline'] nav > ul > li > .ui-nav__toggle {
262
+ padding-inline-end: var(--_nav-item-pad-inline);
263
+ }
264
+
265
+ /* Dropdowns: float below their item. */
266
+ :is(ui-navigation, .ui-nav)[data-enhanced][data-mode='inline']:not([data-submenus='always']) nav > ul > li > ul {
267
+ position: absolute;
268
+ inset-block-start: 100%;
269
+ inset-inline-start: 0;
270
+ z-index: var(--_nav-z);
271
+ inline-size: max-content;
272
+ min-inline-size: 12rem;
273
+ max-inline-size: min(var(--_nav-dropdown-width), 100vw - 2 * var(--_nav-gutter));
274
+ margin-block-start: 4px;
275
+ padding: var(--_nav-dropdown-pad);
276
+ border: 1px solid var(--_nav-border);
277
+ border-radius: var(--_nav-radius);
278
+ background: var(--_nav-bg);
279
+ box-shadow: 0 4px 12px color-mix(in srgb, CanvasText 15%, transparent);
280
+ }
281
+
282
+ /* Links in a dropdown fill their row, so the hover background, focus ring and click target span it. */
283
+ :is(ui-navigation, .ui-nav)[data-enhanced][data-mode='inline']:not([data-submenus='always'])
284
+ nav > ul > li > ul :is(a, li > span) {
285
+ flex: 1 1 auto;
286
+ padding-inline: var(--_nav-dropdown-item-pad-inline);
287
+ border-radius: var(--_nav-radius);
288
+ }
289
+
290
+ /* Edge overflow: the last two top-level items (from the third on) open their dropdown to the inline end. */
291
+ :is(ui-navigation, .ui-nav)[data-enhanced][data-mode='inline']:not([data-submenus='always'])
292
+ nav > ul > li:nth-last-child(-n + 2):nth-child(n + 3) > ul {
293
+ inset-inline-start: auto;
294
+ inset-inline-end: 0;
295
+ }
296
+
297
+ /* Inside a dropdown, and for "always": submenus expand in the flow, indented. */
298
+ :is(ui-navigation, .ui-nav)[data-enhanced][data-mode='inline'] nav ul ul li,
299
+ :is(ui-navigation, .ui-nav)[data-enhanced][data-mode='inline'][data-submenus='always'] nav > ul > li {
300
+ display: flex;
301
+ flex-wrap: wrap;
302
+ align-items: center;
303
+ column-gap: var(--_nav-toggle-gap);
304
+ }
305
+
306
+ :is(ui-navigation, .ui-nav)[data-enhanced][data-mode='inline'] nav ul ul li > ul,
307
+ :is(ui-navigation, .ui-nav)[data-enhanced][data-mode='inline'][data-submenus='always'] nav > ul > li > ul {
308
+ flex-basis: 100%;
309
+ }
310
+
311
+ :is(ui-navigation, .ui-nav)[data-enhanced][data-mode='inline'][data-submenus='always'] nav > ul {
312
+ align-items: flex-start;
313
+ }
314
+
315
+ /* Enhanced overlay. */
316
+
317
+ :is(ui-navigation, .ui-nav)[data-enhanced][data-mode='overlay'] .ui-nav__menu-button,
318
+ :is(ui-navigation, .ui-nav)[data-enhanced] .ui-nav__panel :is(a, button, li > span) {
319
+ min-block-size: 44px;
320
+ }
321
+
322
+ :is(ui-navigation, .ui-nav)[data-enhanced][data-mode='overlay'] .ui-nav__menu-button {
323
+ padding-inline: var(--_nav-button-pad-inline);
324
+ border: 1px solid var(--_nav-border);
325
+ }
326
+
327
+ :is(ui-navigation, .ui-nav)[data-enhanced] .ui-nav__panel {
328
+ box-sizing: border-box;
329
+ inset-block: 0;
330
+ inset-inline: auto 0;
331
+ inline-size: var(--_nav-panel-width);
332
+ max-inline-size: 100vw;
333
+ block-size: 100dvh;
334
+ max-block-size: none;
335
+ margin: 0;
336
+ padding: 0;
337
+ border: 0;
338
+ border-inline-start: 1px solid var(--_nav-border);
339
+ background: var(--_nav-bg);
340
+ color: var(--_nav-color);
341
+ font-size: var(--_nav-panel-font-size);
342
+ overflow-y: auto;
343
+ overscroll-behavior: contain;
344
+ }
345
+
346
+ :is(ui-navigation, .ui-nav)[data-enhanced] .ui-nav__panel::backdrop {
347
+ background: var(--_nav-backdrop);
348
+ overflow: hidden;
349
+ overscroll-behavior: contain;
350
+ }
351
+
352
+ :is(ui-navigation, .ui-nav)[data-enhanced] .ui-nav__panel-inner {
353
+ display: flex;
354
+ flex-direction: column;
355
+ gap: var(--_nav-panel-gap);
356
+ padding-block: max(var(--_nav-panel-pad), env(safe-area-inset-top)) max(var(--_nav-panel-pad), env(safe-area-inset-bottom));
357
+ padding-inline: max(var(--_nav-panel-pad), env(safe-area-inset-left)) max(var(--_nav-panel-pad), env(safe-area-inset-right));
358
+ }
359
+
360
+ :is(ui-navigation, .ui-nav)[data-enhanced] .ui-nav__close {
361
+ align-self: flex-end;
362
+ padding-inline: var(--_nav-button-pad-inline);
363
+ }
364
+
365
+ :is(ui-navigation, .ui-nav)[data-enhanced] .ui-nav__panel-inner > ul {
366
+ display: flex;
367
+ flex-direction: column;
368
+ gap: 0;
369
+ }
370
+
371
+ :is(ui-navigation, .ui-nav)[data-enhanced] .ui-nav__panel li {
372
+ display: flex;
373
+ flex-wrap: wrap;
374
+ align-items: center;
375
+ column-gap: var(--_nav-toggle-gap);
376
+ }
377
+
378
+ :is(ui-navigation, .ui-nav)[data-enhanced] .ui-nav__panel li > :is(a, span, .ui-nav__toggle--label) {
379
+ flex: 1;
380
+ }
381
+
382
+ :is(ui-navigation, .ui-nav)[data-enhanced] .ui-nav__panel li > ul {
383
+ flex-basis: 100%;
384
+ }
385
+
386
+ /* While the overlay is open the page does not scroll; the gutter keeps the layout from shifting. */
387
+ :root:has(.ui-nav__panel[open]) {
388
+ overflow: hidden;
389
+ scrollbar-gutter: stable;
390
+ }
391
+
392
+ /* Motion, only without a reduced-motion preference. */
393
+ @media (prefers-reduced-motion: no-preference) {
394
+ :is(ui-navigation, .ui-nav)[data-enhanced] .ui-nav__chevron {
395
+ transition: rotate var(--_nav-duration);
396
+ }
397
+
398
+ :is(ui-navigation, .ui-nav)[data-enhanced] .ui-nav__panel {
399
+ opacity: 0;
400
+ translate: 100% 0;
401
+ transition:
402
+ opacity var(--_nav-duration),
403
+ translate var(--_nav-duration),
404
+ display var(--_nav-duration),
405
+ overlay var(--_nav-duration);
406
+ transition-behavior: allow-discrete;
407
+ }
408
+
409
+ :is(ui-navigation, .ui-nav)[data-enhanced] .ui-nav__panel:dir(rtl) {
410
+ translate: -100% 0;
411
+ }
412
+
413
+ :is(ui-navigation, .ui-nav)[data-enhanced] .ui-nav__panel[open] {
414
+ opacity: 1;
415
+ translate: 0 0;
416
+ }
417
+
418
+ :is(ui-navigation, .ui-nav)[data-enhanced] .ui-nav__panel::backdrop {
419
+ opacity: 0;
420
+ transition:
421
+ opacity var(--_nav-duration),
422
+ display var(--_nav-duration),
423
+ overlay var(--_nav-duration);
424
+ transition-behavior: allow-discrete;
425
+ }
426
+
427
+ :is(ui-navigation, .ui-nav)[data-enhanced] .ui-nav__panel[open]::backdrop {
428
+ opacity: 1;
429
+ }
430
+
431
+ @starting-style {
432
+ :is(ui-navigation, .ui-nav)[data-enhanced] .ui-nav__panel[open] {
433
+ opacity: 0;
434
+ translate: 100% 0;
435
+ }
436
+
437
+ :is(ui-navigation, .ui-nav)[data-enhanced] .ui-nav__panel[open]:dir(rtl) {
438
+ translate: -100% 0;
439
+ }
440
+
441
+ :is(ui-navigation, .ui-nav)[data-enhanced] .ui-nav__panel[open]::backdrop {
442
+ opacity: 0;
443
+ }
444
+ }
445
+ }
446
+
447
+ /* Forced colours: states keep a non-colour cue and the panel gets a full border. */
448
+ @media (forced-colors: active) {
449
+ :is(ui-navigation, .ui-nav)[data-enhanced] .ui-nav__panel {
450
+ border: 1px solid CanvasText;
451
+ }
452
+
453
+ :is(ui-navigation, .ui-nav)[data-enhanced][data-mode='inline'] nav > ul > li > ul {
454
+ border-color: CanvasText;
455
+ }
456
+
457
+ :is(ui-navigation, .ui-nav) :is(a, button):focus-visible {
458
+ outline-color: Highlight;
459
+ }
460
+ }
461
+ }