@ultimat3/render 25.2.0 → 26.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.
@@ -0,0 +1,233 @@
1
+ /**
2
+ * The route-presented modal: ONE native `<dialog>` over the page on screen, holding the `<main>` of
3
+ * a page whose route declared `navigation: 'modal'`. The page beneath is never touched — its
4
+ * islands, socket and state stay live, `showModal()` makes it inert — and the modal is addressed by
5
+ * the URL's hash (`/runs#/runs/new`), so a reload, Back/Forward and a pasted link reopen it.
6
+ * `navigation.ts` decides WHEN (`navigation-modal-rules.ts`); this file only shows and hides.
7
+ */
8
+
9
+ import { metaOf } from './navigation-fetch';
10
+ import { entryOf, STATE_KEY, withoutFragment } from './navigation-history';
11
+ import {
12
+ leaveModal,
13
+ modalLocation,
14
+ NAVIGATION_MODAL_ATTRIBUTE,
15
+ NAVIGATION_PRESENTATION_META,
16
+ type Presentation,
17
+ type PresentationFacts,
18
+ presentation,
19
+ } from './navigation-modal-rules';
20
+ import { disposeIslands, runScripts } from './navigation-swap';
21
+
22
+ export interface ModalController {
23
+ /** The open modal's address (`/runs/new`), or `undefined`. */
24
+ readonly address: string | undefined;
25
+ /** The entry on screen is the one this router pushed for the open modal. */
26
+ owned(): boolean;
27
+ /** `presentation`, with what this tab knows filled in: the answer's meta, the open modal. */
28
+ presentationOf(
29
+ next: Document | null,
30
+ landed: string,
31
+ facts: Omit<PresentationFacts, 'modalPage' | 'modalOpen' | 'landed' | 'rendered'>,
32
+ ): Presentation;
33
+ /** `leaveModal` for a page answered over the open modal; `undefined` with none open. */
34
+ leaveFor(
35
+ history: 'push' | 'replace' | 'none' | undefined,
36
+ landed: string,
37
+ ): 'back' | 'replace' | 'none' | undefined;
38
+ /** Shows `next`'s `<main>` as the modal at `address`; its stylesheets are already loaded. */
39
+ present(
40
+ next: Document,
41
+ address: string,
42
+ landed: string,
43
+ history: 'push' | 'replace' | 'none',
44
+ ): Promise<void>;
45
+ /** Escape, a native close, a link to the page beneath: Back when this router pushed the entry. */
46
+ close(): void;
47
+ /** The hash names no modal for this tab: dropped in place, the page stays. */
48
+ clear(): void;
49
+ /** The DOM half only, once history already says closed. */
50
+ dismiss(): void;
51
+ }
52
+
53
+ /**
54
+ * Hrefs and actions as written resolve against the MODAL's URL, as on its own page: moved over
55
+ * another page, `href="edit"` and a form with no `action` would point at the page beneath.
56
+ */
57
+ function rebase(root: Element, landed: string): void {
58
+ const resolve = (el: Element, name: string): void => {
59
+ const raw = el.getAttribute(name) ?? '';
60
+ if (raw.startsWith('#') || !URL.canParse(raw, landed)) return;
61
+ el.setAttribute(name, new URL(raw, landed).href);
62
+ };
63
+ for (const el of root.querySelectorAll('a[href], area[href]')) resolve(el, 'href');
64
+ for (const el of root.querySelectorAll('[formaction]')) resolve(el, 'formaction');
65
+ for (const form of root.querySelectorAll('form')) {
66
+ if ((form.getAttribute('action') ?? '') === '') form.setAttribute('action', landed);
67
+ else resolve(form, 'action');
68
+ }
69
+ }
70
+
71
+ /** Named by its first heading, as `@ultimat3/ui`'s `Dialog` is by its title. */
72
+ function label(dialog: HTMLDialogElement, title: string): void {
73
+ const heading = dialog.querySelector('h1, h2');
74
+ if (heading === null) {
75
+ dialog.setAttribute('aria-label', title);
76
+ return;
77
+ }
78
+ if (heading.id === '') heading.id = 'x-modal-title';
79
+ dialog.setAttribute('aria-labelledby', heading.id);
80
+ }
81
+
82
+ export function modalController(
83
+ win: Window,
84
+ rendered: () => string,
85
+ ran: Set<string>,
86
+ head: WeakSet<Element>,
87
+ ): ModalController {
88
+ const doc = win.document;
89
+ let dialog: HTMLDialogElement | undefined;
90
+ /** The open modal's path (`/runs/new`), as the URL bar shows it. */
91
+ let openAt: string | undefined;
92
+ let opener: Element | null = null;
93
+ let title = '';
94
+ /** A close is under way (a Back in flight): a second Escape must not go Back twice. */
95
+ let leaving = false;
96
+
97
+ const owned = (): boolean => {
98
+ // The modal the entry on screen was pushed for — a path, compared with the one open.
99
+ const pushedFor = entryOf(win.history.state)?.modal;
100
+ return openAt !== undefined && pushedFor === openAt;
101
+ };
102
+
103
+ const dismiss = (): void => {
104
+ const open = dialog;
105
+ if (open === undefined) return;
106
+ dialog = undefined;
107
+ openAt = undefined;
108
+ leaving = false;
109
+ disposeIslands(open, () => false);
110
+ if (open.open) open.close();
111
+ open.remove();
112
+ doc.title = title;
113
+ // Back where the visitor was: the link or button that opened it, when it is still there.
114
+ if (opener?.isConnected === true) (opener as HTMLElement).focus?.({ preventScroll: true });
115
+ opener = null;
116
+ };
117
+
118
+ /** The modal's mark off the entry, the hash off the URL — Back would leave the app. */
119
+ const clearEntry = (): void => {
120
+ const state = (win.history.state ?? {}) as Record<string, unknown>;
121
+ const { modal: _modal, ...entry } = entryOf(state) ?? {
122
+ scroll: [win.scrollX, win.scrollY] as const,
123
+ doc: rendered(),
124
+ };
125
+ win.history.replaceState({ ...state, [STATE_KEY]: entry }, '', rendered());
126
+ };
127
+
128
+ const close = (): void => {
129
+ if (openAt === undefined || leaving) return;
130
+ if (owned()) {
131
+ leaving = true;
132
+ // `popstate` reconciles: the entry beneath has no modal, so `dismiss` runs there.
133
+ win.history.back();
134
+ return;
135
+ }
136
+ clearEntry();
137
+ dismiss();
138
+ };
139
+
140
+ const onCancel = (event: Event): void => {
141
+ event.preventDefault();
142
+ close();
143
+ };
144
+ // A `<form method="dialog">`, or an Escape the browser would not let be cancelled: the dialog
145
+ // closed itself, and the URL must follow. One it closed for `dismiss` is no longer `dialog`.
146
+ const onClose = (event: Event): void => {
147
+ if (event.target === dialog && dialog !== undefined && !dialog.open) close();
148
+ };
149
+
150
+ const present = async (
151
+ next: Document,
152
+ at: string,
153
+ landed: string,
154
+ history: 'push' | 'replace' | 'none',
155
+ ): Promise<void> => {
156
+ const source = next.querySelector('main') ?? next.body;
157
+ // Scripts outside `<main>` (the hydration runtime, island modules) boot what is inside it.
158
+ const outside = [...next.body.querySelectorAll('script')].filter((s) => !source.contains(s));
159
+ rebase(source, landed);
160
+ const content = [...source.childNodes, ...outside].map((node) => doc.adoptNode(node));
161
+ const headScripts = [...next.head.querySelectorAll('script[src]')] as HTMLScriptElement[];
162
+ let open = dialog;
163
+ if (open === undefined) {
164
+ opener = doc.activeElement;
165
+ title = doc.title;
166
+ open = doc.createElement('dialog');
167
+ open.setAttribute(NAVIGATION_MODAL_ATTRIBUTE, '');
168
+ open.addEventListener('cancel', onCancel);
169
+ open.addEventListener('close', onClose);
170
+ doc.body.append(open);
171
+ } else {
172
+ // One modal at a time: the next address replaces this one's content, in the same dialog.
173
+ // Closed as `dismiss` closes it — no longer `dialog` — so its own `close` event, queued or
174
+ // (in any engine that fires it at once) synchronous, is never read as the visitor closing.
175
+ disposeIslands(open, () => false);
176
+ dialog = undefined;
177
+ if (open.open) open.close();
178
+ }
179
+ dialog = open;
180
+ openAt = at;
181
+ leaving = false;
182
+ open.replaceChildren(...content);
183
+ label(open, next.title);
184
+ doc.title = next.title;
185
+ open.showModal();
186
+ if (history !== 'none') {
187
+ const state = {
188
+ [STATE_KEY]: { scroll: [win.scrollX, win.scrollY], doc: rendered(), modal: at },
189
+ };
190
+ const url = modalLocation(rendered(), at);
191
+ if (history === 'push') win.history.pushState(state, '', url);
192
+ else win.history.replaceState(state, '', url);
193
+ }
194
+ await runScripts(headScripts, ran, (fresh) => {
195
+ doc.head.append(fresh);
196
+ head.add(fresh);
197
+ });
198
+ await runScripts([...open.querySelectorAll('script')], ran, (fresh, inert) =>
199
+ inert.replaceWith(fresh),
200
+ );
201
+ };
202
+
203
+ return {
204
+ get address() {
205
+ return openAt;
206
+ },
207
+ owned,
208
+ presentationOf: (next, landed, facts) =>
209
+ presentation({
210
+ ...facts,
211
+ modalPage: next !== null && metaOf(next, NAVIGATION_PRESENTATION_META) === 'modal',
212
+ modalOpen: openAt !== undefined,
213
+ landed: withoutFragment(landed),
214
+ rendered: rendered(),
215
+ }),
216
+ leaveFor: (history, landed) =>
217
+ openAt === undefined
218
+ ? undefined
219
+ : leaveModal({
220
+ history,
221
+ landed: withoutFragment(landed),
222
+ rendered: rendered(),
223
+ owned: owned(),
224
+ }),
225
+ present,
226
+ close,
227
+ clear() {
228
+ clearEntry();
229
+ dismiss();
230
+ },
231
+ dismiss,
232
+ };
233
+ }
@@ -0,0 +1,59 @@
1
+ /**
2
+ * The client router's press bookkeeping: between a press and its click (the focus a press gives a
3
+ * link is not a hover), and the view transition still painting — whose overlay swallows a click,
4
+ * which is given back to the element under the pointer. Split from `navigation.ts` so that file is
5
+ * the controller and nothing else.
6
+ */
7
+
8
+ import type { RunningTransition } from './navigation-dom';
9
+
10
+ export interface PressTracker {
11
+ /** Between a press and its click. */
12
+ pressed(): boolean;
13
+ /** `transition`'s `track`: the running transition, forgotten by identity once it finishes. */
14
+ track(running: RunningTransition, finished: boolean): void;
15
+ onPress(): void;
16
+ onRelease(): void;
17
+ /**
18
+ * True when `event` is a click the transition overlay swallowed: the transition is skipped and
19
+ * the click re-aimed at the element under the pointer, and the caller has nothing left to do.
20
+ */
21
+ overlayClick(event: MouseEvent): boolean;
22
+ }
23
+
24
+ export function pressTracker(doc: Document): PressTracker {
25
+ let animating: RunningTransition | undefined;
26
+ let pressed = false;
27
+ /** The press landed while a transition was painting over the page. */
28
+ let pressedOverTransition = false;
29
+ return {
30
+ pressed: () => pressed,
31
+ track(running, finished) {
32
+ if (!finished) animating = running;
33
+ else if (animating === running) animating = undefined;
34
+ },
35
+ onPress() {
36
+ pressed = true;
37
+ // A press alone never skips the running transition — that snapped every named element to
38
+ // its end mid-glide (#621); only the click it becomes may.
39
+ pressedOverTransition = animating !== undefined;
40
+ },
41
+ onRelease() {
42
+ pressed = false;
43
+ },
44
+ // While a view transition paints, the browser hit-tests every press to `<html>` — Chrome does
45
+ // so whatever `pointer-events` the `::view-transition` overlay has — so the click reached
46
+ // nothing: the visitor's first click after a swap was lost. It is given to the element under
47
+ // the pointer, whatever it is: a link, a submit button, an island's own control. Only a
48
+ // skipped transition hit-tests the page, so the skip happens at the click, never at the
49
+ // press; when the animation already ended between the two, there is nothing to skip.
50
+ overlayClick(event) {
51
+ if (!pressedOverTransition || event.target !== doc.documentElement) return false;
52
+ pressedOverTransition = false;
53
+ animating?.skipTransition?.();
54
+ const hit = doc.elementFromPoint(event.clientX, event.clientY);
55
+ if (hit !== null && hit !== doc.documentElement && hit instanceof HTMLElement) hit.click();
56
+ return true;
57
+ },
58
+ };
59
+ }
@@ -84,12 +84,19 @@ export function loadStylesheets(
84
84
  /**
85
85
  * Calls each island's disposer — what its `mount` returned, held by `el.__x` — unless the island
86
86
  * is carried across. A rejected boot has nothing to dispose, and the rejection is already on the
87
- * element as `data-x-failed`.
87
+ * element as `data-x-failed`. An island whose boot has not STARTED (an `idle` one still waiting
88
+ * for the browser, a `visible` one never scrolled to) is settled instead: the hydration runtime's
89
+ * `boot` answers an element that already has `__x` with it, so the pending boot mounts nothing into
90
+ * a body that left the document — a mount nobody would ever dispose.
88
91
  */
89
92
  export function disposeIslands(root: Element, kept: (el: Element) => boolean): number {
90
93
  let disposed = 0;
91
94
  for (const el of root.querySelectorAll<IslandElement>(ISLAND_SELECTOR)) {
92
- if (kept(el) || el.__x === undefined) continue;
95
+ if (kept(el)) continue;
96
+ if (el.__x === undefined) {
97
+ el.__x = Promise.resolve();
98
+ continue;
99
+ }
93
100
  disposed += 1;
94
101
  el.__x.then(
95
102
  (dispose) => {
@@ -1,19 +1,23 @@
1
1
  /**
2
2
  * What an opted-in document carries for the client router: which surface rendered it, which build
3
- * (the skew check — the same meta realtime's sync tags write, deduped by key), and the router's
4
- * script. A surface that did not opt in gets none of the three, so it pays no byte.
3
+ * (the skew check — the same meta realtime's sync tags write, deduped by key), the router's
4
+ * script, and — for a `navigation: 'modal'` route — that it is presented over another page. A
5
+ * surface that did not opt in gets none of them, so it pays no byte.
5
6
  */
6
7
 
7
8
  import { CLIENT_BUILD_META } from '@ultimat3/core';
8
9
  import type { HeadTag } from './head';
10
+ import { NAVIGATION_PRESENTATION_META } from './navigation-modal-rules';
9
11
  import { NAVIGATION_META } from './navigation-rules';
10
12
 
11
13
  export interface ClientNavigationHead {
12
14
  /** The surface this document belongs to — a link that lands on another is a full load. */
13
15
  readonly surface: string;
14
16
  readonly buildId: string;
15
- /** `/_x/navigation/<hash>.js`, content-addressed and served `immutable`. */
17
+ /** `/_x/assets/navigation/<hash>.js`, content-addressed and served `immutable`. */
16
18
  readonly scriptUrl: string;
19
+ /** The route declared `navigation: 'modal'`: the router shows it over the page beneath. */
20
+ readonly modal?: boolean;
17
21
  }
18
22
 
19
23
  export function clientNavigationTags(head: ClientNavigationHead): readonly HeadTag[] {
@@ -28,6 +32,15 @@ export function clientNavigationTags(head: ClientNavigationHead): readonly HeadT
28
32
  key: `meta:${CLIENT_BUILD_META}`,
29
33
  attrs: { name: CLIENT_BUILD_META, content: head.buildId },
30
34
  },
35
+ ...(head.modal === true
36
+ ? [
37
+ {
38
+ kind: 'meta' as const,
39
+ key: `meta:${NAVIGATION_PRESENTATION_META}`,
40
+ attrs: { name: NAVIGATION_PRESENTATION_META, content: 'modal' },
41
+ },
42
+ ]
43
+ : []),
31
44
  // Deferred, in `<head>`: it runs once, after parsing, and survives every swap of the body.
32
45
  {
33
46
  kind: 'script',