@radishland/runtime 0.0.2 → 0.0.4

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/client/index.d.ts CHANGED
@@ -1,3 +1,89 @@
1
- export { HandlerRegistry } from "./handler-registry.ts";
2
- export * from "./reactivity.ts";
3
- export * from "./handlers.ts";
1
+ import { Signal } from 'signal-polyfill';
2
+
3
+ interface AutonomousCustomElement {
4
+ /**
5
+ * A static getter
6
+ */
7
+ readonly observedAttributes?: string[] | undefined;
8
+ /**
9
+ * A static getter
10
+ */
11
+ readonly disabledFeatures?: ("internals" | "shadow")[] | undefined;
12
+ /**
13
+ * A static getter
14
+ */
15
+ readonly formAssociated?: boolean | undefined;
16
+
17
+ connectedCallback?(): void;
18
+ disconnectedCallback?(): void;
19
+ adoptedCallback?(): void;
20
+
21
+ attributeChangedCallback?(
22
+ name: string,
23
+ previous: string,
24
+ next: string,
25
+ ): void;
26
+
27
+ formAssociatedCallback?(): void;
28
+ formResetCallback?(): void;
29
+ formDisabledCallback?(): void;
30
+ formStateRestoreCallback?(): void;
31
+ }
32
+
33
+ type ReactivityOptions = { deep: boolean };
34
+ type Destructor = () => void;
35
+ type EffectCallback = () => Destructor | void;
36
+ type EffectOptions = {
37
+ signal: AbortSignal;
38
+ };
39
+
40
+ /**
41
+ * A Scoped Handler Registry implements the logic for handling effect requests.
42
+ *
43
+ * Extend this class by adding new methods in your subclass to implement your own effect handlers
44
+ */
45
+ declare class HandlerRegistry extends HTMLElement implements AutonomousCustomElement {
46
+ #private;
47
+ [key: string]: any;
48
+ /**
49
+ * References the handler's `AbortController`.
50
+ *
51
+ * The `abort` method of the controller is called in the `disconnectedCallback` method. It allows to cleanup event handlers and other abortable operations
52
+ */
53
+ abortController: AbortController;
54
+ constructor();
55
+ /**
56
+ * Creates an effect that is automatically cleaned up when the component is disconnected
57
+ *
58
+ * An optional AbortSignal can be provided to abort the effect prematurely
59
+ */
60
+ $effect(callback: EffectCallback, options?: EffectOptions): void;
61
+ connectedCallback(): void;
62
+ disconnectedCallback(): void;
63
+ }
64
+
65
+ declare class ReactiveValue<T> extends Signal.State<T> {
66
+ private get;
67
+ private set;
68
+ get value(): T;
69
+ set value(newValue: T);
70
+ }
71
+ declare class ReactiveComputation<T> extends Signal.Computed<T> {
72
+ private get;
73
+ get value(): T;
74
+ }
75
+ declare const $object: <T extends Record<PropertyKey, any>>(init: T, options?: ReactivityOptions) => T;
76
+ declare const $array: <T extends ArrayLike<any>>(init: T, options?: ReactivityOptions) => T;
77
+ declare const isState: (s: unknown) => s is InstanceType<typeof ReactiveValue>;
78
+ declare const isComputed: (s: unknown) => s is InstanceType<typeof ReactiveComputation>;
79
+ declare const getValue: (signal: unknown) => unknown;
80
+ declare const $state: <T>(initialValue: T, options?: Signal.Options<T | undefined>) => ReactiveValue<T>;
81
+ declare const $computed: <T>(computation: () => T, options?: Signal.Options<T>) => ReactiveComputation<T>;
82
+ /**
83
+ * Create an unowned effect that must be cleanup up manually
84
+ *
85
+ * Accept an AbortSignal to abort the effect
86
+ */
87
+ declare const $effect: (cb: EffectCallback, options?: EffectOptions) => Destructor;
88
+
89
+ export { $array, $computed, $effect, $object, $state, HandlerRegistry, ReactiveComputation, ReactiveValue, getValue, isComputed, isState };
package/client/index.js CHANGED
@@ -1,3 +1,515 @@
1
- export { HandlerRegistry } from "./handler-registry.js";
2
- export * from "./reactivity.js";
3
- export * from "./handlers.js";
1
+ import { Signal } from 'signal-polyfill';
2
+
3
+ // src/utils.ts
4
+ var spaces_sep_by_comma = /\s*,\s*/;
5
+ var booleanAttributes = [
6
+ "allowfullscreen",
7
+ // on <iframe>
8
+ "async",
9
+ // on <script>
10
+ "autofocus",
11
+ // on <button>, <input>, <select>, <textarea>
12
+ "autoplay",
13
+ // on <audio>, <video>
14
+ "checked",
15
+ // on <input type="checkbox">, <input type="radio">
16
+ "controls",
17
+ // on <audio>, <video>
18
+ "default",
19
+ // on <track>
20
+ "defer",
21
+ // on <script>
22
+ "disabled",
23
+ // on form elements like <button>, <fieldset>, <input>, <optgroup>, <option>,<select>, <textarea>
24
+ "formnovalidate",
25
+ // on <button>, <input type="submit">
26
+ "hidden",
27
+ // global
28
+ "inert",
29
+ // global
30
+ "ismap",
31
+ // on <img>
32
+ "itemscope",
33
+ // global; part of microdata
34
+ "loop",
35
+ // on <audio>, <video>
36
+ "multiple",
37
+ // on <input type="file">, <select>
38
+ "muted",
39
+ // on <audio>, <video>
40
+ "nomodule",
41
+ // on <script>
42
+ "novalidate",
43
+ // on <form>
44
+ "open",
45
+ // on <details>
46
+ "readonly",
47
+ // on <input>, <textarea>
48
+ "required",
49
+ // on <input>, <select>, <textarea>
50
+ "reversed",
51
+ // on <ol>
52
+ "selected"
53
+ // on <option>
54
+ ];
55
+
56
+ // src/config.ts
57
+ var bindingConfig = {
58
+ "checked": {
59
+ element: ["input"],
60
+ type: ["boolean"],
61
+ event: "change"
62
+ },
63
+ "value": {
64
+ element: ["input", "select", "textarea"],
65
+ type: ["string", "number"],
66
+ event: "input"
67
+ }
68
+ };
69
+ var ReactiveValue = class extends Signal.State {
70
+ // @ts-ignore see above
71
+ get;
72
+ // @ts-ignore see above
73
+ set;
74
+ get value() {
75
+ return super.get();
76
+ }
77
+ set value(newValue) {
78
+ super.set(newValue);
79
+ }
80
+ };
81
+ var ReactiveComputation = class extends Signal.Computed {
82
+ // @ts-ignore see above
83
+ get;
84
+ get value() {
85
+ return super.get();
86
+ }
87
+ };
88
+ var maybeReactiveObjectType = (thing, options) => {
89
+ if (typeof thing === "object") {
90
+ if (Array.isArray(thing)) {
91
+ return $array(thing, options);
92
+ } else if (thing) {
93
+ return $object(thing, options);
94
+ }
95
+ }
96
+ return thing;
97
+ };
98
+ var $object = (init, options = { deep: false }) => {
99
+ if (options.deep === true) {
100
+ for (const [key, value] of Object.entries(init)) {
101
+ init[key] = maybeReactiveObjectType(value, options);
102
+ }
103
+ }
104
+ const state = new Signal.State(init);
105
+ const proxy = new Proxy(init, {
106
+ get(_target, p, _receiver) {
107
+ return state.get()[p];
108
+ },
109
+ set(_target, p, newValue, _receiver) {
110
+ state.set({
111
+ ...state.get(),
112
+ [p]: maybeReactiveObjectType(newValue, options)
113
+ });
114
+ return true;
115
+ }
116
+ });
117
+ return proxy;
118
+ };
119
+ var $array = (init, options = { deep: false }) => {
120
+ if (options.deep) {
121
+ for (const [key, value] of Object.entries(init)) {
122
+ init[key] = maybeReactiveObjectType(value, options);
123
+ }
124
+ }
125
+ const state = new Signal.State(init);
126
+ const proxy = new Proxy(init, {
127
+ get(_target, p, _receiver) {
128
+ return state.get()[p];
129
+ },
130
+ set(_target, p, newValue, _receiver) {
131
+ state.set({
132
+ ...state.get(),
133
+ [p]: maybeReactiveObjectType(newValue, options)
134
+ });
135
+ return true;
136
+ }
137
+ });
138
+ return proxy;
139
+ };
140
+ var isState = (s) => {
141
+ return Signal.isState(s);
142
+ };
143
+ var isComputed = (s) => {
144
+ return Signal.isComputed(s);
145
+ };
146
+ var getValue = (signal) => {
147
+ if (isState(signal) || isComputed(signal)) {
148
+ return signal.value;
149
+ }
150
+ return signal;
151
+ };
152
+ var $state = (initialValue, options) => {
153
+ return new ReactiveValue(initialValue, options);
154
+ };
155
+ var $computed = (computation, options) => {
156
+ return new ReactiveComputation(computation, options);
157
+ };
158
+ var pending = false;
159
+ var watcher = new Signal.subtle.Watcher(() => {
160
+ if (!pending) {
161
+ pending = true;
162
+ queueMicrotask(() => {
163
+ pending = false;
164
+ for (const s of watcher.getPending()) s.get();
165
+ watcher.watch();
166
+ });
167
+ }
168
+ });
169
+ var $effect = (cb, options) => {
170
+ if (options?.signal?.aborted) return () => {
171
+ };
172
+ let destroy;
173
+ const c = new Signal.Computed(() => {
174
+ destroy?.();
175
+ destroy = cb() ?? void 0;
176
+ });
177
+ watcher.watch(c);
178
+ c.get();
179
+ let cleaned = false;
180
+ const cleanup = () => {
181
+ if (cleaned) return;
182
+ destroy?.();
183
+ watcher.unwatch(c);
184
+ cleaned = true;
185
+ };
186
+ options?.signal.addEventListener("abort", cleanup);
187
+ return cleanup;
188
+ };
189
+
190
+ // src/handler-registry.ts
191
+ var HandlerRegistry = class extends HTMLElement {
192
+ #cleanup = [];
193
+ /**
194
+ * References the handler's `AbortController`.
195
+ *
196
+ * The `abort` method of the controller is called in the `disconnectedCallback` method. It allows to cleanup event handlers and other abortable operations
197
+ */
198
+ abortController;
199
+ constructor() {
200
+ super();
201
+ this.abortController = new AbortController();
202
+ }
203
+ /**
204
+ * Creates an effect that is automatically cleaned up when the component is disconnected
205
+ *
206
+ * An optional AbortSignal can be provided to abort the effect prematurely
207
+ */
208
+ $effect(callback, options) {
209
+ const signals = [this.abortController.signal];
210
+ if (options?.signal) signals.push(options.signal);
211
+ $effect(callback, { ...options, signal: AbortSignal.any(signals) });
212
+ }
213
+ #get(identifier) {
214
+ return this[identifier];
215
+ }
216
+ #handleOn(e) {
217
+ if (e instanceof CustomEvent) {
218
+ const { handler, type } = e.detail;
219
+ if (handler in this && typeof this.#get(handler) === "function") {
220
+ e.target?.addEventListener(type, this.#get(handler).bind(this));
221
+ e.stopPropagation();
222
+ }
223
+ }
224
+ }
225
+ #handleClass(e) {
226
+ const target = e.target;
227
+ if (e instanceof CustomEvent && target) {
228
+ const { identifier } = e.detail;
229
+ if (identifier in this) {
230
+ this.$effect(() => {
231
+ const classList = getValue(this.#get(identifier));
232
+ if (classList && typeof classList === "object") {
233
+ for (const [k, v] of Object.entries(classList)) {
234
+ const force = !!getValue(v);
235
+ for (const className of k.split(" ")) {
236
+ target.classList.toggle(
237
+ className,
238
+ force
239
+ );
240
+ }
241
+ }
242
+ }
243
+ });
244
+ e.stopPropagation();
245
+ }
246
+ }
247
+ }
248
+ #handleUse(e) {
249
+ if (e instanceof CustomEvent) {
250
+ const { hook } = e.detail;
251
+ if (hook in this && typeof this.#get(hook) === "function") {
252
+ const cleanup = this.#get(hook).bind(this)(e.target);
253
+ if (typeof cleanup === "function") {
254
+ this.#cleanup.push(cleanup);
255
+ }
256
+ e.stopPropagation();
257
+ }
258
+ }
259
+ }
260
+ #handleAttr(e) {
261
+ if (e instanceof CustomEvent) {
262
+ const { identifier, attribute } = e.detail;
263
+ const target = e.target;
264
+ if (identifier in this && target instanceof HTMLElement && attribute in target) {
265
+ const ref = this.#get(identifier);
266
+ const setAttr = () => {
267
+ const value = getValue(ref);
268
+ if (booleanAttributes.includes(attribute)) {
269
+ value ? target.setAttribute(attribute, "") : target.removeAttribute(attribute);
270
+ } else {
271
+ target.setAttribute(attribute, `${value}`);
272
+ }
273
+ };
274
+ if (isState(ref) || isComputed(ref)) {
275
+ this.$effect(() => setAttr());
276
+ } else {
277
+ setAttr();
278
+ }
279
+ e.stopPropagation();
280
+ }
281
+ }
282
+ }
283
+ #handleProp(e) {
284
+ if (e instanceof CustomEvent) {
285
+ const { identifier, property } = e.detail;
286
+ const target = e.target;
287
+ if (identifier in this && target && property in target) {
288
+ const ref = this.#get(identifier);
289
+ const setProp = () => {
290
+ const value = getValue(ref);
291
+ target[property] = value;
292
+ };
293
+ if (isState(ref) || isComputed(ref)) {
294
+ this.$effect(() => setProp());
295
+ } else {
296
+ setProp();
297
+ }
298
+ e.stopPropagation();
299
+ }
300
+ }
301
+ }
302
+ #handleText(e) {
303
+ if (e instanceof CustomEvent) {
304
+ const target = e.target;
305
+ const { identifier } = e.detail;
306
+ if (identifier in this && target instanceof HTMLElement) {
307
+ const ref = this.#get(identifier);
308
+ const setTextContent = () => {
309
+ const value = getValue(ref);
310
+ target.textContent = `${value}`;
311
+ };
312
+ if (isState(ref) || isComputed(ref)) {
313
+ this.$effect(() => setTextContent());
314
+ } else {
315
+ setTextContent();
316
+ }
317
+ e.stopPropagation();
318
+ }
319
+ }
320
+ }
321
+ #handleHTML(e) {
322
+ if (e instanceof CustomEvent) {
323
+ const { identifier } = e.detail;
324
+ const target = e.target;
325
+ if (identifier in this && target instanceof HTMLElement) {
326
+ const ref = this.#get(identifier);
327
+ const setInnerHTML = () => {
328
+ const value = getValue(ref);
329
+ target.innerHTML = `${value}`;
330
+ };
331
+ if (isState(ref) || isComputed(ref)) {
332
+ this.$effect(() => setInnerHTML());
333
+ } else {
334
+ setInnerHTML();
335
+ }
336
+ e.stopPropagation();
337
+ }
338
+ }
339
+ }
340
+ #handleBind(e) {
341
+ if (e instanceof CustomEvent) {
342
+ const { identifier, property } = e.detail;
343
+ const target = e.target;
344
+ if (identifier in this && target instanceof HTMLElement && property in target) {
345
+ const state = this.#get(identifier);
346
+ if (isState(state)) {
347
+ state.value = target[property];
348
+ target.addEventListener(bindingConfig[property].event, () => {
349
+ state.value = target[property];
350
+ });
351
+ this.$effect(() => {
352
+ target[property] = state.value;
353
+ });
354
+ }
355
+ e.stopPropagation();
356
+ }
357
+ }
358
+ }
359
+ connectedCallback() {
360
+ const { signal } = this.abortController;
361
+ this.addEventListener("@attr-request", this.#handleAttr, { signal });
362
+ this.addEventListener("@class-request", this.#handleClass, { signal });
363
+ this.addEventListener("@on-request", this.#handleOn, { signal });
364
+ this.addEventListener("@use-request", this.#handleUse, { signal });
365
+ this.addEventListener("@prop-request", this.#handleProp, { signal });
366
+ this.addEventListener("@html-request", this.#handleHTML, { signal });
367
+ this.addEventListener("@text-request", this.#handleText, { signal });
368
+ this.addEventListener("@bind-request", this.#handleBind, { signal });
369
+ }
370
+ disconnectedCallback() {
371
+ this.abortController.abort();
372
+ for (const cleanup of this.#cleanup) {
373
+ cleanup();
374
+ }
375
+ }
376
+ };
377
+ if (window && !customElements.get("handler-registry")) {
378
+ customElements.define("handler-registry", HandlerRegistry);
379
+ }
380
+
381
+ // src/handlers.ts
382
+ var bindingsQueryString = Object.keys(bindingConfig).map(
383
+ (property) => `[\\@bind\\:${property}]`
384
+ ).join(",");
385
+ setTimeout(() => {
386
+ customElements?.whenDefined("handler-registry").then(() => {
387
+ document.querySelectorAll(
388
+ `[\\@on],[\\@use],[\\@attr],[\\@attr\\|client],[\\@prop],${bindingsQueryString},[\\@text],[\\@html]`
389
+ ).forEach(
390
+ (entry) => {
391
+ const events = entry.getAttribute("@on")?.trim()?.split(spaces_sep_by_comma);
392
+ if (events) {
393
+ for (const event of events) {
394
+ const [type, handler] = event.split(":");
395
+ const onRequest = new CustomEvent("@on-request", {
396
+ bubbles: true,
397
+ cancelable: true,
398
+ composed: true,
399
+ detail: {
400
+ type,
401
+ handler: handler || type
402
+ }
403
+ });
404
+ entry.dispatchEvent(onRequest);
405
+ }
406
+ }
407
+ const hooks = entry.getAttribute("@use")?.trim()?.split(spaces_sep_by_comma);
408
+ if (hooks) {
409
+ for (const hook of hooks) {
410
+ const useRequest = new CustomEvent("@use-request", {
411
+ bubbles: true,
412
+ cancelable: true,
413
+ composed: true,
414
+ detail: {
415
+ hook
416
+ }
417
+ });
418
+ entry.dispatchEvent(useRequest);
419
+ }
420
+ }
421
+ const props = entry.getAttribute("@prop")?.trim().split(spaces_sep_by_comma);
422
+ if (props) {
423
+ for (const prop of props) {
424
+ const [key, value] = prop.split(":");
425
+ const propRequest = new CustomEvent("@prop-request", {
426
+ bubbles: true,
427
+ cancelable: true,
428
+ composed: true,
429
+ detail: {
430
+ property: key,
431
+ identifier: value || key
432
+ }
433
+ });
434
+ entry.dispatchEvent(propRequest);
435
+ }
436
+ }
437
+ const text = entry.hasAttribute("@text");
438
+ if (text) {
439
+ const identifier = entry.getAttribute("@text") || "text";
440
+ const textRequest = new CustomEvent("@text-request", {
441
+ bubbles: true,
442
+ cancelable: true,
443
+ composed: true,
444
+ detail: {
445
+ identifier
446
+ }
447
+ });
448
+ entry.dispatchEvent(textRequest);
449
+ }
450
+ const html = entry.hasAttribute("@html");
451
+ if (html) {
452
+ const identifier = entry.getAttribute("@html") || "html";
453
+ const htmlRequest = new CustomEvent("@html-request", {
454
+ bubbles: true,
455
+ cancelable: true,
456
+ composed: true,
457
+ detail: {
458
+ identifier
459
+ }
460
+ });
461
+ entry.dispatchEvent(htmlRequest);
462
+ }
463
+ const classList = entry.hasAttribute("@class");
464
+ if (classList) {
465
+ const identifier = entry.getAttribute("@class") || "class";
466
+ const classRequest = new CustomEvent("@class-request", {
467
+ bubbles: true,
468
+ cancelable: true,
469
+ composed: true,
470
+ detail: {
471
+ identifier
472
+ }
473
+ });
474
+ entry.dispatchEvent(classRequest);
475
+ }
476
+ const attributes = [
477
+ ...entry.getAttribute("@attr")?.trim().split(spaces_sep_by_comma) ?? [],
478
+ ...entry.getAttribute("@attr|client")?.trim().split(spaces_sep_by_comma) ?? []
479
+ ];
480
+ if (attributes.length > 0) {
481
+ for (const attribute of attributes) {
482
+ const [key, value] = attribute.split(":");
483
+ const attrRequest = new CustomEvent("@attr-request", {
484
+ bubbles: true,
485
+ cancelable: true,
486
+ composed: true,
487
+ detail: {
488
+ attribute: key,
489
+ identifier: value || key
490
+ }
491
+ });
492
+ entry.dispatchEvent(attrRequest);
493
+ }
494
+ }
495
+ for (const property of Object.keys(bindingConfig)) {
496
+ if (entry.hasAttribute(`@bind:${property}`)) {
497
+ const identifier = entry.getAttribute(`@bind:${property}`)?.trim() || property;
498
+ const bindRequest = new CustomEvent("@bind-request", {
499
+ bubbles: true,
500
+ cancelable: true,
501
+ composed: true,
502
+ detail: {
503
+ property,
504
+ identifier
505
+ }
506
+ });
507
+ entry.dispatchEvent(bindRequest);
508
+ }
509
+ }
510
+ }
511
+ );
512
+ });
513
+ }, 100);
514
+
515
+ export { $array, $computed, $effect, $object, $state, HandlerRegistry, ReactiveComputation, ReactiveValue, getValue, isComputed, isState };
package/package.json CHANGED
@@ -1,21 +1,26 @@
1
1
  {
2
2
  "name": "@radishland/runtime",
3
- "version": "0.0.2",
3
+ "version": "0.0.4",
4
4
  "type": "module",
5
5
  "description": "The Radish runtime",
6
6
  "author": "Frédéric Crozatier",
7
7
  "license": "MIT",
8
8
  "scripts": {
9
- "build": "tsc",
10
- "prepublishOnly": "pnpm build && cp src/types.d.ts client/"
9
+ "build": "tsup",
10
+ "prepublishOnly": "pnpm build"
11
11
  },
12
12
  "main": "./client/index.js",
13
13
  "exports": {
14
14
  ".": "./client/index.js"
15
15
  },
16
- "files": ["./client", "README.md", "LICENCE"],
16
+ "files": [
17
+ "./client",
18
+ "README.md",
19
+ "LICENCE"
20
+ ],
17
21
  "dependencies": {
18
- "signal-polyfill": "^0.2.2"
22
+ "signal-polyfill": "^0.2.2",
23
+ "tsup": "^8.4.0"
19
24
  },
20
25
  "devDependencies": {
21
26
  "typescript": "^5.7.3"
@@ -1,12 +0,0 @@
1
- export declare const bindingConfig: {
2
- checked: {
3
- element: string[];
4
- type: string[];
5
- event: string;
6
- };
7
- value: {
8
- element: string[];
9
- type: string[];
10
- event: string;
11
- };
12
- };
package/client/config.js DELETED
@@ -1,12 +0,0 @@
1
- export const bindingConfig = {
2
- "checked": {
3
- element: ["input"],
4
- type: ["boolean"],
5
- event: "change",
6
- },
7
- "value": {
8
- element: ["input", "select", "textarea"],
9
- type: ["string", "number"],
10
- event: "input",
11
- },
12
- };
@@ -1,25 +0,0 @@
1
- import type { AutonomousCustomElement, EffectCallback, EffectOptions } from "./types.d.ts";
2
- /**
3
- * A Scoped Handler Registry implements the logic for handling effect requests.
4
- *
5
- * Extend this class by adding new methods in your subclass to implement your own effect handlers
6
- */
7
- export declare class HandlerRegistry extends HTMLElement implements AutonomousCustomElement {
8
- #private;
9
- [key: string]: any;
10
- /**
11
- * References the handler's `AbortController`.
12
- *
13
- * The `abort` method of the controller is called in the `disconnectedCallback` method. It allows to cleanup event handlers and other abortable operations
14
- */
15
- abortController: AbortController;
16
- constructor();
17
- /**
18
- * Creates an effect that is automatically cleaned up when the component is disconnected
19
- *
20
- * An optional AbortSignal can be provided to abort the effect prematurely
21
- */
22
- $effect(callback: EffectCallback, options?: EffectOptions): void;
23
- connectedCallback(): void;
24
- disconnectedCallback(): void;
25
- }
@@ -1,211 +0,0 @@
1
- import { booleanAttributes } from "./utils.js";
2
- import { bindingConfig } from "./config.js";
3
- import { $effect, getValue, isComputed, isState } from "./reactivity.js";
4
- /**
5
- * A Scoped Handler Registry implements the logic for handling effect requests.
6
- *
7
- * Extend this class by adding new methods in your subclass to implement your own effect handlers
8
- */
9
- export class HandlerRegistry extends HTMLElement {
10
- #cleanup = [];
11
- /**
12
- * References the handler's `AbortController`.
13
- *
14
- * The `abort` method of the controller is called in the `disconnectedCallback` method. It allows to cleanup event handlers and other abortable operations
15
- */
16
- abortController;
17
- constructor() {
18
- super();
19
- this.abortController = new AbortController();
20
- }
21
- /**
22
- * Creates an effect that is automatically cleaned up when the component is disconnected
23
- *
24
- * An optional AbortSignal can be provided to abort the effect prematurely
25
- */
26
- $effect(callback, options) {
27
- const signals = [this.abortController.signal];
28
- if (options?.signal)
29
- signals.push(options.signal);
30
- $effect(callback, { ...options, signal: AbortSignal.any(signals) });
31
- }
32
- #get(identifier) {
33
- return this[identifier];
34
- }
35
- #handleOn(e) {
36
- if (e instanceof CustomEvent) {
37
- const { handler, type } = e.detail;
38
- if (handler in this && typeof this.#get(handler) === "function") {
39
- e.target?.addEventListener(type, this.#get(handler).bind(this));
40
- e.stopPropagation();
41
- }
42
- }
43
- }
44
- #handleClass(e) {
45
- const target = e.target;
46
- if (e instanceof CustomEvent && target) {
47
- const { identifier } = e.detail;
48
- if (identifier in this) {
49
- this.$effect(() => {
50
- const classList = getValue(this.#get(identifier));
51
- if (classList && typeof classList === "object") {
52
- for (const [k, v] of Object.entries(classList)) {
53
- const force = !!getValue(v);
54
- for (const className of k.split(" ")) {
55
- // @ts-ignore target is an HTMLElement
56
- target.classList.toggle(className, force);
57
- }
58
- }
59
- }
60
- });
61
- e.stopPropagation();
62
- }
63
- }
64
- }
65
- #handleUse(e) {
66
- if (e instanceof CustomEvent) {
67
- const { hook } = e.detail;
68
- if (hook in this && typeof this.#get(hook) === "function") {
69
- const cleanup = this.#get(hook).bind(this)(e.target);
70
- if (typeof cleanup === "function") {
71
- this.#cleanup.push(cleanup);
72
- }
73
- e.stopPropagation();
74
- }
75
- }
76
- }
77
- #handleAttr(e) {
78
- if (e instanceof CustomEvent) {
79
- const { identifier, attribute } = e.detail;
80
- const target = e.target;
81
- if (identifier in this && target instanceof HTMLElement &&
82
- attribute in target) {
83
- const ref = this.#get(identifier);
84
- const setAttr = () => {
85
- const value = getValue(ref);
86
- if (booleanAttributes.includes(attribute)) {
87
- value
88
- ? target.setAttribute(attribute, "")
89
- : target.removeAttribute(attribute);
90
- }
91
- else {
92
- target.setAttribute(attribute, `${value}`);
93
- }
94
- };
95
- if (isState(ref) || isComputed(ref)) {
96
- this.$effect(() => setAttr());
97
- }
98
- else {
99
- setAttr();
100
- }
101
- e.stopPropagation();
102
- }
103
- }
104
- }
105
- #handleProp(e) {
106
- if (e instanceof CustomEvent) {
107
- const { identifier, property } = e.detail;
108
- const target = e.target;
109
- if (identifier in this && target && property in target) {
110
- const ref = this.#get(identifier);
111
- const setProp = () => {
112
- const value = getValue(ref);
113
- // @ts-ignore property is in target
114
- target[property] = value;
115
- };
116
- if (isState(ref) || isComputed(ref)) {
117
- this.$effect(() => setProp());
118
- }
119
- else {
120
- setProp();
121
- }
122
- e.stopPropagation();
123
- }
124
- }
125
- }
126
- #handleText(e) {
127
- if (e instanceof CustomEvent) {
128
- const target = e.target;
129
- const { identifier } = e.detail;
130
- if (identifier in this && target instanceof HTMLElement) {
131
- const ref = this.#get(identifier);
132
- const setTextContent = () => {
133
- const value = getValue(ref);
134
- target.textContent = `${value}`;
135
- };
136
- if (isState(ref) || isComputed(ref)) {
137
- this.$effect(() => setTextContent());
138
- }
139
- else {
140
- setTextContent();
141
- }
142
- e.stopPropagation();
143
- }
144
- }
145
- }
146
- #handleHTML(e) {
147
- if (e instanceof CustomEvent) {
148
- const { identifier } = e.detail;
149
- const target = e.target;
150
- if (identifier in this && target instanceof HTMLElement) {
151
- const ref = this.#get(identifier);
152
- const setInnerHTML = () => {
153
- const value = getValue(ref);
154
- target.innerHTML = `${value}`;
155
- };
156
- if (isState(ref) || isComputed(ref)) {
157
- this.$effect(() => setInnerHTML());
158
- }
159
- else {
160
- setInnerHTML();
161
- }
162
- e.stopPropagation();
163
- }
164
- }
165
- }
166
- #handleBind(e) {
167
- if (e instanceof CustomEvent) {
168
- const { identifier, property } = e.detail;
169
- const target = e.target;
170
- if (identifier in this && target instanceof HTMLElement &&
171
- property in target) {
172
- const state = this.#get(identifier);
173
- if (isState(state)) {
174
- // @ts-ignore property is in target
175
- state.value = target[property];
176
- // Add change listener
177
- target.addEventListener(bindingConfig[property].event, () => {
178
- // @ts-ignore property is in target
179
- state.value = target[property];
180
- });
181
- // Sync
182
- this.$effect(() => {
183
- // @ts-ignore property is in target
184
- target[property] = state.value;
185
- });
186
- }
187
- e.stopPropagation();
188
- }
189
- }
190
- }
191
- connectedCallback() {
192
- const { signal } = this.abortController;
193
- this.addEventListener("@attr-request", this.#handleAttr, { signal });
194
- this.addEventListener("@class-request", this.#handleClass, { signal });
195
- this.addEventListener("@on-request", this.#handleOn, { signal });
196
- this.addEventListener("@use-request", this.#handleUse, { signal });
197
- this.addEventListener("@prop-request", this.#handleProp, { signal });
198
- this.addEventListener("@html-request", this.#handleHTML, { signal });
199
- this.addEventListener("@text-request", this.#handleText, { signal });
200
- this.addEventListener("@bind-request", this.#handleBind, { signal });
201
- }
202
- disconnectedCallback() {
203
- this.abortController.abort();
204
- for (const cleanup of this.#cleanup) {
205
- cleanup();
206
- }
207
- }
208
- }
209
- if (window && !customElements.get("handler-registry")) {
210
- customElements.define("handler-registry", HandlerRegistry);
211
- }
@@ -1 +0,0 @@
1
- export {};
@@ -1,136 +0,0 @@
1
- import { bindingConfig } from "./config.js";
2
- import { spaces_sep_by_comma } from "./utils.js";
3
- const bindingsQueryString = Object.keys(bindingConfig).map((property) => `[\\@bind\\:${property}]`).join(",");
4
- setTimeout(() => {
5
- customElements?.whenDefined("handler-registry").then(() => {
6
- document.querySelectorAll(`[\\@on],[\\@use],[\\@attr],[\\@attr\\|client],[\\@prop],${bindingsQueryString},[\\@text],[\\@html]`)
7
- .forEach((entry) => {
8
- const events = entry.getAttribute("@on")?.trim()
9
- ?.split(spaces_sep_by_comma);
10
- if (events) {
11
- for (const event of events) {
12
- const [type, handler] = event.split(":");
13
- const onRequest = new CustomEvent("@on-request", {
14
- bubbles: true,
15
- cancelable: true,
16
- composed: true,
17
- detail: {
18
- type,
19
- handler: handler || type,
20
- },
21
- });
22
- entry.dispatchEvent(onRequest);
23
- }
24
- }
25
- const hooks = entry.getAttribute("@use")?.trim()
26
- ?.split(spaces_sep_by_comma);
27
- if (hooks) {
28
- for (const hook of hooks) {
29
- const useRequest = new CustomEvent("@use-request", {
30
- bubbles: true,
31
- cancelable: true,
32
- composed: true,
33
- detail: {
34
- hook,
35
- },
36
- });
37
- entry.dispatchEvent(useRequest);
38
- }
39
- }
40
- const props = (entry.getAttribute("@prop"))?.trim()
41
- .split(spaces_sep_by_comma);
42
- if (props) {
43
- for (const prop of props) {
44
- const [key, value] = prop.split(":");
45
- const propRequest = new CustomEvent("@prop-request", {
46
- bubbles: true,
47
- cancelable: true,
48
- composed: true,
49
- detail: {
50
- property: key,
51
- identifier: value || key,
52
- },
53
- });
54
- entry.dispatchEvent(propRequest);
55
- }
56
- }
57
- const text = entry.hasAttribute("@text");
58
- if (text) {
59
- const identifier = entry.getAttribute("@text") || "text";
60
- const textRequest = new CustomEvent("@text-request", {
61
- bubbles: true,
62
- cancelable: true,
63
- composed: true,
64
- detail: {
65
- identifier,
66
- },
67
- });
68
- entry.dispatchEvent(textRequest);
69
- }
70
- const html = entry.hasAttribute("@html");
71
- if (html) {
72
- const identifier = entry.getAttribute("@html") || "html";
73
- const htmlRequest = new CustomEvent("@html-request", {
74
- bubbles: true,
75
- cancelable: true,
76
- composed: true,
77
- detail: {
78
- identifier,
79
- },
80
- });
81
- entry.dispatchEvent(htmlRequest);
82
- }
83
- const classList = entry.hasAttribute("@class");
84
- if (classList) {
85
- const identifier = entry.getAttribute("@class") || "class";
86
- const classRequest = new CustomEvent("@class-request", {
87
- bubbles: true,
88
- cancelable: true,
89
- composed: true,
90
- detail: {
91
- identifier,
92
- },
93
- });
94
- entry.dispatchEvent(classRequest);
95
- }
96
- const attributes = [
97
- ...(entry.getAttribute("@attr"))?.trim()
98
- .split(spaces_sep_by_comma) ?? [],
99
- ...(entry.getAttribute("@attr|client"))?.trim()
100
- .split(spaces_sep_by_comma) ?? [],
101
- ];
102
- if (attributes.length > 0) {
103
- for (const attribute of attributes) {
104
- const [key, value] = attribute.split(":");
105
- const attrRequest = new CustomEvent("@attr-request", {
106
- bubbles: true,
107
- cancelable: true,
108
- composed: true,
109
- detail: {
110
- attribute: key,
111
- identifier: value || key,
112
- },
113
- });
114
- entry.dispatchEvent(attrRequest);
115
- }
116
- }
117
- for (const property of Object.keys(bindingConfig)) {
118
- if (entry.hasAttribute(`@bind:${property}`)) {
119
- const identifier = entry.getAttribute(`@bind:${property}`)?.trim() ||
120
- property;
121
- const bindRequest = new CustomEvent("@bind-request", {
122
- bubbles: true,
123
- cancelable: true,
124
- composed: true,
125
- detail: {
126
- property,
127
- identifier,
128
- handled: false,
129
- },
130
- });
131
- entry.dispatchEvent(bindRequest);
132
- }
133
- }
134
- });
135
- });
136
- }, 100);
@@ -1,25 +0,0 @@
1
- import { Signal } from "signal-polyfill";
2
- import type { Destructor, EffectCallback, EffectOptions, ReactivityOptions } from "./types.d.ts";
3
- export declare class ReactiveValue<T> extends Signal.State<T> {
4
- private get;
5
- private set;
6
- get value(): T;
7
- set value(newValue: T);
8
- }
9
- export declare class ReactiveComputation<T> extends Signal.Computed<T> {
10
- private get;
11
- get value(): T;
12
- }
13
- export declare const $object: <T extends Record<PropertyKey, any>>(init: T, options?: ReactivityOptions) => T;
14
- export declare const $array: <T extends ArrayLike<any>>(init: T, options?: ReactivityOptions) => T;
15
- export declare const isState: (s: unknown) => s is InstanceType<typeof ReactiveValue>;
16
- export declare const isComputed: (s: unknown) => s is InstanceType<typeof ReactiveComputation>;
17
- export declare const getValue: (signal: unknown) => unknown;
18
- export declare const $state: <T>(initialValue: T, options?: Signal.Options<T | undefined>) => ReactiveValue<T>;
19
- export declare const $computed: <T>(computation: () => T, options?: Signal.Options<T>) => ReactiveComputation<T>;
20
- /**
21
- * Create an unowned effect that must be cleanup up manually
22
- *
23
- * Accept an AbortSignal to abort the effect
24
- */
25
- export declare const $effect: (cb: EffectCallback, options?: EffectOptions) => Destructor;
@@ -1,132 +0,0 @@
1
- import { Signal } from "signal-polyfill";
2
- // @ts-ignore we're hiding get and set
3
- export class ReactiveValue extends Signal.State {
4
- // @ts-ignore see above
5
- get;
6
- // @ts-ignore see above
7
- set;
8
- get value() {
9
- return super.get();
10
- }
11
- set value(newValue) {
12
- super.set(newValue);
13
- }
14
- }
15
- // @ts-ignore we're hiding get and set
16
- export class ReactiveComputation extends Signal.Computed {
17
- // @ts-ignore see above
18
- get;
19
- get value() {
20
- return super.get();
21
- }
22
- }
23
- const maybeReactiveObjectType = (thing, options) => {
24
- if (typeof thing === "object") {
25
- if (Array.isArray(thing)) {
26
- return $array(thing, options);
27
- }
28
- else if (thing) {
29
- return $object(thing, options);
30
- }
31
- }
32
- return thing;
33
- };
34
- export const $object = (init, options = { deep: false }) => {
35
- if (options.deep === true) {
36
- for (const [key, value] of Object.entries(init)) {
37
- init[key] = maybeReactiveObjectType(value, options);
38
- }
39
- }
40
- const state = new Signal.State(init);
41
- const proxy = new Proxy(init, {
42
- get(_target, p, _receiver) {
43
- return state.get()[p];
44
- },
45
- set(_target, p, newValue, _receiver) {
46
- state.set({
47
- ...state.get(),
48
- [p]: maybeReactiveObjectType(newValue, options),
49
- });
50
- return true;
51
- },
52
- });
53
- return proxy;
54
- };
55
- export const $array = (init, options = { deep: false }) => {
56
- if (options.deep) {
57
- for (const [key, value] of Object.entries(init)) {
58
- init[key] = maybeReactiveObjectType(value, options);
59
- }
60
- }
61
- const state = new Signal.State(init);
62
- const proxy = new Proxy(init, {
63
- get(_target, p, _receiver) {
64
- // @ts-ignore state has p
65
- return state.get()[p];
66
- },
67
- set(_target, p, newValue, _receiver) {
68
- state.set({
69
- ...state.get(),
70
- [p]: maybeReactiveObjectType(newValue, options),
71
- });
72
- return true;
73
- },
74
- });
75
- return proxy;
76
- };
77
- export const isState = (s) => {
78
- return Signal.isState(s);
79
- };
80
- export const isComputed = (s) => {
81
- return Signal.isComputed(s);
82
- };
83
- export const getValue = (signal) => {
84
- if (isState(signal) || isComputed(signal)) {
85
- return signal.value;
86
- }
87
- return signal;
88
- };
89
- export const $state = (initialValue, options) => {
90
- return new ReactiveValue(initialValue, options);
91
- };
92
- export const $computed = (computation, options) => {
93
- return new ReactiveComputation(computation, options);
94
- };
95
- let pending = false;
96
- const watcher = new Signal.subtle.Watcher(() => {
97
- if (!pending) {
98
- pending = true;
99
- queueMicrotask(() => {
100
- pending = false;
101
- for (const s of watcher.getPending())
102
- s.get();
103
- watcher.watch();
104
- });
105
- }
106
- });
107
- /**
108
- * Create an unowned effect that must be cleanup up manually
109
- *
110
- * Accept an AbortSignal to abort the effect
111
- */
112
- export const $effect = (cb, options) => {
113
- if (options?.signal?.aborted)
114
- return () => { };
115
- let destroy;
116
- const c = new Signal.Computed(() => {
117
- destroy?.();
118
- destroy = cb() ?? undefined;
119
- });
120
- watcher.watch(c);
121
- c.get();
122
- let cleaned = false;
123
- const cleanup = () => {
124
- if (cleaned)
125
- return;
126
- destroy?.();
127
- watcher.unwatch(c);
128
- cleaned = true;
129
- };
130
- options?.signal.addEventListener("abort", cleanup);
131
- return cleanup;
132
- };
package/client/types.d.ts DELETED
@@ -1,75 +0,0 @@
1
- export type OnRequestDetail = {
2
- type: string;
3
- handler: string;
4
- };
5
-
6
- export type UseRequestDetail = {
7
- hook: string;
8
- };
9
-
10
- export type AttrRequestDetail = {
11
- attribute: string;
12
- identifier: string;
13
- };
14
-
15
- export type PropRequestDetail = {
16
- property: string;
17
- identifier: string;
18
- };
19
-
20
- export type TextRequestDetail = {
21
- identifier: string;
22
- };
23
-
24
- export type HTMLRequestDetail = {
25
- identifier: string;
26
- };
27
-
28
- export type ClassRequestDetail = {
29
- identifier: string;
30
- };
31
-
32
- type BindableProperty = "checked" | "value";
33
-
34
- export type BindRequestDetail = {
35
- property: BindableProperty;
36
- identifier: string;
37
- handled: boolean;
38
- };
39
-
40
- export interface AutonomousCustomElement {
41
- /**
42
- * A static getter
43
- */
44
- readonly observedAttributes?: string[] | undefined;
45
- /**
46
- * A static getter
47
- */
48
- readonly disabledFeatures?: ("internals" | "shadow")[] | undefined;
49
- /**
50
- * A static getter
51
- */
52
- readonly formAssociated?: boolean | undefined;
53
-
54
- connectedCallback?(): void;
55
- disconnectedCallback?(): void;
56
- adoptedCallback?(): void;
57
-
58
- attributeChangedCallback?(
59
- name: string,
60
- previous: string,
61
- next: string,
62
- ): void;
63
-
64
- formAssociatedCallback?(): void;
65
- formResetCallback?(): void;
66
- formDisabledCallback?(): void;
67
- formStateRestoreCallback?(): void;
68
- }
69
-
70
- export type ReactivityOptions = { deep: boolean };
71
- export type Destructor = () => void;
72
- export type EffectCallback = () => Destructor | void;
73
- export type EffectOptions = {
74
- signal: AbortSignal;
75
- };
package/client/utils.d.ts DELETED
@@ -1,10 +0,0 @@
1
- export declare const spaces_sep_by_comma: RegExp;
2
- /**
3
- * Idempotent string conversion to kebab-case
4
- */
5
- export declare const toKebabCase: (str: string) => string;
6
- /**
7
- * Idempotent string conversion to PascalCase
8
- */
9
- export declare const toPascalCase: (str: string) => string;
10
- export declare const booleanAttributes: string[];
package/client/utils.js DELETED
@@ -1,66 +0,0 @@
1
- const is_upper = /[A-Z]/;
2
- export const spaces_sep_by_comma = /\s*,\s*/;
3
- /**
4
- * Idempotent string conversion to kebab-case
5
- */
6
- export const toKebabCase = (str) => {
7
- let kebab = "";
8
- for (let index = 0; index < str.length; index++) {
9
- const char = str[index];
10
- if (index !== 0 && is_upper.test(char)) {
11
- kebab += `-${char.toLowerCase()}`;
12
- }
13
- else {
14
- kebab += char.toLowerCase();
15
- }
16
- }
17
- return kebab;
18
- };
19
- /**
20
- * Idempotent string conversion to PascalCase
21
- */
22
- export const toPascalCase = (str) => {
23
- let pascal = "";
24
- let toUpper = true;
25
- for (let index = 0; index < str.length; index++) {
26
- const char = str[index];
27
- if (char === "-") {
28
- toUpper = true;
29
- continue;
30
- }
31
- if (toUpper) {
32
- pascal += char.toUpperCase();
33
- toUpper = false;
34
- }
35
- else {
36
- pascal += char.toLowerCase();
37
- }
38
- }
39
- return pascal;
40
- };
41
- export const booleanAttributes = [
42
- "allowfullscreen", // on <iframe>
43
- "async", // on <script>
44
- "autofocus", // on <button>, <input>, <select>, <textarea>
45
- "autoplay", // on <audio>, <video>
46
- "checked", // on <input type="checkbox">, <input type="radio">
47
- "controls", // on <audio>, <video>
48
- "default", // on <track>
49
- "defer", // on <script>
50
- "disabled", // on form elements like <button>, <fieldset>, <input>, <optgroup>, <option>,<select>, <textarea>
51
- "formnovalidate", // on <button>, <input type="submit">
52
- "hidden", // global
53
- "inert", // global
54
- "ismap", // on <img>
55
- "itemscope", // global; part of microdata
56
- "loop", // on <audio>, <video>
57
- "multiple", // on <input type="file">, <select>
58
- "muted", // on <audio>, <video>
59
- "nomodule", // on <script>
60
- "novalidate", // on <form>
61
- "open", // on <details>
62
- "readonly", // on <input>, <textarea>
63
- "required", // on <input>, <select>, <textarea>
64
- "reversed", // on <ol>
65
- "selected", // on <option>
66
- ];