unfold-nav 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/CHANGELOG.md ADDED
@@ -0,0 +1,15 @@
1
+ # Changelog
2
+
3
+ ## 0.1.0
4
+
5
+ Initial release of the dependency-free `<unfold-nav>` Web Component.
6
+
7
+ - Press-and-hold, drag, tap and keyboard navigation through nested pages.
8
+ - Nine fixed positions and an inline trigger, with CSS variables, parts and custom icons.
9
+ - Curved, straight and Circuit connectors; Circuit paths use separate ports and rounded turns.
10
+ - Dialog and tree semantics, focus management, reduced motion, translated strings and router hooks.
11
+ - Validated configuration, cycle detection, safe default URL navigation and complete disconnect cleanup.
12
+ - ESM and CommonJS builds with matching TypeScript declarations, safe server-side imports and MIT licensing.
13
+ - Public showcase, layout and interaction regression tests, fresh-package verification and CI.
14
+
15
+ This is a pre-1.0 release. Test real menu content and target browsers; extreme density can exceed the layout's space.
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 unfold-nav contributors
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,421 @@
1
+ # unfold-nav
2
+
3
+ One button for your whole site map. Press and hold it, and your pages unfold around it as a small graph.
4
+ Drag along the graph and release on a page to go there. Or tap the button and tap your way through. The
5
+ same component works with a mouse, a finger or a keyboard, on desktop and on phones.
6
+
7
+ - **Zero dependencies.** It's a standard Web Component, so it works in plain HTML, React, Vue, Svelte, Astro and so on. About 24 KB gzipped (ESM).
8
+ - **Any position:** the four corners, the middle of each edge, the centre of the screen, or `inline` inside your own header. The graph prefers the available space toward the centre of the screen.
9
+ - **Space-aware layout.** The layout reserves room for nodes and labels, separates Circuit connectors, and searches for more space as branches unfold. Dense menus still need testing; see [Layout limits](#layout-limits).
10
+ - **Fed with an object.** Give it a nested list of pages with `label`, `href`, `icon` and `children`.
11
+ - **Customisable:** options, `--unfold-*` CSS variables, `::part()` selectors, a slot for the button icon, per-page colours, and a hook to plug in any icon library.
12
+ - **Accessibility features:** a modal dialog with a standard tree of pages for screen readers, full keyboard support, focus management, high-contrast and forced-colors support, text that follows the reader's font size, and reduced motion. See [Accessibility](#accessibility).
13
+
14
+ Version 0.1.0 is an initial release for compact site maps. The API may change before 1.0.
15
+
16
+ ## Quick start
17
+
18
+ Install it in your project:
19
+
20
+ ```bash
21
+ npm install unfold-nav
22
+ ```
23
+
24
+ ```js
25
+ import 'unfold-nav';
26
+ ```
27
+
28
+ Or load the built module directly in HTML:
29
+
30
+ ```html
31
+ <script type="module" src="/path/to/unfold-nav.js"></script>
32
+
33
+ <unfold-nav position="bottom-right"></unfold-nav>
34
+
35
+ <script type="module">
36
+ document.querySelector('unfold-nav').pages = [
37
+ { label: 'Home', href: '/', icon: '🏠' },
38
+ {
39
+ label: 'Products',
40
+ href: '/products',
41
+ icon: '/icons/box.svg',
42
+ description: 'Everything we make',
43
+ children: [
44
+ { label: 'Software', href: '/products/software', icon: '💻' },
45
+ { label: 'Hardware', href: '/products/hardware', icon: '🔧' },
46
+ ],
47
+ },
48
+ { label: 'About', icon: 'ℹ️', children: [{ label: 'Team', href: '/about/team' }] },
49
+ ];
50
+ </script>
51
+ ```
52
+
53
+ Without any JavaScript of your own, put the pages in the element as JSON:
54
+
55
+ ```html
56
+ <unfold-nav position="bottom">
57
+ <script type="application/json">
58
+ [
59
+ { "label": "Home", "href": "/" },
60
+ { "label": "Blog", "href": "/blog" }
61
+ ]
62
+ </script>
63
+ </unfold-nav>
64
+ ```
65
+
66
+ Or create it from code:
67
+
68
+ ```js
69
+ import { createUnfoldNav } from 'unfold-nav';
70
+
71
+ const nav = createUnfoldNav({
72
+ pages: [
73
+ { label: 'Home', href: '/', icon: '🏠' },
74
+ { label: 'Docs', href: '/docs', icon: '📖' },
75
+ ],
76
+ position: 'bottom',
77
+ });
78
+
79
+ nav.remove(); // cleanup when your page or component unmounts
80
+ ```
81
+
82
+ ## How people use it
83
+
84
+ | | |
85
+ | ----------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
86
+ | **Press and hold → drag → release** | Holding the button fills a ring around it, then the graph opens. Pause on a page that has subpages and they unfold further out. Release on a page to go there, or on empty space to cancel. Moving straight away skips the wait, so a quick flick works too. |
87
+ | **Hold and let go** | The graph stays open for tapping. |
88
+ | **Tap** | Opens the graph for tapping. Tap a group to unfold it; tap it again to open its own page (if it has one; its label shows a "→"). The centre button goes back a level, and tapping outside closes. Labels count as part of their page, so you can tap a name too. |
89
+ | **Keyboard** | See below. |
90
+
91
+ Ctrl- or Cmd-clicking a page opens it in a new tab.
92
+
93
+ ### Keyboard
94
+
95
+ Enter, Space, ↑ or ↓ on the button opens the graph and moves focus to the current page's section, or to the first page.
96
+
97
+ | Key | Tree keys (default, `arrow-keys="tree"`) | Spatial keys (`arrow-keys="spatial"`) |
98
+ | ------------- | ------------------------------------------------------- | ------------------------------------- |
99
+ | ↓ / ↑ | Next / previous page in reading order | Nearest page below / above |
100
+ | → | Open the group, or move into it | Nearest page to the right |
101
+ | ← | Close the group, or move to its parent | Nearest page to the left |
102
+ | Home / End | First / last page | First / last sibling |
103
+ | Enter / Space | Go to the page (a group without a page opens or closes) | Like tapping |
104
+ | Backspace | Close the group you're in and focus it | (same) |
105
+ | Escape | Close and return focus to the button | (same) |
106
+ | Tab | Move between the pages and the centre button | (same) |
107
+ | Letters | Jump to the next page starting with them | Among siblings |
108
+
109
+ In right-to-left documents ← and → swap.
110
+
111
+ ## Pages
112
+
113
+ ```ts
114
+ interface NavPage {
115
+ label: string;
116
+ href?: string; // pages without href are groups (or actions — see `unfold-select`)
117
+ target?: string; // '_blank' opens a new tab
118
+ icon?: IconSource; // see below
119
+ description?: string; // shown under the label while highlighted
120
+ color?: string; // per-page accent
121
+ disabled?: boolean;
122
+ children?: NavPage[];
123
+ id?: string; // generated when omitted
124
+ data?: unknown; // anything; handed back in events
125
+ }
126
+ ```
127
+
128
+ `pages` can be an array (the top level), or a single root page whose `children` are the top level. The
129
+ root page's `icon` then becomes the button icon.
130
+
131
+ ### Icons
132
+
133
+ An `icon` can be:
134
+
135
+ - inline SVG markup: `'<svg viewBox="0 0 24 24">…</svg>'` (inserted as-is, so only use markup you trust)
136
+ - an image URL: `'/icons/home.svg'`, `'https://…'`, `'data:image/…'`
137
+ - an emoji or short text: `'🏠'`, `'A'`
138
+ - a DOM node (cloned for each use), or a function that returns any of the above
139
+ - a **name** that your `iconResolver` turns into one of the above, which makes it easy to use any icon library:
140
+
141
+ ```js
142
+ import { createElement, icons } from 'lucide'; // any library works
143
+ nav.iconResolver = (name) => (icons[name] ? createElement(icons[name]) : null);
144
+ nav.pages = [{ label: 'Home', href: '/', icon: 'House' }];
145
+ ```
146
+
147
+ Pages without an icon show their first letter.
148
+
149
+ ## Options
150
+
151
+ Set options as properties (`nav.spacing = 110`) or with `nav.configure({...})`. Serializable options also have the attributes listed below (`spacing="110"`); callbacks and translated strings use properties.
152
+
153
+ Invalid property/configuration updates throw `TypeError` or `RangeError` and preserve the previous configuration. Invalid attributes log a warning and are ignored. Dimensions must be finite and positive; delays, gap and offsets can be zero. Set a property to `undefined`, or remove its attribute, to restore the default. Call `configure` after editing a pages array; nested objects are not observed.
154
+
155
+ | Property | Attribute | Default | |
156
+ | -------------- | ------------------------------ | ---------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
157
+ | `pages` | `pages` (JSON) | `[]` | The site map |
158
+ | `position` | `position` | `'bottom-right'` | `top-left` `top` `top-right` `left` `center` `right` `bottom-left` `bottom` `bottom-right` `inline` |
159
+ | `dock` | `dock` | `'float'` | `float`: the button floats over the page. `bar`: it sits in a strip along its edge, combine it with page padding to keep content clear. See [Keeping content clear](#keeping-content-clear-of-the-button). |
160
+ | `offset` | `offset` (`"24"` or `"24 32"`) | `24` | Distance from the screen edges in px (`number` or `{ x, y }`). Safe-area insets are added. |
161
+ | `holdDelay` | `hold-delay` | `220` | ms to hold before drag mode starts |
162
+ | `expandDelay` | `expand-delay` | `140` | ms to pause on a group before it unfolds while dragging |
163
+ | `openOnTap` | `open-on-tap` | `true` | Whether a short tap opens the graph |
164
+ | `nodeSize` | `node-size` | `52` | Page node diameter (px) |
165
+ | `triggerSize` | `trigger-size` | `60` | Button diameter (px) |
166
+ | `spacing` | `spacing` | `96` | Preferred distance between a node and its children (px) |
167
+ | `gap` | `gap` | `16` | Minimum space between neighbouring nodes (px) |
168
+ | `labels` | `labels` | `'auto'` | `auto` (the current level + the highlighted page), `always`, `hover`, `none` |
169
+ | `edges` | `edges` | `'curved'` | `curved`, `straight`, `step` (circuit-style, separate ports and two rounded turns), `none` |
170
+ | `backdrop` | `backdrop` | `true` | Dim and blur the page behind the graph |
171
+ | `haptics` | `haptics` | `true` | Short vibrations on open and highlight (where supported) |
172
+ | `theme` | `theme` | `'auto'` | `auto`, `light`, `dark` |
173
+ | `current` | `current` | location | URL of the current page; `null` for none. The matching page and the trail to it are marked. |
174
+ | `label` | `label` | `'Navigation'` | Accessible name of the button and the dialog |
175
+ | `arrowKeys` | `arrow-keys` | `'tree'` | `tree` (standard tree keys) or `spatial` (arrows follow the layout). See [Keyboard](#keyboard). |
176
+ | `strings` | — | English | Built-in text, for other languages: `{ pages, close, backToTop, back }` (`back` contains `{label}`) |
177
+ | `triggerIcon` | `trigger-icon` | graph glyph | Button icon (any `IconSource`). Or slot one in: `<svg slot="icon">…</svg>` |
178
+ | `iconResolver` | — | — | `(name, page) => IconSource` |
179
+ | `navigate` | — | — | `(page, detail) => void`, called instead of `location.assign` (for client-side routers) |
180
+
181
+ Methods: `open({ focus })`, `close({ focusTrigger })`, `toggle()`, `configure(options)`, and the `isOpen` property.
182
+
183
+ ## Keeping content clear of the button
184
+
185
+ A fixed button always floats over something. The component tells the page how much room it takes, and can
186
+ draw its own strip:
187
+
188
+ - It publishes `--unfold-inset-top`, `--unfold-inset-right`, `--unfold-inset-bottom` or `--unfold-inset-left`
189
+ on `<html>` (safe areas included), for the edge it's docked to. Multiple instances use the largest inset on each edge, and removing the last instance restores the previous value. Pad your content with it:
190
+
191
+ ```css
192
+ body {
193
+ padding-bottom: var(--unfold-inset-bottom, 0px);
194
+ }
195
+ ```
196
+
197
+ - With `dock="bar"`, the button sits in a strip along that edge (full width for top and bottom, full
198
+ height for the sides). Use the published inset for content padding; the strip alone does not change your page layout. Style it with
199
+ `--unfold-dock-bg`, `--unfold-dock-border`, `--unfold-dock-blur`, or `::part(dock-bar)`.
200
+
201
+ For top positions, a sticky header that leaves the button's corner free works just as well. A centred
202
+ button floats by design, so give it a gutter in your layout.
203
+
204
+ ## Accessibility
205
+
206
+ - **A real dialog.** When opened by tapping, clicking or keyboard, the graph is a modal `<dialog>`: the
207
+ rest of the page becomes inert, screen readers stay inside, and closing returns focus to the button.
208
+ Escape closes it. Browser close requests step out of an open group first.
209
+ - **A real tree.** Pages are `treeitem`s with level, position in set, `aria-expanded` and `aria-current="page"`,
210
+ in reading order, with a roving tab stop. Descriptions are exposed with `aria-description`.
211
+ - **Keyboard:** the standard tree keys by default (see [Keyboard](#keyboard)). Focus moves through a roving tab stop: when a branch closes under it, focus moves to its parent.
212
+ - **Gestures have alternatives.** The press-and-drag gesture is a shortcut. Everything also works with
213
+ single taps or clicks, and with the keyboard. Keep `open-on-tap` on (the default) so dragging is optional.
214
+ Letting go anywhere off the graph cancels, and nothing activates on press.
215
+ - **Readable labels.** Label text follows the reader's font-size setting (`max(13px, .8125rem)`) and wraps
216
+ instead of truncating. The highlighted page's label stays while the pointer moves onto it, and can be
217
+ clicked.
218
+ - **Targets:** the default page diameter is 52px and the button is 60px. Check target spacing and contrast after changing the theme or sizes.
219
+ - **Preferences:** `prefers-reduced-motion` turns off movement; `prefers-contrast: more` adds solid
220
+ borders and opaque surfaces; `prefers-reduced-transparency` removes blur; Windows High Contrast and
221
+ other forced palettes get outline-based states. Right-to-left documents mirror the keys and the back icon.
222
+ - **Your language:** every built-in phrase can be replaced with `strings`.
223
+
224
+ These are implementation features, not a WCAG certification. Check the final menu with keyboard navigation, zoom, screen readers and your real content. Keep a visible “Menu” label or another clear cue if visitors may not recognise the graph icon.
225
+
226
+ ## Events
227
+
228
+ All events bubble and cross shadow DOM boundaries.
229
+
230
+ | Event | `detail` | |
231
+ | ------------------ | --------------------------- | ------------------------------------------------------------------------------------------------------------------- |
232
+ | `unfold-select` | `{ page, trail, via }` | Cancelable. Call `preventDefault()` to handle navigation yourself. `via` is `'release'`, `'click'` or `'keyboard'`. |
233
+ | `unfold-open` | `{ mode: 'drag' \| 'tap' }` | |
234
+ | `unfold-close` | — | |
235
+ | `unfold-highlight` | `{ page \| null }` | The page under the pointer or keyboard focus |
236
+
237
+ Client-side routing uses the `navigate` callback. It receives the selected page and trail; modifier clicks and explicit link targets use browser navigation instead. Alternatively, cancel `unfold-select` to handle every selection yourself.
238
+
239
+ ```js
240
+ nav.navigate = (page) => {
241
+ if (page.href) router.push(page.href); // your router
242
+ };
243
+ ```
244
+
245
+ Default browser navigation supports relative links, HTTP(S), `mailto:`, `tel:` and `sms:`. Other protocols are ignored with a warning. Use a custom router or canceled selection event for application-specific URLs. Page labels are rendered as text; SVG/HTML icons and icon resolvers accept **trusted markup only**, so never feed them unsanitized user input.
246
+
247
+ ### React and server-rendered apps
248
+
249
+ Importing the package on the server is safe. Create elements in the browser after mounting; the factory throws a clear error if called without a DOM. This React example also works with TypeScript and avoids a custom JSX tag declaration:
250
+
251
+ ```tsx
252
+ 'use client';
253
+ import { useEffect, useRef } from 'react';
254
+ import { createUnfoldNav, type NavPage } from 'unfold-nav';
255
+
256
+ export function Navigation({ pages }: { pages: NavPage[] }) {
257
+ const host = useRef<HTMLSpanElement>(null);
258
+ useEffect(() => {
259
+ if (!host.current) return;
260
+ const nav = createUnfoldNav({ pages, position: 'bottom-right' }, host.current);
261
+ return () => nav.remove();
262
+ }, [pages]);
263
+ return <span ref={host} />;
264
+ }
265
+ ```
266
+
267
+ Vue, Svelte, Astro and plain HTML can use `<unfold-nav>` directly; pass arrays, icons and callbacks as DOM properties. Import the package in your browser entry point. `define()` is idempotent and registration happens on import; `define('my-navigation')` registers a custom tag alias.
268
+
269
+ Pages without `href` work as actions:
270
+
271
+ ```js
272
+ nav.addEventListener('unfold-select', (e) => {
273
+ if (e.detail.page.id === 'theme') toggleTheme();
274
+ });
275
+ ```
276
+
277
+ ## Styling
278
+
279
+ Set any of these on the element or an ancestor:
280
+
281
+ ```css
282
+ unfold-nav {
283
+ --unfold-accent: #e8590c; /* highlights, current page, active path */
284
+ --unfold-fg: #1b1b1f;
285
+ --unfold-surface: rgb(255 255 255 / 0.8); /* button and node background */
286
+ --unfold-surface-active: #fff;
287
+ --unfold-border: rgb(255 255 255 / 0.7);
288
+ --unfold-shadow: 0 10px 30px -10px rgb(0 0 0 / 0.3);
289
+ --unfold-blur: 18px; /* frosted glass on controls */
290
+ --unfold-edge: rgb(0 0 0 / 0.2);
291
+ --unfold-edge-width: 1.5px;
292
+ --unfold-label-bg: rgb(255 255 255 / 0.9);
293
+ --unfold-label-fg: #1b1b1f;
294
+ --unfold-label-max-width: 180px;
295
+ --unfold-backdrop: rgb(244 244 248 / 0.4);
296
+ --unfold-backdrop-blur: 6px;
297
+ --unfold-icon-size: 22px;
298
+ --unfold-font: inherit;
299
+ --unfold-duration: 340ms;
300
+ --unfold-easing: cubic-bezier(0.22, 1.2, 0.36, 1);
301
+ --unfold-z-index: 2147483000;
302
+
303
+ /* shape */
304
+ --unfold-node-radius: 50%; /* 0 for squares, 14px for rounded squares */
305
+ --unfold-trigger-radius: 50%;
306
+ --unfold-border-width: 1px;
307
+ --unfold-active-scale: 1.14; /* highlighted node */
308
+ --unfold-dim-opacity: 0.55; /* pages beside the open branch */
309
+ --unfold-dim-scale: 0.86;
310
+
311
+ /* labels */
312
+ --unfold-label-radius: 9px;
313
+ --unfold-label-font: inherit;
314
+ --unfold-label-size: 13px;
315
+ --unfold-label-weight: 600;
316
+ --unfold-label-tracking: normal;
317
+ --unfold-label-case: none; /* uppercase, … */
318
+ }
319
+ ```
320
+
321
+ Anything else can be styled with `::part()`: `trigger`, `trigger-icon`, `dock`, `overlay`, `backdrop`, `edges`, `edge`, `node`, `icon`, `label`, `hub`.
322
+
323
+ `::part()` can't match attributes, so **states are exposed as part names too**:
324
+
325
+ | Part | When |
326
+ | ------------------------------ | --------------------------------------------------- |
327
+ | `node-active` | under the pointer / keyboard focus |
328
+ | `node-current` | the page you're on (`node-trail` for its ancestors) |
329
+ | `node-path` | an open branch |
330
+ | `node-frontier` / `node-dim` | the level you're looking at / the pages beside it |
331
+ | `node-branch`, `node-disabled` | has children / disabled |
332
+ | `edge-lit`, `edge-dim` | edge on the open path / beside it |
333
+ | `label-active`, `label-path` | label of the highlighted node / of an open branch |
334
+ | `hub-back` | the centre button is acting as "back" |
335
+
336
+ ```css
337
+ unfold-nav::part(node-active) {
338
+ background: var(--unfold-accent);
339
+ color: #fff;
340
+ }
341
+ unfold-nav::part(edge-lit) {
342
+ filter: drop-shadow(0 0 4px var(--unfold-accent));
343
+ }
344
+ unfold-nav::part(label) {
345
+ font-style: italic;
346
+ box-shadow: none;
347
+ }
348
+ ```
349
+
350
+ Use the `--unfold-active-scale` / `--unfold-dim-*` variables rather than setting `scale` or `opacity` on parts.
351
+ Those two properties drive the unfold animation.
352
+
353
+ ## Showcase: 9 industries × 9 designs
354
+
355
+ `pnpm dev` opens a showcase with a desktop and a phone frame side by side, running the same mock website. Pick:
356
+
357
+ - **an industry:** restaurant, fashion, SaaS, healthcare, real estate, developer docs, creative agency, banking, travel. Each is a fictional brand with its own site map, typography and artwork.
358
+ - **a design:** Glass, Solid, Paper, Mono, Neon, Luxe, Soft, Circuit, Brutal.
359
+ - **a position:** any of the nine, or inside the site's header.
360
+
361
+ Every design is built only from the public styling API above (see `demo/designs.ts`), and the showcase
362
+ prints the exact HTML and CSS for whatever you've picked. The state lives in the URL hash, so
363
+ `/#docs/circuit/left` is a shareable link. `site.html?industry=…&design=…&position=…` opens one mock site
364
+ on its own, which is handy on a real phone.
365
+
366
+ ```bash
367
+ pnpm build:showcase # static site in showcase-dist/ (relative paths, host it anywhere)
368
+ pnpm preview:showcase # serve that build locally
369
+ ```
370
+
371
+ ## How the layout works
372
+
373
+ All the geometry lives in `src/layout.ts` as pure functions.
374
+
375
+ - **Top level:** pages fan out from the button towards the middle of the screen. A button in the centre gets a full ring.
376
+ - **Unfolding a page:** its children fan out _beyond_ it, continuing the direction you were already moving in. Each fan searches for the closest direction and smallest distance that keep every node on screen and clear of nodes already shown. If it gets cornered, it may turn any way it needs to.
377
+ - **Stability:** fans are cached by their path, so pages you've already seen never move while you drag deeper.
378
+ - **Labels reserve room first.** A fan is only accepted when every new page _and_ its label fit clear of all
379
+ pages, labels and lines already on screen. Deeper levels keep clear of those labels too. An unfolding
380
+ branch keeps a spot for its own name: next to it if there's room, otherwise on its own highlighted
381
+ path, like a breadcrumb.
382
+ - **When space runs out.** If nothing fits, the search grows the distance, turns the fan, then
383
+ allows lines to pass under the labels of pages that are dimmed at that moment. As a last resort a
384
+ page may give up its visible label; its accessible name remains available. Extreme density can relax placement constraints.
385
+ - **Labels never slide** between spots (they'd cross each other on the way); they reappear instead.
386
+
387
+ Regression tests cover the nine showcase site maps in all nine fixed positions, on phone and desktop viewports, through every branch. They check node, label and edge clearance for those fixtures and prevent Circuit connectors from sharing long segments.
388
+
389
+ ### Layout limits
390
+
391
+ Use compact hierarchies, roughly 5–8 siblings per level and a few levels deep. Very large trees, long labels, large nodes or small viewports can exhaust the available space. The layout may hide labels or relax clearance in its last fallback; it cannot guarantee collision-free placement for arbitrary input. Test your real pages, fonts, zoom levels and viewports, and keep critical destinations visible elsewhere on the page.
392
+
393
+ ## Development
394
+
395
+ Use Node.js 22.12+ or 24 (recommended), and pnpm 12.8.1. The library has no runtime dependencies.
396
+
397
+ ```bash
398
+ pnpm install --frozen-lockfile
399
+ pnpm dev # showcase at http://localhost:5173 (add --host to try it on your phone)
400
+ pnpm check # formatting, types, regression tests, build and fresh-package verification
401
+ pnpm build # dist/unfold-nav.js (ESM), dist/unfold-nav.umd.cjs, type definitions
402
+ ```
403
+
404
+ Browser target: modern evergreen browsers with Custom Elements, Shadow DOM, `<dialog>` and modern CSS (`color-mix`, individual transforms). Chrome has been checked interactively; test Safari, Firefox and assistive technology in your target environments before rollout. The Popover API puts drag navigation in the top layer; browsers without it use a non-modal dialog. IE and legacy browsers are not supported.
405
+
406
+ See [CONTRIBUTING.md](CONTRIBUTING.md) for the development workflow and reproducible bug reports. GitHub Actions runs the checks and showcase build on Node 22 and 24.
407
+
408
+ A note on SEO: the graph only exists while it is open, so keep a plain list of links somewhere (a footer or a sitemap) for crawlers and no-JS visitors.
409
+
410
+ ## Publishing
411
+
412
+ ```bash
413
+ pnpm check
414
+ npm pack --dry-run # builds the library and lists exactly what will ship
415
+ npm login
416
+ npm publish --access public
417
+ ```
418
+
419
+ Before publishing, confirm ownership of the `unfold-nav` name on npm, review the version and changelog, and use your npm account's required authentication. `prepublishOnly` runs the full check; `prepack` rebuilds both module formats and their TypeScript declarations. `test:package` installs a fresh tarball and checks ESM, CommonJS, server-side imports, and strict TypeScript in both Bundler and NodeNext modes.
420
+
421
+ The package includes only `dist/`, this README, the changelog, the MIT license and package metadata. The showcase and developer tooling remain in this repository. GitHub CI validates changes; npm publishing is a separate maintainer action.
@@ -0,0 +1,37 @@
1
+ import type { NavPage, UnfoldNavOptions } from './types.cjs';
2
+ export { DEFAULT_OPTIONS, DEFAULT_STRINGS } from './options.cjs';
3
+ declare const Base: typeof HTMLElement;
4
+ export interface UnfoldNav extends UnfoldNavOptions {
5
+ }
6
+ /**
7
+ * `<unfold-nav>`: press and hold the button, the site map unfolds around it as a graph. Drag along the
8
+ * graph and release on a page to go there, or tap to open it and tap your way through.
9
+ *
10
+ * While open for tapping or the keyboard it is a modal dialog: the rest of the page is inert, focus moves
11
+ * into a tree of pages (standard tree keys), and returns to the button when it closes.
12
+ *
13
+ * Events (all bubble and cross shadow boundaries):
14
+ * - `unfold-select` (cancelable) `detail: SelectDetail`: call `preventDefault()` to handle navigation yourself
15
+ * - `unfold-open` `detail: { mode: 'drag' | 'tap' }`
16
+ * - `unfold-close`
17
+ * - `unfold-highlight` `detail: { page: NavPage | null }`
18
+ */
19
+ export declare class UnfoldNav extends Base {
20
+ static get observedAttributes(): string[];
21
+ constructor();
22
+ /** Update several options at once. */
23
+ configure(options: Partial<UnfoldNavOptions>): this;
24
+ get isOpen(): boolean;
25
+ /** Open for tapping. With `focus`, the first page shows keyboard focus. */
26
+ open({ focus }?: {
27
+ focus?: boolean;
28
+ }): void;
29
+ close({ focusTrigger }?: {
30
+ focusTrigger?: boolean;
31
+ }): void;
32
+ toggle(): void;
33
+ connectedCallback(): void;
34
+ disconnectedCallback(): void;
35
+ attributeChangedCallback(name: string, oldValue: string | null, value: string | null): void;
36
+ }
37
+ export type { NavPage };
@@ -0,0 +1,37 @@
1
+ import type { NavPage, UnfoldNavOptions } from './types.js';
2
+ export { DEFAULT_OPTIONS, DEFAULT_STRINGS } from './options.js';
3
+ declare const Base: typeof HTMLElement;
4
+ export interface UnfoldNav extends UnfoldNavOptions {
5
+ }
6
+ /**
7
+ * `<unfold-nav>`: press and hold the button, the site map unfolds around it as a graph. Drag along the
8
+ * graph and release on a page to go there, or tap to open it and tap your way through.
9
+ *
10
+ * While open for tapping or the keyboard it is a modal dialog: the rest of the page is inert, focus moves
11
+ * into a tree of pages (standard tree keys), and returns to the button when it closes.
12
+ *
13
+ * Events (all bubble and cross shadow boundaries):
14
+ * - `unfold-select` (cancelable) `detail: SelectDetail`: call `preventDefault()` to handle navigation yourself
15
+ * - `unfold-open` `detail: { mode: 'drag' | 'tap' }`
16
+ * - `unfold-close`
17
+ * - `unfold-highlight` `detail: { page: NavPage | null }`
18
+ */
19
+ export declare class UnfoldNav extends Base {
20
+ static get observedAttributes(): string[];
21
+ constructor();
22
+ /** Update several options at once. */
23
+ configure(options: Partial<UnfoldNavOptions>): this;
24
+ get isOpen(): boolean;
25
+ /** Open for tapping. With `focus`, the first page shows keyboard focus. */
26
+ open({ focus }?: {
27
+ focus?: boolean;
28
+ }): void;
29
+ close({ focusTrigger }?: {
30
+ focusTrigger?: boolean;
31
+ }): void;
32
+ toggle(): void;
33
+ connectedCallback(): void;
34
+ disconnectedCallback(): void;
35
+ attributeChangedCallback(name: string, oldValue: string | null, value: string | null): void;
36
+ }
37
+ export type { NavPage };
@@ -0,0 +1,12 @@
1
+ import type { IconSource, NavPage } from './types.cjs';
2
+ export type IconResolver = ((name: string, page?: NavPage) => Node | string | null | undefined) | undefined;
3
+ /**
4
+ * Turns an `IconSource` into DOM. Returns null when there is nothing to show (the caller falls back to a
5
+ * monogram). Markup strings are inserted as-is, so only pass markup you trust.
6
+ */
7
+ export declare function renderIcon(src: IconSource | undefined, resolve: IconResolver, page?: NavPage, depth?: number): Node | null;
8
+ export declare function monogram(label: string): HTMLElement;
9
+ /** A small graph unfolding upwards; rotated towards wherever the graph will open. */
10
+ export declare const GRAPH_ICON: string;
11
+ export declare const CLOSE_ICON: string;
12
+ export declare const BACK_ICON: string;
@@ -0,0 +1,12 @@
1
+ import type { IconSource, NavPage } from './types.js';
2
+ export type IconResolver = ((name: string, page?: NavPage) => Node | string | null | undefined) | undefined;
3
+ /**
4
+ * Turns an `IconSource` into DOM. Returns null when there is nothing to show (the caller falls back to a
5
+ * monogram). Markup strings are inserted as-is, so only pass markup you trust.
6
+ */
7
+ export declare function renderIcon(src: IconSource | undefined, resolve: IconResolver, page?: NavPage, depth?: number): Node | null;
8
+ export declare function monogram(label: string): HTMLElement;
9
+ /** A small graph unfolding upwards; rotated towards wherever the graph will open. */
10
+ export declare const GRAPH_ICON: string;
11
+ export declare const CLOSE_ICON: string;
12
+ export declare const BACK_ICON: string;
@@ -0,0 +1,32 @@
1
+ import { UnfoldNav } from './element.cjs';
2
+ import type { SelectDetail, UnfoldNavOptions } from './types.cjs';
3
+ export { UnfoldNav, DEFAULT_OPTIONS, DEFAULT_STRINGS } from './element.cjs';
4
+ export { buildTree, findCurrent } from './tree.cjs';
5
+ export { placeFan, layoutGraph, placeLabel } from './layout.cjs';
6
+ export type * from './types.cjs';
7
+ export declare const TAG = "unfold-nav";
8
+ /** Registers `<unfold-nav>` (idempotent; importing this module already does it). */
9
+ export declare function define(tag?: string): void;
10
+ /**
11
+ * Creates a navigation, appends it to `container` (default `document.body`) and returns the element.
12
+ *
13
+ * const nav = createUnfoldNav({ pages, position: 'bottom' });
14
+ * nav.addEventListener('unfold-select', (e) => console.log(e.detail.page));
15
+ * nav.remove(); // to tear it down
16
+ */
17
+ export declare function createUnfoldNav(options: Partial<UnfoldNavOptions> & Pick<UnfoldNavOptions, 'pages'>, container?: Element | DocumentFragment): UnfoldNav;
18
+ declare global {
19
+ interface HTMLElementTagNameMap {
20
+ 'unfold-nav': UnfoldNav;
21
+ }
22
+ interface HTMLElementEventMap {
23
+ 'unfold-select': CustomEvent<SelectDetail>;
24
+ 'unfold-open': CustomEvent<{
25
+ mode: 'drag' | 'tap';
26
+ }>;
27
+ 'unfold-close': CustomEvent<undefined>;
28
+ 'unfold-highlight': CustomEvent<{
29
+ page: SelectDetail['page'] | null;
30
+ }>;
31
+ }
32
+ }
@@ -0,0 +1,32 @@
1
+ import { UnfoldNav } from './element.js';
2
+ import type { SelectDetail, UnfoldNavOptions } from './types.js';
3
+ export { UnfoldNav, DEFAULT_OPTIONS, DEFAULT_STRINGS } from './element.js';
4
+ export { buildTree, findCurrent } from './tree.js';
5
+ export { placeFan, layoutGraph, placeLabel } from './layout.js';
6
+ export type * from './types.js';
7
+ export declare const TAG = "unfold-nav";
8
+ /** Registers `<unfold-nav>` (idempotent; importing this module already does it). */
9
+ export declare function define(tag?: string): void;
10
+ /**
11
+ * Creates a navigation, appends it to `container` (default `document.body`) and returns the element.
12
+ *
13
+ * const nav = createUnfoldNav({ pages, position: 'bottom' });
14
+ * nav.addEventListener('unfold-select', (e) => console.log(e.detail.page));
15
+ * nav.remove(); // to tear it down
16
+ */
17
+ export declare function createUnfoldNav(options: Partial<UnfoldNavOptions> & Pick<UnfoldNavOptions, 'pages'>, container?: Element | DocumentFragment): UnfoldNav;
18
+ declare global {
19
+ interface HTMLElementTagNameMap {
20
+ 'unfold-nav': UnfoldNav;
21
+ }
22
+ interface HTMLElementEventMap {
23
+ 'unfold-select': CustomEvent<SelectDetail>;
24
+ 'unfold-open': CustomEvent<{
25
+ mode: 'drag' | 'tap';
26
+ }>;
27
+ 'unfold-close': CustomEvent<undefined>;
28
+ 'unfold-highlight': CustomEvent<{
29
+ page: SelectDetail['page'] | null;
30
+ }>;
31
+ }
32
+ }
@@ -0,0 +1,4 @@
1
+ type Edge = 'top' | 'right' | 'bottom' | 'left';
2
+ /** Multiple navigations share each edge; removing one restores the remaining inset or the host's value. */
3
+ export declare function updateInsets(owner: object, doc: Document, edge: Edge | null, size?: number): void;
4
+ export {};
@@ -0,0 +1,4 @@
1
+ type Edge = 'top' | 'right' | 'bottom' | 'left';
2
+ /** Multiple navigations share each edge; removing one restores the remaining inset or the host's value. */
3
+ export declare function updateInsets(owner: object, doc: Document, edge: Edge | null, size?: number): void;
4
+ export {};