@webjsdev/core 0.7.10 → 0.7.12

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,21 @@
1
+ export interface SignalState<T = unknown> {
2
+ readonly __isSignal: true;
3
+ get(): T;
4
+ set(value: T): void;
5
+ }
6
+
7
+ export interface SignalComputed<T = unknown> {
8
+ readonly __isSignal: true;
9
+ get(): T;
10
+ }
11
+
12
+ export function batch<T>(fn: () => T): T;
13
+ export function signal<T>(initial: T, options?: Record<string, unknown>): SignalState<T>;
14
+ export function computed<T>(fn: () => T, options?: Record<string, unknown>): SignalComputed<T>;
15
+ export function isSignal(v: unknown): v is SignalState | SignalComputed;
16
+ export function effect(fn: () => void): () => void;
17
+
18
+ export const Signal: {
19
+ State: new <T>(initial: T, options?: Record<string, unknown>) => SignalState<T>;
20
+ Computed: new <T>(fn: () => T, options?: Record<string, unknown>) => SignalComputed<T>;
21
+ };
package/src/task.d.ts ADDED
@@ -0,0 +1,33 @@
1
+ import type { ReactiveController, WebComponent } from './component.js';
2
+
3
+ export const TaskStatus: {
4
+ readonly INITIAL: 0;
5
+ readonly PENDING: 1;
6
+ readonly COMPLETE: 2;
7
+ readonly ERROR: 3;
8
+ };
9
+
10
+ export type TaskStatusValue = (typeof TaskStatus)[keyof typeof TaskStatus];
11
+
12
+ export class Task<T = unknown> implements ReactiveController {
13
+ constructor(host: WebComponent, options: {
14
+ task: (...args: any[]) => Promise<T>;
15
+ args?: () => unknown[];
16
+ autoRun?: boolean;
17
+ });
18
+ get status(): TaskStatusValue;
19
+ get value(): T | undefined;
20
+ get error(): unknown;
21
+ run(): Promise<void>;
22
+ abort(): void;
23
+ render(handlers?: {
24
+ initial?: () => unknown;
25
+ pending?: () => unknown;
26
+ complete?: (value: T) => unknown;
27
+ error?: (error: unknown) => unknown;
28
+ }): unknown;
29
+ hostUpdate(): void;
30
+ hostUpdated(): void;
31
+ hostConnected(): void;
32
+ hostDisconnected(): void;
33
+ }
@@ -0,0 +1,9 @@
1
+ import type { TemplateResult } from './html.js';
2
+
3
+ export function fixture(template: TemplateResult | string): Promise<Element>;
4
+ export function ssrFixture(template: TemplateResult | string): Promise<Element>;
5
+ export function waitForUpdate(el: Element): Promise<void>;
6
+ export function assertNoA11yViolations(el: Element, opts?: Record<string, unknown>): Promise<void>;
7
+ export function click(el: Element): void;
8
+ export function shadowQuery(el: Element, selector: string): Element | null;
9
+ export function shadowQueryAll(el: Element, selector: string): Element[];
@@ -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 and `querySelector` on the response.
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 HTML by the
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
- // No shadow root and no render(). The element exists purely as a
56
- // swap anchor with an addressable `id`. Children are normal
57
- // light-DOM children. The router does all the work.
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 };