@webjsdev/core 0.7.9 → 0.7.11
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/dist/webjs-core-browser.js +3 -3
- package/dist/webjs-core-browser.js.map +3 -3
- package/dist/webjs-core-lazy-loader.js +1 -1
- package/dist/webjs-core-lazy-loader.js.map +2 -2
- package/dist/webjs-core-testing.js +23 -2
- package/dist/webjs-core-testing.js.map +3 -3
- package/dist/webjs-core.js +3 -3
- package/dist/webjs-core.js.map +3 -3
- package/index-browser.js +4 -0
- package/index.d.ts +9 -0
- package/index.js +4 -0
- package/package.json +1 -1
- package/src/lazy-loader.js +56 -0
- package/src/metadata.d.ts +25 -0
- package/src/optimistic.js +64 -0
- package/src/render-client.js +176 -25
- package/src/router-client.js +763 -37
- package/src/testing.js +145 -3
- package/src/webjs-frame.js +154 -6
- package/src/webjs-stream.js +170 -0
package/src/testing.js
CHANGED
|
@@ -57,6 +57,60 @@ export async function fixture(template) {
|
|
|
57
57
|
return el;
|
|
58
58
|
}
|
|
59
59
|
|
|
60
|
+
/**
|
|
61
|
+
* Server-render a template THEN hydrate it in the same browser session,
|
|
62
|
+
* awaiting the element's native `updateComplete` so the post-hydration DOM
|
|
63
|
+
* is observable deterministically.
|
|
64
|
+
*
|
|
65
|
+
* This is the SSR + hydrate entry, distinct from `fixture()`. `fixture()`
|
|
66
|
+
* server-renders and parses the HTML into the container, but it only waits
|
|
67
|
+
* two macrotasks and never awaits the real update cycle. `ssrFixture()`
|
|
68
|
+
* renders the SAME SSR markup (with DSD), lets the browser upgrade the
|
|
69
|
+
* custom element, then awaits the element's `updateComplete` promise (the
|
|
70
|
+
* actual render-cycle resolution), not a timer.
|
|
71
|
+
*
|
|
72
|
+
* **What it proves.** The SSR'd markup and the post-hydration DOM agree.
|
|
73
|
+
* Because the returned element is the live, hydrated element, a hydration
|
|
74
|
+
* mismatch (the server rendered one thing, the client rendered another) is
|
|
75
|
+
* observable: compare the SSR'd inner HTML against `el.innerHTML` /
|
|
76
|
+
* `el.shadowRoot.innerHTML` after this resolves and a divergence shows up.
|
|
77
|
+
*
|
|
78
|
+
* **When to use (AI hint):** Use `ssrFixture()` when the test is about the
|
|
79
|
+
* SSR-then-hydrate round-trip (does the server paint survive hydration, does
|
|
80
|
+
* a `.prop` decode back, does a signal-backed render match). Use the plain
|
|
81
|
+
* `fixture()` for a quick mount where the SSR-vs-hydrate distinction does not
|
|
82
|
+
* matter.
|
|
83
|
+
*
|
|
84
|
+
* Requires a real DOM (the WTR Chromium session). The component class must
|
|
85
|
+
* already be registered (the test imports its module, same as `fixture()`).
|
|
86
|
+
*
|
|
87
|
+
* @param {import('./html.js').TemplateResult | string} template
|
|
88
|
+
* Either a `html\`…\`` result or a raw HTML string.
|
|
89
|
+
* @returns {Promise<Element>} The hydrated root element.
|
|
90
|
+
*/
|
|
91
|
+
export async function ssrFixture(template) {
|
|
92
|
+
const container = getContainer();
|
|
93
|
+
if (typeof template === 'string') {
|
|
94
|
+
container.innerHTML = template;
|
|
95
|
+
} else if (template && typeof template === 'object' && template._$webjs === 'template') {
|
|
96
|
+
const { renderToString } = await import('./render-server.js');
|
|
97
|
+
const html = await renderToString(template, { ssr: true });
|
|
98
|
+
container.innerHTML = html;
|
|
99
|
+
} else {
|
|
100
|
+
throw new Error('ssrFixture() expects an html`…` template or an HTML string');
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
const el = container.firstElementChild;
|
|
104
|
+
if (!el) throw new Error('ssrFixture() produced no element');
|
|
105
|
+
|
|
106
|
+
// Drive the real update cycle. The browser upgrades the SSR'd custom
|
|
107
|
+
// element on innerHTML assignment; its connectedCallback queues the first
|
|
108
|
+
// render. Awaiting the native updateComplete promise (not a timer) is the
|
|
109
|
+
// whole point: the test observes the post-hydration DOM deterministically.
|
|
110
|
+
await flushUpdate(el);
|
|
111
|
+
return el;
|
|
112
|
+
}
|
|
113
|
+
|
|
60
114
|
/**
|
|
61
115
|
* Wait for a component's next render cycle to complete.
|
|
62
116
|
*
|
|
@@ -65,17 +119,105 @@ export async function fixture(template) {
|
|
|
65
119
|
* subscribes to, any change that triggers a re-render. The re-render
|
|
66
120
|
* is async (microtask-batched), so you need to await before asserting.
|
|
67
121
|
*
|
|
122
|
+
* Awaits the element's native `updateComplete` promise when present (the
|
|
123
|
+
* real render-cycle resolution), falling back to a microtask flush for a
|
|
124
|
+
* plain element that has no `updateComplete` (back-compatible).
|
|
125
|
+
*
|
|
68
126
|
* @param {Element} el The component element.
|
|
69
127
|
* @returns {Promise<void>}
|
|
70
128
|
*/
|
|
71
129
|
export async function waitForUpdate(el) {
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
130
|
+
await flushUpdate(el);
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
/**
|
|
134
|
+
* Await the real update cycle of an element. For a WebComponent this awaits
|
|
135
|
+
* the native `updateComplete` promise; for anything else it yields two
|
|
136
|
+
* macrotasks (the legacy behaviour) so cascading updates settle.
|
|
137
|
+
*
|
|
138
|
+
* @param {Element} el
|
|
139
|
+
* @returns {Promise<void>}
|
|
140
|
+
*/
|
|
141
|
+
async function flushUpdate(el) {
|
|
142
|
+
const uc = el && /** @type {any} */ (el).updateComplete;
|
|
143
|
+
if (uc && typeof uc.then === 'function') {
|
|
144
|
+
await uc;
|
|
145
|
+
// A microtask yield lets any cascading child update flush too.
|
|
146
|
+
await Promise.resolve();
|
|
147
|
+
return;
|
|
148
|
+
}
|
|
75
149
|
await new Promise((r) => setTimeout(r, 0));
|
|
76
150
|
await new Promise((r) => setTimeout(r, 0));
|
|
77
151
|
}
|
|
78
152
|
|
|
153
|
+
/**
|
|
154
|
+
* Assert an element's subtree has no accessibility violations, using the
|
|
155
|
+
* axe-core engine in the real browser. OPT-IN: nothing calls this for you,
|
|
156
|
+
* it is never a forced gate.
|
|
157
|
+
*
|
|
158
|
+
* axe-core is a TEST-ONLY peer: it is imported dynamically here, so it is
|
|
159
|
+
* NOT a hard dependency of `@webjsdev/core`. An app that does not do a11y
|
|
160
|
+
* testing never needs it. Install it where you run the test:
|
|
161
|
+
* `npm install -D axe-core`.
|
|
162
|
+
*
|
|
163
|
+
* On a violation it throws an Error whose message lists each violation's id,
|
|
164
|
+
* impact, a short help string, and the failing nodes' selectors, so the test
|
|
165
|
+
* failure is actionable. On zero violations it resolves.
|
|
166
|
+
*
|
|
167
|
+
* ```js
|
|
168
|
+
* import { ssrFixture, assertNoA11yViolations } from '@webjsdev/core/testing';
|
|
169
|
+
* const el = await ssrFixture(html`<my-form></my-form>`);
|
|
170
|
+
* await assertNoA11yViolations(el);
|
|
171
|
+
* // tune rules: await assertNoA11yViolations(el, { rules: { 'color-contrast': { enabled: false } } });
|
|
172
|
+
* ```
|
|
173
|
+
*
|
|
174
|
+
* @param {Element} el The element (subtree root) to scan.
|
|
175
|
+
* @param {object} [opts] Options passed through to `axe.run` (e.g. `rules`).
|
|
176
|
+
* @returns {Promise<void>}
|
|
177
|
+
*/
|
|
178
|
+
export async function assertNoA11yViolations(el, opts) {
|
|
179
|
+
if (!el || typeof el !== 'object') {
|
|
180
|
+
throw new Error('assertNoA11yViolations() expects a DOM element');
|
|
181
|
+
}
|
|
182
|
+
let axe;
|
|
183
|
+
try {
|
|
184
|
+
const mod = await import('axe-core');
|
|
185
|
+
// axe-core ships a UMD bundle. Depending on how the test runner / bundler
|
|
186
|
+
// wraps it, the engine may sit on the namespace, on `.default`, on a
|
|
187
|
+
// nested `.default.default`, or only as the `window.axe` side-effect the
|
|
188
|
+
// UMD installs. Probe in that order for a `run` function.
|
|
189
|
+
const candidates = [mod, mod && mod.default, mod && mod.default && mod.default.default,
|
|
190
|
+
typeof globalThis !== 'undefined' ? globalThis.axe : undefined];
|
|
191
|
+
axe = candidates.find((c) => c && typeof c.run === 'function');
|
|
192
|
+
} catch {
|
|
193
|
+
throw new Error(
|
|
194
|
+
'assertNoA11yViolations needs axe-core. Install it: npm install -D axe-core'
|
|
195
|
+
);
|
|
196
|
+
}
|
|
197
|
+
if (!axe || typeof axe.run !== 'function') {
|
|
198
|
+
throw new Error(
|
|
199
|
+
'assertNoA11yViolations needs axe-core. Install it: npm install -D axe-core'
|
|
200
|
+
);
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
const results = await axe.run(el, opts || {});
|
|
204
|
+
const violations = results.violations || [];
|
|
205
|
+
if (violations.length === 0) return;
|
|
206
|
+
|
|
207
|
+
const lines = violations.map((v) => {
|
|
208
|
+
const targets = (v.nodes || [])
|
|
209
|
+
.map((n) => (n.target || []).join(' '))
|
|
210
|
+
.filter(Boolean)
|
|
211
|
+
.join(', ');
|
|
212
|
+
return ` - [${v.id}] (${v.impact || 'unknown'}) ${v.help}${targets ? ` -> ${targets}` : ''}`;
|
|
213
|
+
});
|
|
214
|
+
const err = new Error(
|
|
215
|
+
`a11y: ${violations.length} accessibility violation(s) found:\n${lines.join('\n')}`
|
|
216
|
+
);
|
|
217
|
+
err.name = 'AssertionError';
|
|
218
|
+
throw err;
|
|
219
|
+
}
|
|
220
|
+
|
|
79
221
|
/**
|
|
80
222
|
* Simulate a click event on an element.
|
|
81
223
|
*
|
package/src/webjs-frame.js
CHANGED
|
@@ -35,26 +35,174 @@
|
|
|
35
35
|
* frame appears in the response, the router falls through to the
|
|
36
36
|
* normal layout-marker mechanism (and then to full body swap).
|
|
37
37
|
*
|
|
38
|
+
* External targeting (Turbo's `data-turbo-frame`): a trigger (an `<a>`
|
|
39
|
+
* or a `<form>`) that is NOT nested inside the frame can still drive it
|
|
40
|
+
* by carrying `data-webjs-frame="<id>"`. The router resolves the id via
|
|
41
|
+
* `getElementById` in the current document, so an external nav/sidebar
|
|
42
|
+
* link or a filter form drives a content frame it does not enclose. An
|
|
43
|
+
* id that does not resolve to a live `<webjs-frame>` warns once and falls
|
|
44
|
+
* back to a normal navigation (fail-safe, never a wrong-region swap). The
|
|
45
|
+
* reserved token `data-webjs-frame="_top"` on a trigger INSIDE a frame
|
|
46
|
+
* breaks OUT to a full-page navigation instead of swapping the frame.
|
|
47
|
+
*
|
|
48
|
+
* Busy state: while a frame's navigation is in flight the router sets the
|
|
49
|
+
* native `aria-busy="true"` on the frame element and clears it (to
|
|
50
|
+
* `"false"`) on completion, success, failure, or abort, so assistive tech
|
|
51
|
+
* announces the loading state and CSS can style `webjs-frame[aria-busy="true"]`.
|
|
52
|
+
* It also dispatches a bubbling `webjs:frame-busy` event on the frame at
|
|
53
|
+
* both edges (detail `{ frameId, busy: true }` then `{ frameId, busy: false }`),
|
|
54
|
+
* so app code can hook the start and finish.
|
|
55
|
+
*
|
|
38
56
|
* The element is **light DOM**: no shadow root, no slot mechanics.
|
|
39
57
|
* Children are normal light-DOM children that the router replaces
|
|
40
58
|
* via the same keyed reconciler used for layout marker swaps. This
|
|
41
59
|
* means scroll position, input values, and `<details>` open state
|
|
42
60
|
* are preserved for any matched elements inside the frame.
|
|
43
61
|
*
|
|
62
|
+
* With JS disabled a `data-webjs-frame` link is an inert attribute on a
|
|
63
|
+
* plain `<a href>`, so the click is a normal full-page navigation, the
|
|
64
|
+
* correct progressive-enhancement fallback.
|
|
65
|
+
*
|
|
66
|
+
* Self-loading (`src` + `loading`): a frame MAY fetch its own content. With
|
|
67
|
+
* a `src` attribute the frame self-fetches that URL as a frame nav and applies
|
|
68
|
+
* the matching `<webjs-frame id>` subtree from the response into itself, through
|
|
69
|
+
* the SAME router frame-swap path a click-driven frame nav uses (so the busy
|
|
70
|
+
* lifecycle, the navigation-error recovery, and the frame-missing fallback all
|
|
71
|
+
* apply). The `loading` attribute picks WHEN:
|
|
72
|
+
*
|
|
73
|
+
* - `loading="eager"` (or absent, the default): fetch on `connectedCallback`.
|
|
74
|
+
* - `loading="lazy"`: fetch when the frame first scrolls into view, reusing
|
|
75
|
+
* the same IntersectionObserver budget (`rootMargin: '200px'`) as a
|
|
76
|
+
* `static lazy = true` component.
|
|
77
|
+
*
|
|
78
|
+
* A `src` change after connect re-loads. Eager-connect, the lazy observer, and
|
|
79
|
+
* a `src` mutation never double-fire: a per-element loaded/loading guard keyed
|
|
80
|
+
* on the resolved URL coalesces them.
|
|
81
|
+
*
|
|
82
|
+
* PROGRESSIVE ENHANCEMENT CAVEAT: a `src`-driven frame is JS-DEPENDENT. The
|
|
83
|
+
* browser does NOT natively fetch `<webjs-frame src>` (unlike `<iframe>`), so
|
|
84
|
+
* with JS off the frame shows only whatever children were server-rendered into
|
|
85
|
+
* it. Use `src`/`loading` for DEFERRED content (comments, a recommendations
|
|
86
|
+
* rail, an expensive card) where a JS-off empty/placeholder state is acceptable.
|
|
87
|
+
* For content that MUST exist without JS, render it server-side into the frame
|
|
88
|
+
* instead of using `src` (the self-load then replaces those fallback children).
|
|
89
|
+
*
|
|
44
90
|
* @element webjs-frame
|
|
45
91
|
* @attr {string} id: Required. The frame's identifier, used by
|
|
46
|
-
* `closest()` on the client
|
|
92
|
+
* `closest()` on the client, `getElementById` for external
|
|
93
|
+
* `data-webjs-frame` targeting, and `querySelector` on the response.
|
|
94
|
+
* @attr {string} src: Optional. A same-origin URL the frame self-fetches as a
|
|
95
|
+
* frame nav, applying the matching `<webjs-frame id>` subtree into itself.
|
|
96
|
+
* @attr {string} loading: Optional. `eager` (default) fetches `src` on connect;
|
|
97
|
+
* `lazy` fetches when the frame scrolls into view.
|
|
98
|
+
* @fires webjs:frame-busy: Bubbling. Dispatched on the frame when its
|
|
99
|
+
* navigation starts (`detail.busy === true`) and finishes
|
|
100
|
+
* (`detail.busy === false`); `detail.frameId` names the frame.
|
|
47
101
|
*/
|
|
48
102
|
// Defined lazily inside an IIFE so that importing this module on the
|
|
49
103
|
// server (Node) doesn't trip a `HTMLElement is not defined` reference
|
|
50
104
|
// error. Server-side renderers never touch the class: the element
|
|
51
|
-
// is always rendered as `<webjs-frame id=...>` plain
|
|
52
|
-
// renderToString pipeline.
|
|
105
|
+
// is always rendered as `<webjs-frame id=... src=... loading=...>` plain
|
|
106
|
+
// HTML by the renderToString pipeline.
|
|
53
107
|
const WebjsFrame = (typeof HTMLElement !== 'undefined')
|
|
54
108
|
? class WebjsFrame extends HTMLElement {
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
109
|
+
constructor() {
|
|
110
|
+
super();
|
|
111
|
+
/**
|
|
112
|
+
* The `src` value most recently loaded (or loading). Guards against a
|
|
113
|
+
* double-load when eager connect, the lazy observer, and a `src`
|
|
114
|
+
* mutation all want to fire: a load only starts when the resolved URL
|
|
115
|
+
* differs from this. `null` means "nothing loaded yet".
|
|
116
|
+
* @type {string | null}
|
|
117
|
+
*/
|
|
118
|
+
this._webjsLoadedSrc = null;
|
|
119
|
+
/**
|
|
120
|
+
* Teardown for the lazy IntersectionObserver subscription, so a
|
|
121
|
+
* disconnect or a `src` change stops observing.
|
|
122
|
+
* @type {(() => void) | null}
|
|
123
|
+
*/
|
|
124
|
+
this._webjsLazyTeardown = null;
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
static get observedAttributes() { return ['src']; }
|
|
128
|
+
|
|
129
|
+
connectedCallback() {
|
|
130
|
+
this._webjsScheduleLoad();
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
disconnectedCallback() {
|
|
134
|
+
if (this._webjsLazyTeardown) { this._webjsLazyTeardown(); this._webjsLazyTeardown = null; }
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
/**
|
|
138
|
+
* @param {string} name
|
|
139
|
+
* @param {string | null} _old
|
|
140
|
+
* @param {string | null} _next
|
|
141
|
+
*/
|
|
142
|
+
attributeChangedCallback(name, _old, _next) {
|
|
143
|
+
// Only react to a `src` change AFTER the element is connected (the
|
|
144
|
+
// initial attribute parse fires this before connectedCallback; the
|
|
145
|
+
// connect path handles the first load). A live `src` swap re-loads.
|
|
146
|
+
if (name === 'src' && this.isConnected) this._webjsScheduleLoad();
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
/**
|
|
150
|
+
* Decide whether and when to self-load based on `src` + `loading`,
|
|
151
|
+
* coalescing the three triggers (eager connect, lazy viewport, `src`
|
|
152
|
+
* mutation) so the frame never double-fetches the same URL.
|
|
153
|
+
*/
|
|
154
|
+
_webjsScheduleLoad() {
|
|
155
|
+
const src = this.getAttribute('src');
|
|
156
|
+
// No src: nothing to self-load (a normal swap-anchor frame). Drop any
|
|
157
|
+
// pending lazy observation left over from a previous src.
|
|
158
|
+
if (!src) {
|
|
159
|
+
if (this._webjsLazyTeardown) { this._webjsLazyTeardown(); this._webjsLazyTeardown = null; }
|
|
160
|
+
this._webjsLoadedSrc = null;
|
|
161
|
+
return;
|
|
162
|
+
}
|
|
163
|
+
// Resolve so the dedupe key matches what loadFrame requests, and so a
|
|
164
|
+
// relative `src` compares stably.
|
|
165
|
+
let resolved = src;
|
|
166
|
+
try { resolved = new URL(src, location.href).href; } catch { /* keep raw */ }
|
|
167
|
+
// Already loaded (or loading) this exact URL: no double-load.
|
|
168
|
+
if (resolved === this._webjsLoadedSrc) return;
|
|
169
|
+
|
|
170
|
+
// A new src supersedes any in-flight lazy observation.
|
|
171
|
+
if (this._webjsLazyTeardown) { this._webjsLazyTeardown(); this._webjsLazyTeardown = null; }
|
|
172
|
+
|
|
173
|
+
const lazy = (this.getAttribute('loading') || 'eager').toLowerCase() === 'lazy';
|
|
174
|
+
if (lazy) {
|
|
175
|
+
// Defer the fetch until the frame enters the viewport, reusing the
|
|
176
|
+
// shared per-element IntersectionObserver. Mark the URL as claimed
|
|
177
|
+
// NOW so a redundant schedule (e.g. attributeChanged firing again
|
|
178
|
+
// with the same src) does not re-observe.
|
|
179
|
+
this._webjsLoadedSrc = resolved;
|
|
180
|
+
import('./lazy-loader.js').then(({ observeViewportOnce }) => {
|
|
181
|
+
// Bail if the src changed again while the import was in flight.
|
|
182
|
+
if (this._webjsLoadedSrc !== resolved || !this.isConnected) return;
|
|
183
|
+
this._webjsLazyTeardown = observeViewportOnce(this, () => {
|
|
184
|
+
this._webjsLazyTeardown = null;
|
|
185
|
+
this._webjsLoad(resolved);
|
|
186
|
+
});
|
|
187
|
+
});
|
|
188
|
+
} else {
|
|
189
|
+
this._webjsLoadedSrc = resolved;
|
|
190
|
+
this._webjsLoad(resolved);
|
|
191
|
+
}
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
/**
|
|
195
|
+
* Run the actual frame self-load through the router's frame-swap path.
|
|
196
|
+
* @param {string} resolved The already-resolved absolute URL.
|
|
197
|
+
*/
|
|
198
|
+
_webjsLoad(resolved) {
|
|
199
|
+
import('./router-client.js').then(({ loadFrame }) => {
|
|
200
|
+
// The src may have changed again (or the frame disconnected) between
|
|
201
|
+
// scheduling and the import resolving; only load if we still own it.
|
|
202
|
+
if (this._webjsLoadedSrc !== resolved || !this.isConnected) return;
|
|
203
|
+
loadFrame(this, resolved);
|
|
204
|
+
});
|
|
205
|
+
}
|
|
58
206
|
}
|
|
59
207
|
: /** @type {any} */ (null);
|
|
60
208
|
|
|
@@ -0,0 +1,170 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `<webjs-stream action="..." target="...">`: a self-applying surgical
|
|
3
|
+
* DOM-update element (Turbo Streams parity, webjs-shaped).
|
|
4
|
+
*
|
|
5
|
+
* The router's primary mechanism swaps a whole layout-marker region (or a
|
|
6
|
+
* `<webjs-frame>`). That is coarse: a comment-append, an optimistic row
|
|
7
|
+
* removal, or a toast insertion has to redraw a whole region or hand-write
|
|
8
|
+
* DOM JS. `<webjs-stream>` is the element-level grammar for those surgical
|
|
9
|
+
* updates. The payload is plain HTML the browser already parses: a
|
|
10
|
+
* `<webjs-stream>` wrapping a single `<template>`, carrying an `action` and a
|
|
11
|
+
* `target` (an element id) or `targets` (a CSS selector). There is no protocol
|
|
12
|
+
* parser; the element clones its `<template>` content and applies it with
|
|
13
|
+
* native DOM methods, then removes itself.
|
|
14
|
+
*
|
|
15
|
+
* <webjs-stream action="append" target="comments">
|
|
16
|
+
* <template><li>Nice post!</li></template>
|
|
17
|
+
* </webjs-stream>
|
|
18
|
+
*
|
|
19
|
+
* Actions (mirroring Turbo's set):
|
|
20
|
+
*
|
|
21
|
+
* - `append` / `prepend`: add the template content as the last / first
|
|
22
|
+
* children of the target.
|
|
23
|
+
* - `before` / `after`: insert the template content as siblings before /
|
|
24
|
+
* after the target.
|
|
25
|
+
* - `replace`: replace the target element itself with the template content.
|
|
26
|
+
* - `update`: replace the target's children with the template content.
|
|
27
|
+
* - `remove`: remove the target element (no template needed).
|
|
28
|
+
*
|
|
29
|
+
* Two delivery paths share this ONE applier:
|
|
30
|
+
*
|
|
31
|
+
* - **HTTP (content-negotiated form response).** A `<form>` submission rides
|
|
32
|
+
* the client router, which sends `Accept: text/vnd.webjs-stream.html`. When
|
|
33
|
+
* the server answers with that content type, the router applies the
|
|
34
|
+
* `<webjs-stream>` elements in the body surgically (no region swap). With
|
|
35
|
+
* JS OFF the browser sends no such Accept, so the server returns a normal
|
|
36
|
+
* render/redirect and the form degrades to a full-page round-trip. The
|
|
37
|
+
* grammar is therefore additive and progressive-enhancement-safe.
|
|
38
|
+
* - **Live channel (`broadcast()` / `connectWS`).** A server message that is
|
|
39
|
+
* a `<webjs-stream>` HTML string is applied by `renderStream(message)` from
|
|
40
|
+
* a `connectWS` handler, so chat / notifications / presence reuse the same
|
|
41
|
+
* applier instead of bespoke per-app DOM code.
|
|
42
|
+
*
|
|
43
|
+
* The element is inert server-side: `renderToString` emits it as plain
|
|
44
|
+
* `<webjs-stream action=... target=...>` HTML and never touches the class
|
|
45
|
+
* (it is defined only when `HTMLElement` exists). A `<webjs-stream>` that is
|
|
46
|
+
* server-rendered into a page (rather than streamed in) self-applies on
|
|
47
|
+
* hydration exactly as a streamed one does, so there is no separate code path.
|
|
48
|
+
*
|
|
49
|
+
* @element webjs-stream
|
|
50
|
+
* @attr {string} action: Required. One of append / prepend / before / after /
|
|
51
|
+
* replace / update / remove.
|
|
52
|
+
* @attr {string} target: An element id to apply the action to.
|
|
53
|
+
* @attr {string} targets: A CSS selector matching one or more targets (applied
|
|
54
|
+
* to each). `target` and `targets` are mutually exclusive; `target` wins.
|
|
55
|
+
*/
|
|
56
|
+
|
|
57
|
+
/** The actions that operate by inserting the template content. */
|
|
58
|
+
const INSERT_ACTIONS = new Set(['append', 'prepend', 'before', 'after', 'replace', 'update']);
|
|
59
|
+
|
|
60
|
+
/**
|
|
61
|
+
* Apply one resolved action against one target element.
|
|
62
|
+
* @param {string} action
|
|
63
|
+
* @param {Element} target
|
|
64
|
+
* @param {DocumentFragment | null} frag Cloned template content (null for remove).
|
|
65
|
+
*/
|
|
66
|
+
function applyTo(action, target, frag) {
|
|
67
|
+
switch (action) {
|
|
68
|
+
case 'append': if (frag) target.append(frag); break;
|
|
69
|
+
case 'prepend': if (frag) target.prepend(frag); break;
|
|
70
|
+
case 'before': if (frag) target.before(frag); break;
|
|
71
|
+
case 'after': if (frag) target.after(frag); break;
|
|
72
|
+
case 'replace': if (frag) target.replaceWith(frag); break;
|
|
73
|
+
case 'update': if (frag) target.replaceChildren(frag); break;
|
|
74
|
+
case 'remove': target.remove(); break;
|
|
75
|
+
default: break; // unknown action: no-op
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
const WebjsStream = (typeof HTMLElement !== 'undefined')
|
|
80
|
+
? class WebjsStream extends HTMLElement {
|
|
81
|
+
connectedCallback() {
|
|
82
|
+
// Apply once. A streamed-in element runs this on insertion; a
|
|
83
|
+
// server-rendered one runs it on upgrade. Either way it self-removes.
|
|
84
|
+
if (this._webjsApplied) return;
|
|
85
|
+
this._webjsApplied = true;
|
|
86
|
+
this._webjsApply();
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
_webjsApply() {
|
|
90
|
+
const action = (this.getAttribute('action') || '').toLowerCase();
|
|
91
|
+
const doc = this.ownerDocument || document;
|
|
92
|
+
// Resolve targets: an id via `target`, or a selector via `targets`.
|
|
93
|
+
/** @type {Element[]} */
|
|
94
|
+
let targets = [];
|
|
95
|
+
const id = this.getAttribute('target');
|
|
96
|
+
if (id) {
|
|
97
|
+
const el = doc.getElementById(id);
|
|
98
|
+
if (el) targets = [el];
|
|
99
|
+
} else {
|
|
100
|
+
const sel = this.getAttribute('targets');
|
|
101
|
+
if (sel) {
|
|
102
|
+
try { targets = Array.from(doc.querySelectorAll(sel)); } catch { targets = []; }
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
// The single child <template> carries the content for an insert action.
|
|
106
|
+
const tpl = INSERT_ACTIONS.has(action)
|
|
107
|
+
? /** @type {HTMLTemplateElement | null} */ (this.querySelector('template'))
|
|
108
|
+
: null;
|
|
109
|
+
try {
|
|
110
|
+
for (const target of targets) {
|
|
111
|
+
// Clone per target so multiple `targets` each get their own nodes.
|
|
112
|
+
const frag = tpl ? /** @type {DocumentFragment} */ (tpl.content.cloneNode(true)) : null;
|
|
113
|
+
applyTo(action, target, frag);
|
|
114
|
+
}
|
|
115
|
+
} finally {
|
|
116
|
+
// The stream element itself never stays in the live DOM.
|
|
117
|
+
this.remove();
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
: /** @type {any} */ (null);
|
|
122
|
+
|
|
123
|
+
if (typeof customElements !== 'undefined' && WebjsStream && !customElements.get('webjs-stream')) {
|
|
124
|
+
customElements.define('webjs-stream', WebjsStream);
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
/**
|
|
128
|
+
* Apply a server-sent stream payload to the live DOM. The payload is HTML
|
|
129
|
+
* containing one or more `<webjs-stream>` elements (each wrapping a
|
|
130
|
+
* `<template>`); appending them to the document upgrades each one, which
|
|
131
|
+
* self-applies its action and self-removes. Use this from a `connectWS` /
|
|
132
|
+
* `broadcast` message handler so a live channel reuses the same applier as the
|
|
133
|
+
* HTTP path.
|
|
134
|
+
*
|
|
135
|
+
* connectWS('/feed', { onMessage: (m) => renderStream(m) });
|
|
136
|
+
*
|
|
137
|
+
* @param {string | DocumentFragment | Node} input The stream HTML (or parsed nodes).
|
|
138
|
+
* @param {Document} [doc] The document to apply into (defaults to `document`).
|
|
139
|
+
*/
|
|
140
|
+
function renderStream(input, doc) {
|
|
141
|
+
const d = doc || (typeof document !== 'undefined' ? document : null);
|
|
142
|
+
if (!d) return;
|
|
143
|
+
/** @type {DocumentFragment | Node | null} */
|
|
144
|
+
let frag = null;
|
|
145
|
+
if (typeof input === 'string') {
|
|
146
|
+
const tpl = d.createElement('template');
|
|
147
|
+
tpl.innerHTML = input;
|
|
148
|
+
frag = tpl.content;
|
|
149
|
+
} else if (input && /** @type any */ (input).nodeType) {
|
|
150
|
+
frag = input; // a DocumentFragment or a single Node
|
|
151
|
+
} else {
|
|
152
|
+
return;
|
|
153
|
+
}
|
|
154
|
+
// Append ONLY the <webjs-stream> elements (each self-applies + self-removes on
|
|
155
|
+
// connect). Skipping the intervening whitespace text nodes a multi-action
|
|
156
|
+
// payload carries (the parts are joined by newlines) avoids accreting stray
|
|
157
|
+
// text nodes in <body> on a long-lived live channel.
|
|
158
|
+
const found = /** @type any */ (frag).querySelectorAll
|
|
159
|
+
? Array.from(/** @type any */ (frag).querySelectorAll('webjs-stream'))
|
|
160
|
+
: [];
|
|
161
|
+
if (found.length) {
|
|
162
|
+
for (const s of found) d.body.append(s);
|
|
163
|
+
} else {
|
|
164
|
+
// No nested <webjs-stream> found (e.g. a single element passed directly):
|
|
165
|
+
// append the input verbatim so the caller's node still reaches the DOM.
|
|
166
|
+
d.body.append(frag);
|
|
167
|
+
}
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
export { WebjsStream, renderStream };
|