@webjsdev/core 0.7.1
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/README.md +55 -0
- package/index.d.ts +28 -0
- package/index.js +39 -0
- package/package.json +46 -0
- package/src/component.d.ts +120 -0
- package/src/component.js +1061 -0
- package/src/context.js +327 -0
- package/src/css.js +54 -0
- package/src/directives.js +454 -0
- package/src/escape.js +15 -0
- package/src/expose.js +90 -0
- package/src/html.js +30 -0
- package/src/lazy-loader.js +107 -0
- package/src/nav.js +45 -0
- package/src/registry.js +190 -0
- package/src/render-client.js +1717 -0
- package/src/render-server.js +1128 -0
- package/src/repeat.js +37 -0
- package/src/rich-fetch.js +70 -0
- package/src/router-client.js +1434 -0
- package/src/serialize.js +452 -0
- package/src/signal.js +684 -0
- package/src/slot.js +860 -0
- package/src/suspense.js +46 -0
- package/src/task.js +386 -0
- package/src/testing.js +157 -0
- package/src/webjs-frame.js +65 -0
- package/src/websocket-client.js +111 -0
package/src/context.js
ADDED
|
@@ -0,0 +1,327 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Context Protocol: cross-component data sharing without prop drilling.
|
|
3
|
+
*
|
|
4
|
+
* Implements the W3C Community Group Context Protocol so any web component
|
|
5
|
+
* (not just webjs components) can participate as a provider or consumer.
|
|
6
|
+
*
|
|
7
|
+
* ## When to use
|
|
8
|
+
*
|
|
9
|
+
* Use Context when you need to share data (theme, auth state, locale,
|
|
10
|
+
* config, feature flags) across deeply nested components without passing
|
|
11
|
+
* props through every intermediate level.
|
|
12
|
+
*
|
|
13
|
+
* Prefer Context over module-level globals when the shared data is **scoped
|
|
14
|
+
* to a subtree**: e.g. a specific page, panel, or dialog: and different
|
|
15
|
+
* subtrees may hold different values for the same context key.
|
|
16
|
+
*
|
|
17
|
+
* ## When NOT to use
|
|
18
|
+
*
|
|
19
|
+
* - For page-level data loading, use async page functions or server actions
|
|
20
|
+
* instead. Context is a client-side primitive.
|
|
21
|
+
* - If only a parent and its direct child need to communicate, plain
|
|
22
|
+
* properties/attributes are simpler.
|
|
23
|
+
* - If every component in the app needs the same single value (e.g. a
|
|
24
|
+
* singleton API client), a module-level export may be simpler than
|
|
25
|
+
* context.
|
|
26
|
+
*
|
|
27
|
+
* ## Quick example
|
|
28
|
+
*
|
|
29
|
+
* ```js
|
|
30
|
+
* import { WebComponent, html } from '@webjsdev/core';
|
|
31
|
+
* import { createContext, ContextProvider, ContextConsumer } from '@webjsdev/core/context';
|
|
32
|
+
*
|
|
33
|
+
* const ThemeCtx = createContext('theme');
|
|
34
|
+
*
|
|
35
|
+
* class MyApp extends WebComponent {
|
|
36
|
+
* _theme = new ContextProvider(this, { context: ThemeCtx, initialValue: 'light' });
|
|
37
|
+
* render() {
|
|
38
|
+
* return html`
|
|
39
|
+
* <button @click=${() => this._theme.setValue(
|
|
40
|
+
* this._theme.value === 'light' ? 'dark' : 'light'
|
|
41
|
+
* )}>Toggle</button>
|
|
42
|
+
* <slot></slot>
|
|
43
|
+
* `;
|
|
44
|
+
* }
|
|
45
|
+
* }
|
|
46
|
+
* MyApp.register('my-app');
|
|
47
|
+
*
|
|
48
|
+
* class ThemedCard extends WebComponent {
|
|
49
|
+
* _theme = new ContextConsumer(this, { context: ThemeCtx, subscribe: true });
|
|
50
|
+
* render() {
|
|
51
|
+
* return html`<div class=${this._theme.value}>Card content</div>`;
|
|
52
|
+
* }
|
|
53
|
+
* }
|
|
54
|
+
* ThemedCard.register('themed-card');
|
|
55
|
+
* ```
|
|
56
|
+
*
|
|
57
|
+
* `<themed-card>` can live at any depth under `<my-app>`: it finds the
|
|
58
|
+
* provider automatically via a bubbling `context-request` event that
|
|
59
|
+
* crosses shadow-DOM boundaries (`composed: true`).
|
|
60
|
+
*
|
|
61
|
+
* @module
|
|
62
|
+
*/
|
|
63
|
+
|
|
64
|
+
// ---------------------------------------------------------------------------
|
|
65
|
+
// Context key
|
|
66
|
+
// ---------------------------------------------------------------------------
|
|
67
|
+
|
|
68
|
+
/**
|
|
69
|
+
* @template T
|
|
70
|
+
* @typedef {{ __context__: typeof CONTEXT_KEY, name: string }} Context
|
|
71
|
+
*/
|
|
72
|
+
|
|
73
|
+
const CONTEXT_KEY = Symbol.for('webjs.context');
|
|
74
|
+
|
|
75
|
+
/**
|
|
76
|
+
* Create a typed context key.
|
|
77
|
+
*
|
|
78
|
+
* The returned object is used as an identity token: two calls to
|
|
79
|
+
* `createContext('theme')` produce **different** contexts even though
|
|
80
|
+
* the debug name is the same. Store the key in a shared module and
|
|
81
|
+
* import it from both provider and consumer.
|
|
82
|
+
*
|
|
83
|
+
* @template T
|
|
84
|
+
* @param {string} name Human-readable name (used in error messages and
|
|
85
|
+
* devtools, not for matching).
|
|
86
|
+
* @returns {Context<T>}
|
|
87
|
+
*/
|
|
88
|
+
export function createContext(name) {
|
|
89
|
+
return { __context__: CONTEXT_KEY, name };
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
// ---------------------------------------------------------------------------
|
|
93
|
+
// ContextRequestEvent
|
|
94
|
+
// ---------------------------------------------------------------------------
|
|
95
|
+
|
|
96
|
+
/**
|
|
97
|
+
* Fired by a consumer to locate a provider higher in the DOM.
|
|
98
|
+
*
|
|
99
|
+
* - `bubbles: true` so it walks up the DOM tree.
|
|
100
|
+
* - `composed: true` so it crosses shadow-DOM boundaries.
|
|
101
|
+
*
|
|
102
|
+
* Providers listen for this event on their host element and respond by
|
|
103
|
+
* calling the event's `callback` with the current value.
|
|
104
|
+
*
|
|
105
|
+
* @template T
|
|
106
|
+
*/
|
|
107
|
+
export class ContextRequestEvent extends Event {
|
|
108
|
+
/**
|
|
109
|
+
* @param {Context<T>} context The context key to request.
|
|
110
|
+
* @param {(value: T, unsubscribe?: () => void) => void} callback
|
|
111
|
+
* Called by the provider with the current value. If `subscribe` is
|
|
112
|
+
* true the provider retains the callback and calls it again whenever
|
|
113
|
+
* the value changes (passing an `unsubscribe` function on each call).
|
|
114
|
+
* @param {boolean} subscribe Whether the consumer wants ongoing updates.
|
|
115
|
+
*/
|
|
116
|
+
constructor(context, callback, subscribe = false) {
|
|
117
|
+
super('context-request', { bubbles: true, composed: true });
|
|
118
|
+
/** @type {Context<T>} */
|
|
119
|
+
this.context = context;
|
|
120
|
+
/** @type {(value: T, unsubscribe?: () => void) => void} */
|
|
121
|
+
this.callback = callback;
|
|
122
|
+
/** @type {boolean} */
|
|
123
|
+
this.subscribe = subscribe;
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
// ---------------------------------------------------------------------------
|
|
128
|
+
// ContextProvider
|
|
129
|
+
// ---------------------------------------------------------------------------
|
|
130
|
+
|
|
131
|
+
/**
|
|
132
|
+
* @template T
|
|
133
|
+
* @typedef {{ callback: (value: T, unsubscribe?: () => void) => void }} Subscription
|
|
134
|
+
*/
|
|
135
|
+
|
|
136
|
+
/**
|
|
137
|
+
* A ReactiveController that provides a context value to descendant
|
|
138
|
+
* components.
|
|
139
|
+
*
|
|
140
|
+
* The provider listens for `context-request` events on its host element.
|
|
141
|
+
* When a matching request arrives it delivers the current value (and,
|
|
142
|
+
* for subscribing consumers, retains the callback for future updates).
|
|
143
|
+
*
|
|
144
|
+
* ## AI guidance
|
|
145
|
+
*
|
|
146
|
+
* - Create one `ContextProvider` per context key per component.
|
|
147
|
+
* - Call `setValue()` to push updates to all subscribing consumers.
|
|
148
|
+
* - The provider **must** be a DOM ancestor of its consumers (shadow-DOM
|
|
149
|
+
* depth does not matter thanks to `composed: true`).
|
|
150
|
+
*
|
|
151
|
+
* @template T
|
|
152
|
+
*/
|
|
153
|
+
export class ContextProvider {
|
|
154
|
+
/**
|
|
155
|
+
* @param {import('./component.js').WebComponent} host
|
|
156
|
+
* The host component that owns this provider.
|
|
157
|
+
* @param {{ context: Context<T>, initialValue?: T }} options
|
|
158
|
+
*/
|
|
159
|
+
constructor(host, { context, initialValue }) {
|
|
160
|
+
/** @type {import('./component.js').WebComponent} */
|
|
161
|
+
this._host = host;
|
|
162
|
+
/** @type {Context<T>} */
|
|
163
|
+
this._context = context;
|
|
164
|
+
/** @type {T} */
|
|
165
|
+
this._value = /** @type {T} */ (initialValue);
|
|
166
|
+
/** @type {Set<Subscription<T>>} */
|
|
167
|
+
this._subscriptions = new Set();
|
|
168
|
+
|
|
169
|
+
/** @type {(e: Event) => void} */
|
|
170
|
+
this._onRequest = (e) => {
|
|
171
|
+
const evt = /** @type {ContextRequestEvent<T>} */ (e);
|
|
172
|
+
if (evt.context !== this._context) return;
|
|
173
|
+
|
|
174
|
+
// Prevent further providers higher in the tree from also responding.
|
|
175
|
+
e.stopPropagation();
|
|
176
|
+
|
|
177
|
+
if (evt.subscribe) {
|
|
178
|
+
/** @type {Subscription<T>} */
|
|
179
|
+
const sub = { callback: evt.callback };
|
|
180
|
+
const unsubscribe = () => { this._subscriptions.delete(sub); };
|
|
181
|
+
this._subscriptions.add(sub);
|
|
182
|
+
evt.callback(this._value, unsubscribe);
|
|
183
|
+
} else {
|
|
184
|
+
evt.callback(this._value);
|
|
185
|
+
}
|
|
186
|
+
};
|
|
187
|
+
|
|
188
|
+
// Follow the ReactiveController protocol.
|
|
189
|
+
if (typeof host.addController === 'function') {
|
|
190
|
+
host.addController(this);
|
|
191
|
+
}
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
/** @returns {T} The current provided value. */
|
|
195
|
+
get value() {
|
|
196
|
+
return this._value;
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
/**
|
|
200
|
+
* Update the provided value and notify all subscribers.
|
|
201
|
+
*
|
|
202
|
+
* Every subscribing consumer's callback is invoked synchronously with
|
|
203
|
+
* the new value. Each consumer then calls `host.requestUpdate()` to
|
|
204
|
+
* schedule a re-render: so one `setValue` batches all downstream
|
|
205
|
+
* re-renders via the microtask queue.
|
|
206
|
+
*
|
|
207
|
+
* @param {T} newValue
|
|
208
|
+
*/
|
|
209
|
+
setValue(newValue) {
|
|
210
|
+
if (Object.is(this._value, newValue)) return;
|
|
211
|
+
this._value = newValue;
|
|
212
|
+
for (const sub of this._subscriptions) {
|
|
213
|
+
const unsubscribe = () => { this._subscriptions.delete(sub); };
|
|
214
|
+
sub.callback(newValue, unsubscribe);
|
|
215
|
+
}
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
/** Called by the host's controller lifecycle when the element connects. */
|
|
219
|
+
hostConnected() {
|
|
220
|
+
this._host.addEventListener('context-request', this._onRequest);
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
/** Called by the host's controller lifecycle when the element disconnects. */
|
|
224
|
+
hostDisconnected() {
|
|
225
|
+
this._host.removeEventListener('context-request', this._onRequest);
|
|
226
|
+
this._subscriptions.clear();
|
|
227
|
+
}
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
// ---------------------------------------------------------------------------
|
|
231
|
+
// ContextConsumer
|
|
232
|
+
// ---------------------------------------------------------------------------
|
|
233
|
+
|
|
234
|
+
/**
|
|
235
|
+
* A ReactiveController that consumes a context value from an ancestor
|
|
236
|
+
* provider.
|
|
237
|
+
*
|
|
238
|
+
* On `hostConnected` it dispatches a `ContextRequestEvent`. If a provider
|
|
239
|
+
* for the matching context key exists higher in the DOM tree, the consumer
|
|
240
|
+
* receives the current value immediately (and, if `subscribe: true`, all
|
|
241
|
+
* future updates as well).
|
|
242
|
+
*
|
|
243
|
+
* ## AI guidance
|
|
244
|
+
*
|
|
245
|
+
* - Use `subscribe: true` (the default) when you want the consumer to
|
|
246
|
+
* update automatically whenever the provider calls `setValue()`. This is
|
|
247
|
+
* the common case for reactive data like theme, auth, or locale.
|
|
248
|
+
* - Use `subscribe: false` for one-shot reads where you only need the
|
|
249
|
+
* value at connection time (e.g. reading a static config once).
|
|
250
|
+
* - Access the value via `consumer.value`. It is `undefined` until a
|
|
251
|
+
* provider responds.
|
|
252
|
+
* - If no provider exists in the ancestor chain, `value` stays
|
|
253
|
+
* `undefined`: design your render method to handle that case.
|
|
254
|
+
*
|
|
255
|
+
* @template T
|
|
256
|
+
*/
|
|
257
|
+
export class ContextConsumer {
|
|
258
|
+
/**
|
|
259
|
+
* @param {import('./component.js').WebComponent} host
|
|
260
|
+
* The host component that owns this consumer.
|
|
261
|
+
* @param {{ context: Context<T>, subscribe?: boolean }} options
|
|
262
|
+
* `subscribe` defaults to `true`.
|
|
263
|
+
*/
|
|
264
|
+
constructor(host, { context, subscribe = true }) {
|
|
265
|
+
/** @type {import('./component.js').WebComponent} */
|
|
266
|
+
this._host = host;
|
|
267
|
+
/** @type {Context<T>} */
|
|
268
|
+
this._context = context;
|
|
269
|
+
/** @type {boolean} */
|
|
270
|
+
this._subscribe = subscribe;
|
|
271
|
+
/** @type {T | undefined} */
|
|
272
|
+
this._value = undefined;
|
|
273
|
+
/** @type {(() => void) | undefined} */
|
|
274
|
+
this._unsubscribe = undefined;
|
|
275
|
+
|
|
276
|
+
if (typeof host.addController === 'function') {
|
|
277
|
+
host.addController(this);
|
|
278
|
+
}
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
/**
|
|
282
|
+
* The current context value. `undefined` if no provider has responded
|
|
283
|
+
* yet.
|
|
284
|
+
* @returns {T | undefined}
|
|
285
|
+
*/
|
|
286
|
+
get value() {
|
|
287
|
+
return this._value;
|
|
288
|
+
}
|
|
289
|
+
|
|
290
|
+
/** Called by the host's controller lifecycle when the element connects. */
|
|
291
|
+
hostConnected() {
|
|
292
|
+
this._dispatchRequest();
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
/** Called by the host's controller lifecycle when the element disconnects. */
|
|
296
|
+
hostDisconnected() {
|
|
297
|
+
if (this._unsubscribe) {
|
|
298
|
+
this._unsubscribe();
|
|
299
|
+
this._unsubscribe = undefined;
|
|
300
|
+
}
|
|
301
|
+
}
|
|
302
|
+
|
|
303
|
+
/** @private */
|
|
304
|
+
_dispatchRequest() {
|
|
305
|
+
const event = new ContextRequestEvent(
|
|
306
|
+
this._context,
|
|
307
|
+
(value, unsubscribe) => {
|
|
308
|
+
// Guard against duplicate updates with the same value.
|
|
309
|
+
const old = this._value;
|
|
310
|
+
this._value = value;
|
|
311
|
+
if (unsubscribe) {
|
|
312
|
+
this._unsubscribe = unsubscribe;
|
|
313
|
+
}
|
|
314
|
+
// Trigger a re-render of the consuming component when the value
|
|
315
|
+
// changes (skip on the very first delivery during connection,
|
|
316
|
+
// since the host will render after connectedCallback anyway -
|
|
317
|
+
// but we still update to be safe with various host lifecycles).
|
|
318
|
+
if (!Object.is(old, value) && typeof this._host.requestUpdate === 'function') {
|
|
319
|
+
this._host.requestUpdate();
|
|
320
|
+
}
|
|
321
|
+
},
|
|
322
|
+
this._subscribe,
|
|
323
|
+
);
|
|
324
|
+
|
|
325
|
+
this._host.dispatchEvent(event);
|
|
326
|
+
}
|
|
327
|
+
}
|
package/src/css.js
ADDED
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Tagged template literal producing a {@link CSSResult}.
|
|
3
|
+
*
|
|
4
|
+
* @typedef {{ _$webjsCss: true, text: string }} CSSResult
|
|
5
|
+
*
|
|
6
|
+
* @param {TemplateStringsArray | string[]} strings
|
|
7
|
+
* @param {...unknown} values
|
|
8
|
+
* @returns {CSSResult}
|
|
9
|
+
*/
|
|
10
|
+
export function css(strings, ...values) {
|
|
11
|
+
let text = strings[0];
|
|
12
|
+
for (let i = 1; i < strings.length; i++) {
|
|
13
|
+
text += String(values[i - 1] ?? '') + strings[i];
|
|
14
|
+
}
|
|
15
|
+
return { _$webjsCss: true, text };
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
/** @param {unknown} x @returns {x is CSSResult} */
|
|
19
|
+
export function isCSS(x) {
|
|
20
|
+
return !!x && typeof x === 'object' && /** @type {any} */ (x)._$webjsCss === true;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* Apply an array of CSSResults to a shadow root (client-side only).
|
|
25
|
+
* Uses adoptedStyleSheets when available, falls back to <style>.
|
|
26
|
+
*
|
|
27
|
+
* @param {ShadowRoot | Document} root
|
|
28
|
+
* @param {CSSResult[]} styles
|
|
29
|
+
*/
|
|
30
|
+
export function adoptStyles(root, styles) {
|
|
31
|
+
if (!styles || !styles.length) return;
|
|
32
|
+
if ('adoptedStyleSheets' in root && typeof CSSStyleSheet !== 'undefined') {
|
|
33
|
+
const sheets = styles.map((s) => {
|
|
34
|
+
const sheet = new CSSStyleSheet();
|
|
35
|
+
sheet.replaceSync(s.text);
|
|
36
|
+
return sheet;
|
|
37
|
+
});
|
|
38
|
+
/** @type {any} */ (root).adoptedStyleSheets = sheets;
|
|
39
|
+
} else {
|
|
40
|
+
const host = /** @type {any} */ (root);
|
|
41
|
+
const el = document.createElement('style');
|
|
42
|
+
el.textContent = styles.map((s) => s.text).join('\n');
|
|
43
|
+
host.appendChild(el);
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/**
|
|
48
|
+
* Serialise styles to a <style> tag string (server-side).
|
|
49
|
+
* @param {CSSResult[]} styles
|
|
50
|
+
*/
|
|
51
|
+
export function stylesToString(styles) {
|
|
52
|
+
if (!styles || !styles.length) return '';
|
|
53
|
+
return `<style>${styles.map((s) => s.text).join('\n')}</style>`;
|
|
54
|
+
}
|