@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.
- package/README.md +239 -2
- package/dist/config.d.ts +26 -0
- package/dist/config.d.ts.map +1 -0
- package/dist/config.js +35 -0
- package/dist/config.js.map +1 -0
- package/dist/controller.d.ts +33 -0
- package/dist/controller.d.ts.map +1 -0
- package/dist/controller.js +324 -0
- package/dist/controller.js.map +1 -0
- package/dist/dom.d.ts +7 -0
- package/dist/dom.d.ts.map +1 -0
- package/dist/dom.js +35 -0
- package/dist/dom.js.map +1 -0
- package/dist/element.d.ts +10 -0
- package/dist/element.d.ts.map +1 -0
- package/dist/element.js +38 -0
- package/dist/element.js.map +1 -0
- package/dist/navigation.d.ts +32 -0
- package/dist/navigation.d.ts.map +1 -0
- package/dist/navigation.js +50 -0
- package/dist/navigation.js.map +1 -0
- package/dist/overlay.d.ts +19 -0
- package/dist/overlay.d.ts.map +1 -0
- package/dist/overlay.js +75 -0
- package/dist/overlay.js.map +1 -0
- package/dist/submenus.d.ts +48 -0
- package/dist/submenus.d.ts.map +1 -0
- package/dist/submenus.js +209 -0
- package/dist/submenus.js.map +1 -0
- package/package.json +28 -3
- package/src/navigation.css +461 -0
package/README.md
CHANGED
|
@@ -1,3 +1,240 @@
|
|
|
1
|
-
#
|
|
1
|
+
# @vakwerk/navigation
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
Accessible site navigation with dropdown submenus and an overlay dialog on narrow screens. You render plain HTML; a light-DOM custom element (or `init()`) enhances it. No framework, no runtime dependencies, and without JavaScript it is a working nested list of links.
|
|
4
|
+
|
|
5
|
+
- Disclosure pattern (WAI-ARIA APG), not an ARIA menu: Tab moves through every link and toggle.
|
|
6
|
+
- One DOM structure for every viewport: the same `<ul>` moves between `<nav>` and a native `<dialog>`.
|
|
7
|
+
- Submenu visibility follows `aria-expanded` only, so what assistive technology hears and what is visible cannot drift apart.
|
|
8
|
+
|
|
9
|
+
## HTML contract
|
|
10
|
+
|
|
11
|
+
Render a `<nav>` with an `aria-label` (or `aria-labelledby`) and a nested list of links, inside the host. The overlay dialog takes the same name:
|
|
12
|
+
|
|
13
|
+
```html
|
|
14
|
+
<ui-navigation>
|
|
15
|
+
<nav aria-label="Hoofdmenu">
|
|
16
|
+
<ul>
|
|
17
|
+
<li><a href="/">Home</a></li>
|
|
18
|
+
<li>
|
|
19
|
+
<a href="/producten">Producten</a>
|
|
20
|
+
<ul>
|
|
21
|
+
<li><a href="/producten/stoelen">Stoelen</a></li>
|
|
22
|
+
</ul>
|
|
23
|
+
</li>
|
|
24
|
+
<li>
|
|
25
|
+
<span>Over ons</span>
|
|
26
|
+
<ul>
|
|
27
|
+
<li><a href="/team">Team</a></li>
|
|
28
|
+
</ul>
|
|
29
|
+
</li>
|
|
30
|
+
</ul>
|
|
31
|
+
</nav>
|
|
32
|
+
</ui-navigation>
|
|
33
|
+
```
|
|
34
|
+
|
|
35
|
+
- **A parent with its own page** is `<li><a href>…</a><ul>…</ul></li>`. The link stays; a separate toggle button with a chevron is added after it (a split button). Its accessible name is visually hidden text from `label-submenu`, not an `aria-label`, so browser translation includes it.
|
|
36
|
+
- **A parent without a page** is `<li><span>…</span><ul>…</ul></li>`. At the top level the `<span>` is swapped for a `<button>` with the same content.
|
|
37
|
+
- **A group** is the same markup below the top level: `<li><span>Continenten</span><ul>…</ul></li>` inside a submenu. It gets no toggle: the `<span>` stays as a bold heading (styled through the `--ui-nav-group-*` properties, and never underlined as a parent of the current page), its links show whenever its parent's submenu shows, and the list is named after the heading (`aria-labelledby`, unless the markup already names it). A parent with its own page inside a group still gets a toggle.
|
|
38
|
+
- Mark the current page with `aria-current="page"`. Its parent gets a dotted underline.
|
|
39
|
+
- At least three levels are supported. From level 3 on, submenus expand inside their dropdown.
|
|
40
|
+
|
|
41
|
+
The script generates the toggles, the menu button and the `<dialog>`. Without JavaScript none of them exist, so there are no dead buttons.
|
|
42
|
+
|
|
43
|
+
## Loading
|
|
44
|
+
|
|
45
|
+
```js
|
|
46
|
+
import { define } from '@vakwerk/navigation';
|
|
47
|
+
import '@vakwerk/navigation/navigation.css'; // or <link rel="stylesheet">
|
|
48
|
+
|
|
49
|
+
define(); // registers <ui-navigation>; calling it again does nothing
|
|
50
|
+
```
|
|
51
|
+
|
|
52
|
+
`define(tag)` registers another tag name, for example `define('site-nav')`. The stylesheet only matches `ui-navigation` and `.ui-nav`, so give such an element `class="ui-nav"` too: `<site-nav class="ui-nav">`. It still reads the bare attribute names.
|
|
53
|
+
|
|
54
|
+
Without custom elements, use a wrapper with `class="ui-nav"` and `data-` attributes, and call `init()`:
|
|
55
|
+
|
|
56
|
+
```html
|
|
57
|
+
<div class="ui-nav" data-overlay="always">
|
|
58
|
+
<nav aria-label="Hoofdmenu">…</nav>
|
|
59
|
+
</div>
|
|
60
|
+
<script type="module">
|
|
61
|
+
import { init } from '@vakwerk/navigation';
|
|
62
|
+
const navigation = init(document.querySelector('.ui-nav'));
|
|
63
|
+
</script>
|
|
64
|
+
```
|
|
65
|
+
|
|
66
|
+
Load the module as `type="module"` or after the markup. If a classic script in `<head>` defines the element before `<nav>` is parsed, the element waits for `DOMContentLoaded`.
|
|
67
|
+
|
|
68
|
+
`dist/` is plain `tsc` output: `navigation.js` imports a few sibling modules with relative `.js` specifiers. In static HTML that is seven small requests; bundlers such as Vite or Astro inline them.
|
|
69
|
+
|
|
70
|
+
## Attributes
|
|
71
|
+
|
|
72
|
+
On `<ui-navigation>` (and any tag registered with `define()`) use the bare names. On a `.ui-nav` wrapper prefix them with `data-`. An invalid value falls back to the default. Changing an attribute on the custom element updates it; with `init()`, call `update()`.
|
|
73
|
+
|
|
74
|
+
| Attribute | Values | Default |
|
|
75
|
+
|---|---|---|
|
|
76
|
+
| `overlay` | `mobile` \| `always` \| `never` | `mobile` |
|
|
77
|
+
| `breakpoint` | px; inline from this width up | `768` |
|
|
78
|
+
| `submenu-visibility` | `click` \| `hover` \| `always` | `click` |
|
|
79
|
+
| `overlay-submenus` | `expanded` \| `accordion` | `expanded` |
|
|
80
|
+
| `hover-close-delay` | ms | `300` |
|
|
81
|
+
| `justify` | `start` \| `center` \| `end` \| `space-between` | `start` |
|
|
82
|
+
| `label-open`, `label-close` | text | `Menu`, `Sluiten` |
|
|
83
|
+
| `label-submenu` | text with a `{label}` placeholder | `Submenu {label}` |
|
|
84
|
+
|
|
85
|
+
- **Modes.** Inline from `breakpoint` up, overlay below it (`overlay="mobile"`). `always` and `never` force a mode. A mode switch closes every submenu and an open overlay; if focus was in the navigation it moves to the menu button (overlay) or stays on the same item, else the first item (inline).
|
|
86
|
+
- **`submenu-visibility`.** `click` opens on click, Enter or Space, one submenu per level. `hover` (inline only) also opens on mouse or pen hover and closes after `hover-close-delay`; a click on the toggle of a hover-opened submenu pins it; focus never opens anything; touch acts as `click`. `always` shows every submenu as an indented group under its label and removes the toggles.
|
|
87
|
+
- **`overlay-submenus`.** `expanded` shows every submenu in the panel without toggles; `accordion` keeps the toggles as nested disclosures.
|
|
88
|
+
|
|
89
|
+
Closing inline: Escape closes the innermost open submenu around focus and focuses its toggle (with focus elsewhere it closes all); focus on another top-level item, focus leaving the navigation, a click outside it and activating a link in a dropdown all close the dropdowns. In the overlay, Escape, the close button, a backdrop click and activating a link close the panel and return focus to the menu button.
|
|
90
|
+
|
|
91
|
+
Host state hooks for CSS: `data-enhanced`, `data-mode="inline|overlay"`, `data-submenus="click|hover|always"`, `data-justify` and `data-open` (overlay open). `destroy()` restores them.
|
|
92
|
+
|
|
93
|
+
## Styling
|
|
94
|
+
|
|
95
|
+
`navigation.css` is plain CSS in one cascade layer, `@layer ui.navigation`, so any unlayered rule of yours wins without a specificity fight. That cuts both ways: a global unlayered `ul { padding-left: … }` or `li { margin: … }` also wins over the component. Put your base styles in a lower layer, scope them, or revert them for the navigation:
|
|
96
|
+
|
|
97
|
+
```css
|
|
98
|
+
ui-navigation :is(ul, li) { padding: revert-layer; margin: revert-layer; }
|
|
99
|
+
```
|
|
100
|
+
|
|
101
|
+
Theme through custom properties, set on the host or any ancestor. Colours fall back to the semantic tokens of `@vakwerk/tokens` and then to a system colour, so on a vakwerk site the component follows the theme and on its own it still works.
|
|
102
|
+
|
|
103
|
+
| Property | Default |
|
|
104
|
+
|---|---|
|
|
105
|
+
| `--ui-nav-color` | `var(--color-text-high, CanvasText)` |
|
|
106
|
+
| `--ui-nav-bg` | `var(--color-bg-app, Canvas)` |
|
|
107
|
+
| `--ui-nav-border` | `var(--color-border-subtle, GrayText)` |
|
|
108
|
+
| `--ui-nav-hover-bg` | `var(--color-bg-ui-hover, transparent)` |
|
|
109
|
+
| `--ui-nav-current` | `var(--color-link, LinkText)` |
|
|
110
|
+
| `--ui-nav-focus` | `var(--color-focus-ring, Highlight)` |
|
|
111
|
+
| `--ui-nav-backdrop` | `var(--color-overlay, color-mix(in srgb, CanvasText 50%, transparent))` |
|
|
112
|
+
| `--ui-nav-font-size` | `1rem` (the bar and dropdowns) |
|
|
113
|
+
| `--ui-nav-panel-font-size` | `1.125rem` (the overlay panel) |
|
|
114
|
+
| `--ui-nav-item-gap` | `0px` (between top-level items; their 8px inline padding already keeps the text 16px apart) |
|
|
115
|
+
| `--ui-nav-item-pad-block` | `4px` (above and below each link) |
|
|
116
|
+
| `--ui-nav-item-pad-inline` | `8px` (beside each link and group heading outside dropdowns, and before a label toggle's text; in the bar a parent with a toggle is one item, padded before its label and after its chevron; dropdowns use `--ui-nav-dropdown-item-pad-inline`) |
|
|
117
|
+
| `--ui-nav-toggle-gap` | `4px` (between a link and its toggle; never less than 4px, the room a focus ring needs) |
|
|
118
|
+
| `--ui-nav-indent` | `16px` (nested submenus) |
|
|
119
|
+
| `--ui-nav-dropdown-pad` | `4px` (inside a dropdown) |
|
|
120
|
+
| `--ui-nav-dropdown-item-pad-inline` | `8px` (beside a link in a dropdown) |
|
|
121
|
+
| `--ui-nav-button-pad-inline` | `12px` (the menu and close buttons) |
|
|
122
|
+
| `--ui-nav-panel-pad` | `16px` (inside the overlay panel, or the safe area if larger) |
|
|
123
|
+
| `--ui-nav-panel-gap` | `8px` (between the close button and the list) |
|
|
124
|
+
| `--ui-nav-group-font-size` | `1em` (a group heading; the same size as the links) |
|
|
125
|
+
| `--ui-nav-group-color` | `currentColor` |
|
|
126
|
+
| `--ui-nav-group-text-transform` | `none` (for example `uppercase`) |
|
|
127
|
+
| `--ui-nav-group-letter-spacing` | `normal` |
|
|
128
|
+
| `--ui-nav-group-divider` | `none` (a border between a group and the item before it, for example `1px solid var(--color-border-subtle)`) |
|
|
129
|
+
| `--ui-nav-group-pad-block-start` | `var(--ui-nav-item-pad-block)` (space above a group heading, between it and its divider) |
|
|
130
|
+
| `--ui-nav-radius` | `var(--radius-md, 6px)` |
|
|
131
|
+
| `--ui-nav-dropdown-width` | `16rem` |
|
|
132
|
+
| `--ui-nav-panel-width` | `min(24rem, 100vw)` |
|
|
133
|
+
| `--ui-nav-gutter` | `16px` (keeps a dropdown off the viewport edge) |
|
|
134
|
+
| `--ui-nav-z` | `100` |
|
|
135
|
+
| `--ui-nav-duration` | `var(--duration-normal, 200ms)` |
|
|
136
|
+
|
|
137
|
+
- **Per mode.** The host carries `data-mode="inline"` or `data-mode="overlay"`, so a property can differ per mode. For example, group dividers only when the navigation is inline: `ui-navigation[data-mode='overlay'] { --ui-nav-group-divider: none; }` (on a `.ui-nav` wrapper, use `.ui-nav[data-mode='overlay']`).
|
|
138
|
+
- **Edge overflow.** The last two top-level items (from the third item on) open their dropdown towards the inline end, and dropdowns are capped at `min(var(--ui-nav-dropdown-width), 100vw - 2 × gutter)`.
|
|
139
|
+
- **Without JavaScript** the top level wraps in a row and stacks below 768px. With another `breakpoint`, override that media query.
|
|
140
|
+
- **Before the script has run** (JavaScript on, `data-enhanced` not set yet) only the top level shows, so a slow first load does not flash the nested list in a header of fixed height. `submenu-visibility="always"` keeps submenus visible. Trade-off: whenever JavaScript is on but the navigation is not enhanced (the script fails to load, `destroy()` without a new `init()`, or markup that `init()` skips), links under a label without a page stay hidden; links on a parent with a page stay reachable through that page.
|
|
141
|
+
- **Scroll lock.** While the overlay is open the page does not scroll (`overflow: hidden` on the root, with `scrollbar-gutter: stable` so the layout does not shift). Known side effect: `position: sticky` on the page underneath can stop sticking while the overlay is open. On a page that is too short to scroll, the reserved gutter appears only while the overlay is open, so the content beside the panel shifts by the scrollbar's width; on systems with overlay scrollbars (macOS default) nothing shifts.
|
|
142
|
+
- `prefers-reduced-motion: reduce` removes every transition; `forced-colors: active` keeps a non-colour cue for every state and gives the panel a full border. Firefox drops the panel's exit transition; it then just disappears.
|
|
143
|
+
|
|
144
|
+
## Events
|
|
145
|
+
|
|
146
|
+
Dispatched on the host, with `bubbles: true`:
|
|
147
|
+
|
|
148
|
+
| Event | `detail` |
|
|
149
|
+
|---|---|
|
|
150
|
+
| `ui-navigation:open` | `{ mode }` |
|
|
151
|
+
| `ui-navigation:close` | `{ mode }` |
|
|
152
|
+
| `ui-navigation:modechange` | `{ mode }` (the new mode) |
|
|
153
|
+
| `ui-navigation:submenu` | `{ open, label, mode, by: 'click' \| 'hover' \| 'pin' }` |
|
|
154
|
+
|
|
155
|
+
On a close, `by` is how the submenu had been opened. Closing a submenu also closes, and reports, every submenu open inside it.
|
|
156
|
+
|
|
157
|
+
## API
|
|
158
|
+
|
|
159
|
+
```ts
|
|
160
|
+
import { init, define, EVENTS, type NavigationInstance, type SubmenuEventDetail } from '@vakwerk/navigation';
|
|
161
|
+
|
|
162
|
+
const navigation: NavigationInstance = init(element, { overlay: 'always' }); // options win over attributes
|
|
163
|
+
navigation.mode; // 'inline' | 'overlay'
|
|
164
|
+
navigation.open(); // opens the overlay (does nothing inline)
|
|
165
|
+
navigation.close(); // closes it and returns focus to the menu button
|
|
166
|
+
navigation.closeSubmenus(); // closes every submenu, focus stays where it is
|
|
167
|
+
navigation.update(); // re-reads attributes, keeps the init options
|
|
168
|
+
navigation.destroy(); // reverts every DOM change, listener and timer
|
|
169
|
+
```
|
|
170
|
+
|
|
171
|
+
`init()` returns the same instance for the same element until `destroy()`. The instance is a frozen object with only the members above. If the host has no `<nav>` with a direct `<ul>` child, `init()` returns an instance that does nothing.
|
|
172
|
+
|
|
173
|
+
## Integration
|
|
174
|
+
|
|
175
|
+
The examples only render markup and load the module.
|
|
176
|
+
|
|
177
|
+
**Plain HTML**
|
|
178
|
+
|
|
179
|
+
```html
|
|
180
|
+
<link rel="stylesheet" href="/node_modules/@vakwerk/navigation/src/navigation.css">
|
|
181
|
+
<ui-navigation>…</ui-navigation>
|
|
182
|
+
<script type="module">
|
|
183
|
+
import { define } from '/node_modules/@vakwerk/navigation/dist/navigation.js';
|
|
184
|
+
define();
|
|
185
|
+
</script>
|
|
186
|
+
```
|
|
187
|
+
|
|
188
|
+
**Astro**
|
|
189
|
+
|
|
190
|
+
```astro
|
|
191
|
+
---
|
|
192
|
+
import '@vakwerk/navigation/navigation.css';
|
|
193
|
+
---
|
|
194
|
+
<ui-navigation>
|
|
195
|
+
<nav aria-label="Hoofdmenu">…</nav>
|
|
196
|
+
</ui-navigation>
|
|
197
|
+
<script>
|
|
198
|
+
import { define } from '@vakwerk/navigation';
|
|
199
|
+
define();
|
|
200
|
+
</script>
|
|
201
|
+
```
|
|
202
|
+
|
|
203
|
+
**Svelte**
|
|
204
|
+
|
|
205
|
+
```svelte
|
|
206
|
+
<script>
|
|
207
|
+
import { onMount } from 'svelte';
|
|
208
|
+
import '@vakwerk/navigation/navigation.css';
|
|
209
|
+
onMount(async () => (await import('@vakwerk/navigation')).define());
|
|
210
|
+
</script>
|
|
211
|
+
|
|
212
|
+
<ui-navigation>
|
|
213
|
+
<nav aria-label="Hoofdmenu">…</nav>
|
|
214
|
+
</ui-navigation>
|
|
215
|
+
```
|
|
216
|
+
|
|
217
|
+
## Deviations from WordPress
|
|
218
|
+
|
|
219
|
+
The behaviour follows the `core/navigation` block of WordPress (Gutenberg), with these deliberate differences:
|
|
220
|
+
|
|
221
|
+
- **Mode switch.** Switching between inline and overlay closes every submenu and an open overlay and puts focus back as described above. In WordPress the switch is CSS only and an open overlay stays open.
|
|
222
|
+
- **Native `<dialog>`.** The overlay is a `<dialog>` opened with `showModal()`, so the page behind it is inert through the top layer. WordPress uses a `div` with a dynamic `role="dialog"`/`aria-modal` and a hand-written focus trap.
|
|
223
|
+
- **Parents with a page stay links.** A parent with its own page keeps its link and gets a separate toggle (a split button). In click mode WordPress replaces such a parent by a button, so the parent URL drops out of the navigation.
|
|
224
|
+
- **Focus never opens a submenu.** In WordPress focus also opens submenus, so tabbing through the navigation expands everything.
|
|
225
|
+
- **Escape closes hover-opened submenus.** WordPress only handles Escape for submenus opened by click, a gap against WCAG 1.4.13.
|
|
226
|
+
|
|
227
|
+
## Decisions
|
|
228
|
+
|
|
229
|
+
- **Initial focus in the overlay** is the close button at the top of the panel. It is the first focusable element in the dialog, so `showModal()` focuses it without an `autofocus` attribute (which would make Chrome log a message when the navigation is inserted while something else has focus). It is the same in every browser, and a screen reader first hears the dialog's name and how to leave it. The first menu item is one Tab further.
|
|
230
|
+
- **Edge overflow** uses the heuristic above, not CSS anchor positioning: anchor positioning still needs a fallback in current browsers, and every dropdown would need its own anchor name and `anchor-scope`.
|
|
231
|
+
- **`hover-close-delay` is an attribute only**, not a custom property: CSS cannot tell the script that a value changed, and it is behaviour, not theme.
|
|
232
|
+
|
|
233
|
+
## Testing
|
|
234
|
+
|
|
235
|
+
```bash
|
|
236
|
+
pnpm --filter @vakwerk/navigation test # Vitest + jsdom; also runs in CI
|
|
237
|
+
pnpm --filter @vakwerk/navigation test:e2e # Playwright (chromium, firefox, webkit) + axe, local only
|
|
238
|
+
```
|
|
239
|
+
|
|
240
|
+
Browser tests never run in CI. Run `pnpm exec playwright install chromium firefox webkit` once. In WebKit the keyboard tests press Option+Tab, because Safari skips links with plain Tab. Screen reader checks are in `MANUAL-TESTING.md` in the repository.
|
package/dist/config.d.ts
ADDED
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
export type OverlaySetting = 'mobile' | 'always' | 'never';
|
|
2
|
+
export type SubmenuVisibility = 'click' | 'hover' | 'always';
|
|
3
|
+
export type OverlaySubmenus = 'expanded' | 'accordion';
|
|
4
|
+
export type Justify = 'start' | 'center' | 'end' | 'space-between';
|
|
5
|
+
export type NavigationMode = 'inline' | 'overlay';
|
|
6
|
+
export interface NavigationConfig {
|
|
7
|
+
overlay: OverlaySetting;
|
|
8
|
+
breakpoint: number;
|
|
9
|
+
submenuVisibility: SubmenuVisibility;
|
|
10
|
+
overlaySubmenus: OverlaySubmenus;
|
|
11
|
+
hoverCloseDelay: number;
|
|
12
|
+
justify: Justify;
|
|
13
|
+
labelOpen: string;
|
|
14
|
+
labelClose: string;
|
|
15
|
+
labelSubmenu: string;
|
|
16
|
+
}
|
|
17
|
+
export type NavigationOptions = Partial<NavigationConfig>;
|
|
18
|
+
export declare const ATTRIBUTES: readonly string[];
|
|
19
|
+
/**
|
|
20
|
+
* Reads the configuration of a navigation host. Custom elements (any tag with a hyphen, since
|
|
21
|
+
* `define(tag)` can register another name) use bare attributes; other hosts, such as a `.ui-nav`
|
|
22
|
+
* wrapper, use `data-` attributes. A valid option wins over a valid attribute, which wins over the
|
|
23
|
+
* default; invalid values are ignored at each step.
|
|
24
|
+
*/
|
|
25
|
+
export declare function readConfig(host: HTMLElement, options?: NavigationOptions): NavigationConfig;
|
|
26
|
+
//# sourceMappingURL=config.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"config.d.ts","sourceRoot":"","sources":["../src/config.ts"],"names":[],"mappings":"AAAA,MAAM,MAAM,cAAc,GAAG,QAAQ,GAAG,QAAQ,GAAG,OAAO,CAAC;AAC3D,MAAM,MAAM,iBAAiB,GAAG,OAAO,GAAG,OAAO,GAAG,QAAQ,CAAC;AAC7D,MAAM,MAAM,eAAe,GAAG,UAAU,GAAG,WAAW,CAAC;AACvD,MAAM,MAAM,OAAO,GAAG,OAAO,GAAG,QAAQ,GAAG,KAAK,GAAG,eAAe,CAAC;AACnE,MAAM,MAAM,cAAc,GAAG,QAAQ,GAAG,SAAS,CAAC;AAElD,MAAM,WAAW,gBAAgB;IAC/B,OAAO,EAAE,cAAc,CAAC;IACxB,UAAU,EAAE,MAAM,CAAC;IACnB,iBAAiB,EAAE,iBAAiB,CAAC;IACrC,eAAe,EAAE,eAAe,CAAC;IACjC,eAAe,EAAE,MAAM,CAAC;IACxB,OAAO,EAAE,OAAO,CAAC;IACjB,SAAS,EAAE,MAAM,CAAC;IAClB,UAAU,EAAE,MAAM,CAAC;IACnB,YAAY,EAAE,MAAM,CAAC;CACtB;AAED,MAAM,MAAM,iBAAiB,GAAG,OAAO,CAAC,gBAAgB,CAAC,CAAC;AA+B1D,eAAO,MAAM,UAAU,EAAE,SAAS,MAAM,EAA2C,CAAC;AAEpF;;;;;GAKG;AACH,wBAAgB,UAAU,CAAC,IAAI,EAAE,WAAW,EAAE,OAAO,GAAE,iBAAsB,GAAG,gBAAgB,CAO/F"}
|
package/dist/config.js
ADDED
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
const oneOf = (...allowed) => (value) => allowed.includes(value) ? value : undefined;
|
|
2
|
+
/** Accepts a whole number >= 0, given as a number or as its decimal string. */
|
|
3
|
+
const wholeNumber = (value) => {
|
|
4
|
+
const number = typeof value === 'string' && /^\d+$/.test(value) ? Number(value) : value;
|
|
5
|
+
return typeof number === 'number' && Number.isInteger(number) && number >= 0 ? number : undefined;
|
|
6
|
+
};
|
|
7
|
+
const label = (value) => typeof value === 'string' && value.trim() !== '' ? value : undefined;
|
|
8
|
+
/** Attribute name, config key, parser and default for every setting. Defaults live only here. */
|
|
9
|
+
const SCHEMA = [
|
|
10
|
+
['overlay', 'overlay', oneOf('mobile', 'always', 'never'), 'mobile'],
|
|
11
|
+
['breakpoint', 'breakpoint', wholeNumber, 768],
|
|
12
|
+
['submenu-visibility', 'submenuVisibility', oneOf('click', 'hover', 'always'), 'click'],
|
|
13
|
+
['overlay-submenus', 'overlaySubmenus', oneOf('expanded', 'accordion'), 'expanded'],
|
|
14
|
+
['hover-close-delay', 'hoverCloseDelay', wholeNumber, 300],
|
|
15
|
+
['justify', 'justify', oneOf('start', 'center', 'end', 'space-between'), 'start'],
|
|
16
|
+
['label-open', 'labelOpen', label, 'Menu'],
|
|
17
|
+
['label-close', 'labelClose', label, 'Sluiten'],
|
|
18
|
+
['label-submenu', 'labelSubmenu', label, 'Submenu {label}'],
|
|
19
|
+
];
|
|
20
|
+
export const ATTRIBUTES = SCHEMA.map(([attribute]) => attribute);
|
|
21
|
+
/**
|
|
22
|
+
* Reads the configuration of a navigation host. Custom elements (any tag with a hyphen, since
|
|
23
|
+
* `define(tag)` can register another name) use bare attributes; other hosts, such as a `.ui-nav`
|
|
24
|
+
* wrapper, use `data-` attributes. A valid option wins over a valid attribute, which wins over the
|
|
25
|
+
* default; invalid values are ignored at each step.
|
|
26
|
+
*/
|
|
27
|
+
export function readConfig(host, options = {}) {
|
|
28
|
+
const prefix = host.localName.includes('-') ? '' : 'data-';
|
|
29
|
+
const config = {};
|
|
30
|
+
for (const [attribute, key, parse, fallback] of SCHEMA) {
|
|
31
|
+
config[key] = parse(options[key]) ?? parse(host.getAttribute(prefix + attribute)) ?? fallback;
|
|
32
|
+
}
|
|
33
|
+
return config;
|
|
34
|
+
}
|
|
35
|
+
//# sourceMappingURL=config.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"config.js","sourceRoot":"","sources":["../src/config.ts"],"names":[],"mappings":"AAsBA,MAAM,KAAK,GACT,CAAmB,GAAG,OAAY,EAAa,EAAE,CACjD,CAAC,KAAK,EAAE,EAAE,CACR,OAAO,CAAC,QAAQ,CAAC,KAAU,CAAC,CAAC,CAAC,CAAE,KAAW,CAAC,CAAC,CAAC,SAAS,CAAC;AAE5D,+EAA+E;AAC/E,MAAM,WAAW,GAAmB,CAAC,KAAK,EAAE,EAAE;IAC5C,MAAM,MAAM,GAAG,OAAO,KAAK,KAAK,QAAQ,IAAI,OAAO,CAAC,IAAI,CAAC,KAAK,CAAC,CAAC,CAAC,CAAC,MAAM,CAAC,KAAK,CAAC,CAAC,CAAC,CAAC,KAAK,CAAC;IACxF,OAAO,OAAO,MAAM,KAAK,QAAQ,IAAI,MAAM,CAAC,SAAS,CAAC,MAAM,CAAC,IAAI,MAAM,IAAI,CAAC,CAAC,CAAC,CAAC,MAAM,CAAC,CAAC,CAAC,SAAS,CAAC;AACpG,CAAC,CAAC;AAEF,MAAM,KAAK,GAAmB,CAAC,KAAK,EAAE,EAAE,CACtC,OAAO,KAAK,KAAK,QAAQ,IAAI,KAAK,CAAC,IAAI,EAAE,KAAK,EAAE,CAAC,CAAC,CAAC,KAAK,CAAC,CAAC,CAAC,SAAS,CAAC;AAEvE,iGAAiG;AACjG,MAAM,MAAM,GAAG;IACb,CAAC,SAAS,EAAE,SAAS,EAAE,KAAK,CAAiB,QAAQ,EAAE,QAAQ,EAAE,OAAO,CAAC,EAAE,QAAQ,CAAC;IACpF,CAAC,YAAY,EAAE,YAAY,EAAE,WAAW,EAAE,GAAG,CAAC;IAC9C,CAAC,oBAAoB,EAAE,mBAAmB,EAAE,KAAK,CAAoB,OAAO,EAAE,OAAO,EAAE,QAAQ,CAAC,EAAE,OAAO,CAAC;IAC1G,CAAC,kBAAkB,EAAE,iBAAiB,EAAE,KAAK,CAAkB,UAAU,EAAE,WAAW,CAAC,EAAE,UAAU,CAAC;IACpG,CAAC,mBAAmB,EAAE,iBAAiB,EAAE,WAAW,EAAE,GAAG,CAAC;IAC1D,CAAC,SAAS,EAAE,SAAS,EAAE,KAAK,CAAU,OAAO,EAAE,QAAQ,EAAE,KAAK,EAAE,eAAe,CAAC,EAAE,OAAO,CAAC;IAC1F,CAAC,YAAY,EAAE,WAAW,EAAE,KAAK,EAAE,MAAM,CAAC;IAC1C,CAAC,aAAa,EAAE,YAAY,EAAE,KAAK,EAAE,SAAS,CAAC;IAC/C,CAAC,eAAe,EAAE,cAAc,EAAE,KAAK,EAAE,iBAAiB,CAAC;CACwC,CAAC;AAEtG,MAAM,CAAC,MAAM,UAAU,GAAsB,MAAM,CAAC,GAAG,CAAC,CAAC,CAAC,SAAS,CAAC,EAAE,EAAE,CAAC,SAAS,CAAC,CAAC;AAEpF;;;;;GAKG;AACH,MAAM,UAAU,UAAU,CAAC,IAAiB,EAAE,UAA6B,EAAE;IAC3E,MAAM,MAAM,GAAG,IAAI,CAAC,SAAS,CAAC,QAAQ,CAAC,GAAG,CAAC,CAAC,CAAC,CAAC,EAAE,CAAC,CAAC,CAAC,OAAO,CAAC;IAC3D,MAAM,MAAM,GAA4B,EAAE,CAAC;IAC3C,KAAK,MAAM,CAAC,SAAS,EAAE,GAAG,EAAE,KAAK,EAAE,QAAQ,CAAC,IAAI,MAAM,EAAE,CAAC;QACvD,MAAM,CAAC,GAAG,CAAC,GAAG,KAAK,CAAC,OAAO,CAAC,GAAG,CAAC,CAAC,IAAI,KAAK,CAAC,IAAI,CAAC,YAAY,CAAC,MAAM,GAAG,SAAS,CAAC,CAAC,IAAI,QAAQ,CAAC;IAChG,CAAC;IACD,OAAO,MAAqC,CAAC;AAC/C,CAAC","sourcesContent":["export type OverlaySetting = 'mobile' | 'always' | 'never';\nexport type SubmenuVisibility = 'click' | 'hover' | 'always';\nexport type OverlaySubmenus = 'expanded' | 'accordion';\nexport type Justify = 'start' | 'center' | 'end' | 'space-between';\nexport type NavigationMode = 'inline' | 'overlay';\n\nexport interface NavigationConfig {\n overlay: OverlaySetting;\n breakpoint: number;\n submenuVisibility: SubmenuVisibility;\n overlaySubmenus: OverlaySubmenus;\n hoverCloseDelay: number;\n justify: Justify;\n labelOpen: string;\n labelClose: string;\n labelSubmenu: string;\n}\n\nexport type NavigationOptions = Partial<NavigationConfig>;\n\ntype Parser<T> = (value: unknown) => T | undefined;\n\nconst oneOf =\n <T extends string>(...allowed: T[]): Parser<T> =>\n (value) =>\n allowed.includes(value as T) ? (value as T) : undefined;\n\n/** Accepts a whole number >= 0, given as a number or as its decimal string. */\nconst wholeNumber: Parser<number> = (value) => {\n const number = typeof value === 'string' && /^\\d+$/.test(value) ? Number(value) : value;\n return typeof number === 'number' && Number.isInteger(number) && number >= 0 ? number : undefined;\n};\n\nconst label: Parser<string> = (value) =>\n typeof value === 'string' && value.trim() !== '' ? value : undefined;\n\n/** Attribute name, config key, parser and default for every setting. Defaults live only here. */\nconst SCHEMA = [\n ['overlay', 'overlay', oneOf<OverlaySetting>('mobile', 'always', 'never'), 'mobile'],\n ['breakpoint', 'breakpoint', wholeNumber, 768],\n ['submenu-visibility', 'submenuVisibility', oneOf<SubmenuVisibility>('click', 'hover', 'always'), 'click'],\n ['overlay-submenus', 'overlaySubmenus', oneOf<OverlaySubmenus>('expanded', 'accordion'), 'expanded'],\n ['hover-close-delay', 'hoverCloseDelay', wholeNumber, 300],\n ['justify', 'justify', oneOf<Justify>('start', 'center', 'end', 'space-between'), 'start'],\n ['label-open', 'labelOpen', label, 'Menu'],\n ['label-close', 'labelClose', label, 'Sluiten'],\n ['label-submenu', 'labelSubmenu', label, 'Submenu {label}'],\n] as const satisfies readonly (readonly [string, keyof NavigationConfig, Parser<unknown>, unknown])[];\n\nexport const ATTRIBUTES: readonly string[] = SCHEMA.map(([attribute]) => attribute);\n\n/**\n * Reads the configuration of a navigation host. Custom elements (any tag with a hyphen, since\n * `define(tag)` can register another name) use bare attributes; other hosts, such as a `.ui-nav`\n * wrapper, use `data-` attributes. A valid option wins over a valid attribute, which wins over the\n * default; invalid values are ignored at each step.\n */\nexport function readConfig(host: HTMLElement, options: NavigationOptions = {}): NavigationConfig {\n const prefix = host.localName.includes('-') ? '' : 'data-';\n const config: Record<string, unknown> = {};\n for (const [attribute, key, parse, fallback] of SCHEMA) {\n config[key] = parse(options[key]) ?? parse(host.getAttribute(prefix + attribute)) ?? fallback;\n }\n return config as unknown as NavigationConfig;\n}\n"]}
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
import { type NavigationConfig, type NavigationMode, type NavigationOptions } from './config.js';
|
|
2
|
+
export declare const EVENTS: {
|
|
3
|
+
readonly open: "ui-navigation:open";
|
|
4
|
+
readonly close: "ui-navigation:close";
|
|
5
|
+
readonly modechange: "ui-navigation:modechange";
|
|
6
|
+
readonly submenu: "ui-navigation:submenu";
|
|
7
|
+
};
|
|
8
|
+
/**
|
|
9
|
+
* Owns the state of one enhanced navigation: configuration, mode, listeners and timers.
|
|
10
|
+
* Every listener uses `signal` and every timer goes through `setTimer`, so `destroy()` can undo
|
|
11
|
+
* all of it.
|
|
12
|
+
*/
|
|
13
|
+
export declare class NavigationController {
|
|
14
|
+
#private;
|
|
15
|
+
readonly host: HTMLElement;
|
|
16
|
+
config: NavigationConfig;
|
|
17
|
+
mode: NavigationMode;
|
|
18
|
+
constructor(host: HTMLElement, nav: HTMLElement, list: HTMLUListElement, options: NavigationOptions, onDestroy: () => void);
|
|
19
|
+
get signal(): AbortSignal;
|
|
20
|
+
/** Re-reads the configuration and re-applies state, keeping the options given to `init()`. */
|
|
21
|
+
update(): void;
|
|
22
|
+
/** Opens the overlay panel; does nothing in inline mode or when it is open. */
|
|
23
|
+
open(): void;
|
|
24
|
+
/** Closes the overlay panel and returns focus to the menu button. */
|
|
25
|
+
close(): void;
|
|
26
|
+
/** Closes every open submenu without moving focus. */
|
|
27
|
+
closeSubmenus(): void;
|
|
28
|
+
dispatch(name: string, detail: object): void;
|
|
29
|
+
setTimer(callback: () => void, ms: number): number;
|
|
30
|
+
clearTimer(id: number): void;
|
|
31
|
+
destroy(): void;
|
|
32
|
+
}
|
|
33
|
+
//# sourceMappingURL=controller.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"controller.d.ts","sourceRoot":"","sources":["../src/controller.ts"],"names":[],"mappings":"AAAA,OAAO,EAAc,KAAK,gBAAgB,EAAE,KAAK,cAAc,EAAE,KAAK,iBAAiB,EAAE,MAAM,aAAa,CAAC;AAI7G,eAAO,MAAM,MAAM;;;;;CAKT,CAAC;AAKX;;;;GAIG;AACH,qBAAa,oBAAoB;;IAC/B,QAAQ,CAAC,IAAI,EAAE,WAAW,CAAC;IAC3B,MAAM,EAAE,gBAAgB,CAAC;IACzB,IAAI,EAAE,cAAc,CAAC;gBAiBnB,IAAI,EAAE,WAAW,EACjB,GAAG,EAAE,WAAW,EAChB,IAAI,EAAE,gBAAgB,EACtB,OAAO,EAAE,iBAAiB,EAC1B,SAAS,EAAE,MAAM,IAAI;IAmBvB,IAAI,MAAM,IAAI,WAAW,CAExB;IAED,8FAA8F;IAC9F,MAAM,IAAI,IAAI;IAWd,+EAA+E;IAC/E,IAAI,IAAI,IAAI;IAQZ,qEAAqE;IACrE,KAAK,IAAI,IAAI;IAoBb,sDAAsD;IACtD,aAAa,IAAI,IAAI;IAIrB,QAAQ,CAAC,IAAI,EAAE,MAAM,EAAE,MAAM,EAAE,MAAM,GAAG,IAAI;IAI5C,QAAQ,CAAC,QAAQ,EAAE,MAAM,IAAI,EAAE,EAAE,EAAE,MAAM,GAAG,MAAM;IASlD,UAAU,CAAC,EAAE,EAAE,MAAM,GAAG,IAAI;IAK5B,OAAO,IAAI,IAAI;CAoNhB"}
|