custom-elements-ts 0.0.17 → 0.2.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/.eslintrc.json +47 -0
- package/.github/workflows/ci.yml +49 -0
- package/.prettierrc +7 -0
- package/LICENSE +20 -0
- package/README.md +279 -37
- package/assets/readme-header.png +0 -0
- package/assets/social-preview.jpg +0 -0
- package/demos/counter/counter.element.html +1 -0
- package/demos/counter/counter.element.scss +234 -0
- package/demos/counter/counter.element.ts +68 -0
- package/demos/counter/index.html +205 -0
- package/demos/counter/index.ts +1 -0
- package/demos/site/code-example/code-example.element.scss +168 -0
- package/demos/site/code-example/code-example.element.ts +88 -0
- package/demos/site/event-log/event-log.element.scss +179 -0
- package/demos/site/event-log/event-log.element.ts +123 -0
- package/demos/site/favicon.svg +14 -0
- package/demos/site/index.html +368 -0
- package/demos/site/index.ts +19 -0
- package/demos/site/llms.txt +53 -0
- package/demos/site/message/message.element.scss +75 -0
- package/demos/site/message/message.element.ts +76 -0
- package/demos/site/og-image.png +0 -0
- package/demos/site/scroll-restoration.ts +28 -0
- package/demos/site/source-toggle/source-toggle.ts +88 -0
- package/demos/site/source-viewer/source-viewer.element.scss +292 -0
- package/demos/site/source-viewer/source-viewer.element.ts +138 -0
- package/demos/site/source-viewer/sources.generated.ts +11 -0
- package/demos/site/styles/site.css +1135 -0
- package/demos/site/styles/tokens.css +56 -0
- package/demos/site/toast/toast.element.scss +110 -0
- package/demos/site/toast/toast.element.ts +63 -0
- package/demos/todo-dashboard/index.html +141 -0
- package/demos/todo-dashboard/index.ts +4 -0
- package/demos/todo-dashboard/todo-dashboard.element.scss +1150 -0
- package/demos/todo-dashboard/todo-dashboard.element.ts +333 -0
- package/demos/todo-dashboard/todo-filters.element.ts +54 -0
- package/demos/todo-dashboard/todo-item.element.ts +127 -0
- package/demos/todo-dashboard/todo-stats.element.ts +189 -0
- package/package.json +73 -24
- package/src/custom-element.ts +206 -0
- package/src/index.ts +8 -0
- package/src/listen.ts +70 -0
- package/src/prop.ts +92 -0
- package/src/signal.ts +66 -0
- package/src/state.ts +141 -0
- package/src/template-runtime.ts +783 -0
- package/src/toggle.ts +66 -0
- package/src/tsconfig.json +24 -0
- package/src/util.ts +33 -0
- package/src/watch.ts +14 -0
- package/tests/basic.spec.ts +70 -0
- package/tests/custom-element.spec.ts +77 -0
- package/tests/dispatch.spec.ts +52 -0
- package/tests/init.spec.ts +94 -0
- package/tests/listen.spec.ts +118 -0
- package/tests/map-shallow-state.spec.ts +184 -0
- package/tests/prop.spec.ts +118 -0
- package/tests/signal.spec.ts +117 -0
- package/tests/templating-runtime.spec.ts +575 -0
- package/tests/toggle.spec.ts +92 -0
- package/tests/watch.spec.ts +183 -0
- package/tools/build.js +128 -0
- package/tools/bundle.js +167 -0
- package/tools/generate-sources.js +76 -0
- package/tools/rollup-config.js +70 -0
- package/tools/start.js +194 -0
- package/tsconfig.json +38 -0
- package/vite.config.mts +30 -0
- package/bundles/custom-elements-ts.umd.js +0 -359
- package/bundles/custom-elements-ts.umd.js.map +0 -1
- package/esm2015/custom-elements-ts.js +0 -283
- package/esm2015/custom-elements-ts.js.map +0 -1
- package/esm5/custom-element.d.ts +0 -12
- package/esm5/custom-elements-ts.js +0 -344
- package/esm5/custom-elements-ts.js.map +0 -1
- package/esm5/index.d.ts +0 -5
- package/esm5/listen.d.ts +0 -17
- package/esm5/prop.d.ts +0 -2
- package/esm5/toggle.d.ts +0 -1
- package/esm5/util.d.ts +0 -4
- package/esm5/watch.d.ts +0 -1
package/package.json
CHANGED
|
@@ -1,24 +1,73 @@
|
|
|
1
|
-
{
|
|
2
|
-
"name": "custom-elements-ts",
|
|
3
|
-
"version": "0.0
|
|
4
|
-
"description": "Create native Custom Elements using Typescript without using any third party libraries",
|
|
5
|
-
"main": "
|
|
6
|
-
"module": "
|
|
7
|
-
"
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
"
|
|
12
|
-
"
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
"
|
|
16
|
-
"
|
|
17
|
-
"
|
|
18
|
-
"
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
1
|
+
{
|
|
2
|
+
"name": "custom-elements-ts",
|
|
3
|
+
"version": "0.2.0",
|
|
4
|
+
"description": "Create native Custom Elements using Typescript without using any third party libraries",
|
|
5
|
+
"main": "index.js",
|
|
6
|
+
"module": "custom-element.js",
|
|
7
|
+
"scripts": {
|
|
8
|
+
"build": "node tools/build.js",
|
|
9
|
+
"start": "node tools/start.js",
|
|
10
|
+
"bundle": "node tools/bundle.js",
|
|
11
|
+
"publish:dist": "npm run bundle && npm publish ./dist --access public",
|
|
12
|
+
"publish:dry-run": "npm run bundle && npm publish ./dist --dry-run",
|
|
13
|
+
"test": "vitest run",
|
|
14
|
+
"test:watch": "vitest",
|
|
15
|
+
"test:coverage": "vitest run --coverage --coverage.reporter=lcov",
|
|
16
|
+
"coveralls": "cat ./coverage/lcov.info | ./node_modules/coveralls/bin/coveralls.js",
|
|
17
|
+
"lint": "eslint \"{src,tests}/**/*.ts\"",
|
|
18
|
+
"lint:fix": "eslint \"{src,tests}/**/*.ts\" --fix"
|
|
19
|
+
},
|
|
20
|
+
"keywords": [
|
|
21
|
+
"components",
|
|
22
|
+
"custom",
|
|
23
|
+
"dom",
|
|
24
|
+
"elements",
|
|
25
|
+
"web",
|
|
26
|
+
"typescript"
|
|
27
|
+
],
|
|
28
|
+
"license": "MIT",
|
|
29
|
+
"devDependencies": {
|
|
30
|
+
"@rollup/plugin-node-resolve": "^15.2.3",
|
|
31
|
+
"@rollup/plugin-terser": "^0.4.4",
|
|
32
|
+
"@typescript-eslint/eslint-plugin": "^8.5.0",
|
|
33
|
+
"@typescript-eslint/parser": "^8.5.0",
|
|
34
|
+
"@vitest/coverage-v8": "2.1.9",
|
|
35
|
+
"@webcomponents/custom-elements": "1.2.2",
|
|
36
|
+
"coveralls": "^3.0.2",
|
|
37
|
+
"eslint": "^8.57.0",
|
|
38
|
+
"eslint-config-prettier": "^9.0.0",
|
|
39
|
+
"eslint-plugin-prettier": "^5.0.0",
|
|
40
|
+
"event-stream": "3.3.4",
|
|
41
|
+
"express": "^4.17.1",
|
|
42
|
+
"glob": "^7.1.6",
|
|
43
|
+
"happy-dom": "^14.12.3",
|
|
44
|
+
"magic-string": "0.25.3",
|
|
45
|
+
"prettier": "^3.0.0",
|
|
46
|
+
"rimraf": "^3.0.2",
|
|
47
|
+
"rollup": "^4.18.0",
|
|
48
|
+
"rollup-plugin-typescript2": "^0.36.0",
|
|
49
|
+
"typescript": "^5.5.4",
|
|
50
|
+
"vitest": "^2.0.5"
|
|
51
|
+
},
|
|
52
|
+
"typings": "index.d.ts",
|
|
53
|
+
"contributors": [
|
|
54
|
+
"Aivan Monceller",
|
|
55
|
+
"Arjay Elbore",
|
|
56
|
+
"Martin Llaneza"
|
|
57
|
+
],
|
|
58
|
+
"repository": {
|
|
59
|
+
"type": "git",
|
|
60
|
+
"url": "git+https://github.com/geocine/custom-elements-ts.git"
|
|
61
|
+
},
|
|
62
|
+
"homepage": "https://geocine.github.io/custom-elements-ts/",
|
|
63
|
+
"overrides": {
|
|
64
|
+
"handlebars": "4.7.8",
|
|
65
|
+
"tar": "6.2.1",
|
|
66
|
+
"minimist": "1.2.8",
|
|
67
|
+
"follow-redirects": "1.15.9",
|
|
68
|
+
"elliptic": "6.6.1",
|
|
69
|
+
"axios": "1.8.4",
|
|
70
|
+
"lodash": "4.17.21",
|
|
71
|
+
"set-value": "4.1.0"
|
|
72
|
+
}
|
|
73
|
+
}
|
|
@@ -0,0 +1,206 @@
|
|
|
1
|
+
import { addEventListeners, ListenerMetadata } from './listen';
|
|
2
|
+
import { initializeProps } from './prop';
|
|
3
|
+
import { isStateAttributeName } from './state';
|
|
4
|
+
import { renderIntoAnchor, RenderState, TemplateValue } from './template-runtime';
|
|
5
|
+
import { toKebabCase, toCamelCase } from './util';
|
|
6
|
+
|
|
7
|
+
export interface CustomElementMetadata {
|
|
8
|
+
tag?: string;
|
|
9
|
+
template?: string;
|
|
10
|
+
templateUrl?: string;
|
|
11
|
+
styleUrl?: string;
|
|
12
|
+
style?: string;
|
|
13
|
+
shadow?: boolean;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
export interface KeyValue {
|
|
17
|
+
[key: string]: any;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
export const CustomElement = (args: CustomElementMetadata) => {
|
|
21
|
+
return (target: any) => {
|
|
22
|
+
const tag: string = args.tag || toKebabCase(target.prototype.constructor.name);
|
|
23
|
+
const customElement: any = class extends (target as { new (): any }) {
|
|
24
|
+
private __connected: boolean = false;
|
|
25
|
+
private __hasMountedStatic: boolean = false;
|
|
26
|
+
private __renderAnchor?: Comment;
|
|
27
|
+
private __renderQueued: boolean = false;
|
|
28
|
+
private __renderState: RenderState = {};
|
|
29
|
+
private __suppressRender: boolean = false;
|
|
30
|
+
|
|
31
|
+
props: KeyValue = {};
|
|
32
|
+
__stateValues!: KeyValue;
|
|
33
|
+
__stateProxyCaches!: KeyValue;
|
|
34
|
+
protected static propsInit: KeyValue;
|
|
35
|
+
protected static stateInit: KeyValue;
|
|
36
|
+
|
|
37
|
+
protected static watchAttributes: KeyValue;
|
|
38
|
+
protected static listeners: ListenerMetadata[];
|
|
39
|
+
|
|
40
|
+
showShadowRoot: boolean;
|
|
41
|
+
|
|
42
|
+
static get observedAttributes() {
|
|
43
|
+
const stateInit = this.stateInit || {};
|
|
44
|
+
return Object.keys(this.propsInit || {})
|
|
45
|
+
.filter((x) => stateInit[x] === undefined)
|
|
46
|
+
.map((x) => toKebabCase(x));
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
constructor() {
|
|
50
|
+
super();
|
|
51
|
+
this.__stateValues ||= {};
|
|
52
|
+
this.__stateProxyCaches ||= {};
|
|
53
|
+
this.showShadowRoot =
|
|
54
|
+
args.shadow === undefined || args.shadow === null ? true : args.shadow;
|
|
55
|
+
if (!this.shadowRoot && this.showShadowRoot) {
|
|
56
|
+
this.attachShadow({ mode: 'open' });
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
attributeChangedCallback(name: string, oldValue: string, newValue: string): void {
|
|
61
|
+
this.onAttributeChange(name, oldValue, newValue);
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
onAttributeChange(name: string, oldValue: string, newValue: string, set: boolean = true) {
|
|
65
|
+
if (oldValue !== newValue) {
|
|
66
|
+
if (set && !isStateAttributeName(this, name)) {
|
|
67
|
+
const propName = toCamelCase(name);
|
|
68
|
+
(this as any)[propName] = newValue;
|
|
69
|
+
}
|
|
70
|
+
const watchAttributes: KeyValue = (this.constructor as any).watchAttributes;
|
|
71
|
+
if (watchAttributes && watchAttributes[name]) {
|
|
72
|
+
const methodToCall: string = watchAttributes[name];
|
|
73
|
+
if (this.__connected) {
|
|
74
|
+
if (typeof (this as any)[methodToCall] === 'function') {
|
|
75
|
+
(this as any)[methodToCall]({ old: oldValue, new: newValue });
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
this.__scheduleRender();
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
connectedCallback() {
|
|
84
|
+
if (this.__connected) return;
|
|
85
|
+
|
|
86
|
+
this.__connected = true;
|
|
87
|
+
if (!this.__hasRender()) {
|
|
88
|
+
this.__renderStaticTemplate();
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
this.__suppressRender = true;
|
|
92
|
+
initializeProps(this);
|
|
93
|
+
this.__suppressRender = false;
|
|
94
|
+
|
|
95
|
+
if (this.__hasRender()) {
|
|
96
|
+
this.__mountStaticStyle();
|
|
97
|
+
this.__performRender();
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
const parentProto =
|
|
101
|
+
(Object.getPrototypeOf(Object.getPrototypeOf(this)) as {
|
|
102
|
+
connectedCallback?: () => void;
|
|
103
|
+
}) || null;
|
|
104
|
+
if (parentProto && typeof parentProto.connectedCallback === 'function') {
|
|
105
|
+
parentProto.connectedCallback.call(this);
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
addEventListeners(this);
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
disconnectedCallback() {
|
|
112
|
+
if (!this.__connected) return;
|
|
113
|
+
this.__connected = false;
|
|
114
|
+
this.__renderQueued = false;
|
|
115
|
+
this.__renderState.instance?.dispose();
|
|
116
|
+
this.__renderState.part?.dispose();
|
|
117
|
+
this.__renderState = {};
|
|
118
|
+
|
|
119
|
+
const parentProto =
|
|
120
|
+
(Object.getPrototypeOf(Object.getPrototypeOf(this)) as {
|
|
121
|
+
disconnectedCallback?: () => void;
|
|
122
|
+
}) || null;
|
|
123
|
+
if (parentProto && typeof parentProto.disconnectedCallback === 'function') {
|
|
124
|
+
parentProto.disconnectedCallback.call(this);
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
__renderStaticTemplate() {
|
|
129
|
+
if (this.__hasMountedStatic) return;
|
|
130
|
+
const template = document.createElement('template');
|
|
131
|
+
const style = `${args.style ? `<style>${args.style}</style>` : ''}`;
|
|
132
|
+
template.innerHTML = `${style}${args.template ? args.template : ''}`;
|
|
133
|
+
(this.showShadowRoot ? this.shadowRoot : this).appendChild(
|
|
134
|
+
document.importNode(template.content, true)
|
|
135
|
+
);
|
|
136
|
+
this.__hasMountedStatic = true;
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
__mountStaticStyle() {
|
|
140
|
+
if (this.__hasMountedStatic) return;
|
|
141
|
+
if (args.style) {
|
|
142
|
+
const style = document.createElement('style');
|
|
143
|
+
style.textContent = args.style;
|
|
144
|
+
(this.showShadowRoot ? this.shadowRoot : this).appendChild(style);
|
|
145
|
+
}
|
|
146
|
+
this.__renderAnchor = document.createComment('custom-elements-ts-render');
|
|
147
|
+
(this.showShadowRoot ? this.shadowRoot : this).appendChild(this.__renderAnchor);
|
|
148
|
+
this.__hasMountedStatic = true;
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
__notifyPropertyChange(propName: string, oldValue: any, newValue: any) {
|
|
152
|
+
const watchAttributes: KeyValue = (this.constructor as any).watchAttributes;
|
|
153
|
+
const watchName = toKebabCase(propName);
|
|
154
|
+
const methodToCall: string | undefined = watchAttributes?.[watchName];
|
|
155
|
+
if (methodToCall && this.__connected && typeof (this as any)[methodToCall] === 'function') {
|
|
156
|
+
(this as any)[methodToCall]({ old: oldValue, new: newValue });
|
|
157
|
+
}
|
|
158
|
+
this.__scheduleRender();
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
__scheduleRender() {
|
|
162
|
+
if (!this.__connected || this.__suppressRender || !this.__hasRender()) {
|
|
163
|
+
return;
|
|
164
|
+
}
|
|
165
|
+
if (this.__renderQueued) {
|
|
166
|
+
return;
|
|
167
|
+
}
|
|
168
|
+
this.__renderQueued = true;
|
|
169
|
+
queueMicrotask(() => {
|
|
170
|
+
if (!this.__connected || !this.__renderQueued) {
|
|
171
|
+
return;
|
|
172
|
+
}
|
|
173
|
+
this.__performRender();
|
|
174
|
+
});
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
__performRender() {
|
|
178
|
+
if (!this.__hasRender()) {
|
|
179
|
+
return;
|
|
180
|
+
}
|
|
181
|
+
this.__mountStaticStyle();
|
|
182
|
+
this.__renderQueued = false;
|
|
183
|
+
try {
|
|
184
|
+
const output = (this as any).render() as TemplateValue;
|
|
185
|
+
this.__renderState = renderIntoAnchor(
|
|
186
|
+
output,
|
|
187
|
+
this.__renderAnchor!,
|
|
188
|
+
this.__renderState,
|
|
189
|
+
this
|
|
190
|
+
);
|
|
191
|
+
} finally {
|
|
192
|
+
this.__renderQueued = false;
|
|
193
|
+
}
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
__hasRender() {
|
|
197
|
+
return typeof (this as any).render === 'function';
|
|
198
|
+
}
|
|
199
|
+
};
|
|
200
|
+
|
|
201
|
+
if (!customElements.get(tag)) {
|
|
202
|
+
customElements.define(tag, customElement);
|
|
203
|
+
}
|
|
204
|
+
return customElement;
|
|
205
|
+
};
|
|
206
|
+
};
|
package/src/index.ts
ADDED
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
export * from './custom-element';
|
|
2
|
+
export * from './watch';
|
|
3
|
+
export * from './prop';
|
|
4
|
+
export * from './toggle';
|
|
5
|
+
export * from './listen';
|
|
6
|
+
export { State, StateOptions } from './state';
|
|
7
|
+
export { signal, Signal, isSignal, SignalSubscriber } from './signal';
|
|
8
|
+
export { html, map, TemplateResult, TemplateValue, MappedTemplates } from './template-runtime';
|
package/src/listen.ts
ADDED
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
import { toDotCase } from './util';
|
|
2
|
+
interface ListenerMetadata {
|
|
3
|
+
eventName: string;
|
|
4
|
+
handler: (e: Event) => void;
|
|
5
|
+
selector?: string;
|
|
6
|
+
}
|
|
7
|
+
|
|
8
|
+
interface CustomEventOptions {
|
|
9
|
+
bubbles?: boolean;
|
|
10
|
+
composed?: boolean;
|
|
11
|
+
detail?: any;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
interface DispatchEmitter {
|
|
15
|
+
emit(options?: CustomEventOptions): void;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
const Listen = (eventName: string, selector?: string) => {
|
|
19
|
+
return (target: any, methodName: string) => {
|
|
20
|
+
if (!target.constructor.listeners) {
|
|
21
|
+
target.constructor.listeners = [];
|
|
22
|
+
}
|
|
23
|
+
target.constructor.listeners.push({
|
|
24
|
+
selector: selector,
|
|
25
|
+
eventName: eventName,
|
|
26
|
+
handler: target[methodName],
|
|
27
|
+
});
|
|
28
|
+
};
|
|
29
|
+
};
|
|
30
|
+
|
|
31
|
+
const addEventListeners = (target: any) => {
|
|
32
|
+
if (target.constructor.listeners) {
|
|
33
|
+
const targetRoot: any = target.shadowRoot || target;
|
|
34
|
+
for (const listener of target.constructor.listeners as Array<ListenerMetadata>) {
|
|
35
|
+
let eventTarget: Element | EventTarget | null = target;
|
|
36
|
+
if (listener.selector) {
|
|
37
|
+
eventTarget = targetRoot.querySelector(listener.selector);
|
|
38
|
+
}
|
|
39
|
+
if (eventTarget) {
|
|
40
|
+
eventTarget.addEventListener(listener.eventName, (e: Event) => {
|
|
41
|
+
listener.handler.call(target, e);
|
|
42
|
+
});
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
};
|
|
47
|
+
|
|
48
|
+
const Dispatch = (eventName?: string) => {
|
|
49
|
+
return (target: HTMLElement, propertyName: string) => {
|
|
50
|
+
function get(this: EventTarget) {
|
|
51
|
+
const self: EventTarget = this as EventTarget;
|
|
52
|
+
return {
|
|
53
|
+
emit(options?: CustomEventOptions) {
|
|
54
|
+
const evtName = eventName ? eventName : toDotCase(propertyName);
|
|
55
|
+
self.dispatchEvent(new CustomEvent(evtName, options));
|
|
56
|
+
},
|
|
57
|
+
};
|
|
58
|
+
}
|
|
59
|
+
Object.defineProperty(target, propertyName, { get });
|
|
60
|
+
};
|
|
61
|
+
};
|
|
62
|
+
|
|
63
|
+
export {
|
|
64
|
+
Listen,
|
|
65
|
+
addEventListeners,
|
|
66
|
+
DispatchEmitter,
|
|
67
|
+
Dispatch,
|
|
68
|
+
CustomEventOptions,
|
|
69
|
+
ListenerMetadata,
|
|
70
|
+
};
|
package/src/prop.ts
ADDED
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
import { toKebabCase, tryParseInt } from './util';
|
|
2
|
+
|
|
3
|
+
export const Prop = (): any => {
|
|
4
|
+
return (target: any, propName: any) => {
|
|
5
|
+
const attrName = toKebabCase(propName);
|
|
6
|
+
function get(this: any) {
|
|
7
|
+
const hasOwn = Object.prototype.hasOwnProperty.call(this.props, propName);
|
|
8
|
+
if (hasOwn) {
|
|
9
|
+
return this.props[propName];
|
|
10
|
+
}
|
|
11
|
+
return this.getAttribute(attrName);
|
|
12
|
+
}
|
|
13
|
+
function set(this: any, value: any) {
|
|
14
|
+
if (this.__connected) {
|
|
15
|
+
const oldValue = this.props[propName];
|
|
16
|
+
this.props[propName] = coercePropValue(
|
|
17
|
+
value,
|
|
18
|
+
oldValue,
|
|
19
|
+
this.constructor.propsInit[propName]
|
|
20
|
+
);
|
|
21
|
+
const changed = !Object.is(oldValue, this.props[propName]);
|
|
22
|
+
const valueType = typeof value;
|
|
23
|
+
const shouldReflect =
|
|
24
|
+
valueType === 'string' || valueType === 'number' || valueType === 'boolean';
|
|
25
|
+
if (shouldReflect) {
|
|
26
|
+
this.setAttribute(attrName, value);
|
|
27
|
+
} else {
|
|
28
|
+
this.onAttributeChange(attrName, oldValue, value, false);
|
|
29
|
+
}
|
|
30
|
+
if (changed) {
|
|
31
|
+
this.__scheduleRender?.();
|
|
32
|
+
}
|
|
33
|
+
} else {
|
|
34
|
+
if (!this.hasAttribute(toKebabCase(propName))) {
|
|
35
|
+
this.constructor.propsInit[propName] = value;
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
if (!target.constructor.propsInit) {
|
|
40
|
+
target.constructor.propsInit = {};
|
|
41
|
+
}
|
|
42
|
+
target.constructor.propsInit[propName] = null;
|
|
43
|
+
Object.defineProperty(target, propName, { get, set });
|
|
44
|
+
};
|
|
45
|
+
};
|
|
46
|
+
|
|
47
|
+
const getProps = (target: any) => {
|
|
48
|
+
const watchAttributes = target.constructor.watchAttributes;
|
|
49
|
+
const plainAttributes = { ...watchAttributes };
|
|
50
|
+
Object.keys(plainAttributes).forEach((v) => (plainAttributes[v] = ''));
|
|
51
|
+
const cycleProps = { ...plainAttributes, ...target.constructor.propsInit };
|
|
52
|
+
const stateInit = target.constructor.stateInit || {};
|
|
53
|
+
return Object.keys(cycleProps).filter((prop) => stateInit[prop] === undefined);
|
|
54
|
+
};
|
|
55
|
+
|
|
56
|
+
export const initializeProps = (target: any) => {
|
|
57
|
+
const watchAttributes = target.constructor.watchAttributes;
|
|
58
|
+
for (const prop of getProps(target)) {
|
|
59
|
+
const attrName = toKebabCase(prop);
|
|
60
|
+
if (watchAttributes) {
|
|
61
|
+
const methodName = watchAttributes[attrName];
|
|
62
|
+
if (methodName === null || methodName === undefined) {
|
|
63
|
+
watchAttributes[attrName] = '';
|
|
64
|
+
} else {
|
|
65
|
+
const hasOwn = Object.prototype.hasOwnProperty.call(target.props, prop);
|
|
66
|
+
const attribValue = hasOwn ? target.props[prop] : target.getAttribute(attrName);
|
|
67
|
+
if (typeof target[methodName] === 'function') {
|
|
68
|
+
target[methodName]({ new: attribValue });
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
const defaultValue = target.constructor.propsInit[prop];
|
|
73
|
+
if (defaultValue !== null && defaultValue !== undefined) {
|
|
74
|
+
if (!target.hasAttribute(attrName)) {
|
|
75
|
+
target[prop] = defaultValue;
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
};
|
|
80
|
+
|
|
81
|
+
const coercePropValue = (value: any, oldValue: any, defaultValue: any): any => {
|
|
82
|
+
const expectedType = typeof (oldValue === undefined ? defaultValue : oldValue);
|
|
83
|
+
if (expectedType === 'boolean' && typeof value === 'string') {
|
|
84
|
+
if (value === '' || value === 'true') {
|
|
85
|
+
return true;
|
|
86
|
+
}
|
|
87
|
+
if (value === 'false') {
|
|
88
|
+
return false;
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
return tryParseInt(value);
|
|
92
|
+
};
|
package/src/signal.ts
ADDED
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
export type SignalSubscriber<T> = (value: T) => void;
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Sentinel thrown by a subscriber whose DOM target has been garbage
|
|
5
|
+
* collected. The notifying signal catches it and drops that subscriber, so
|
|
6
|
+
* bindings clean themselves up lazily — no explicit disposal bookkeeping is
|
|
7
|
+
* needed when rendered subtrees are discarded wholesale.
|
|
8
|
+
*/
|
|
9
|
+
export const signalTargetCollected: symbol = Symbol('custom-elements-ts-signal-target-collected');
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* A writable reactive value. When a signal is embedded in a template
|
|
13
|
+
* (`html`\`<td>${row.label}</td>\`` or `class=${row.selected}`), the render
|
|
14
|
+
* part subscribes directly to it: assigning `.value` updates just that DOM
|
|
15
|
+
* node, bypassing component re-rendering entirely.
|
|
16
|
+
*/
|
|
17
|
+
export class Signal<T> {
|
|
18
|
+
private currentValue: T;
|
|
19
|
+
private observers?: Set<SignalSubscriber<T>>;
|
|
20
|
+
|
|
21
|
+
constructor(initialValue: T) {
|
|
22
|
+
this.currentValue = initialValue;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
get value(): T {
|
|
26
|
+
return this.currentValue;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
set value(newValue: T) {
|
|
30
|
+
if (newValue === this.currentValue) {
|
|
31
|
+
return;
|
|
32
|
+
}
|
|
33
|
+
this.currentValue = newValue;
|
|
34
|
+
const observers = this.observers;
|
|
35
|
+
if (!observers) {
|
|
36
|
+
return;
|
|
37
|
+
}
|
|
38
|
+
observers.forEach((observer) => {
|
|
39
|
+
try {
|
|
40
|
+
observer(newValue);
|
|
41
|
+
} catch (error) {
|
|
42
|
+
if (error === signalTargetCollected) {
|
|
43
|
+
observers.delete(observer);
|
|
44
|
+
return;
|
|
45
|
+
}
|
|
46
|
+
throw error;
|
|
47
|
+
}
|
|
48
|
+
});
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
subscribe(observer: SignalSubscriber<T>): void {
|
|
52
|
+
(this.observers || (this.observers = new Set())).add(observer);
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
unsubscribe(observer: SignalSubscriber<T>): void {
|
|
56
|
+
this.observers?.delete(observer);
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
valueOf(): T {
|
|
60
|
+
return this.currentValue;
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
export const signal = <T>(initialValue: T): Signal<T> => new Signal(initialValue);
|
|
65
|
+
|
|
66
|
+
export const isSignal = (value: unknown): value is Signal<unknown> => value instanceof Signal;
|
package/src/state.ts
ADDED
|
@@ -0,0 +1,141 @@
|
|
|
1
|
+
import { toKebabCase } from './util';
|
|
2
|
+
|
|
3
|
+
export interface StateMetadata {
|
|
4
|
+
[key: string]: null;
|
|
5
|
+
}
|
|
6
|
+
|
|
7
|
+
const proxyToRaw = new WeakMap<object, object>();
|
|
8
|
+
|
|
9
|
+
export interface StateOptions {
|
|
10
|
+
/**
|
|
11
|
+
* When false, the value is stored as-is: no deep proxy is created and
|
|
12
|
+
* nested mutations do not schedule renders — reassign the property to
|
|
13
|
+
* re-render. Much cheaper for large arrays of objects. Defaults to true.
|
|
14
|
+
*/
|
|
15
|
+
deep?: boolean;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
export const State = (options?: StateOptions): any => {
|
|
19
|
+
const deep = options?.deep !== false;
|
|
20
|
+
return (target: any, propName: string) => {
|
|
21
|
+
if (!target.constructor.stateInit) {
|
|
22
|
+
target.constructor.stateInit = {};
|
|
23
|
+
}
|
|
24
|
+
target.constructor.stateInit[propName] = null;
|
|
25
|
+
|
|
26
|
+
function get(this: any) {
|
|
27
|
+
return this.__stateValues ? this.__stateValues[propName] : undefined;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
function set(this: any, value: any) {
|
|
31
|
+
if (!this.__stateValues) {
|
|
32
|
+
this.__stateValues = {};
|
|
33
|
+
}
|
|
34
|
+
if (!this.__stateProxyCaches) {
|
|
35
|
+
this.__stateProxyCaches = {};
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
const oldValue = this.__stateValues[propName];
|
|
39
|
+
const proxyCache = (this.__stateProxyCaches[propName] ||= new WeakMap<object, unknown>());
|
|
40
|
+
const newValue = deep
|
|
41
|
+
? createStateProxy(value, proxyCache, () => {
|
|
42
|
+
this.__notifyPropertyChange?.(
|
|
43
|
+
propName,
|
|
44
|
+
this.__stateValues[propName],
|
|
45
|
+
this.__stateValues[propName]
|
|
46
|
+
);
|
|
47
|
+
})
|
|
48
|
+
: value;
|
|
49
|
+
|
|
50
|
+
if (Object.is(oldValue, newValue)) {
|
|
51
|
+
return;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
this.__stateValues[propName] = newValue;
|
|
55
|
+
if (this.__connected) {
|
|
56
|
+
this.__notifyPropertyChange?.(propName, oldValue, newValue);
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
Object.defineProperty(target, propName, { get, set });
|
|
61
|
+
};
|
|
62
|
+
};
|
|
63
|
+
|
|
64
|
+
const createStateProxy = (
|
|
65
|
+
value: unknown,
|
|
66
|
+
cache: WeakMap<object, unknown>,
|
|
67
|
+
notify: () => void
|
|
68
|
+
): unknown => {
|
|
69
|
+
if (!isProxyable(value)) {
|
|
70
|
+
return value;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
if (proxyToRaw.has(value)) {
|
|
74
|
+
return value;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
const cached = cache.get(value);
|
|
78
|
+
if (cached) {
|
|
79
|
+
return cached;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
const proxy = new Proxy(value, {
|
|
83
|
+
get(target, property, receiver) {
|
|
84
|
+
const result = Reflect.get(target, property, receiver);
|
|
85
|
+
return createStateProxy(result, cache, notify);
|
|
86
|
+
},
|
|
87
|
+
set(target, property, nextValue, receiver) {
|
|
88
|
+
const previous = Reflect.get(target, property, receiver);
|
|
89
|
+
const proxiedValue = createStateProxy(nextValue, cache, notify);
|
|
90
|
+
const changed = !Object.is(previous, proxiedValue);
|
|
91
|
+
const didSet = Reflect.set(target, property, proxiedValue, receiver);
|
|
92
|
+
if (didSet && changed) {
|
|
93
|
+
notify();
|
|
94
|
+
}
|
|
95
|
+
return didSet;
|
|
96
|
+
},
|
|
97
|
+
deleteProperty(target, property) {
|
|
98
|
+
const hadProperty = Object.prototype.hasOwnProperty.call(target, property);
|
|
99
|
+
const deleted = Reflect.deleteProperty(target, property);
|
|
100
|
+
if (deleted && hadProperty) {
|
|
101
|
+
notify();
|
|
102
|
+
}
|
|
103
|
+
return deleted;
|
|
104
|
+
},
|
|
105
|
+
});
|
|
106
|
+
|
|
107
|
+
proxyToRaw.set(proxy, value);
|
|
108
|
+
cache.set(value, proxy);
|
|
109
|
+
return proxy;
|
|
110
|
+
};
|
|
111
|
+
|
|
112
|
+
const isProxyable = (value: unknown): value is object => {
|
|
113
|
+
if (!value || typeof value !== 'object') {
|
|
114
|
+
return false;
|
|
115
|
+
}
|
|
116
|
+
if (typeof Node !== 'undefined' && value instanceof Node) {
|
|
117
|
+
return false;
|
|
118
|
+
}
|
|
119
|
+
if (
|
|
120
|
+
value instanceof Date ||
|
|
121
|
+
value instanceof Map ||
|
|
122
|
+
value instanceof Set ||
|
|
123
|
+
value instanceof WeakMap ||
|
|
124
|
+
value instanceof WeakSet
|
|
125
|
+
) {
|
|
126
|
+
return false;
|
|
127
|
+
}
|
|
128
|
+
return Array.isArray(value) || Object.getPrototypeOf(value) === Object.prototype;
|
|
129
|
+
};
|
|
130
|
+
|
|
131
|
+
export const isStateName = (target: any, propName: string): boolean => {
|
|
132
|
+
return Boolean(target.constructor.stateInit?.[propName] !== undefined);
|
|
133
|
+
};
|
|
134
|
+
|
|
135
|
+
export const isStateAttributeName = (target: any, attrName: string): boolean => {
|
|
136
|
+
const stateInit: StateMetadata | undefined = target.constructor.stateInit;
|
|
137
|
+
if (!stateInit) {
|
|
138
|
+
return false;
|
|
139
|
+
}
|
|
140
|
+
return Object.keys(stateInit).some((propName) => toKebabCase(propName) === attrName);
|
|
141
|
+
};
|