@ultimat3/render 29.0.0 → 29.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/CLAUDE.md +3 -2
- package/README.md +16 -1
- package/package.json +6 -6
- package/src/errors.ts +4 -4
- package/src/index.ts +20 -9
- package/src/navigation-modal-frame.ts +159 -0
- package/src/navigation-modal-rules.ts +75 -0
- package/src/navigation-modal.ts +118 -30
- package/src/navigation-tags.ts +54 -6
- package/src/navigation.ts +15 -1
- package/src/route-navigation.ts +64 -0
- package/src/route.ts +12 -23
package/CLAUDE.md
CHANGED
|
@@ -30,7 +30,7 @@ axiom 6). Never `cli` (upward).
|
|
|
30
30
|
|---|---|
|
|
31
31
|
| `offline`, `meta` | required by `RouteDefinition`. Never make them optional. |
|
|
32
32
|
| `hydrate` | optional, **derived from `island()`** — `'interaction'` when the module declared one, `'never'` when not. Declaring it still wins and is the only way to reach `idle` / `visible`. Never give an island its own strategy: `RouteDescriptor.hydrate` is read by `sw.js`, the web manifest and `x routes`. |
|
|
33
|
-
| `defineRoute` shape | exactly the contract's 12 keys (`cache`, `ssr` only, is the tenth; `post`, an action's export name bound to `POST` at the page URL, the eleventh — never on `static`, `X_ROUTE_POST_INVALID`; the CLI mounts it and refuses an unregistered name at boot; `navigation`,
|
|
33
|
+
| `defineRoute` shape | exactly the contract's 12 keys (`cache`, `ssr` only, is the tenth; `post`, an action's export name bound to `POST` at the page URL, the eleventh — never on `static`, `X_ROUTE_POST_INVALID`; the CLI mounts it and refuses an unregistered name at boot; `navigation`, the twelfth — `route-navigation.ts`, read only through `navigationModeOf`/`navigationPrefetches`; refused off a `navigation.client` surface at boot, `X_ROUTE_NAVIGATION_INVALID`). New route *metadata* goes inside `meta`. |
|
|
34
34
|
| `load` | optional, and the ONE server-side data seam. Resolved once per render by `routeDataFor()` and handed to **both** `meta` and the page component. Absent `load`, the context IS the data (`{ params, url }`). |
|
|
35
35
|
| `load` is required when the context cannot supply the data | `LoadRequirement<TData>` in `defineRoute`'s parameter. `RouteContext` is a type ALIAS on purpose — only an alias carries the implicit index signature that makes it a `RouteData`. |
|
|
36
36
|
| A loader's own error | rethrown only when `isUltimateError` says so (core's brand), never a `code` property and never `instanceof UltimateError` — a tier-0 error is branded, not a subclass. Everything else is `X_ROUTE_LOAD_FAILED`. |
|
|
@@ -115,10 +115,11 @@ axiom 6). Never `cli` (upward).
|
|
|
115
115
|
| An island's `mount` | may return `() => void`, its disposer; the runtime never calls it, `@ultimat3/testing`'s `mountIsland` does. |
|
|
116
116
|
| `<head>` binding | `head.ts` stays injection-only; `head-seo.ts` is the ONE binding of `HeadRenderers` to `@ultimat3/seo`. |
|
|
117
117
|
| Client navigation | `navigation*.ts` — soft navigation over server-rendered documents, NOT a mode: the router renders nothing, it swaps the document the route already rendered. Rules are pure (`navigation-rules.ts`: `linkVerdict`, `formVerdict`, `responseVerdict`, `mayPrefetch`) and unit-tested; the DOM half (`navigation-swap.ts`, `navigation.ts`) is proven in Chrome by `packages/cli/e2e/client-navigation-*.e2e.test.ts`. `./navigation` (`navigation-entry.ts`) is the script the CLI builds; importing it starts it, and a document without `ultimate-navigation` is never intercepted. Route-presented modals (`navigation: 'modal'`): the hash grammar and verdicts are pure (`navigation-modal-rules.ts`), the `<dialog>` is `navigation-modal.ts`, styled by `@ultimat3/ui`'s `global.scss` under `NAVIGATION_MODAL_ATTRIBUTE` (pinned by `navigation-modal-style.test.ts` — render never imports ui), proven by `client-navigation-modal.e2e.test.ts`. |
|
|
118
|
+
| Modal on the click | `navigation-modal-frame.ts`: `modal.loading(url)` in `navigate`'s synchronous prefix opens the frame off `NAVIGATION_MODALS_META`; the answer's meta still decides. Closing aborts (`abandon`); the router `release()`s before its own dismissals; `settle()` in `finally`. Never `showModal()` where popovers exist, never a toggle on `<html>`/`<body>` ([why](../../docs/history/render.md#the-modal-that-opens-on-the-click)) |
|
|
118
119
|
| Router ↔ the page's lifecycle | `navigation-scroll.ts` owns `scrollRestoration = 'manual'` and RESTORES the entry's saved offset at start, before its first save (a reload, or Back after a full load); `navigation-tabs.ts` owns the `BroadcastChannel`, closed on `pagehide` (it blocks the back/forward cache), reopened and the cache emptied on a persisted `pageshow`. A sheet `loadStylesheets` appends is `owned` at once, so an aborted navigation's sheets are retired by the next swap. A view transition is tracked by identity: an older one finishing never clears the running one. |
|
|
119
120
|
| Router ↔ islands | ONE bootstrap: the swap re-runs the inline hydration runtime, whose `each` visits a root once per tab (`el.__v`). Never add a second mount path for the router. Dispose = call what `el.__x` resolved to. |
|
|
120
121
|
| Router head diff | only elements a SERVER document put in `<head>` (`documentHead`'s set) may be removed; a script-added one is the tab's. Scripts and stylesheets are never diffed. |
|
|
121
|
-
| Router: nothing is sent twice | every
|
|
122
|
+
| Router: nothing is sent twice | every fetch `cache: 'no-cache'` (#693), `redirect: 'manual'`, a POST never re-sent; the cache answers only a reusable 2xx and is emptied by every write signal ([full rule](../../docs/history/render.md#router-nothing-is-sent-twice)) |
|
|
122
123
|
|
|
123
124
|
Cross-package: `@ultimat3/pwa` consumes route descriptors as **data**, never by import.
|
|
124
125
|
Keep `RouteDescriptor` additive — removing a field breaks `sw.js` generation.
|
package/README.md
CHANGED
|
@@ -144,7 +144,20 @@ On a surface listed in `app.config.ts`'s `navigation.client` only (elsewhere it
|
|
|
144
144
|
|---|---|
|
|
145
145
|
| `'prefetch'` | may also be fetched on hover/focus — a GET that does nothing but render |
|
|
146
146
|
| `'document'` | always a real document load; carries no router |
|
|
147
|
-
| `'modal'` | `As of 26.1.0`. Shown OVER the page a visitor is on, in a native `<dialog>`, addressed by the hash (`/runs#/runs/new`); reload, Back/Forward and a pasted URL reopen it, a stale hash is just the page. A full load of its own URL is still the whole page — the server renders one document for both and names it a modal (`NAVIGATION_PRESENTATION_META`) |
|
|
147
|
+
| `'modal'` | `As of 26.1.0`. Shown OVER the page a visitor is on, in a native `<dialog>`, addressed by the hash (`/runs#/runs/new`); reload, Back/Forward and a pasted URL reopen it, a stale hash is just the page. A full load of its own URL is still the whole page — the server renders one document for both and names it a modal (`NAVIGATION_PRESENTATION_META`). Never prefetched |
|
|
148
|
+
| `{ modal: true, prefetch: 'intent' }` | `As of 29.1.0`. A `'modal'` whose GET only reads (a peek, a detail view): also fetched on hover/focus/touchstart, as `'prefetch'` is. Exactly these two keys and values (`isRouteNavigation`); read it with `navigationModeOf` / `navigationPrefetches`, never by comparing against a string |
|
|
149
|
+
|
|
150
|
+
**A modal opens on the click** (`As of 29.1.0`). Every document of a surface with a modal route
|
|
151
|
+
names those routes (`<meta name="ultimate-modals">`, `modalRouteList` — most specific first, with
|
|
152
|
+
the pages that outrank one as `!<pattern>`) and the frame's words in its locale (`ui.close`,
|
|
153
|
+
`ui.loading`). A click on a link to one, `openModal(path)` or a hash addressing one opens the
|
|
154
|
+
dialog in the same task: `[data-x-modal-body][aria-busy="true"]` with a skeleton, and a close button
|
|
155
|
+
(`[data-x-modal-close]`, a `type="button"`, never a `<form>`). The answer fills it; closing it first aborts
|
|
156
|
+
the request and pushes nothing; an answer that is no modal takes it down and is handled as before.
|
|
157
|
+
The dialog is opened with `show()` + `showPopover()`, never `showModal()`, whose inert document
|
|
158
|
+
restyles every element beneath (124 ms of RecalcStyle on a 7,011-element page at 4x CPU, against
|
|
159
|
+
2.6 ms) — `aria-modal`, focus containment and Escape are the router's. Numbers and rules:
|
|
160
|
+
[Client Navigation](../../wiki/Client-Navigation.md#opening-on-the-click).
|
|
148
161
|
|
|
149
162
|
Declared on the route, never on a link: the route is the one place that covers every way of
|
|
150
163
|
reaching a page — a link, a redirect after a POST, a pasted hash.
|
|
@@ -581,6 +594,8 @@ a job boundary the class is gone and the `code` is what survives — match on th
|
|
|
581
594
|
| `navigate`‡, `refresh`‡, `openModal`‡, `closeModal`‡, `NavigateToOptions`‡, `NavigationModalPathInvalidError`‡ | navigating from code: the router when the page has one, the browser's own load when not. Import from `/client` in an island |
|
|
582
595
|
| `disposeIslands` (on `/client` only) | dispose the islands mounted under an element: what the router does on a swap, for an island that drops markup holding other islands |
|
|
583
596
|
| `modalAddress`, `modalLocation`, `addressOf`, `presentation`, `modalHistory`, `leaveModal`, `NAVIGATION_PRESENTATION_META`, `NAVIGATION_MODAL_ATTRIBUTE` | route-presented modals (`navigation: 'modal'`): the hash grammar, when an answer is a modal, and what leaving one does to history |
|
|
597
|
+
| `declaresModal`, `parseModalRoutes`, `modalRouteList`, `ModalRoutes`, `NAVIGATION_MODALS_META`, `NAVIGATION_MODAL_BODY_ATTRIBUTE`, `NAVIGATION_MODAL_CLOSE_ATTRIBUTE`, `NAVIGATION_MODAL_SKELETON_ATTRIBUTE` | the modal that opens on the click: the meta naming a surface's modal routes (composed by the server, read by the router) and the frame's attributes, styled by `@ultimat3/ui` |
|
|
598
|
+
| `isRouteNavigation`, `navigationModeOf`, `navigationPrefetches`, `RouteNavigationDeclaration`, `RouteModalPrefetch` | `navigation:` read back: the four spellings, which mode, prefetched or not |
|
|
584
599
|
| `clientPathStyleTags`, `CLIENT_PATH_STYLE_META` | `<meta name="ultimate-path-style">` — the action path style the server serves, for the browser's typed client. No tag for `'resource'`, the default |
|
|
585
600
|
|
|
586
601
|
## Notes
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@ultimat3/render",
|
|
3
|
-
"version": "29.
|
|
3
|
+
"version": "29.1.0",
|
|
4
4
|
"description": "The route primitive and the five render modes: static, isr, ssr, stream, spa.",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"type": "module",
|
|
@@ -40,11 +40,11 @@
|
|
|
40
40
|
"test": "bun test"
|
|
41
41
|
},
|
|
42
42
|
"dependencies": {
|
|
43
|
-
"@ultimat3/cache": "29.
|
|
44
|
-
"@ultimat3/core": "29.
|
|
45
|
-
"@ultimat3/http": "29.
|
|
46
|
-
"@ultimat3/i18n": "29.
|
|
47
|
-
"@ultimat3/seo": "29.
|
|
43
|
+
"@ultimat3/cache": "29.1.0",
|
|
44
|
+
"@ultimat3/core": "29.1.0",
|
|
45
|
+
"@ultimat3/http": "29.1.0",
|
|
46
|
+
"@ultimat3/i18n": "29.1.0",
|
|
47
|
+
"@ultimat3/seo": "29.1.0",
|
|
48
48
|
"sass": "1.105.1"
|
|
49
49
|
}
|
|
50
50
|
}
|
package/src/errors.ts
CHANGED
|
@@ -54,7 +54,7 @@ export const RENDER_ERROR_TITLES: Readonly<Record<RenderErrorCode, string>> = {
|
|
|
54
54
|
X_ASSET_MISSING: 'a page names a site asset the app does not have',
|
|
55
55
|
X_ROUTE_POST_INVALID: "a page route's post binding cannot be mounted",
|
|
56
56
|
X_ROUTE_NAVIGATION_INVALID:
|
|
57
|
-
"a route's navigation is not 'prefetch', 'document' or
|
|
57
|
+
"a route's navigation is not 'prefetch', 'document', 'modal' or { modal: true, prefetch: 'intent' }, or its surface has no client navigation",
|
|
58
58
|
X_NAVIGATION_MODAL_PATH_INVALID:
|
|
59
59
|
'openModal was given something that is not a path of this origin',
|
|
60
60
|
};
|
|
@@ -208,8 +208,8 @@ export class RoutePostInvalidError extends UltimateError {
|
|
|
208
208
|
|
|
209
209
|
/**
|
|
210
210
|
* `defineRoute({ navigation })` that says nothing a process can honour: a value other than
|
|
211
|
-
* `'prefetch'`, `'document'` or `
|
|
212
|
-
* navigation.
|
|
211
|
+
* `'prefetch'`, `'document'`, `'modal'` or `{ modal: true, prefetch: 'intent' }`, or — at boot — a
|
|
212
|
+
* page whose surface has no client navigation.
|
|
213
213
|
*/
|
|
214
214
|
export class RouteNavigationInvalidError extends UltimateError {
|
|
215
215
|
static readonly code = 'X_ROUTE_NAVIGATION_INVALID' as const;
|
|
@@ -217,7 +217,7 @@ export class RouteNavigationInvalidError extends UltimateError {
|
|
|
217
217
|
super({
|
|
218
218
|
code: RouteNavigationInvalidError.code,
|
|
219
219
|
cause,
|
|
220
|
-
fix: "defineRoute({ ..., navigation: 'prefetch' | 'document' | 'modal' }) on a page whose surface is listed in app.config.ts navigation.client, or remove the key",
|
|
220
|
+
fix: "defineRoute({ ..., navigation: 'prefetch' | 'document' | 'modal' | { modal: true, prefetch: 'intent' } }) on a page whose surface is listed in app.config.ts navigation.client, or remove the key",
|
|
221
221
|
});
|
|
222
222
|
}
|
|
223
223
|
}
|
package/src/index.ts
CHANGED
|
@@ -117,15 +117,21 @@ export { closeModal, navigate, openModal, refresh } from './navigation-api';
|
|
|
117
117
|
/** Client-side navigation over server-rendered documents — `navigation: { client }` surfaces. */
|
|
118
118
|
export type { CachedDocument, NavigationCache, NavigationCacheOptions } from './navigation-cache';
|
|
119
119
|
export { cacheKey, NAVIGATION_CACHE_MAX_ENTRIES, navigationCache } from './navigation-cache';
|
|
120
|
-
export type { Presentation, PresentationFacts } from './navigation-modal-rules';
|
|
120
|
+
export type { ModalRoutes, Presentation, PresentationFacts } from './navigation-modal-rules';
|
|
121
121
|
export {
|
|
122
122
|
addressOf,
|
|
123
|
+
declaresModal,
|
|
123
124
|
leaveModal,
|
|
124
125
|
modalAddress,
|
|
125
126
|
modalHistory,
|
|
126
127
|
modalLocation,
|
|
127
128
|
NAVIGATION_MODAL_ATTRIBUTE,
|
|
129
|
+
NAVIGATION_MODAL_BODY_ATTRIBUTE,
|
|
130
|
+
NAVIGATION_MODAL_CLOSE_ATTRIBUTE,
|
|
131
|
+
NAVIGATION_MODAL_SKELETON_ATTRIBUTE,
|
|
132
|
+
NAVIGATION_MODALS_META,
|
|
128
133
|
NAVIGATION_PRESENTATION_META,
|
|
134
|
+
parseModalRoutes,
|
|
129
135
|
presentation,
|
|
130
136
|
} from './navigation-modal-rules';
|
|
131
137
|
export type {
|
|
@@ -160,7 +166,7 @@ export {
|
|
|
160
166
|
reusable,
|
|
161
167
|
} from './navigation-rules';
|
|
162
168
|
export type { ClientNavigationHead } from './navigation-tags';
|
|
163
|
-
export { clientNavigationTags } from './navigation-tags';
|
|
169
|
+
export { clientNavigationTags, modalRouteList } from './navigation-tags';
|
|
164
170
|
export type {
|
|
165
171
|
MountedRouteInput,
|
|
166
172
|
RegisterRouteInput,
|
|
@@ -200,18 +206,23 @@ export type {
|
|
|
200
206
|
RouteMetaAsyncFn,
|
|
201
207
|
RouteMetaContext,
|
|
202
208
|
RouteMetaFn,
|
|
203
|
-
RouteNavigationMode,
|
|
204
209
|
RouteParams,
|
|
205
210
|
} from './route';
|
|
206
|
-
export {
|
|
207
|
-
DEFAULT_ISLAND_HYDRATE,
|
|
208
|
-
defineRoute,
|
|
209
|
-
isRouteConfig,
|
|
210
|
-
ROUTE_NAVIGATION_MODES,
|
|
211
|
-
} from './route';
|
|
211
|
+
export { DEFAULT_ISLAND_HYDRATE, defineRoute, isRouteConfig } from './route';
|
|
212
212
|
export type { RouteComponent } from './route-component';
|
|
213
213
|
export { pageComponentOf } from './route-component';
|
|
214
214
|
export { metaContextFor, routeDataFor } from './route-data';
|
|
215
|
+
export type {
|
|
216
|
+
RouteModalPrefetch,
|
|
217
|
+
RouteNavigationDeclaration,
|
|
218
|
+
RouteNavigationMode,
|
|
219
|
+
} from './route-navigation';
|
|
220
|
+
export {
|
|
221
|
+
isRouteNavigation,
|
|
222
|
+
navigationModeOf,
|
|
223
|
+
navigationPrefetches,
|
|
224
|
+
ROUTE_NAVIGATION_MODES,
|
|
225
|
+
} from './route-navigation';
|
|
215
226
|
export type { CompiledPattern } from './route-pattern';
|
|
216
227
|
export { compilePattern } from './route-pattern';
|
|
217
228
|
export { noStore, routeNoStoreOf, routeStatusOf, withStatus } from './route-status';
|
|
@@ -0,0 +1,159 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The route-presented modal's `<dialog>` as DOM: its frame (a body region, a close button), how it
|
|
3
|
+
* is opened into the top layer and kept modal, and where focus goes. `navigation-modal.ts` decides
|
|
4
|
+
* when; this file only builds, opens, fills and hides.
|
|
5
|
+
*
|
|
6
|
+
* Opened with `show()` + `showPopover()`, NOT `showModal()`: a modal dialog makes the rest of the
|
|
7
|
+
* document inert, and Chrome computes inertness in style — every element of the page beneath has
|
|
8
|
+
* its style recalculated (7,011 elements, 4x CPU: 124 ms of RecalcStyle for `showModal()`, 2.6 ms
|
|
9
|
+
* this way; `inert` on one wrapper costs the same 111 ms). The popover is the top layer and the
|
|
10
|
+
* `::backdrop`; `aria-modal` tells assistive technology the page beneath is out of reach; the
|
|
11
|
+
* backdrop takes every press, and Tab wraps inside. A browser without popovers gets `showModal()`.
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
import {
|
|
15
|
+
type ModalRoutes,
|
|
16
|
+
NAVIGATION_MODAL_ATTRIBUTE,
|
|
17
|
+
NAVIGATION_MODAL_BODY_ATTRIBUTE,
|
|
18
|
+
NAVIGATION_MODAL_CLOSE_ATTRIBUTE,
|
|
19
|
+
NAVIGATION_MODAL_SKELETON_ATTRIBUTE,
|
|
20
|
+
} from './navigation-modal-rules';
|
|
21
|
+
|
|
22
|
+
export type ModalLabels = Pick<ModalRoutes, 'close' | 'loading'>;
|
|
23
|
+
|
|
24
|
+
/** What may take focus inside the dialog — filtered for `disabled` and `tabindex="-1"` below. */
|
|
25
|
+
const FOCUSABLE =
|
|
26
|
+
'a[href], area[href], button, input, select, textarea, [tabindex], [contenteditable]';
|
|
27
|
+
|
|
28
|
+
const supportsPopover = (el: HTMLElement): boolean =>
|
|
29
|
+
typeof (el as { showPopover?: unknown }).showPopover === 'function';
|
|
30
|
+
|
|
31
|
+
/** The region the modal page's `<main>` goes in. */
|
|
32
|
+
export const bodyOf = (dialog: HTMLDialogElement): Element =>
|
|
33
|
+
dialog.querySelector(`[${NAVIGATION_MODAL_BODY_ATTRIBUTE}]`) ?? dialog;
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* A dialog with its body region and, when the document named the words for it, its close button:
|
|
37
|
+
* a plain `type="button"` that closes as Escape does (`onClose`) — never a `<form>`, which a page's
|
|
38
|
+
* own `dialog form` would find first. AFTER the body, so the first focusable thing is the page's.
|
|
39
|
+
*/
|
|
40
|
+
export function createModal(
|
|
41
|
+
doc: Document,
|
|
42
|
+
labels: ModalLabels | undefined,
|
|
43
|
+
onClose: () => void,
|
|
44
|
+
): HTMLDialogElement {
|
|
45
|
+
const dialog = doc.createElement('dialog');
|
|
46
|
+
dialog.setAttribute(NAVIGATION_MODAL_ATTRIBUTE, '');
|
|
47
|
+
// Light dismiss: Escape AND a press outside close it, as every modal does (`Dialog` included).
|
|
48
|
+
dialog.setAttribute('closedby', 'any');
|
|
49
|
+
dialog.setAttribute('aria-modal', 'true');
|
|
50
|
+
// Focusable itself, so focus is inside from the first frame — before there is content to take it.
|
|
51
|
+
dialog.setAttribute('tabindex', '-1');
|
|
52
|
+
const body = doc.createElement('div');
|
|
53
|
+
body.setAttribute(NAVIGATION_MODAL_BODY_ATTRIBUTE, '');
|
|
54
|
+
dialog.append(body);
|
|
55
|
+
if (labels !== undefined) {
|
|
56
|
+
const close = doc.createElement('button');
|
|
57
|
+
close.setAttribute('type', 'button');
|
|
58
|
+
close.setAttribute(NAVIGATION_MODAL_CLOSE_ATTRIBUTE, '');
|
|
59
|
+
close.setAttribute('aria-label', labels.close);
|
|
60
|
+
close.addEventListener('click', onClose);
|
|
61
|
+
dialog.append(close);
|
|
62
|
+
}
|
|
63
|
+
return dialog;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/** The body as "on its way": busy, a skeleton in place of content that is not there yet. */
|
|
67
|
+
export function markBusy(dialog: HTMLDialogElement, empty: boolean): void {
|
|
68
|
+
const body = bodyOf(dialog);
|
|
69
|
+
body.setAttribute('aria-busy', 'true');
|
|
70
|
+
if (!empty) return;
|
|
71
|
+
const skeleton = dialog.ownerDocument.createElement('div');
|
|
72
|
+
skeleton.setAttribute(NAVIGATION_MODAL_SKELETON_ATTRIBUTE, '');
|
|
73
|
+
skeleton.setAttribute('aria-hidden', 'true');
|
|
74
|
+
body.replaceChildren(skeleton);
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
export const markIdle = (dialog: HTMLDialogElement): void =>
|
|
78
|
+
bodyOf(dialog).removeAttribute('aria-busy');
|
|
79
|
+
|
|
80
|
+
/** Into the top layer, without the inert document (see the header); `showModal()` without popovers. */
|
|
81
|
+
export function raiseModal(dialog: HTMLDialogElement): void {
|
|
82
|
+
if (!supportsPopover(dialog)) {
|
|
83
|
+
dialog.showModal();
|
|
84
|
+
return;
|
|
85
|
+
}
|
|
86
|
+
dialog.setAttribute('popover', 'manual');
|
|
87
|
+
dialog.show();
|
|
88
|
+
dialog.showPopover();
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
/** Out of the top layer: the popover first, so no frame shows a backdrop over nothing. */
|
|
92
|
+
export function lowerModal(dialog: HTMLDialogElement): void {
|
|
93
|
+
if (dialog.hasAttribute('popover') && supportsPopover(dialog)) {
|
|
94
|
+
try {
|
|
95
|
+
dialog.hidePopover();
|
|
96
|
+
} catch {
|
|
97
|
+
// Already hidden (removed, or hidden by the browser): nothing is showing.
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
if (dialog.open) dialog.close();
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
const focusable = (dialog: HTMLDialogElement): HTMLElement[] =>
|
|
104
|
+
[...dialog.querySelectorAll(FOCUSABLE)].filter(
|
|
105
|
+
(el) =>
|
|
106
|
+
!el.hasAttribute('disabled') &&
|
|
107
|
+
el.getAttribute('tabindex') !== '-1' &&
|
|
108
|
+
el.getAttribute('type') !== 'hidden',
|
|
109
|
+
) as HTMLElement[];
|
|
110
|
+
|
|
111
|
+
/** As `showModal()` focuses: `[autofocus]`, else the first focusable thing, else the dialog. */
|
|
112
|
+
export function focusInto(dialog: HTMLDialogElement): void {
|
|
113
|
+
const target =
|
|
114
|
+
(dialog.querySelector('[autofocus]') as HTMLElement | null) ?? focusable(dialog)[0] ?? dialog;
|
|
115
|
+
target.focus?.({ preventScroll: true });
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
/**
|
|
119
|
+
* What `showModal()` gave for free and a popover does not: Tab stays inside, and — where the
|
|
120
|
+
* browser has no `closedby` — Escape closes. Returns the remover. Escape on something that is
|
|
121
|
+
* itself a dialog or popover inside (a `Dialog`, a menu) is that one's.
|
|
122
|
+
*/
|
|
123
|
+
export function keepModal(
|
|
124
|
+
doc: Document,
|
|
125
|
+
dialog: HTMLDialogElement,
|
|
126
|
+
onEscape: () => void,
|
|
127
|
+
): () => void {
|
|
128
|
+
if (!dialog.hasAttribute('popover')) return () => undefined;
|
|
129
|
+
const escapes = !('closedBy' in dialog);
|
|
130
|
+
const onKey = (event: KeyboardEvent): void => {
|
|
131
|
+
if (event.defaultPrevented) return;
|
|
132
|
+
const at = event.target as Element | null;
|
|
133
|
+
const inner = at?.closest?.('dialog[open], [popover]') ?? null;
|
|
134
|
+
if (event.key === 'Escape' && escapes && (inner === null || inner === dialog)) {
|
|
135
|
+
event.preventDefault();
|
|
136
|
+
onEscape();
|
|
137
|
+
return;
|
|
138
|
+
}
|
|
139
|
+
if (event.key !== 'Tab' || (inner !== null && inner !== dialog)) return;
|
|
140
|
+
const all = focusable(dialog);
|
|
141
|
+
const first = all[0];
|
|
142
|
+
const last = all[all.length - 1];
|
|
143
|
+
if (first === undefined || last === undefined) {
|
|
144
|
+
event.preventDefault();
|
|
145
|
+
dialog.focus();
|
|
146
|
+
return;
|
|
147
|
+
}
|
|
148
|
+
const active = doc.activeElement;
|
|
149
|
+
const inside = active !== null && dialog.contains(active);
|
|
150
|
+
if (
|
|
151
|
+
event.shiftKey ? !inside || active === first || active === dialog : !inside || active === last
|
|
152
|
+
) {
|
|
153
|
+
event.preventDefault();
|
|
154
|
+
(event.shiftKey ? last : first).focus();
|
|
155
|
+
}
|
|
156
|
+
};
|
|
157
|
+
doc.addEventListener('keydown', onKey as EventListener);
|
|
158
|
+
return () => doc.removeEventListener('keydown', onKey as EventListener);
|
|
159
|
+
}
|
|
@@ -16,6 +16,81 @@ export const NAVIGATION_PRESENTATION_META = 'ultimate-presentation';
|
|
|
16
16
|
/** On the `<dialog>` the router opens. `@ultimat3/ui`'s `global.scss` styles it, by this name. */
|
|
17
17
|
export const NAVIGATION_MODAL_ATTRIBUTE = 'data-x-modal';
|
|
18
18
|
|
|
19
|
+
/**
|
|
20
|
+
* `<meta name="ultimate-modals" content='{"routes":[…],"close":"…","loading":"…"}'>` — on every
|
|
21
|
+
* opted-in document of a surface with a `navigation: 'modal'` route: the patterns of the routes
|
|
22
|
+
* that present as a modal, so a click opens the dialog BEFORE their document arrives, and the
|
|
23
|
+
* frame's two words in the document's own locale. Still not the router's decision: the answer's
|
|
24
|
+
* `NAVIGATION_PRESENTATION_META` is, and an answer that is no modal takes the frame down.
|
|
25
|
+
*/
|
|
26
|
+
export const NAVIGATION_MODALS_META = 'ultimate-modals';
|
|
27
|
+
/** The dialog's content region; `aria-busy="true"` on it while the modal's document is on its way. */
|
|
28
|
+
export const NAVIGATION_MODAL_BODY_ATTRIBUTE = 'data-x-modal-body';
|
|
29
|
+
/** The placeholder inside a busy body: no words, so nothing to translate; hidden from AT. */
|
|
30
|
+
export const NAVIGATION_MODAL_SKELETON_ATTRIBUTE = 'data-x-modal-skeleton';
|
|
31
|
+
/** The frame's close button — a `<form method="dialog">` button, so it closes without the router. */
|
|
32
|
+
export const NAVIGATION_MODAL_CLOSE_ATTRIBUTE = 'data-x-modal-close';
|
|
33
|
+
|
|
34
|
+
/** What the modals meta says. `routes`: most specific first; a `!` prefix is a page that is not. */
|
|
35
|
+
export interface ModalRoutes {
|
|
36
|
+
readonly routes: readonly string[];
|
|
37
|
+
/** The close button's accessible name. */
|
|
38
|
+
readonly close: string;
|
|
39
|
+
/** The busy dialog's accessible name until the modal's own heading names it. */
|
|
40
|
+
readonly loading: string;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/** The meta's content read back, or `undefined` for anything malformed — never a throw. */
|
|
44
|
+
export function parseModalRoutes(content: string | null): ModalRoutes | undefined {
|
|
45
|
+
if (content === null) return undefined;
|
|
46
|
+
let said: unknown;
|
|
47
|
+
try {
|
|
48
|
+
said = JSON.parse(content);
|
|
49
|
+
} catch {
|
|
50
|
+
return undefined;
|
|
51
|
+
}
|
|
52
|
+
if (typeof said !== 'object' || said === null) return undefined;
|
|
53
|
+
const { routes, close, loading } = said as Record<string, unknown>;
|
|
54
|
+
if (!Array.isArray(routes) || !routes.every((r) => typeof r === 'string')) return undefined;
|
|
55
|
+
if (typeof close !== 'string' || typeof loading !== 'string') return undefined;
|
|
56
|
+
return { routes, close, loading };
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/** One pattern (`/runs/:id/peek`, `/docs/*rest`) against a pathname's decoded segments. */
|
|
60
|
+
function patternMatches(pattern: string, segments: readonly string[]): boolean {
|
|
61
|
+
const parts = pattern.split('/').filter((part) => part.length > 0);
|
|
62
|
+
for (const [i, part] of parts.entries()) {
|
|
63
|
+
if (part.startsWith('*')) return true;
|
|
64
|
+
const segment = segments[i];
|
|
65
|
+
if (segment === undefined) return false;
|
|
66
|
+
if (!part.startsWith(':') && part !== segment) return false;
|
|
67
|
+
}
|
|
68
|
+
return parts.length === segments.length;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/**
|
|
72
|
+
* Whether `url`'s path is a route the meta names a modal: the FIRST pattern that matches decides,
|
|
73
|
+
* as the list is most specific first — a more specific page (`!/runs/new`) beats `/runs/:id`.
|
|
74
|
+
* A path that does not decode is no modal: the router then waits for the answer, as before.
|
|
75
|
+
*/
|
|
76
|
+
export function declaresModal(url: string, routes: readonly string[]): boolean {
|
|
77
|
+
if (!URL.canParse(url)) return false;
|
|
78
|
+
let segments: string[];
|
|
79
|
+
try {
|
|
80
|
+
segments = new URL(url).pathname
|
|
81
|
+
.split('/')
|
|
82
|
+
.filter((segment) => segment.length > 0)
|
|
83
|
+
.map(decodeURIComponent);
|
|
84
|
+
} catch {
|
|
85
|
+
return false;
|
|
86
|
+
}
|
|
87
|
+
for (const route of routes) {
|
|
88
|
+
const not = route.startsWith('!');
|
|
89
|
+
if (patternMatches(not ? route.slice(1) : route, segments)) return !not;
|
|
90
|
+
}
|
|
91
|
+
return false;
|
|
92
|
+
}
|
|
93
|
+
|
|
19
94
|
/**
|
|
20
95
|
* The modal a hash addresses — `#/runs/new?x=1` → `/runs/new?x=1` — or `null` for every other
|
|
21
96
|
* fragment (`#below`, `#`, `#//host`, one that leaves the origin). NEVER throws: a stale or
|
package/src/navigation-modal.ts
CHANGED
|
@@ -1,21 +1,36 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* The route-presented modal: ONE native `<dialog>` over the page on screen, holding the `<main>` of
|
|
3
3
|
* a page whose route declared `navigation: 'modal'`. The page beneath is never touched — its
|
|
4
|
-
* islands, socket and state stay live,
|
|
5
|
-
* the URL's hash (`/runs#/runs/new`), so a reload, Back/Forward and a pasted link
|
|
6
|
-
*
|
|
4
|
+
* islands, socket and state stay live, out of reach behind the top layer — and the modal is
|
|
5
|
+
* addressed by the URL's hash (`/runs#/runs/new`), so a reload, Back/Forward and a pasted link
|
|
6
|
+
* reopen it. A route the document names a modal (`NAVIGATION_MODALS_META`) opens on the click, as a
|
|
7
|
+
* busy frame the answer fills. `navigation.ts` decides WHEN (`navigation-modal-rules.ts`); this
|
|
8
|
+
* file only shows and hides, and `navigation-modal-frame.ts` is the dialog's DOM.
|
|
7
9
|
*/
|
|
8
10
|
|
|
9
11
|
import { disposeIslands } from './island-dispose';
|
|
10
12
|
import { metaOf } from './navigation-fetch';
|
|
11
13
|
import { entryOf, STATE_KEY, withoutFragment } from './navigation-history';
|
|
12
14
|
import {
|
|
15
|
+
bodyOf,
|
|
16
|
+
createModal,
|
|
17
|
+
focusInto,
|
|
18
|
+
keepModal,
|
|
19
|
+
lowerModal,
|
|
20
|
+
type ModalLabels,
|
|
21
|
+
markBusy,
|
|
22
|
+
markIdle,
|
|
23
|
+
raiseModal,
|
|
24
|
+
} from './navigation-modal-frame';
|
|
25
|
+
import {
|
|
26
|
+
declaresModal,
|
|
13
27
|
leaveModal,
|
|
14
28
|
modalLocation,
|
|
15
|
-
|
|
29
|
+
NAVIGATION_MODALS_META,
|
|
16
30
|
NAVIGATION_PRESENTATION_META,
|
|
17
31
|
type Presentation,
|
|
18
32
|
type PresentationFacts,
|
|
33
|
+
parseModalRoutes,
|
|
19
34
|
presentation,
|
|
20
35
|
} from './navigation-modal-rules';
|
|
21
36
|
import { runScripts } from './navigation-swap';
|
|
@@ -43,6 +58,16 @@ export interface ModalController {
|
|
|
43
58
|
landed: string,
|
|
44
59
|
history: 'push' | 'replace' | 'none',
|
|
45
60
|
): Promise<void>;
|
|
61
|
+
/**
|
|
62
|
+
* A GET of `url` is starting: when the document names it a modal, the dialog opens NOW — a busy
|
|
63
|
+
* frame, or the open modal marked busy — and `true` is answered. Nothing is pushed until
|
|
64
|
+
* `present`; closing it before then aborts the request (the controller's `abandon`).
|
|
65
|
+
*/
|
|
66
|
+
loading(url: string): boolean;
|
|
67
|
+
/** A newer navigation took over: the frame is no longer this request's to abort. */
|
|
68
|
+
release(): void;
|
|
69
|
+
/** The request ended without `present`: a frame with no content goes, an open modal is idle. */
|
|
70
|
+
settle(): void;
|
|
46
71
|
/** Escape, a native close, a link to the page beneath: Back when this router pushed the entry. */
|
|
47
72
|
close(): void;
|
|
48
73
|
/** The hash names no modal for this tab: dropped in place, the page stays. */
|
|
@@ -71,23 +96,33 @@ function rebase(root: Element, landed: string): void {
|
|
|
71
96
|
|
|
72
97
|
/** Named by its first heading, as `@ultimat3/ui`'s `Dialog` is by its title. */
|
|
73
98
|
function label(dialog: HTMLDialogElement, title: string): void {
|
|
74
|
-
const heading = dialog.querySelector('h1, h2');
|
|
99
|
+
const heading = bodyOf(dialog).querySelector('h1, h2');
|
|
75
100
|
if (heading === null) {
|
|
101
|
+
dialog.removeAttribute('aria-labelledby');
|
|
76
102
|
dialog.setAttribute('aria-label', title);
|
|
77
103
|
return;
|
|
78
104
|
}
|
|
79
105
|
if (heading.id === '') heading.id = 'x-modal-title';
|
|
106
|
+
dialog.removeAttribute('aria-label');
|
|
80
107
|
dialog.setAttribute('aria-labelledby', heading.id);
|
|
81
108
|
}
|
|
82
109
|
|
|
110
|
+
/**
|
|
111
|
+
* `abandon` aborts the request a frame is waiting on — called when the visitor closes it (Escape,
|
|
112
|
+
* the close button, Back) before its answer arrived, never when the router itself moves on.
|
|
113
|
+
*/
|
|
83
114
|
export function modalController(
|
|
84
115
|
win: Window,
|
|
85
116
|
rendered: () => string,
|
|
86
117
|
ran: Set<string>,
|
|
87
118
|
head: WeakSet<Element>,
|
|
119
|
+
abandon: () => void = () => undefined,
|
|
88
120
|
): ModalController {
|
|
89
121
|
const doc = win.document;
|
|
90
122
|
let dialog: HTMLDialogElement | undefined;
|
|
123
|
+
/** A request is on its way to fill the dialog: closing it now aborts that request. */
|
|
124
|
+
let pending = false;
|
|
125
|
+
let unkeep: () => void = () => undefined;
|
|
91
126
|
/** The open modal's path (`/runs/new`), as the URL bar shows it. */
|
|
92
127
|
let openAt: string | undefined;
|
|
93
128
|
let opener: Element | null = null;
|
|
@@ -102,13 +137,18 @@ export function modalController(
|
|
|
102
137
|
};
|
|
103
138
|
|
|
104
139
|
const dismiss = (): void => {
|
|
140
|
+
if (pending) {
|
|
141
|
+
pending = false;
|
|
142
|
+
abandon();
|
|
143
|
+
}
|
|
105
144
|
const open = dialog;
|
|
106
145
|
if (open === undefined) return;
|
|
107
146
|
dialog = undefined;
|
|
108
147
|
openAt = undefined;
|
|
109
148
|
leaving = false;
|
|
149
|
+
unkeep();
|
|
110
150
|
disposeIslands(open);
|
|
111
|
-
|
|
151
|
+
lowerModal(open);
|
|
112
152
|
open.remove();
|
|
113
153
|
doc.title = title;
|
|
114
154
|
// Back where the visitor was: the link or button that opened it, when it is still there.
|
|
@@ -127,6 +167,15 @@ export function modalController(
|
|
|
127
167
|
};
|
|
128
168
|
|
|
129
169
|
const close = (): void => {
|
|
170
|
+
// A frame with no content yet: nothing was pushed, so closing is the request abandoned.
|
|
171
|
+
if (openAt === undefined && pending) {
|
|
172
|
+
dismiss();
|
|
173
|
+
return;
|
|
174
|
+
}
|
|
175
|
+
if (pending) {
|
|
176
|
+
pending = false;
|
|
177
|
+
abandon();
|
|
178
|
+
}
|
|
130
179
|
if (openAt === undefined || leaving) return;
|
|
131
180
|
if (owned()) {
|
|
132
181
|
leaving = true;
|
|
@@ -145,7 +194,10 @@ export function modalController(
|
|
|
145
194
|
// A `<form method="dialog">`, or an Escape the browser would not let be cancelled: the dialog
|
|
146
195
|
// closed itself, and the URL must follow. One it closed for `dismiss` is no longer `dialog`.
|
|
147
196
|
const onClose = (event: Event): void => {
|
|
148
|
-
if (event.target
|
|
197
|
+
if (event.target !== dialog || dialog === undefined || dialog.open) return;
|
|
198
|
+
// Closed by the browser, still a popover: out of the top layer now, not when history settles.
|
|
199
|
+
lowerModal(dialog);
|
|
200
|
+
close();
|
|
149
201
|
};
|
|
150
202
|
// A press on the backdrop. `closedby="any"` has the browser close the dialog itself (and fire
|
|
151
203
|
// `close`, read above); a browser without it reports the press as a click whose target is the
|
|
@@ -171,6 +223,45 @@ export function modalController(
|
|
|
171
223
|
if (began && outside(event)) close();
|
|
172
224
|
};
|
|
173
225
|
|
|
226
|
+
/** A new dialog, its listeners, opened into the top layer: the page's title and focus kept. */
|
|
227
|
+
const raise = (labels: ModalLabels | undefined): HTMLDialogElement => {
|
|
228
|
+
opener = doc.activeElement;
|
|
229
|
+
title = doc.title;
|
|
230
|
+
const open = createModal(doc, labels, () => close());
|
|
231
|
+
open.addEventListener('cancel', onCancel);
|
|
232
|
+
open.addEventListener('close', onClose);
|
|
233
|
+
open.addEventListener('pointerdown', onPointerDown);
|
|
234
|
+
open.addEventListener('click', onClick);
|
|
235
|
+
doc.body.append(open);
|
|
236
|
+
raiseModal(open);
|
|
237
|
+
unkeep = keepModal(doc, open, close);
|
|
238
|
+
return open;
|
|
239
|
+
};
|
|
240
|
+
|
|
241
|
+
const loading = (url: string): boolean => {
|
|
242
|
+
const named = parseModalRoutes(metaOf(doc, NAVIGATION_MODALS_META));
|
|
243
|
+
if (named === undefined || !declaresModal(url, named.routes)) return false;
|
|
244
|
+
pending = true;
|
|
245
|
+
if (dialog !== undefined) {
|
|
246
|
+
// One modal at a time: the open one stays on screen, busy, until the next one replaces it.
|
|
247
|
+
markBusy(dialog, false);
|
|
248
|
+
return true;
|
|
249
|
+
}
|
|
250
|
+
dialog = raise(named);
|
|
251
|
+
markBusy(dialog, true);
|
|
252
|
+
dialog.setAttribute('aria-label', named.loading);
|
|
253
|
+
// Inside from the first frame: Escape, Tab and a screen reader are already the dialog's.
|
|
254
|
+
dialog.focus({ preventScroll: true });
|
|
255
|
+
return true;
|
|
256
|
+
};
|
|
257
|
+
|
|
258
|
+
const settle = (): void => {
|
|
259
|
+
pending = false;
|
|
260
|
+
if (dialog === undefined) return;
|
|
261
|
+
if (openAt === undefined) dismiss();
|
|
262
|
+
else markIdle(dialog);
|
|
263
|
+
};
|
|
264
|
+
|
|
174
265
|
const present = async (
|
|
175
266
|
next: Document,
|
|
176
267
|
at: string,
|
|
@@ -183,34 +274,26 @@ export function modalController(
|
|
|
183
274
|
rebase(source, landed);
|
|
184
275
|
const content = [...source.childNodes, ...outside].map((node) => doc.adoptNode(node));
|
|
185
276
|
const headScripts = [...next.head.querySelectorAll('script[src]')] as HTMLScriptElement[];
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
// Light dismiss: Escape AND a press outside close it, as every modal does (`Dialog` included).
|
|
193
|
-
open.setAttribute('closedby', 'any');
|
|
194
|
-
open.addEventListener('cancel', onCancel);
|
|
195
|
-
open.addEventListener('close', onClose);
|
|
196
|
-
open.addEventListener('pointerdown', onPointerDown);
|
|
197
|
-
open.addEventListener('click', onClick);
|
|
198
|
-
doc.body.append(open);
|
|
199
|
-
} else {
|
|
200
|
-
// One modal at a time: the next address replaces this one's content, in the same dialog.
|
|
201
|
-
// Closed as `dismiss` closes it — no longer `dialog` — so its own `close` event, queued or
|
|
202
|
-
// (in any engine that fires it at once) synchronous, is never read as the visitor closing.
|
|
203
|
-
disposeIslands(open);
|
|
204
|
-
dialog = undefined;
|
|
205
|
-
if (open.open) open.close();
|
|
206
|
-
}
|
|
277
|
+
// The frame the click opened, the modal already open, or — for a document that names no
|
|
278
|
+
// modal routes — a dialog opened now. Never closed and reopened: its `close` event would read
|
|
279
|
+
// as the visitor closing, and reopening is the work this frame exists to do once.
|
|
280
|
+
const fresh = dialog === undefined;
|
|
281
|
+
const open = dialog ?? raise(parseModalRoutes(metaOf(doc, NAVIGATION_MODALS_META)));
|
|
282
|
+
disposeIslands(open);
|
|
207
283
|
dialog = open;
|
|
208
284
|
openAt = at;
|
|
209
285
|
leaving = false;
|
|
210
|
-
|
|
286
|
+
pending = false;
|
|
287
|
+
const body = bodyOf(open);
|
|
288
|
+
// Focus moves in as `showModal()` moved it — unless the visitor already moved it to the close
|
|
289
|
+
// button while the frame waited.
|
|
290
|
+
const active = doc.activeElement;
|
|
291
|
+
const moveFocus = fresh || active === null || active === open || body.contains(active);
|
|
292
|
+
body.replaceChildren(...content);
|
|
293
|
+
markIdle(open);
|
|
211
294
|
label(open, next.title);
|
|
212
295
|
doc.title = next.title;
|
|
213
|
-
|
|
296
|
+
if (moveFocus || doc.activeElement?.isConnected !== true) focusInto(open);
|
|
214
297
|
if (history !== 'none') {
|
|
215
298
|
const state = {
|
|
216
299
|
[STATE_KEY]: { scroll: [win.scrollX, win.scrollY], doc: rendered(), modal: at },
|
|
@@ -251,6 +334,11 @@ export function modalController(
|
|
|
251
334
|
owned: owned(),
|
|
252
335
|
}),
|
|
253
336
|
present,
|
|
337
|
+
loading,
|
|
338
|
+
release() {
|
|
339
|
+
pending = false;
|
|
340
|
+
},
|
|
341
|
+
settle,
|
|
254
342
|
close,
|
|
255
343
|
clear() {
|
|
256
344
|
clearEntry();
|
package/src/navigation-tags.ts
CHANGED
|
@@ -1,13 +1,17 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* What an opted-in document carries for the client router:
|
|
3
|
-
*
|
|
4
|
-
*
|
|
5
|
-
* surface that did not opt in gets none of them, so it pays no byte.
|
|
2
|
+
* What an opted-in document carries for the client router: its surface, its build (the skew check,
|
|
3
|
+
* deduped with realtime's sync tags by key), the router's script, a modal route's presentation, and
|
|
4
|
+
* the surface's modal routes so a click opens the dialog before the answer. Nothing elsewhere.
|
|
6
5
|
*/
|
|
7
6
|
|
|
8
|
-
import { CLIENT_BUILD_META } from '@ultimat3/core';
|
|
7
|
+
import { CLIENT_BUILD_META, routeRank } from '@ultimat3/core';
|
|
8
|
+
import { byCodeUnit } from './code-unit-order';
|
|
9
9
|
import type { HeadTag } from './head';
|
|
10
|
-
import {
|
|
10
|
+
import {
|
|
11
|
+
type ModalRoutes,
|
|
12
|
+
NAVIGATION_MODALS_META,
|
|
13
|
+
NAVIGATION_PRESENTATION_META,
|
|
14
|
+
} from './navigation-modal-rules';
|
|
11
15
|
import { NAVIGATION_META } from './navigation-rules';
|
|
12
16
|
|
|
13
17
|
export interface ClientNavigationHead {
|
|
@@ -18,6 +22,41 @@ export interface ClientNavigationHead {
|
|
|
18
22
|
readonly scriptUrl: string;
|
|
19
23
|
/** The route declared `navigation: 'modal'`: the router shows it over the page beneath. */
|
|
20
24
|
readonly modal?: boolean;
|
|
25
|
+
/** The surface's modal routes (`modalRouteList`) and the frame's words, in the page's locale. */
|
|
26
|
+
readonly modals?: ModalRoutes;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/** Whether some pathname matches both patterns — segment by segment, a catch-all takes the rest. */
|
|
30
|
+
function overlap(a: string, b: string): boolean {
|
|
31
|
+
const left = a.split('/').filter((s) => s.length > 0);
|
|
32
|
+
const right = b.split('/').filter((s) => s.length > 0);
|
|
33
|
+
for (let i = 0; i < Math.max(left.length, right.length); i += 1) {
|
|
34
|
+
const l = left[i];
|
|
35
|
+
const r = right[i];
|
|
36
|
+
if (l?.startsWith('*') === true || r?.startsWith('*') === true) return true;
|
|
37
|
+
if (l === undefined || r === undefined) return false;
|
|
38
|
+
if (!l.startsWith(':') && !r.startsWith(':') && l !== r) return false;
|
|
39
|
+
}
|
|
40
|
+
return true;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/**
|
|
44
|
+
* The modals meta's `routes`: every modal pattern, plus — as `!<pattern>` — every page that is NOT
|
|
45
|
+
* a modal yet matches a path a modal pattern matches and outranks it (`routeRank`, the request
|
|
46
|
+
* router's own order: `/runs/new/peek` before `/runs/:id/peek`). Most specific first, so the
|
|
47
|
+
* router's first match is the route the server will run. Deterministic: ties by code unit.
|
|
48
|
+
*/
|
|
49
|
+
export function modalRouteList(
|
|
50
|
+
pages: readonly { readonly path: string; readonly modal: boolean }[],
|
|
51
|
+
): string[] {
|
|
52
|
+
const modals = pages.filter((page) => page.modal).map((page) => page.path);
|
|
53
|
+
const shadows = pages
|
|
54
|
+
.filter((page) => !page.modal)
|
|
55
|
+
.map((page) => page.path)
|
|
56
|
+
.filter((path) => modals.some((m) => routeRank(path) > routeRank(m) && overlap(path, m)));
|
|
57
|
+
return [...modals, ...shadows]
|
|
58
|
+
.sort((a, b) => routeRank(b) - routeRank(a) || byCodeUnit(a, b))
|
|
59
|
+
.map((path) => (modals.includes(path) ? path : `!${path}`));
|
|
21
60
|
}
|
|
22
61
|
|
|
23
62
|
export function clientNavigationTags(head: ClientNavigationHead): readonly HeadTag[] {
|
|
@@ -41,6 +80,15 @@ export function clientNavigationTags(head: ClientNavigationHead): readonly HeadT
|
|
|
41
80
|
},
|
|
42
81
|
]
|
|
43
82
|
: []),
|
|
83
|
+
...(head.modals !== undefined && head.modals.routes.length > 0
|
|
84
|
+
? [
|
|
85
|
+
{
|
|
86
|
+
kind: 'meta' as const,
|
|
87
|
+
key: `meta:${NAVIGATION_MODALS_META}`,
|
|
88
|
+
attrs: { name: NAVIGATION_MODALS_META, content: JSON.stringify(head.modals) },
|
|
89
|
+
},
|
|
90
|
+
]
|
|
91
|
+
: []),
|
|
44
92
|
// Deferred, in `<head>`: it runs once, after parsing, and survives every swap of the body.
|
|
45
93
|
{
|
|
46
94
|
kind: 'script',
|
package/src/navigation.ts
CHANGED
|
@@ -125,7 +125,14 @@ export function startNavigation(win: RouterWindow = window): NavigationRouter |
|
|
|
125
125
|
const tabs = tabSync(win, () => cache.clear());
|
|
126
126
|
const forget = tabs.forget;
|
|
127
127
|
const scroll = scrollKeeper(win, () => rendered);
|
|
128
|
-
|
|
128
|
+
// Closing a frame before its answer arrived abandons the request it waits on.
|
|
129
|
+
const modal = modalController(
|
|
130
|
+
win,
|
|
131
|
+
() => rendered,
|
|
132
|
+
ran,
|
|
133
|
+
owned,
|
|
134
|
+
() => inflight?.abort(),
|
|
135
|
+
);
|
|
129
136
|
|
|
130
137
|
const request = (url: string, purpose: 'soft' | 'prefetch', init?: RequestInit) =>
|
|
131
138
|
fetchDocument(win, doc, url, purpose, init);
|
|
@@ -187,8 +194,11 @@ export function startNavigation(win: RouterWindow = window): NavigationRouter |
|
|
|
187
194
|
inflight?.abort();
|
|
188
195
|
const mine = new AbortController();
|
|
189
196
|
inflight = mine;
|
|
197
|
+
modal.release();
|
|
190
198
|
win.clearTimeout(intent);
|
|
191
199
|
navigatingTo = withoutFragment(url);
|
|
200
|
+
// A route the document names a modal opens NOW, in the click's own task; the answer fills it.
|
|
201
|
+
if (method === 'GET' && navigatingTo !== rendered) modal.loading(url);
|
|
192
202
|
if (options.history !== 'none') scroll.save();
|
|
193
203
|
const progress = win.setTimeout(
|
|
194
204
|
() => doc.documentElement.setAttribute(NAVIGATING_ATTRIBUTE, ''),
|
|
@@ -219,6 +229,7 @@ export function startNavigation(win: RouterWindow = window): NavigationRouter |
|
|
|
219
229
|
movesTab: answerMovesTab(facts),
|
|
220
230
|
});
|
|
221
231
|
if (shown === 'degrade') {
|
|
232
|
+
modal.release();
|
|
222
233
|
modal.clear();
|
|
223
234
|
return;
|
|
224
235
|
}
|
|
@@ -266,6 +277,7 @@ export function startNavigation(win: RouterWindow = window): NavigationRouter |
|
|
|
266
277
|
backing = true;
|
|
267
278
|
win.history.back();
|
|
268
279
|
}
|
|
280
|
+
modal.release();
|
|
269
281
|
modal.dismiss();
|
|
270
282
|
let swapped: ReturnType<typeof swapDocument> | undefined;
|
|
271
283
|
await transition(
|
|
@@ -317,6 +329,8 @@ export function startNavigation(win: RouterWindow = window): NavigationRouter |
|
|
|
317
329
|
if (inflight === mine) {
|
|
318
330
|
inflight = undefined;
|
|
319
331
|
navigatingTo = undefined;
|
|
332
|
+
// Whatever the answer was, a frame nothing filled goes; an open modal is no longer busy.
|
|
333
|
+
modal.settle();
|
|
320
334
|
doc.documentElement.removeAttribute(NAVIGATING_ATTRIBUTE);
|
|
321
335
|
}
|
|
322
336
|
}
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `defineRoute({ navigation })` read back: the ONE spelling per meaning — `'prefetch'`,
|
|
3
|
+
* `'document'`, `'modal'`, or `{ modal: true, prefetch: 'intent' }` for a modal that may also be
|
|
4
|
+
* fetched on intent — and the two questions every reader asks of it (which mode, prefetched or
|
|
5
|
+
* not), so no reader compares against a string the object form would slip past.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* How the client router treats this page (`navigation: { client }` surfaces only). Absent: swapped
|
|
10
|
+
* in on a click, never fetched before one. `'prefetch'`: may also be fetched on intent — only for a
|
|
11
|
+
* page whose GET does nothing but render. `'document'`: always a real document load, the route run
|
|
12
|
+
* once by the browser — for a GET that records something (an open, a download, a token consumed).
|
|
13
|
+
* `'modal'`: presented OVER the page a visitor came from, addressed by the hash
|
|
14
|
+
* (`/runs#/runs/new`) — a create form, a confirmation; a full load of its own URL is still the
|
|
15
|
+
* whole page. Never fetched before a click: a modal is often a form, and its GET may prepare one.
|
|
16
|
+
* The server enforces all three before `load` (`@ultimat3/http`'s navigation gate) and names a
|
|
17
|
+
* modal in its document (`NAVIGATION_PRESENTATION_META`).
|
|
18
|
+
*/
|
|
19
|
+
export type RouteNavigationMode = 'prefetch' | 'document' | 'modal';
|
|
20
|
+
|
|
21
|
+
export const ROUTE_NAVIGATION_MODES: readonly RouteNavigationMode[] = [
|
|
22
|
+
'prefetch',
|
|
23
|
+
'document',
|
|
24
|
+
'modal',
|
|
25
|
+
];
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* A `'modal'` whose GET only reads (a peek, a detail view), so it may be fetched on hover, focus
|
|
29
|
+
* or touchstart, as `'prefetch'` pages are — the click then fills the frame from the answer
|
|
30
|
+
* already held. `'intent'` is the one value: the router's own hover/focus/touchstart prefetch.
|
|
31
|
+
*/
|
|
32
|
+
export interface RouteModalPrefetch {
|
|
33
|
+
readonly modal: true;
|
|
34
|
+
readonly prefetch: 'intent';
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/** What `navigation:` takes. */
|
|
38
|
+
export type RouteNavigationDeclaration = RouteNavigationMode | RouteModalPrefetch;
|
|
39
|
+
|
|
40
|
+
const MODAL_PREFETCH_KEYS = ['modal', 'prefetch'] as const;
|
|
41
|
+
|
|
42
|
+
/** Exactly one of the four spellings — an object with anything more, or less, is not one. */
|
|
43
|
+
export function isRouteNavigation(value: unknown): value is RouteNavigationDeclaration {
|
|
44
|
+
if (typeof value === 'string') return ROUTE_NAVIGATION_MODES.some((mode) => mode === value);
|
|
45
|
+
if (typeof value !== 'object' || value === null || Array.isArray(value)) return false;
|
|
46
|
+
const keys = Object.keys(value);
|
|
47
|
+
const said = value as Record<string, unknown>;
|
|
48
|
+
return (
|
|
49
|
+
keys.length === MODAL_PREFETCH_KEYS.length &&
|
|
50
|
+
MODAL_PREFETCH_KEYS.every((key) => keys.includes(key)) &&
|
|
51
|
+
said['modal'] === true &&
|
|
52
|
+
said['prefetch'] === 'intent'
|
|
53
|
+
);
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/** The mode a declaration says; the object form is a `'modal'`. */
|
|
57
|
+
export const navigationModeOf = (
|
|
58
|
+
declared: RouteNavigationDeclaration | undefined,
|
|
59
|
+
): RouteNavigationMode | undefined =>
|
|
60
|
+
declared === undefined || typeof declared === 'string' ? declared : 'modal';
|
|
61
|
+
|
|
62
|
+
/** Whether the server answers a prefetch of this page as a visit (`RouteNavigation.prefetch`). */
|
|
63
|
+
export const navigationPrefetches = (declared: RouteNavigationDeclaration | undefined): boolean =>
|
|
64
|
+
declared === 'prefetch' || (typeof declared === 'object' && declared.prefetch === 'intent');
|
package/src/route.ts
CHANGED
|
@@ -29,6 +29,11 @@ import {
|
|
|
29
29
|
import type { IslandSpec } from './island';
|
|
30
30
|
import { drainDeclaredIslands } from './island';
|
|
31
31
|
import { assertModeShape } from './modes';
|
|
32
|
+
import {
|
|
33
|
+
isRouteNavigation,
|
|
34
|
+
ROUTE_NAVIGATION_MODES,
|
|
35
|
+
type RouteNavigationDeclaration,
|
|
36
|
+
} from './route-navigation';
|
|
32
37
|
import { isErrorStatus, routeStatusOf } from './route-status';
|
|
33
38
|
|
|
34
39
|
/**
|
|
@@ -234,25 +239,6 @@ export type LoadRequirement<TData> = RouteContext extends TData
|
|
|
234
239
|
*/
|
|
235
240
|
export type RouteCache = 'no-store' | Omit<CacheHint, 'tags'>;
|
|
236
241
|
|
|
237
|
-
/**
|
|
238
|
-
* How the client router treats this page (`navigation: { client }` surfaces only). Absent: swapped
|
|
239
|
-
* in on a click, never fetched before one. `'prefetch'`: may also be fetched on intent — only for a
|
|
240
|
-
* page whose GET does nothing but render. `'document'`: always a real document load, the route run
|
|
241
|
-
* once by the browser — for a GET that records something (an open, a download, a token consumed).
|
|
242
|
-
* `'modal'`: presented OVER the page a visitor came from, addressed by the hash
|
|
243
|
-
* (`/runs#/runs/new`) — a create form, a confirmation; a full load of its own URL is still the
|
|
244
|
-
* whole page. Never fetched before a click. The server enforces all three before `load`
|
|
245
|
-
* (`@ultimat3/http`'s navigation gate) and names a modal in its document
|
|
246
|
-
* (`NAVIGATION_PRESENTATION_META`).
|
|
247
|
-
*/
|
|
248
|
-
export type RouteNavigationMode = 'prefetch' | 'document' | 'modal';
|
|
249
|
-
|
|
250
|
-
export const ROUTE_NAVIGATION_MODES: readonly RouteNavigationMode[] = [
|
|
251
|
-
'prefetch',
|
|
252
|
-
'document',
|
|
253
|
-
'modal',
|
|
254
|
-
];
|
|
255
|
-
|
|
256
242
|
/** The input shape of `defineRoute` — exactly the contract's twelve keys, nothing else. */
|
|
257
243
|
export interface RouteDefinition<TData = RouteData> {
|
|
258
244
|
readonly render: RenderMode;
|
|
@@ -284,8 +270,11 @@ export interface RouteDefinition<TData = RouteData> {
|
|
|
284
270
|
* Never on `static`: a file on disk has no process to answer a `POST`.
|
|
285
271
|
*/
|
|
286
272
|
readonly post?: string;
|
|
287
|
-
/**
|
|
288
|
-
|
|
273
|
+
/**
|
|
274
|
+
* See `RouteNavigationMode` and `RouteModalPrefetch` (`route-navigation.ts`). Refused on a
|
|
275
|
+
* surface without client navigation, at boot.
|
|
276
|
+
*/
|
|
277
|
+
readonly navigation?: RouteNavigationDeclaration;
|
|
289
278
|
}
|
|
290
279
|
|
|
291
280
|
/**
|
|
@@ -368,9 +357,9 @@ export function defineRoute<TData = RouteData>(
|
|
|
368
357
|
}
|
|
369
358
|
|
|
370
359
|
if (def.post !== undefined) assertPostBinding(def.post, def.render);
|
|
371
|
-
if (def.navigation !== undefined && !
|
|
360
|
+
if (def.navigation !== undefined && !isRouteNavigation(def.navigation)) {
|
|
372
361
|
throw new RouteNavigationInvalidError(
|
|
373
|
-
`navigation: ${renderCauseValue(def.navigation)} is not one of ${ROUTE_NAVIGATION_MODES.join(', ')}`,
|
|
362
|
+
`navigation: ${renderCauseValue(def.navigation)} is not one of ${ROUTE_NAVIGATION_MODES.join(', ')} or { modal: true, prefetch: 'intent' }`,
|
|
374
363
|
);
|
|
375
364
|
}
|
|
376
365
|
|