@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/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
+ }