@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 +150 -0
- package/dist/html-element.d.ts +36 -0
- package/dist/html-element.d.ts.map +1 -0
- package/dist/html-element.js +190 -0
- package/dist/index.d.ts +4 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +2 -0
- package/dist/observable-element.d.ts +33 -0
- package/dist/observable-element.d.ts.map +1 -0
- package/dist/observable-element.js +222 -0
- package/dist/observable.d.ts +16 -0
- package/dist/observable.d.ts.map +1 -0
- package/dist/observable.js +80 -0
- package/dist/reactive-element.d.ts +41 -0
- package/dist/reactive-element.d.ts.map +1 -0
- package/dist/reactive-element.js +190 -0
- package/package.json +26 -0
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
|
+
}
|
package/dist/index.d.ts
ADDED
|
@@ -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,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
|
+
}
|