@rupertsworld/observable 0.1.0

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 ADDED
@@ -0,0 +1,150 @@
1
+ # @rupertsworld/observable
2
+
3
+ Observable properties for classes and custom elements.
4
+
5
+ ## Why?
6
+
7
+ Reacting to property changes typically means writing boilerplate getters/setters or remembering to call update functions. This library provides a declarative `observedProperties` pattern that works on any class — and extends naturally to custom elements with attribute reflection.
8
+
9
+ ## Install
10
+
11
+ ```bash
12
+ npm install @rupertsworld/observable
13
+ ```
14
+
15
+ ## Quick Examples
16
+
17
+ ### Any class with `observable(Base)`
18
+
19
+ ```ts
20
+ import { observable } from "@rupertsworld/observable";
21
+
22
+ class Model extends observable(Object) {
23
+ static observedProperties = ["data"];
24
+
25
+ data = null;
26
+
27
+ propertyChangedCallback(name: string, oldValue: unknown, newValue: unknown) {
28
+ console.log(`${name} changed from`, oldValue, "to", newValue);
29
+ }
30
+ }
31
+
32
+ const model = new Model();
33
+ model.data = { foo: 1 }; // logs: "data changed from null to { foo: 1 }"
34
+ ```
35
+
36
+ ### `Observable` with typed events
37
+
38
+ `Observable` is `observable(EventTarget)` — use it when you want to dispatch events:
39
+
40
+ ```ts
41
+ import { Observable } from "@rupertsworld/observable";
42
+
43
+ class ChangeEvent extends Event {
44
+ type = "change" as const;
45
+ constructor(public property: string, public value: unknown) {
46
+ super("change");
47
+ }
48
+ }
49
+
50
+ class Counter extends Observable<ChangeEvent> {
51
+ static observedProperties = ["count"];
52
+
53
+ count = 0;
54
+
55
+ propertyChangedCallback(name: string, _oldValue: unknown, newValue: unknown) {
56
+ this.dispatchEvent(new ChangeEvent(name, newValue));
57
+ }
58
+ }
59
+
60
+ const counter = new Counter();
61
+ counter.addEventListener("change", (e) => {
62
+ console.log(e.property, e.value); // typed
63
+ });
64
+ counter.count = 5; // logs: "count" 5
65
+ ```
66
+
67
+ ### Custom elements with `ObservableElement`
68
+
69
+ ```ts
70
+ import { ObservableElement } from "@rupertsworld/observable";
71
+
72
+ class MyCounter extends ObservableElement {
73
+ static observedProperties = {
74
+ count: { type: Number, attribute: "count" },
75
+ };
76
+
77
+ count = 0;
78
+
79
+ connectedCallback() {
80
+ this.render();
81
+ this.addEventListener("click", () => this.count++);
82
+ }
83
+
84
+ propertyChangedCallback() {
85
+ this.render();
86
+ }
87
+
88
+ render() {
89
+ this.innerHTML = `<button>Count: ${this.count}</button>`;
90
+ }
91
+ }
92
+
93
+ customElements.define("my-counter", MyCounter);
94
+ ```
95
+
96
+ ```html
97
+ <my-counter count="5"></my-counter>
98
+ ```
99
+
100
+ The `count` property:
101
+
102
+ - Syncs with the `count` attribute automatically
103
+ - Coerces strings to numbers
104
+ - Triggers `propertyChangedCallback` when changed
105
+
106
+ ## Typed Events
107
+
108
+ You can add type safety for custom events by passing a generic parameter:
109
+
110
+ ```ts
111
+ class CountChangeEvent extends Event {
112
+ type = "count-change";
113
+ }
114
+
115
+ class MyCounter extends ObservableElement<CountChangeEvent> {
116
+ // ...
117
+
118
+ propertyChangedCallback(name: string) {
119
+ if (name === "count") {
120
+ this.dispatchEvent(new CountChangeEvent('count-change'));
121
+ }
122
+ this.render();
123
+ }
124
+ }
125
+ ```
126
+
127
+ ```ts
128
+ const counter = document.querySelector("my-counter") as MyCounter;
129
+
130
+ counter.addEventListener("countchange", (e) => {
131
+ console.log(e.count); // typed as number
132
+ });
133
+ ```
134
+
135
+ ## Features
136
+
137
+ - **Works on any class** — `observable(Base)` mixin works with `Object`, `EventTarget`, or any base
138
+ - **Reactive properties** — declare once, get automatic getters/setters
139
+ - **Attribute sync** — `ObservableElement` keeps properties and attributes in sync
140
+ - **Type coercion** — `Number`, `Boolean`, `String`, `Object` built-in
141
+ - **Typed events** — optional strong typing for `addEventListener`/`dispatchEvent`
142
+ - **Drop-in replacement** — `ObservableElement` behaves exactly like native `HTMLElement`
143
+
144
+ ## Documentation
145
+
146
+ See [docs/SPEC.md](docs/SPEC.md) for the full API reference.
147
+
148
+ ## License
149
+
150
+ MIT
@@ -0,0 +1,36 @@
1
+ import type { EventNames, EventForType } from "@rupertsworld/event-target";
2
+ type NativeHTMLElement = InstanceType<typeof globalThis.HTMLElement>;
3
+ type ReducedHTMLElement = Omit<NativeHTMLElement, "addEventListener" | "removeEventListener" | "dispatchEvent">;
4
+ /** Supported type constructors for property coercion. */
5
+ type ObservedType = StringConstructor | NumberConstructor | BooleanConstructor | ObjectConstructor;
6
+ /** Configuration for a single observed property. */
7
+ export type ObservedPropertyConfig = {
8
+ type: ObservedType;
9
+ /** If set, syncs this property to/from the named attribute. */
10
+ attribute?: string;
11
+ };
12
+ /** Map of property names to their observation config. */
13
+ export type ObservedPropertyMap = Record<string, ObservedPropertyConfig>;
14
+ export interface BaseElement<T extends Event = Event> extends ReducedHTMLElement {
15
+ addEventListener<K extends EventNames<T>>(type: K, listener: ((ev: EventForType<T, K>) => void) | null, options?: boolean | AddEventListenerOptions): void;
16
+ addEventListener<K extends EventNames<T>>(type: K, listener: EventListener | EventListenerObject | null, options?: boolean | AddEventListenerOptions): void;
17
+ removeEventListener<K extends EventNames<T>>(type: K, listener: ((ev: EventForType<T, K>) => void) | null, options?: boolean | EventListenerOptions): void;
18
+ removeEventListener<K extends EventNames<T>>(type: K, listener: EventListener | EventListenerObject | null, options?: boolean | EventListenerOptions): void;
19
+ dispatchEvent(event: T): boolean;
20
+ }
21
+ /**
22
+ * Base class for custom elements with reactive observed properties.
23
+ *
24
+ * Define `static observedProperties` to automatically sync properties with
25
+ * attributes and receive `propertyChangedCallback` notifications.
26
+ */
27
+ export declare class BaseElement<T extends Event = Event> extends globalThis.HTMLElement {
28
+ static observedProperties?: ObservedPropertyMap;
29
+ static get observedAttributes(): string[];
30
+ constructor();
31
+ attributeChangedCallback(name: string, _old: string | null, value: string | null): void;
32
+ /** Override to react to observed property changes. */
33
+ propertyChangedCallback(_name: string, _oldValue: unknown, _newValue: unknown): void;
34
+ }
35
+ export {};
36
+ //# sourceMappingURL=html-element.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"html-element.d.ts","sourceRoot":"","sources":["../src/html-element.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,UAAU,EAAE,YAAY,EAAE,MAAM,4BAA4B,CAAC;AAE3E,KAAK,iBAAiB,GAAG,YAAY,CAAC,OAAO,UAAU,CAAC,WAAW,CAAC,CAAC;AAErE,KAAK,kBAAkB,GAAG,IAAI,CAC5B,iBAAiB,EACjB,kBAAkB,GAAG,qBAAqB,GAAG,eAAe,CAC7D,CAAC;AAEF,yDAAyD;AACzD,KAAK,YAAY,GACb,iBAAiB,GACjB,iBAAiB,GACjB,kBAAkB,GAClB,iBAAiB,CAAC;AAEtB,oDAAoD;AACpD,MAAM,MAAM,sBAAsB,GAAG;IACnC,IAAI,EAAE,YAAY,CAAC;IACnB,+DAA+D;IAC/D,SAAS,CAAC,EAAE,MAAM,CAAC;CACpB,CAAC;AAEF,yDAAyD;AACzD,MAAM,MAAM,mBAAmB,GAAG,MAAM,CAAC,MAAM,EAAE,sBAAsB,CAAC,CAAC;AAoLzE,MAAM,WAAW,WAAW,CAAC,CAAC,SAAS,KAAK,GAAG,KAAK,CAAE,SAAQ,kBAAkB;IAC9E,gBAAgB,CAAC,CAAC,SAAS,UAAU,CAAC,CAAC,CAAC,EACtC,IAAI,EAAE,CAAC,EACP,QAAQ,EAAE,CAAC,CAAC,EAAE,EAAE,YAAY,CAAC,CAAC,EAAE,CAAC,CAAC,KAAK,IAAI,CAAC,GAAG,IAAI,EACnD,OAAO,CAAC,EAAE,OAAO,GAAG,uBAAuB,GAC1C,IAAI,CAAC;IAER,gBAAgB,CAAC,CAAC,SAAS,UAAU,CAAC,CAAC,CAAC,EACtC,IAAI,EAAE,CAAC,EACP,QAAQ,EAAE,aAAa,GAAG,mBAAmB,GAAG,IAAI,EACpD,OAAO,CAAC,EAAE,OAAO,GAAG,uBAAuB,GAC1C,IAAI,CAAC;IAER,mBAAmB,CAAC,CAAC,SAAS,UAAU,CAAC,CAAC,CAAC,EACzC,IAAI,EAAE,CAAC,EACP,QAAQ,EAAE,CAAC,CAAC,EAAE,EAAE,YAAY,CAAC,CAAC,EAAE,CAAC,CAAC,KAAK,IAAI,CAAC,GAAG,IAAI,EACnD,OAAO,CAAC,EAAE,OAAO,GAAG,oBAAoB,GACvC,IAAI,CAAC;IAER,mBAAmB,CAAC,CAAC,SAAS,UAAU,CAAC,CAAC,CAAC,EACzC,IAAI,EAAE,CAAC,EACP,QAAQ,EAAE,aAAa,GAAG,mBAAmB,GAAG,IAAI,EACpD,OAAO,CAAC,EAAE,OAAO,GAAG,oBAAoB,GACvC,IAAI,CAAC;IAER,aAAa,CAAC,KAAK,EAAE,CAAC,GAAG,OAAO,CAAC;CAClC;AAED;;;;;GAKG;AACH,qBAAa,WAAW,CAAC,CAAC,SAAS,KAAK,GAAG,KAAK,CAAE,SAAQ,UAAU,CAAC,WAAW;IAC9E,MAAM,CAAC,kBAAkB,CAAC,EAAE,mBAAmB,CAAC;IAEhD,MAAM,KAAK,kBAAkB,IAAI,MAAM,EAAE,CAWxC;;IAgBD,wBAAwB,CAAC,IAAI,EAAE,MAAM,EAAE,IAAI,EAAE,MAAM,GAAG,IAAI,EAAE,KAAK,EAAE,MAAM,GAAG,IAAI,GAAG,IAAI;IA0BvF,sDAAsD;IACtD,uBAAuB,CAAC,KAAK,EAAE,MAAM,EAAE,SAAS,EAAE,OAAO,EAAE,SAAS,EAAE,OAAO,GAAG,IAAI;CACrF"}
@@ -0,0 +1,190 @@
1
+ const stateByInstance = new WeakMap();
2
+ const setupByConstructor = new WeakSet();
3
+ const managedProps = new WeakMap();
4
+ const asRecord = (obj) => obj;
5
+ function coerceFromAttribute(type, value) {
6
+ if (type === Boolean) {
7
+ if (value === null)
8
+ return false;
9
+ return value !== "false";
10
+ }
11
+ if (value === null)
12
+ return null;
13
+ if (type === Number)
14
+ return Number(value);
15
+ if (type === Object)
16
+ return JSON.parse(value);
17
+ return value;
18
+ }
19
+ function serializeToAttribute(type, value) {
20
+ if (value == null)
21
+ return null;
22
+ if (type === Boolean)
23
+ return value ? "" : "false";
24
+ if (type === Object)
25
+ return JSON.stringify(value);
26
+ return String(value);
27
+ }
28
+ function configForAttribute(ctor, attr) {
29
+ for (const [prop, config] of Object.entries(ctor.observedProperties ?? {})) {
30
+ if (config.attribute === attr)
31
+ return { prop, config };
32
+ }
33
+ return null;
34
+ }
35
+ function getState(instance) {
36
+ let state = stateByInstance.get(instance);
37
+ if (!state) {
38
+ state = {
39
+ constructing: true,
40
+ syncingFromAttribute: null,
41
+ syncingToAttribute: null,
42
+ values: new Map(),
43
+ };
44
+ stateByInstance.set(instance, state);
45
+ }
46
+ return state;
47
+ }
48
+ function isManaged(ctor, prop) {
49
+ return managedProps.get(ctor)?.has(prop) ?? false;
50
+ }
51
+ function wrapUserAttributeCallback(ctor) {
52
+ const desc = Object.getOwnPropertyDescriptor(ctor.prototype, "attributeChangedCallback");
53
+ if (!desc || typeof desc.value !== "function")
54
+ return;
55
+ const userFn = desc.value;
56
+ if (userFn === BaseElement.prototype.attributeChangedCallback)
57
+ return;
58
+ Object.defineProperty(ctor.prototype, "attributeChangedCallback", {
59
+ configurable: true,
60
+ writable: true,
61
+ value(name, oldVal, newVal) {
62
+ BaseElement.prototype.attributeChangedCallback.call(this, name, oldVal, newVal);
63
+ userFn.call(this, name, oldVal, newVal);
64
+ },
65
+ });
66
+ }
67
+ function setup(ctor) {
68
+ wrapUserAttributeCallback(ctor);
69
+ const observed = ctor.observedProperties ?? {};
70
+ const managed = new Set();
71
+ managedProps.set(ctor, managed);
72
+ for (const [prop, config] of Object.entries(observed)) {
73
+ const existing = Object.getOwnPropertyDescriptor(ctor.prototype, prop);
74
+ if (existing?.get || existing?.set) {
75
+ throw new Error(`Observed property "${prop}" cannot define a custom getter/setter. ` +
76
+ `Use propertyChangedCallback for side effects instead.`);
77
+ }
78
+ if (existing)
79
+ continue;
80
+ managed.add(prop);
81
+ const attr = config.attribute;
82
+ Object.defineProperty(ctor.prototype, prop, {
83
+ enumerable: true,
84
+ configurable: true,
85
+ get() {
86
+ return getState(this).values.get(prop);
87
+ },
88
+ set(next) {
89
+ const state = getState(this);
90
+ const prev = state.values.get(prop);
91
+ if (Object.is(prev, next))
92
+ return;
93
+ state.values.set(prop, next);
94
+ if (attr && state.syncingFromAttribute !== attr && state.syncingToAttribute !== attr) {
95
+ const serialized = serializeToAttribute(config.type, next);
96
+ state.syncingToAttribute = attr;
97
+ try {
98
+ serialized === null ? this.removeAttribute(attr) : this.setAttribute(attr, serialized);
99
+ }
100
+ finally {
101
+ state.syncingToAttribute = null;
102
+ }
103
+ }
104
+ if (!state.constructing || state.syncingFromAttribute !== null) {
105
+ this.propertyChangedCallback(prop, prev, next);
106
+ }
107
+ },
108
+ });
109
+ }
110
+ }
111
+ function initializeProperties(el, ctor) {
112
+ const state = getState(el);
113
+ const observed = ctor.observedProperties ?? {};
114
+ const rec = asRecord(el);
115
+ for (const [prop, config] of Object.entries(observed)) {
116
+ if (!isManaged(ctor, prop))
117
+ continue;
118
+ const hasOwn = Object.prototype.hasOwnProperty.call(el, prop);
119
+ const ownValue = hasOwn ? rec[prop] : undefined;
120
+ if (hasOwn)
121
+ delete rec[prop];
122
+ const attr = config.attribute;
123
+ if (attr && el.hasAttribute(attr)) {
124
+ const coerced = coerceFromAttribute(config.type, el.getAttribute(attr));
125
+ state.syncingFromAttribute = attr;
126
+ try {
127
+ rec[prop] = coerced;
128
+ }
129
+ finally {
130
+ state.syncingFromAttribute = null;
131
+ }
132
+ continue;
133
+ }
134
+ if (hasOwn)
135
+ rec[prop] = ownValue;
136
+ }
137
+ state.constructing = false;
138
+ }
139
+ /**
140
+ * Base class for custom elements with reactive observed properties.
141
+ *
142
+ * Define `static observedProperties` to automatically sync properties with
143
+ * attributes and receive `propertyChangedCallback` notifications.
144
+ */
145
+ export class BaseElement extends globalThis.HTMLElement {
146
+ static observedProperties;
147
+ static get observedAttributes() {
148
+ const ctor = this;
149
+ if (!setupByConstructor.has(ctor)) {
150
+ setup(ctor);
151
+ setupByConstructor.add(ctor);
152
+ }
153
+ return Object.values(this.observedProperties ?? {})
154
+ .map((c) => c.attribute)
155
+ .filter((a) => Boolean(a));
156
+ }
157
+ constructor() {
158
+ super();
159
+ const ctor = this.constructor;
160
+ if (!setupByConstructor.has(ctor)) {
161
+ setup(ctor);
162
+ setupByConstructor.add(ctor);
163
+ }
164
+ getState(this);
165
+ queueMicrotask(() => initializeProperties(this, ctor));
166
+ }
167
+ attributeChangedCallback(name, _old, value) {
168
+ const ctor = this.constructor;
169
+ const found = configForAttribute(ctor, name);
170
+ if (!found || !isManaged(ctor, found.prop))
171
+ return;
172
+ const state = getState(this);
173
+ if (state.syncingToAttribute === name)
174
+ return;
175
+ const coerced = coerceFromAttribute(found.config.type, value);
176
+ const rec = asRecord(this);
177
+ if (Object.prototype.hasOwnProperty.call(this, found.prop)) {
178
+ delete rec[found.prop];
179
+ }
180
+ state.syncingFromAttribute = name;
181
+ try {
182
+ rec[found.prop] = coerced;
183
+ }
184
+ finally {
185
+ state.syncingFromAttribute = null;
186
+ }
187
+ }
188
+ /** Override to react to observed property changes. */
189
+ propertyChangedCallback(_name, _oldValue, _newValue) { }
190
+ }
@@ -0,0 +1,4 @@
1
+ export { Observable, observable } from "./observable";
2
+ export { ObservableElement } from "./observable-element";
3
+ export type { ObservablePropertyConfig, ObservablePropertyMap, ObservedProperties, } from "./observable-element";
4
+ //# sourceMappingURL=index.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,UAAU,EAAE,UAAU,EAAE,MAAM,cAAc,CAAC;AACtD,OAAO,EAAE,iBAAiB,EAAE,MAAM,sBAAsB,CAAC;AACzD,YAAY,EACV,wBAAwB,EACxB,qBAAqB,EACrB,kBAAkB,GACnB,MAAM,sBAAsB,CAAC"}
package/dist/index.js ADDED
@@ -0,0 +1,2 @@
1
+ export { Observable, observable } from "./observable";
2
+ export { ObservableElement } from "./observable-element";
@@ -0,0 +1,33 @@
1
+ import type { EventForType, EventNames } from "@rupertsworld/event-target";
2
+ type NativeHTMLElement = InstanceType<typeof globalThis.HTMLElement>;
3
+ type ReducedHTMLElement = Omit<NativeHTMLElement, "addEventListener" | "removeEventListener" | "dispatchEvent">;
4
+ type ObservedType = StringConstructor | NumberConstructor | BooleanConstructor | ObjectConstructor;
5
+ export type ObservablePropertyConfig = {
6
+ type?: ObservedType;
7
+ attribute?: string | false;
8
+ };
9
+ export type ObservablePropertyMap = Record<string, ObservablePropertyConfig>;
10
+ export type ObservedProperties = string[] | ObservablePropertyMap;
11
+ type ObservableInstance = {
12
+ propertyChangedCallback(name: string, oldValue: unknown, newValue: unknown): void;
13
+ };
14
+ export type ObservableElementConstructor = Function & {
15
+ observedProperties?: ObservedProperties;
16
+ prototype: ObservableInstance;
17
+ };
18
+ export interface ObservableElement<T extends Event = Event> extends ReducedHTMLElement {
19
+ addEventListener<K extends EventNames<T>>(type: K, listener: ((ev: EventForType<T, K>) => void) | null, options?: boolean | AddEventListenerOptions): void;
20
+ addEventListener<K extends EventNames<T>>(type: K, listener: EventListener | EventListenerObject | null, options?: boolean | AddEventListenerOptions): void;
21
+ removeEventListener<K extends EventNames<T>>(type: K, listener: ((ev: EventForType<T, K>) => void) | null, options?: boolean | EventListenerOptions): void;
22
+ removeEventListener<K extends EventNames<T>>(type: K, listener: EventListener | EventListenerObject | null, options?: boolean | EventListenerOptions): void;
23
+ dispatchEvent(event: T): boolean;
24
+ }
25
+ export declare class ObservableElement<T extends Event = Event> extends globalThis.HTMLElement {
26
+ static observedProperties?: ObservedProperties;
27
+ static get observedAttributes(): string[];
28
+ constructor();
29
+ attributeChangedCallback(name: string, _oldValue: string | null, value: string | null): void;
30
+ propertyChangedCallback(_name: string, _oldValue: unknown, _newValue: unknown): void;
31
+ }
32
+ export {};
33
+ //# sourceMappingURL=observable-element.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"observable-element.d.ts","sourceRoot":"","sources":["../src/observable-element.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,YAAY,EAAE,UAAU,EAAE,MAAM,4BAA4B,CAAC;AAE3E,KAAK,iBAAiB,GAAG,YAAY,CAAC,OAAO,UAAU,CAAC,WAAW,CAAC,CAAC;AACrE,KAAK,kBAAkB,GAAG,IAAI,CAC5B,iBAAiB,EACjB,kBAAkB,GAAG,qBAAqB,GAAG,eAAe,CAC7D,CAAC;AAIF,KAAK,YAAY,GACb,iBAAiB,GACjB,iBAAiB,GACjB,kBAAkB,GAClB,iBAAiB,CAAC;AAEtB,MAAM,MAAM,wBAAwB,GAAG;IACrC,IAAI,CAAC,EAAE,YAAY,CAAC;IACpB,SAAS,CAAC,EAAE,MAAM,GAAG,KAAK,CAAC;CAC5B,CAAC;AAEF,MAAM,MAAM,qBAAqB,GAAG,MAAM,CAAC,MAAM,EAAE,wBAAwB,CAAC,CAAC;AAC7E,MAAM,MAAM,kBAAkB,GAAG,MAAM,EAAE,GAAG,qBAAqB,CAAC;AAOlE,KAAK,kBAAkB,GAAG;IACxB,uBAAuB,CAAC,IAAI,EAAE,MAAM,EAAE,QAAQ,EAAE,OAAO,EAAE,QAAQ,EAAE,OAAO,GAAG,IAAI,CAAC;CACnF,CAAC;AAEF,MAAM,MAAM,4BAA4B,GAAG,QAAQ,GAAG;IACpD,kBAAkB,CAAC,EAAE,kBAAkB,CAAC;IACxC,SAAS,EAAE,kBAAkB,CAAC;CAC/B,CAAC;AAsMF,MAAM,WAAW,iBAAiB,CAAC,CAAC,SAAS,KAAK,GAAG,KAAK,CAAE,SAAQ,kBAAkB;IACpF,gBAAgB,CAAC,CAAC,SAAS,UAAU,CAAC,CAAC,CAAC,EACtC,IAAI,EAAE,CAAC,EACP,QAAQ,EAAE,CAAC,CAAC,EAAE,EAAE,YAAY,CAAC,CAAC,EAAE,CAAC,CAAC,KAAK,IAAI,CAAC,GAAG,IAAI,EACnD,OAAO,CAAC,EAAE,OAAO,GAAG,uBAAuB,GAC1C,IAAI,CAAC;IAER,gBAAgB,CAAC,CAAC,SAAS,UAAU,CAAC,CAAC,CAAC,EACtC,IAAI,EAAE,CAAC,EACP,QAAQ,EAAE,aAAa,GAAG,mBAAmB,GAAG,IAAI,EACpD,OAAO,CAAC,EAAE,OAAO,GAAG,uBAAuB,GAC1C,IAAI,CAAC;IAER,mBAAmB,CAAC,CAAC,SAAS,UAAU,CAAC,CAAC,CAAC,EACzC,IAAI,EAAE,CAAC,EACP,QAAQ,EAAE,CAAC,CAAC,EAAE,EAAE,YAAY,CAAC,CAAC,EAAE,CAAC,CAAC,KAAK,IAAI,CAAC,GAAG,IAAI,EACnD,OAAO,CAAC,EAAE,OAAO,GAAG,oBAAoB,GACvC,IAAI,CAAC;IAER,mBAAmB,CAAC,CAAC,SAAS,UAAU,CAAC,CAAC,CAAC,EACzC,IAAI,EAAE,CAAC,EACP,QAAQ,EAAE,aAAa,GAAG,mBAAmB,GAAG,IAAI,EACpD,OAAO,CAAC,EAAE,OAAO,GAAG,oBAAoB,GACvC,IAAI,CAAC;IAER,aAAa,CAAC,KAAK,EAAE,CAAC,GAAG,OAAO,CAAC;CAClC;AAED,qBAAa,iBAAiB,CAAC,CAAC,SAAS,KAAK,GAAG,KAAK,CAAE,SAAQ,UAAU,CAAC,WAAW;IACpF,MAAM,CAAC,kBAAkB,CAAC,EAAE,kBAAkB,CAAC;IAE/C,MAAM,KAAK,kBAAkB,IAAI,MAAM,EAAE,CAOxC;;IAUD,wBAAwB,CAAC,IAAI,EAAE,MAAM,EAAE,SAAS,EAAE,MAAM,GAAG,IAAI,EAAE,KAAK,EAAE,MAAM,GAAG,IAAI,GAAG,IAAI;IAuB5F,uBAAuB,CAAC,KAAK,EAAE,MAAM,EAAE,SAAS,EAAE,OAAO,EAAE,SAAS,EAAE,OAAO,GAAG,IAAI;CACrF"}
@@ -0,0 +1,222 @@
1
+ const stateByInstance = new WeakMap();
2
+ const setupByConstructor = new WeakSet();
3
+ const managedProps = new WeakMap();
4
+ const normalizedConfigsByConstructor = new WeakMap();
5
+ const userAttributeCallbackWrapped = new WeakSet();
6
+ const asRecord = (obj) => obj;
7
+ function coerceFromAttribute(type, value) {
8
+ if (type === Boolean) {
9
+ if (value === null)
10
+ return false;
11
+ return value !== "false";
12
+ }
13
+ if (value === null)
14
+ return null;
15
+ if (type === Number)
16
+ return Number(value);
17
+ if (type === Object)
18
+ return JSON.parse(value);
19
+ return value;
20
+ }
21
+ function serializeToAttribute(type, value) {
22
+ if (value == null)
23
+ return null;
24
+ if (type === Boolean)
25
+ return value ? "" : "false";
26
+ if (type === Object)
27
+ return JSON.stringify(value);
28
+ return String(value);
29
+ }
30
+ function normalizeObservedProperties(observed) {
31
+ const normalized = new Map();
32
+ if (!observed)
33
+ return normalized;
34
+ if (Array.isArray(observed)) {
35
+ for (const prop of observed) {
36
+ normalized.set(prop, { attribute: false });
37
+ }
38
+ return normalized;
39
+ }
40
+ for (const [prop, config] of Object.entries(observed)) {
41
+ normalized.set(prop, {
42
+ type: config.type,
43
+ attribute: config.attribute ?? false,
44
+ });
45
+ }
46
+ return normalized;
47
+ }
48
+ function getState(instance) {
49
+ let state = stateByInstance.get(instance);
50
+ if (!state) {
51
+ state = {
52
+ constructing: true,
53
+ syncingFromAttribute: null,
54
+ syncingToAttribute: null,
55
+ values: new Map(),
56
+ };
57
+ stateByInstance.set(instance, state);
58
+ }
59
+ return state;
60
+ }
61
+ function getConfigs(ctor) {
62
+ return normalizedConfigsByConstructor.get(ctor) ?? new Map();
63
+ }
64
+ function isManaged(ctor, prop) {
65
+ return managedProps.get(ctor)?.has(prop) ?? false;
66
+ }
67
+ function reflectedAttribute(config) {
68
+ return typeof config.attribute === "string" ? config.attribute : null;
69
+ }
70
+ function configForAttribute(ctor, attr) {
71
+ for (const [prop, config] of getConfigs(ctor)) {
72
+ if (reflectedAttribute(config) === attr)
73
+ return { prop, config };
74
+ }
75
+ return null;
76
+ }
77
+ function ensureSetup(ctor) {
78
+ if (setupByConstructor.has(ctor))
79
+ return;
80
+ const normalized = normalizeObservedProperties(ctor.observedProperties);
81
+ normalizedConfigsByConstructor.set(ctor, normalized);
82
+ const managed = new Set();
83
+ managedProps.set(ctor, managed);
84
+ for (const [prop, config] of normalized) {
85
+ const attr = reflectedAttribute(config);
86
+ if (attr && !config.type) {
87
+ throw new Error(`Observed property "${prop}" requires a "type" when "attribute" is set.`);
88
+ }
89
+ const existing = Object.getOwnPropertyDescriptor(ctor.prototype, prop);
90
+ if (existing?.get || existing?.set) {
91
+ throw new Error(`Observed property "${prop}" cannot define a custom getter/setter. ` +
92
+ `Use propertyChangedCallback for side effects instead.`);
93
+ }
94
+ if (existing)
95
+ continue;
96
+ managed.add(prop);
97
+ Object.defineProperty(ctor.prototype, prop, {
98
+ enumerable: true,
99
+ configurable: true,
100
+ get() {
101
+ return getState(this).values.get(prop);
102
+ },
103
+ set(next) {
104
+ const state = getState(this);
105
+ const prev = state.values.get(prop);
106
+ if (Object.is(prev, next))
107
+ return;
108
+ state.values.set(prop, next);
109
+ const attribute = reflectedAttribute(config);
110
+ if (attribute &&
111
+ state.syncingFromAttribute !== attribute &&
112
+ state.syncingToAttribute !== attribute) {
113
+ const serialized = serializeToAttribute(config.type, next);
114
+ state.syncingToAttribute = attribute;
115
+ try {
116
+ const el = this;
117
+ serialized === null ? el.removeAttribute(attribute) : el.setAttribute(attribute, serialized);
118
+ }
119
+ finally {
120
+ state.syncingToAttribute = null;
121
+ }
122
+ }
123
+ if (!state.constructing || state.syncingFromAttribute !== null || prev !== undefined) {
124
+ this.propertyChangedCallback(prop, prev, next);
125
+ }
126
+ },
127
+ });
128
+ }
129
+ setupByConstructor.add(ctor);
130
+ }
131
+ function initializeProperties(instance, ctor) {
132
+ ensureSetup(ctor);
133
+ const state = getState(instance);
134
+ const rec = asRecord(instance);
135
+ const configs = getConfigs(ctor);
136
+ for (const [prop, config] of configs) {
137
+ if (!isManaged(ctor, prop))
138
+ continue;
139
+ const hasOwn = Object.prototype.hasOwnProperty.call(instance, prop);
140
+ const ownValue = hasOwn ? rec[prop] : undefined;
141
+ if (hasOwn)
142
+ delete rec[prop];
143
+ const attr = reflectedAttribute(config);
144
+ if (attr && instance.hasAttribute(attr)) {
145
+ const coerced = coerceFromAttribute(config.type, instance.getAttribute(attr));
146
+ state.syncingFromAttribute = attr;
147
+ try {
148
+ rec[prop] = coerced;
149
+ }
150
+ finally {
151
+ state.syncingFromAttribute = null;
152
+ }
153
+ continue;
154
+ }
155
+ if (hasOwn)
156
+ rec[prop] = ownValue;
157
+ }
158
+ state.constructing = false;
159
+ }
160
+ export class ObservableElement extends globalThis.HTMLElement {
161
+ static observedProperties;
162
+ static get observedAttributes() {
163
+ const ctor = this;
164
+ ensureSetup(ctor);
165
+ wrapUserAttributeCallback(ctor);
166
+ return [...getConfigs(ctor).values()]
167
+ .map((config) => reflectedAttribute(config))
168
+ .filter((attr) => Boolean(attr));
169
+ }
170
+ constructor() {
171
+ super();
172
+ const ctor = this.constructor;
173
+ ensureSetup(ctor);
174
+ getState(this);
175
+ queueMicrotask(() => initializeProperties(this, ctor));
176
+ }
177
+ attributeChangedCallback(name, _oldValue, value) {
178
+ const ctor = this.constructor;
179
+ ensureSetup(ctor);
180
+ const found = configForAttribute(ctor, name);
181
+ if (!found || !isManaged(ctor, found.prop))
182
+ return;
183
+ const state = getState(this);
184
+ if (state.syncingToAttribute === name)
185
+ return;
186
+ const rec = asRecord(this);
187
+ if (Object.prototype.hasOwnProperty.call(this, found.prop)) {
188
+ delete rec[found.prop];
189
+ }
190
+ state.syncingFromAttribute = name;
191
+ try {
192
+ rec[found.prop] = coerceFromAttribute(found.config.type, value);
193
+ }
194
+ finally {
195
+ state.syncingFromAttribute = null;
196
+ }
197
+ }
198
+ propertyChangedCallback(_name, _oldValue, _newValue) { }
199
+ }
200
+ function wrapUserAttributeCallback(ctor) {
201
+ if (userAttributeCallbackWrapped.has(ctor))
202
+ return;
203
+ const desc = Object.getOwnPropertyDescriptor(ctor.prototype, "attributeChangedCallback");
204
+ if (!desc || typeof desc.value !== "function") {
205
+ userAttributeCallbackWrapped.add(ctor);
206
+ return;
207
+ }
208
+ const userFn = desc.value;
209
+ if (userFn === ObservableElement.prototype.attributeChangedCallback) {
210
+ userAttributeCallbackWrapped.add(ctor);
211
+ return;
212
+ }
213
+ Object.defineProperty(ctor.prototype, "attributeChangedCallback", {
214
+ configurable: true,
215
+ writable: true,
216
+ value(name, oldValue, newValue) {
217
+ ObservableElement.prototype.attributeChangedCallback.call(this, name, oldValue, newValue);
218
+ userFn.call(this, name, oldValue, newValue);
219
+ },
220
+ });
221
+ userAttributeCallbackWrapped.add(ctor);
222
+ }
@@ -0,0 +1,16 @@
1
+ type Constructor<T = object> = abstract new (...args: any[]) => T;
2
+ type ObservableInstance = {
3
+ propertyChangedCallback(name: string, oldValue: unknown, newValue: unknown): void;
4
+ };
5
+ type ObservableClass<TBase extends Constructor> = TBase & Constructor<InstanceType<TBase> & ObservableInstance> & {
6
+ observedProperties?: string[];
7
+ };
8
+ export declare function observable<TBase extends Constructor>(Base: TBase): ObservableClass<TBase>;
9
+ declare const Observable_base: ObservableClass<{
10
+ new (): EventTarget;
11
+ prototype: EventTarget;
12
+ }>;
13
+ export declare class Observable extends Observable_base {
14
+ }
15
+ export {};
16
+ //# sourceMappingURL=observable.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"observable.d.ts","sourceRoot":"","sources":["../src/observable.ts"],"names":[],"mappings":"AAAA,KAAK,WAAW,CAAC,CAAC,GAAG,MAAM,IAAI,QAAQ,MAAM,GAAG,IAAI,EAAE,GAAG,EAAE,KAAK,CAAC,CAAC;AAIlE,KAAK,kBAAkB,GAAG;IACxB,uBAAuB,CAAC,IAAI,EAAE,MAAM,EAAE,QAAQ,EAAE,OAAO,EAAE,QAAQ,EAAE,OAAO,GAAG,IAAI,CAAC;CACnF,CAAC;AA8FF,KAAK,eAAe,CAAC,KAAK,SAAS,WAAW,IAAI,KAAK,GACrD,WAAW,CAAC,YAAY,CAAC,KAAK,CAAC,GAAG,kBAAkB,CAAC,GAAG;IACtD,kBAAkB,CAAC,EAAE,MAAM,EAAE,CAAC;CAC/B,CAAC;AAEJ,wBAAgB,UAAU,CAAC,KAAK,SAAS,WAAW,EAAE,IAAI,EAAE,KAAK,GAAG,eAAe,CAAC,KAAK,CAAC,CAgBzF;;;;;AAED,qBAAa,UAAW,SAAQ,eAAuB;CAAG"}
@@ -0,0 +1,80 @@
1
+ const stateByInstance = new WeakMap();
2
+ const setupByConstructor = new WeakSet();
3
+ const managedProps = new WeakMap();
4
+ const asRecord = (obj) => obj;
5
+ function getState(instance) {
6
+ let state = stateByInstance.get(instance);
7
+ if (!state) {
8
+ state = {
9
+ constructing: true,
10
+ values: new Map(),
11
+ };
12
+ stateByInstance.set(instance, state);
13
+ }
14
+ return state;
15
+ }
16
+ function ensureSetup(ctor) {
17
+ if (setupByConstructor.has(ctor))
18
+ return;
19
+ const observed = ctor.observedProperties ?? [];
20
+ const managed = new Set();
21
+ managedProps.set(ctor, managed);
22
+ for (const prop of observed) {
23
+ const existing = Object.getOwnPropertyDescriptor(ctor.prototype, prop);
24
+ if (existing?.get || existing?.set) {
25
+ throw new Error(`Observed property "${prop}" cannot define a custom getter/setter. ` +
26
+ `Use propertyChangedCallback for side effects instead.`);
27
+ }
28
+ if (existing)
29
+ continue;
30
+ managed.add(prop);
31
+ Object.defineProperty(ctor.prototype, prop, {
32
+ enumerable: true,
33
+ configurable: true,
34
+ get() {
35
+ return getState(this).values.get(prop);
36
+ },
37
+ set(next) {
38
+ const state = getState(this);
39
+ const prev = state.values.get(prop);
40
+ if (Object.is(prev, next))
41
+ return;
42
+ state.values.set(prop, next);
43
+ if (!state.constructing || prev !== undefined) {
44
+ this.propertyChangedCallback(prop, prev, next);
45
+ }
46
+ },
47
+ });
48
+ }
49
+ setupByConstructor.add(ctor);
50
+ }
51
+ function initializeProperties(instance, ctor) {
52
+ ensureSetup(ctor);
53
+ const state = getState(instance);
54
+ const rec = asRecord(instance);
55
+ for (const prop of managedProps.get(ctor) ?? []) {
56
+ const hasOwn = Object.prototype.hasOwnProperty.call(instance, prop);
57
+ const ownValue = hasOwn ? rec[prop] : undefined;
58
+ if (hasOwn)
59
+ delete rec[prop];
60
+ if (hasOwn)
61
+ rec[prop] = ownValue;
62
+ }
63
+ state.constructing = false;
64
+ }
65
+ export function observable(Base) {
66
+ class ObservableMixin extends Base {
67
+ static observedProperties;
68
+ constructor(...args) {
69
+ super(...args);
70
+ const ctor = this.constructor;
71
+ ensureSetup(ctor);
72
+ getState(this);
73
+ queueMicrotask(() => initializeProperties(this, ctor));
74
+ }
75
+ propertyChangedCallback(_name, _oldValue, _newValue) { }
76
+ }
77
+ return ObservableMixin;
78
+ }
79
+ export class Observable extends observable(EventTarget) {
80
+ }
@@ -0,0 +1,41 @@
1
+ type EventNames<U extends Event> = U extends unknown ? U extends {
2
+ type: infer T extends string;
3
+ } ? T : never : never;
4
+ type EventForType<U extends Event, K extends string> = Extract<U, {
5
+ type: K;
6
+ }>;
7
+ type NativeHTMLElement = InstanceType<typeof globalThis.HTMLElement>;
8
+ type ReducedHTMLElement = Omit<NativeHTMLElement, "addEventListener" | "removeEventListener" | "dispatchEvent">;
9
+ /** Supported type constructors for property coercion. */
10
+ type ObservedType = StringConstructor | NumberConstructor | BooleanConstructor | ObjectConstructor;
11
+ /** Configuration for a single observed property. */
12
+ export type ObservedPropertyConfig = {
13
+ type: ObservedType;
14
+ /** If set, syncs this property to/from the named attribute. */
15
+ attribute?: string;
16
+ };
17
+ /** Map of property names to their observation config. */
18
+ export type ObservedPropertyMap = Record<string, ObservedPropertyConfig>;
19
+ export interface BaseElement<T extends Event = Event> extends ReducedHTMLElement {
20
+ addEventListener<K extends EventNames<T>>(type: K, listener: ((ev: EventForType<T, K>) => void) | null, options?: boolean | AddEventListenerOptions): void;
21
+ addEventListener<K extends EventNames<T>>(type: K, listener: EventListener | EventListenerObject | null, options?: boolean | AddEventListenerOptions): void;
22
+ removeEventListener<K extends EventNames<T>>(type: K, listener: ((ev: EventForType<T, K>) => void) | null, options?: boolean | EventListenerOptions): void;
23
+ removeEventListener<K extends EventNames<T>>(type: K, listener: EventListener | EventListenerObject | null, options?: boolean | EventListenerOptions): void;
24
+ dispatchEvent(event: T): boolean;
25
+ }
26
+ /**
27
+ * Base class for custom elements with reactive observed properties.
28
+ *
29
+ * Define `static observedProperties` to automatically sync properties with
30
+ * attributes and receive `propertyChangedCallback` notifications.
31
+ */
32
+ export declare class BaseElement<T extends Event = Event> extends globalThis.HTMLElement {
33
+ static observedProperties?: ObservedPropertyMap;
34
+ static get observedAttributes(): string[];
35
+ constructor();
36
+ attributeChangedCallback(name: string, _old: string | null, value: string | null): void;
37
+ /** Override to react to observed property changes. */
38
+ propertyChangedCallback(_name: string, _oldValue: unknown, _newValue: unknown): void;
39
+ }
40
+ export {};
41
+ //# sourceMappingURL=reactive-element.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"reactive-element.d.ts","sourceRoot":"","sources":["../src/reactive-element.ts"],"names":[],"mappings":"AAAA,KAAK,UAAU,CAAC,CAAC,SAAS,KAAK,IAAI,CAAC,SAAS,OAAO,GAChD,CAAC,SAAS;IAAE,IAAI,EAAE,MAAM,CAAC,SAAS,MAAM,CAAA;CAAE,GACxC,CAAC,GACD,KAAK,GACP,KAAK,CAAC;AAEV,KAAK,YAAY,CAAC,CAAC,SAAS,KAAK,EAAE,CAAC,SAAS,MAAM,IAAI,OAAO,CAAC,CAAC,EAAE;IAAE,IAAI,EAAE,CAAC,CAAA;CAAE,CAAC,CAAC;AAE/E,KAAK,iBAAiB,GAAG,YAAY,CAAC,OAAO,UAAU,CAAC,WAAW,CAAC,CAAC;AAErE,KAAK,kBAAkB,GAAG,IAAI,CAC5B,iBAAiB,EACjB,kBAAkB,GAAG,qBAAqB,GAAG,eAAe,CAC7D,CAAC;AAEF,yDAAyD;AACzD,KAAK,YAAY,GACb,iBAAiB,GACjB,iBAAiB,GACjB,kBAAkB,GAClB,iBAAiB,CAAC;AAEtB,oDAAoD;AACpD,MAAM,MAAM,sBAAsB,GAAG;IACnC,IAAI,EAAE,YAAY,CAAC;IACnB,+DAA+D;IAC/D,SAAS,CAAC,EAAE,MAAM,CAAC;CACpB,CAAC;AAEF,yDAAyD;AACzD,MAAM,MAAM,mBAAmB,GAAG,MAAM,CAAC,MAAM,EAAE,sBAAsB,CAAC,CAAC;AAoLzE,MAAM,WAAW,WAAW,CAAC,CAAC,SAAS,KAAK,GAAG,KAAK,CAAE,SAAQ,kBAAkB;IAC9E,gBAAgB,CAAC,CAAC,SAAS,UAAU,CAAC,CAAC,CAAC,EACtC,IAAI,EAAE,CAAC,EACP,QAAQ,EAAE,CAAC,CAAC,EAAE,EAAE,YAAY,CAAC,CAAC,EAAE,CAAC,CAAC,KAAK,IAAI,CAAC,GAAG,IAAI,EACnD,OAAO,CAAC,EAAE,OAAO,GAAG,uBAAuB,GAC1C,IAAI,CAAC;IAER,gBAAgB,CAAC,CAAC,SAAS,UAAU,CAAC,CAAC,CAAC,EACtC,IAAI,EAAE,CAAC,EACP,QAAQ,EAAE,aAAa,GAAG,mBAAmB,GAAG,IAAI,EACpD,OAAO,CAAC,EAAE,OAAO,GAAG,uBAAuB,GAC1C,IAAI,CAAC;IAER,mBAAmB,CAAC,CAAC,SAAS,UAAU,CAAC,CAAC,CAAC,EACzC,IAAI,EAAE,CAAC,EACP,QAAQ,EAAE,CAAC,CAAC,EAAE,EAAE,YAAY,CAAC,CAAC,EAAE,CAAC,CAAC,KAAK,IAAI,CAAC,GAAG,IAAI,EACnD,OAAO,CAAC,EAAE,OAAO,GAAG,oBAAoB,GACvC,IAAI,CAAC;IAER,mBAAmB,CAAC,CAAC,SAAS,UAAU,CAAC,CAAC,CAAC,EACzC,IAAI,EAAE,CAAC,EACP,QAAQ,EAAE,aAAa,GAAG,mBAAmB,GAAG,IAAI,EACpD,OAAO,CAAC,EAAE,OAAO,GAAG,oBAAoB,GACvC,IAAI,CAAC;IAER,aAAa,CAAC,KAAK,EAAE,CAAC,GAAG,OAAO,CAAC;CAClC;AAED;;;;;GAKG;AACH,qBAAa,WAAW,CAAC,CAAC,SAAS,KAAK,GAAG,KAAK,CAAE,SAAQ,UAAU,CAAC,WAAW;IAC9E,MAAM,CAAC,kBAAkB,CAAC,EAAE,mBAAmB,CAAC;IAEhD,MAAM,KAAK,kBAAkB,IAAI,MAAM,EAAE,CAWxC;;IAgBD,wBAAwB,CAAC,IAAI,EAAE,MAAM,EAAE,IAAI,EAAE,MAAM,GAAG,IAAI,EAAE,KAAK,EAAE,MAAM,GAAG,IAAI,GAAG,IAAI;IA0BvF,sDAAsD;IACtD,uBAAuB,CAAC,KAAK,EAAE,MAAM,EAAE,SAAS,EAAE,OAAO,EAAE,SAAS,EAAE,OAAO,GAAG,IAAI;CACrF"}
@@ -0,0 +1,190 @@
1
+ const stateByInstance = new WeakMap();
2
+ const setupByConstructor = new WeakSet();
3
+ const managedProps = new WeakMap();
4
+ const asRecord = (obj) => obj;
5
+ function coerceFromAttribute(type, value) {
6
+ if (type === Boolean) {
7
+ if (value === null)
8
+ return false;
9
+ return value !== "false";
10
+ }
11
+ if (value === null)
12
+ return null;
13
+ if (type === Number)
14
+ return Number(value);
15
+ if (type === Object)
16
+ return JSON.parse(value);
17
+ return value;
18
+ }
19
+ function serializeToAttribute(type, value) {
20
+ if (value == null)
21
+ return null;
22
+ if (type === Boolean)
23
+ return value ? "" : "false";
24
+ if (type === Object)
25
+ return JSON.stringify(value);
26
+ return String(value);
27
+ }
28
+ function configForAttribute(ctor, attr) {
29
+ for (const [prop, config] of Object.entries(ctor.observedProperties ?? {})) {
30
+ if (config.attribute === attr)
31
+ return { prop, config };
32
+ }
33
+ return null;
34
+ }
35
+ function getState(instance) {
36
+ let state = stateByInstance.get(instance);
37
+ if (!state) {
38
+ state = {
39
+ constructing: true,
40
+ syncingFromAttribute: null,
41
+ syncingToAttribute: null,
42
+ values: new Map(),
43
+ };
44
+ stateByInstance.set(instance, state);
45
+ }
46
+ return state;
47
+ }
48
+ function isManaged(ctor, prop) {
49
+ return managedProps.get(ctor)?.has(prop) ?? false;
50
+ }
51
+ function wrapUserAttributeCallback(ctor) {
52
+ const desc = Object.getOwnPropertyDescriptor(ctor.prototype, "attributeChangedCallback");
53
+ if (!desc || typeof desc.value !== "function")
54
+ return;
55
+ const userFn = desc.value;
56
+ if (userFn === BaseElement.prototype.attributeChangedCallback)
57
+ return;
58
+ Object.defineProperty(ctor.prototype, "attributeChangedCallback", {
59
+ configurable: true,
60
+ writable: true,
61
+ value(name, oldVal, newVal) {
62
+ BaseElement.prototype.attributeChangedCallback.call(this, name, oldVal, newVal);
63
+ userFn.call(this, name, oldVal, newVal);
64
+ },
65
+ });
66
+ }
67
+ function setup(ctor) {
68
+ wrapUserAttributeCallback(ctor);
69
+ const observed = ctor.observedProperties ?? {};
70
+ const managed = new Set();
71
+ managedProps.set(ctor, managed);
72
+ for (const [prop, config] of Object.entries(observed)) {
73
+ const existing = Object.getOwnPropertyDescriptor(ctor.prototype, prop);
74
+ if (existing?.get || existing?.set) {
75
+ throw new Error(`Observed property "${prop}" cannot define a custom getter/setter. ` +
76
+ `Use propertyChangedCallback for side effects instead.`);
77
+ }
78
+ if (existing)
79
+ continue;
80
+ managed.add(prop);
81
+ const attr = config.attribute;
82
+ Object.defineProperty(ctor.prototype, prop, {
83
+ enumerable: true,
84
+ configurable: true,
85
+ get() {
86
+ return getState(this).values.get(prop);
87
+ },
88
+ set(next) {
89
+ const state = getState(this);
90
+ const prev = state.values.get(prop);
91
+ if (Object.is(prev, next))
92
+ return;
93
+ state.values.set(prop, next);
94
+ if (attr && state.syncingFromAttribute !== attr && state.syncingToAttribute !== attr) {
95
+ const serialized = serializeToAttribute(config.type, next);
96
+ state.syncingToAttribute = attr;
97
+ try {
98
+ serialized === null ? this.removeAttribute(attr) : this.setAttribute(attr, serialized);
99
+ }
100
+ finally {
101
+ state.syncingToAttribute = null;
102
+ }
103
+ }
104
+ if (!state.constructing || state.syncingFromAttribute !== null) {
105
+ this.propertyChangedCallback(prop, prev, next);
106
+ }
107
+ },
108
+ });
109
+ }
110
+ }
111
+ function initializeProperties(el, ctor) {
112
+ const state = getState(el);
113
+ const observed = ctor.observedProperties ?? {};
114
+ const rec = asRecord(el);
115
+ for (const [prop, config] of Object.entries(observed)) {
116
+ if (!isManaged(ctor, prop))
117
+ continue;
118
+ const hasOwn = Object.prototype.hasOwnProperty.call(el, prop);
119
+ const ownValue = hasOwn ? rec[prop] : undefined;
120
+ if (hasOwn)
121
+ delete rec[prop];
122
+ const attr = config.attribute;
123
+ if (attr && el.hasAttribute(attr)) {
124
+ const coerced = coerceFromAttribute(config.type, el.getAttribute(attr));
125
+ state.syncingFromAttribute = attr;
126
+ try {
127
+ rec[prop] = coerced;
128
+ }
129
+ finally {
130
+ state.syncingFromAttribute = null;
131
+ }
132
+ continue;
133
+ }
134
+ if (hasOwn)
135
+ rec[prop] = ownValue;
136
+ }
137
+ state.constructing = false;
138
+ }
139
+ /**
140
+ * Base class for custom elements with reactive observed properties.
141
+ *
142
+ * Define `static observedProperties` to automatically sync properties with
143
+ * attributes and receive `propertyChangedCallback` notifications.
144
+ */
145
+ export class BaseElement extends globalThis.HTMLElement {
146
+ static observedProperties;
147
+ static get observedAttributes() {
148
+ const ctor = this;
149
+ if (!setupByConstructor.has(ctor)) {
150
+ setup(ctor);
151
+ setupByConstructor.add(ctor);
152
+ }
153
+ return Object.values(this.observedProperties ?? {})
154
+ .map((c) => c.attribute)
155
+ .filter((a) => Boolean(a));
156
+ }
157
+ constructor() {
158
+ super();
159
+ const ctor = this.constructor;
160
+ if (!setupByConstructor.has(ctor)) {
161
+ setup(ctor);
162
+ setupByConstructor.add(ctor);
163
+ }
164
+ getState(this);
165
+ queueMicrotask(() => initializeProperties(this, ctor));
166
+ }
167
+ attributeChangedCallback(name, _old, value) {
168
+ const ctor = this.constructor;
169
+ const found = configForAttribute(ctor, name);
170
+ if (!found || !isManaged(ctor, found.prop))
171
+ return;
172
+ const state = getState(this);
173
+ if (state.syncingToAttribute === name)
174
+ return;
175
+ const coerced = coerceFromAttribute(found.config.type, value);
176
+ const rec = asRecord(this);
177
+ if (Object.prototype.hasOwnProperty.call(this, found.prop)) {
178
+ delete rec[found.prop];
179
+ }
180
+ state.syncingFromAttribute = name;
181
+ try {
182
+ rec[found.prop] = coerced;
183
+ }
184
+ finally {
185
+ state.syncingFromAttribute = null;
186
+ }
187
+ }
188
+ /** Override to react to observed property changes. */
189
+ propertyChangedCallback(_name, _oldValue, _newValue) { }
190
+ }
package/package.json ADDED
@@ -0,0 +1,26 @@
1
+ {
2
+ "name": "@rupertsworld/observable",
3
+ "version": "0.1.0",
4
+ "description": "Observable properties mixin and ObservableElement with attribute reflection",
5
+ "type": "module",
6
+ "main": "dist/index.js",
7
+ "types": "dist/index.d.ts",
8
+ "files": [
9
+ "dist"
10
+ ],
11
+ "scripts": {
12
+ "build": "tsc -p tsconfig.json",
13
+ "typecheck": "tsc -p tsconfig.json --noEmit",
14
+ "test": "vitest run --environment happy-dom",
15
+ "example": "vite --config example/vite.config.js"
16
+ },
17
+ "devDependencies": {
18
+ "happy-dom": "latest",
19
+ "typescript": "latest",
20
+ "vite": "latest",
21
+ "vitest": "latest"
22
+ },
23
+ "dependencies": {
24
+ "@rupertsworld/event-target": "^0.1.1"
25
+ }
26
+ }