@radishland/runtime 0.0.7 → 1.0.0-alpha.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.
@@ -0,0 +1,274 @@
1
+ import { booleanAttributes } from "./utils.ts";
2
+ import { bindingConfig } from "./config.ts";
3
+
4
+ import { $effect, getValue, isComputed, isState } from "./reactivity.ts";
5
+ import type {
6
+ AttrRequestDetail,
7
+ AutonomousCustomElement,
8
+ BindRequestDetail,
9
+ ClassRequestDetail,
10
+ Destructor,
11
+ EffectCallback,
12
+ EffectOptions,
13
+ HTMLRequestDetail,
14
+ OnRequestDetail,
15
+ PropRequestDetail,
16
+ TextRequestDetail,
17
+ UseRequestDetail,
18
+ } from "./types.d.ts";
19
+
20
+ /**
21
+ * A Scoped Handler Registry implements the logic for handling effect requests.
22
+ *
23
+ * Extend this class by adding new methods in your subclass to implement your own effect handlers
24
+ */
25
+ export class HandlerRegistry extends HTMLElement
26
+ implements AutonomousCustomElement {
27
+ [key: string]: any;
28
+
29
+ #cleanup: Destructor[] = [];
30
+
31
+ /**
32
+ * References the handler's `AbortController`.
33
+ *
34
+ * The `abort` method of the controller is called in the `disconnectedCallback` method. It allows to cleanup event handlers and other abortable operations
35
+ */
36
+ abortController: AbortController;
37
+
38
+ constructor() {
39
+ super();
40
+ this.abortController = new AbortController();
41
+ }
42
+
43
+ /**
44
+ * Creates an effect that is automatically cleaned up when the component is disconnected
45
+ *
46
+ * An optional AbortSignal can be provided to abort the effect prematurely
47
+ */
48
+ $effect(callback: EffectCallback, options?: EffectOptions) {
49
+ const signals = [this.abortController.signal];
50
+ if (options?.signal) signals.push(options.signal);
51
+
52
+ $effect(callback, { ...options, signal: AbortSignal.any(signals) });
53
+ }
54
+
55
+ #get(identifier: string) {
56
+ return this[identifier];
57
+ }
58
+
59
+ #handleOn(e: Event) {
60
+ if (e instanceof CustomEvent) {
61
+ const { handler, type }: OnRequestDetail = e.detail;
62
+
63
+ if (handler in this && typeof this.#get(handler) === "function") {
64
+ e.target?.addEventListener(type, this.#get(handler).bind(this));
65
+ e.stopPropagation();
66
+ }
67
+ }
68
+ }
69
+
70
+ #handleClass(e: Event) {
71
+ const target = e.target;
72
+ if (e instanceof CustomEvent && target) {
73
+ const { identifier }: ClassRequestDetail = e.detail;
74
+
75
+ if (identifier in this) {
76
+ this.$effect(() => {
77
+ const classList = getValue(this.#get(identifier));
78
+ if (classList && typeof classList === "object") {
79
+ for (const [k, v] of Object.entries(classList)) {
80
+ const force = !!getValue(v);
81
+ for (const className of k.split(" ")) {
82
+ // @ts-ignore target is an HTMLElement
83
+ target.classList.toggle(
84
+ className,
85
+ force,
86
+ );
87
+ }
88
+ }
89
+ }
90
+ });
91
+
92
+ e.stopPropagation();
93
+ }
94
+ }
95
+ }
96
+
97
+ #handleUse(e: Event) {
98
+ if (e instanceof CustomEvent) {
99
+ const { hook }: UseRequestDetail = e.detail;
100
+
101
+ if (hook in this && typeof this.#get(hook) === "function") {
102
+ const cleanup = this.#get(hook).bind(this)(e.target);
103
+ if (typeof cleanup === "function") {
104
+ this.#cleanup.push(cleanup);
105
+ }
106
+ e.stopPropagation();
107
+ }
108
+ }
109
+ }
110
+
111
+ #handleAttr(e: Event) {
112
+ if (e instanceof CustomEvent) {
113
+ const { identifier, attribute }: AttrRequestDetail = e.detail;
114
+ const target = e.target;
115
+
116
+ if (
117
+ identifier in this && target instanceof HTMLElement &&
118
+ attribute in target
119
+ ) {
120
+ const ref = this.#get(identifier);
121
+
122
+ const setAttr = () => {
123
+ const value = getValue(ref);
124
+ if (booleanAttributes.includes(attribute)) {
125
+ value
126
+ ? target.setAttribute(attribute, "")
127
+ : target.removeAttribute(attribute);
128
+ } else {
129
+ target.setAttribute(attribute, `${value}`);
130
+ }
131
+ };
132
+
133
+ if (isState(ref) || isComputed(ref)) {
134
+ this.$effect(() => setAttr());
135
+ } else {
136
+ setAttr();
137
+ }
138
+
139
+ e.stopPropagation();
140
+ }
141
+ }
142
+ }
143
+
144
+ #handleProp(e: Event) {
145
+ if (e instanceof CustomEvent) {
146
+ const { identifier, property }: PropRequestDetail = e.detail;
147
+ const target = e.target;
148
+
149
+ if (identifier in this && target && property in target) {
150
+ const ref = this.#get(identifier);
151
+
152
+ const setProp = () => {
153
+ const value = getValue(ref);
154
+ // @ts-ignore property is in target
155
+ target[property] = value;
156
+ };
157
+
158
+ if (isState(ref) || isComputed(ref)) {
159
+ this.$effect(() => setProp());
160
+ } else {
161
+ setProp();
162
+ }
163
+
164
+ e.stopPropagation();
165
+ }
166
+ }
167
+ }
168
+
169
+ #handleText(e: Event) {
170
+ if (e instanceof CustomEvent) {
171
+ const target = e.target;
172
+
173
+ const { identifier }: TextRequestDetail = e.detail;
174
+
175
+ if (identifier in this && target instanceof HTMLElement) {
176
+ const ref = this.#get(identifier);
177
+
178
+ const setTextContent = () => {
179
+ const value = getValue(ref);
180
+ target.textContent = `${value}`;
181
+ };
182
+
183
+ if (isState(ref) || isComputed(ref)) {
184
+ this.$effect(() => setTextContent());
185
+ } else {
186
+ setTextContent();
187
+ }
188
+
189
+ e.stopPropagation();
190
+ }
191
+ }
192
+ }
193
+
194
+ #handleHTML(e: Event) {
195
+ if (e instanceof CustomEvent) {
196
+ const { identifier }: HTMLRequestDetail = e.detail;
197
+ const target = e.target;
198
+
199
+ if (identifier in this && target instanceof HTMLElement) {
200
+ const ref = this.#get(identifier);
201
+
202
+ const setInnerHTML = () => {
203
+ const value = getValue(ref);
204
+ target.innerHTML = `${value}`;
205
+ };
206
+
207
+ if (isState(ref) || isComputed(ref)) {
208
+ this.$effect(() => setInnerHTML());
209
+ } else {
210
+ setInnerHTML();
211
+ }
212
+
213
+ e.stopPropagation();
214
+ }
215
+ }
216
+ }
217
+
218
+ #handleBind(e: Event) {
219
+ if (e instanceof CustomEvent) {
220
+ const { identifier, property }: BindRequestDetail = e.detail;
221
+ const target = e.target;
222
+
223
+ if (
224
+ identifier in this && target instanceof HTMLElement &&
225
+ property in target
226
+ ) {
227
+ const state = this.#get(identifier);
228
+ if (isState(state)) {
229
+ // @ts-ignore property is in target
230
+ state.value = target[property];
231
+
232
+ // Add change listener
233
+ target.addEventListener(bindingConfig[property].event, () => {
234
+ // @ts-ignore property is in target
235
+ state.value = target[property];
236
+ });
237
+
238
+ // Sync
239
+ this.$effect(() => {
240
+ // @ts-ignore property is in target
241
+ target[property] = state.value;
242
+ });
243
+ }
244
+
245
+ e.stopPropagation();
246
+ }
247
+ }
248
+ }
249
+
250
+ connectedCallback() {
251
+ const { signal } = this.abortController;
252
+
253
+ this.addEventListener("@attr-request", this.#handleAttr, { signal });
254
+ this.addEventListener("@class-request", this.#handleClass, { signal });
255
+ this.addEventListener("@on-request", this.#handleOn, { signal });
256
+ this.addEventListener("@use-request", this.#handleUse, { signal });
257
+ this.addEventListener("@prop-request", this.#handleProp, { signal });
258
+ this.addEventListener("@html-request", this.#handleHTML, { signal });
259
+ this.addEventListener("@text-request", this.#handleText, { signal });
260
+ this.addEventListener("@bind-request", this.#handleBind, { signal });
261
+ }
262
+
263
+ disconnectedCallback() {
264
+ this.abortController.abort();
265
+
266
+ for (const cleanup of this.#cleanup) {
267
+ cleanup();
268
+ }
269
+ }
270
+ }
271
+
272
+ if (window && !customElements.get("handler-registry")) {
273
+ customElements.define("handler-registry", HandlerRegistry);
274
+ }
@@ -0,0 +1,174 @@
1
+ import { bindingConfig } from "./config.ts";
2
+ import { spaces_sep_by_comma } from "./utils.ts";
3
+
4
+ const bindingsQueryString = Object.keys(bindingConfig).map((property) =>
5
+ `[\\@bind\\:${property}]`
6
+ ).join(",");
7
+
8
+ setTimeout(() => {
9
+ customElements?.whenDefined("handler-registry").then(() => {
10
+ document.querySelectorAll(
11
+ `[\\@on],[\\@use],[\\@attr],[\\@attr\\|client],[\\@prop],${bindingsQueryString},[\\@text],[\\@html]`,
12
+ )
13
+ .forEach(
14
+ (entry) => {
15
+ const events = entry.getAttribute("@on")?.trim()
16
+ ?.split(spaces_sep_by_comma);
17
+
18
+ if (events) {
19
+ for (const event of events) {
20
+ const [type, handler] = event.split(":");
21
+
22
+ const onRequest = new CustomEvent("@on-request", {
23
+ bubbles: true,
24
+ cancelable: true,
25
+ composed: true,
26
+ detail: {
27
+ type,
28
+ handler: handler || type,
29
+ },
30
+ });
31
+
32
+ entry.dispatchEvent(onRequest);
33
+ }
34
+ }
35
+
36
+ const hooks = entry.getAttribute("@use")?.trim()
37
+ ?.split(spaces_sep_by_comma);
38
+
39
+ if (hooks) {
40
+ for (const hook of hooks) {
41
+ const useRequest = new CustomEvent("@use-request", {
42
+ bubbles: true,
43
+ cancelable: true,
44
+ composed: true,
45
+ detail: {
46
+ hook,
47
+ },
48
+ });
49
+
50
+ entry.dispatchEvent(useRequest);
51
+ }
52
+ }
53
+
54
+ const props = (entry.getAttribute("@prop"))?.trim()
55
+ .split(spaces_sep_by_comma);
56
+
57
+ if (props) {
58
+ for (const prop of props) {
59
+ const [key, value] = prop.split(":");
60
+
61
+ const propRequest = new CustomEvent("@prop-request", {
62
+ bubbles: true,
63
+ cancelable: true,
64
+ composed: true,
65
+ detail: {
66
+ property: key,
67
+ identifier: value || key,
68
+ },
69
+ });
70
+
71
+ entry.dispatchEvent(propRequest);
72
+ }
73
+ }
74
+
75
+ const text = entry.hasAttribute("@text");
76
+
77
+ if (text) {
78
+ const identifier = entry.getAttribute("@text") || "text";
79
+
80
+ const textRequest = new CustomEvent("@text-request", {
81
+ bubbles: true,
82
+ cancelable: true,
83
+ composed: true,
84
+ detail: {
85
+ identifier,
86
+ },
87
+ });
88
+
89
+ entry.dispatchEvent(textRequest);
90
+ }
91
+
92
+ const html = entry.hasAttribute("@html");
93
+
94
+ if (html) {
95
+ const identifier = entry.getAttribute("@html") || "html";
96
+
97
+ const htmlRequest = new CustomEvent("@html-request", {
98
+ bubbles: true,
99
+ cancelable: true,
100
+ composed: true,
101
+ detail: {
102
+ identifier,
103
+ },
104
+ });
105
+
106
+ entry.dispatchEvent(htmlRequest);
107
+ }
108
+
109
+ const classList = entry.hasAttribute("@class");
110
+
111
+ if (classList) {
112
+ const identifier = entry.getAttribute("@class") || "class";
113
+
114
+ const classRequest = new CustomEvent("@class-request", {
115
+ bubbles: true,
116
+ cancelable: true,
117
+ composed: true,
118
+ detail: {
119
+ identifier,
120
+ },
121
+ });
122
+
123
+ entry.dispatchEvent(classRequest);
124
+ }
125
+
126
+ const attributes = [
127
+ ...(entry.getAttribute("@attr"))?.trim()
128
+ .split(spaces_sep_by_comma) ?? [],
129
+ ...(entry.getAttribute("@attr|client"))?.trim()
130
+ .split(spaces_sep_by_comma) ?? [],
131
+ ];
132
+
133
+ if (attributes.length > 0) {
134
+ for (const attribute of attributes) {
135
+ const [key, value] = attribute.split(":");
136
+
137
+ const attrRequest = new CustomEvent("@attr-request", {
138
+ bubbles: true,
139
+ cancelable: true,
140
+ composed: true,
141
+ detail: {
142
+ attribute: key,
143
+ identifier: value || key,
144
+ },
145
+ });
146
+
147
+ entry.dispatchEvent(attrRequest);
148
+ }
149
+ }
150
+
151
+ for (const property of Object.keys(bindingConfig)) {
152
+ if (entry.hasAttribute(`@bind:${property}`)) {
153
+ const identifier =
154
+ entry.getAttribute(`@bind:${property}`)?.trim() ||
155
+ property;
156
+
157
+ const bindRequest = new CustomEvent("@bind-request", {
158
+ bubbles: true,
159
+ cancelable: true,
160
+ composed: true,
161
+ detail: {
162
+ property,
163
+ identifier,
164
+ handled: false,
165
+ },
166
+ });
167
+
168
+ entry.dispatchEvent(bindRequest);
169
+ }
170
+ }
171
+ },
172
+ );
173
+ });
174
+ }, 100);
package/src/index.ts ADDED
@@ -0,0 +1,3 @@
1
+ export { HandlerRegistry } from "./handler-registry.ts";
2
+ export * from "./reactivity.ts";
3
+ export * from "./handlers.ts";
@@ -0,0 +1,179 @@
1
+ import { Signal } from "signal-polyfill";
2
+ import type {
3
+ Destructor,
4
+ EffectCallback,
5
+ EffectOptions,
6
+ ReactivityOptions,
7
+ } from "./types.d.ts";
8
+
9
+ // @ts-ignore we're hiding get and set
10
+ export class ReactiveValue<T> extends Signal.State<T> {
11
+ // @ts-ignore see above
12
+ private override get;
13
+ // @ts-ignore see above
14
+ private override set;
15
+
16
+ get value(): T {
17
+ return super.get();
18
+ }
19
+
20
+ set value(newValue: T) {
21
+ super.set(newValue);
22
+ }
23
+ }
24
+
25
+ // @ts-ignore we're hiding get and set
26
+ export class ReactiveComputation<T> extends Signal.Computed<T> {
27
+ // @ts-ignore see above
28
+ private override get;
29
+
30
+ get value(): T {
31
+ return super.get();
32
+ }
33
+ }
34
+
35
+ const maybeReactiveObjectType = <T>(thing: T, options: ReactivityOptions) => {
36
+ if (typeof thing === "object") {
37
+ if (Array.isArray(thing)) {
38
+ return $array(thing, options);
39
+ } else if (thing) {
40
+ return $object(thing, options);
41
+ }
42
+ }
43
+ return thing;
44
+ };
45
+
46
+ export const $object = <T extends Record<PropertyKey, any>>(
47
+ init: T,
48
+ options: ReactivityOptions = { deep: false },
49
+ ): T => {
50
+ if (options.deep === true) {
51
+ for (const [key, value] of Object.entries(init)) {
52
+ init[key as keyof T] = maybeReactiveObjectType(value, options);
53
+ }
54
+ }
55
+ const state = new Signal.State(init);
56
+
57
+ const proxy = new Proxy(init, {
58
+ get(_target, p, _receiver) {
59
+ return state.get()[p];
60
+ },
61
+ set(_target, p, newValue, _receiver) {
62
+ state.set({
63
+ ...state.get(),
64
+ [p]: maybeReactiveObjectType(newValue, options),
65
+ });
66
+ return true;
67
+ },
68
+ });
69
+
70
+ return proxy;
71
+ };
72
+
73
+ export const $array = <T extends ArrayLike<any>>(
74
+ init: T,
75
+ options: ReactivityOptions = { deep: false },
76
+ ): T => {
77
+ if (options.deep) {
78
+ for (const [key, value] of Object.entries(init)) {
79
+ init[key as keyof T] = maybeReactiveObjectType(value, options);
80
+ }
81
+ }
82
+ const state = new Signal.State(init);
83
+
84
+ const proxy = new Proxy(init, {
85
+ get(_target, p, _receiver) {
86
+ // @ts-ignore state has p
87
+ return state.get()[p];
88
+ },
89
+ set(_target, p, newValue, _receiver) {
90
+ state.set({
91
+ ...state.get(),
92
+ [p]: maybeReactiveObjectType(newValue, options),
93
+ });
94
+ return true;
95
+ },
96
+ });
97
+
98
+ return proxy;
99
+ };
100
+
101
+ export const isState = (
102
+ s: unknown,
103
+ ): s is InstanceType<typeof ReactiveValue> => {
104
+ return Signal.isState(s);
105
+ };
106
+
107
+ export const isComputed = (
108
+ s: unknown,
109
+ ): s is InstanceType<typeof ReactiveComputation> => {
110
+ return Signal.isComputed(s);
111
+ };
112
+
113
+ export const getValue = (signal: unknown): unknown => {
114
+ if (isState(signal) || isComputed(signal)) {
115
+ return signal.value;
116
+ }
117
+ return signal;
118
+ };
119
+
120
+ export const $state = <T>(
121
+ initialValue: T,
122
+ options?: Signal.Options<T | undefined>,
123
+ ): ReactiveValue<T> => {
124
+ return new ReactiveValue(initialValue, options);
125
+ };
126
+
127
+ export const $computed = <T>(
128
+ computation: () => T,
129
+ options?: Signal.Options<T>,
130
+ ): ReactiveComputation<T> => {
131
+ return new ReactiveComputation(computation, options);
132
+ };
133
+
134
+ let pending = false;
135
+
136
+ const watcher = new Signal.subtle.Watcher(() => {
137
+ if (!pending) {
138
+ pending = true;
139
+
140
+ queueMicrotask(() => {
141
+ pending = false;
142
+ for (const s of watcher.getPending()) s.get();
143
+ watcher.watch();
144
+ });
145
+ }
146
+ });
147
+
148
+ /**
149
+ * Create an unowned effect that must be cleanup up manually
150
+ *
151
+ * Accept an AbortSignal to abort the effect
152
+ */
153
+ export const $effect = (
154
+ cb: EffectCallback,
155
+ options?: EffectOptions,
156
+ ): Destructor => {
157
+ if (options?.signal?.aborted) return () => {};
158
+
159
+ let destroy: Destructor | undefined;
160
+ const c = new Signal.Computed(() => {
161
+ destroy?.();
162
+ destroy = cb() ?? undefined;
163
+ });
164
+ watcher.watch(c);
165
+ c.get();
166
+
167
+ let cleaned = false;
168
+
169
+ const cleanup = () => {
170
+ if (cleaned) return;
171
+ destroy?.();
172
+ watcher.unwatch(c);
173
+ cleaned = true;
174
+ };
175
+
176
+ options?.signal.addEventListener("abort", cleanup);
177
+
178
+ return cleanup;
179
+ };
package/src/types.d.ts ADDED
@@ -0,0 +1,75 @@
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
+ };