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
|
@@ -0,0 +1,575 @@
|
|
|
1
|
+
import { describe, it, expect, afterEach, vi } from 'vitest';
|
|
2
|
+
import {
|
|
3
|
+
CustomElement,
|
|
4
|
+
Prop,
|
|
5
|
+
State,
|
|
6
|
+
TemplateResult,
|
|
7
|
+
TemplateValue,
|
|
8
|
+
Toggle,
|
|
9
|
+
Watch,
|
|
10
|
+
html,
|
|
11
|
+
} from 'custom-elements-ts';
|
|
12
|
+
import { isStateName } from '../src/state';
|
|
13
|
+
import { RenderState, renderIntoAnchor } from '../src/template-runtime';
|
|
14
|
+
|
|
15
|
+
const nextMicrotask = () => Promise.resolve();
|
|
16
|
+
|
|
17
|
+
@CustomElement({
|
|
18
|
+
tag: 'render-static-element',
|
|
19
|
+
style: ':host{display:block}',
|
|
20
|
+
})
|
|
21
|
+
class RenderStaticElement extends HTMLElement {
|
|
22
|
+
connectedSawRendered = false;
|
|
23
|
+
|
|
24
|
+
render(): TemplateResult {
|
|
25
|
+
return html`<span id="message">Hello</span>`;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
connectedCallback() {
|
|
29
|
+
this.connectedSawRendered = Boolean(this.shadowRoot!.querySelector('#message'));
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
@CustomElement({
|
|
34
|
+
tag: 'render-light-element',
|
|
35
|
+
shadow: false,
|
|
36
|
+
})
|
|
37
|
+
class RenderLightElement extends HTMLElement {
|
|
38
|
+
render(): TemplateResult {
|
|
39
|
+
return html`<span id="light">Light</span>`;
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
@CustomElement({
|
|
44
|
+
tag: 'render-dynamic-element',
|
|
45
|
+
})
|
|
46
|
+
class RenderDynamicElement extends HTMLElement {
|
|
47
|
+
@Prop() label = 'Alpha';
|
|
48
|
+
@Prop() title = 'Ready';
|
|
49
|
+
@State() enabled = true;
|
|
50
|
+
@Prop() value = 'one';
|
|
51
|
+
@Prop() items = [{ label: 'First' }];
|
|
52
|
+
|
|
53
|
+
render(): TemplateResult {
|
|
54
|
+
return html`
|
|
55
|
+
<p id="label">${this.label}</p>
|
|
56
|
+
<p id="fn">${() => this.label}</p>
|
|
57
|
+
<div id="attr" title=${this.title} data-enabled=${this.enabled}></div>
|
|
58
|
+
<input id="field" .value=${this.value} />
|
|
59
|
+
<ul id="list">
|
|
60
|
+
${this.items.map((item: { label: string }) => html`<li>${item.label}</li>`)}
|
|
61
|
+
</ul>
|
|
62
|
+
`;
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
@CustomElement({
|
|
67
|
+
tag: 'render-event-element',
|
|
68
|
+
})
|
|
69
|
+
class RenderEventElement extends HTMLElement {
|
|
70
|
+
@State() alternate = false;
|
|
71
|
+
primary = vi.fn();
|
|
72
|
+
secondary = vi.fn();
|
|
73
|
+
|
|
74
|
+
render(): TemplateResult {
|
|
75
|
+
return html`<button id="action" @click=${this.alternate ? this.secondary : this.primary}>
|
|
76
|
+
Action
|
|
77
|
+
</button>`;
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
@CustomElement({
|
|
82
|
+
tag: 'render-stable-event-element',
|
|
83
|
+
})
|
|
84
|
+
class RenderStableEventElement extends HTMLElement {
|
|
85
|
+
@State() count = 0;
|
|
86
|
+
clicks = 0;
|
|
87
|
+
|
|
88
|
+
render(): TemplateResult {
|
|
89
|
+
return html`
|
|
90
|
+
<button id="stable" @click=${this.handleClick}>Stable</button>
|
|
91
|
+
<span id="stable-count">${this.count}</span>
|
|
92
|
+
`;
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
handleClick() {
|
|
96
|
+
this.clicks++;
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
@CustomElement({
|
|
101
|
+
tag: 'render-shape-element',
|
|
102
|
+
})
|
|
103
|
+
class RenderShapeElement extends HTMLElement {
|
|
104
|
+
@State() detail = false;
|
|
105
|
+
|
|
106
|
+
render(): TemplateResult {
|
|
107
|
+
return this.detail
|
|
108
|
+
? html`<strong id="detail">Detail</strong>`
|
|
109
|
+
: html`<span id="summary">Summary</span>`;
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
@CustomElement({
|
|
114
|
+
tag: 'render-reactive-element',
|
|
115
|
+
})
|
|
116
|
+
class RenderReactiveElement extends HTMLElement {
|
|
117
|
+
renderCount = 0;
|
|
118
|
+
watched: any[] = [];
|
|
119
|
+
|
|
120
|
+
@Prop() label = 'prop';
|
|
121
|
+
@Toggle() active = false;
|
|
122
|
+
@State() count = 0;
|
|
123
|
+
@State() user = { name: 'Ada' };
|
|
124
|
+
@State() items = [{ label: 'One' }];
|
|
125
|
+
@State() created = new Date('2026-01-01T00:00:00Z');
|
|
126
|
+
|
|
127
|
+
@Watch('count')
|
|
128
|
+
watchCount(value: any) {
|
|
129
|
+
this.watched.push(value);
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
render(): TemplateResult {
|
|
133
|
+
this.renderCount++;
|
|
134
|
+
return html`
|
|
135
|
+
<span id="label">${this.label}</span>
|
|
136
|
+
<span id="active">${this.active}</span>
|
|
137
|
+
<span id="count">${this.count}</span>
|
|
138
|
+
<span id="name">${this.user.name}</span>
|
|
139
|
+
<span id="first">${this.items[0]?.label}</span>
|
|
140
|
+
<span id="size">${this.items.length}</span>
|
|
141
|
+
`;
|
|
142
|
+
}
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
@CustomElement({
|
|
146
|
+
tag: 'render-lifecycle-edge-element',
|
|
147
|
+
})
|
|
148
|
+
class RenderLifecycleEdgeElement extends HTMLElement {
|
|
149
|
+
connectedCalls = 0;
|
|
150
|
+
disconnectedCalls = 0;
|
|
151
|
+
renderCount = 0;
|
|
152
|
+
|
|
153
|
+
@State() label = 'one';
|
|
154
|
+
|
|
155
|
+
connectedCallback() {
|
|
156
|
+
this.connectedCalls++;
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
disconnectedCallback() {
|
|
160
|
+
this.disconnectedCalls++;
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
render(): TemplateResult {
|
|
164
|
+
this.renderCount++;
|
|
165
|
+
if (this.label === 'throw') {
|
|
166
|
+
throw new Error('render failed');
|
|
167
|
+
}
|
|
168
|
+
return html`<span id="edge">${this.label}</span>`;
|
|
169
|
+
}
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
@CustomElement({
|
|
173
|
+
tag: 'render-static-only-edge',
|
|
174
|
+
template: '<span id="static-only">Static only</span>',
|
|
175
|
+
style: ':host{display:block}',
|
|
176
|
+
})
|
|
177
|
+
class RenderStaticOnlyEdge extends HTMLElement {}
|
|
178
|
+
|
|
179
|
+
@CustomElement({
|
|
180
|
+
tag: 'render-primitive-edge',
|
|
181
|
+
})
|
|
182
|
+
class RenderPrimitiveEdge extends HTMLElement {
|
|
183
|
+
@State() text = 'plain render';
|
|
184
|
+
|
|
185
|
+
render(): TemplateValue {
|
|
186
|
+
return this.text;
|
|
187
|
+
}
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
@CustomElement({
|
|
191
|
+
tag: 'render-light-style-edge',
|
|
192
|
+
shadow: false,
|
|
193
|
+
style: '.light-edge{color:red}',
|
|
194
|
+
})
|
|
195
|
+
class RenderLightStyleEdge extends HTMLElement {
|
|
196
|
+
render(): TemplateResult {
|
|
197
|
+
return html`<span class="light-edge">Light styled</span>`;
|
|
198
|
+
}
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
@CustomElement({
|
|
202
|
+
tag: 'prop-watch-attr-edge',
|
|
203
|
+
})
|
|
204
|
+
class PropWatchAttrEdge extends HTMLElement {
|
|
205
|
+
seen: any;
|
|
206
|
+
|
|
207
|
+
@Prop() tracked: any;
|
|
208
|
+
|
|
209
|
+
@Watch('tracked')
|
|
210
|
+
onTracked(value: any) {
|
|
211
|
+
this.seen = value;
|
|
212
|
+
}
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
@CustomElement({
|
|
216
|
+
tag: 'state-edge-element',
|
|
217
|
+
})
|
|
218
|
+
class StateEdgeElement extends HTMLElement {
|
|
219
|
+
renderCount = 0;
|
|
220
|
+
|
|
221
|
+
@State() maybe: any;
|
|
222
|
+
@State() model = { user: { name: 'Ada' }, count: 1 };
|
|
223
|
+
@State() spanState = document.createElement('span');
|
|
224
|
+
@State() mapValue = new Map<string, string>();
|
|
225
|
+
|
|
226
|
+
render(): TemplateResult {
|
|
227
|
+
this.renderCount++;
|
|
228
|
+
return html`
|
|
229
|
+
<span id="maybe">${String(this.maybe)}</span>
|
|
230
|
+
<span id="name">${this.model.user?.name ?? 'missing'}</span>
|
|
231
|
+
<span id="count">${this.model.count ?? 'missing'}</span>
|
|
232
|
+
<span id="node">${this.spanState.tagName}</span>
|
|
233
|
+
<span id="map">${this.mapValue.size}</span>
|
|
234
|
+
`;
|
|
235
|
+
}
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
describe('runtime templating and render lifecycle', () => {
|
|
239
|
+
afterEach(() => {
|
|
240
|
+
document.body.innerHTML = '';
|
|
241
|
+
});
|
|
242
|
+
|
|
243
|
+
it('mounts static render output with style before rendered content', () => {
|
|
244
|
+
const element = document.body.appendChild(
|
|
245
|
+
document.createElement('render-static-element')
|
|
246
|
+
) as RenderStaticElement;
|
|
247
|
+
|
|
248
|
+
expect(element.shadowRoot!.querySelector('style')!.textContent).toBe(':host{display:block}');
|
|
249
|
+
expect(element.shadowRoot!.querySelector('#message')!.textContent).toBe('Hello');
|
|
250
|
+
expect(element.connectedSawRendered).toBe(true);
|
|
251
|
+
expect(element.shadowRoot!.firstElementChild!.tagName).toBe('STYLE');
|
|
252
|
+
});
|
|
253
|
+
|
|
254
|
+
it('renders into the host when shadow=false', () => {
|
|
255
|
+
const element = document.body.appendChild(document.createElement('render-light-element'));
|
|
256
|
+
|
|
257
|
+
expect(element.shadowRoot).toBeNull();
|
|
258
|
+
expect(element.querySelector('#light')!.textContent).toBe('Light');
|
|
259
|
+
});
|
|
260
|
+
|
|
261
|
+
it('updates child, function child, attribute, property, nested template, and array parts', async () => {
|
|
262
|
+
const element = document.body.appendChild(
|
|
263
|
+
document.createElement('render-dynamic-element')
|
|
264
|
+
) as any;
|
|
265
|
+
|
|
266
|
+
expect(element.shadowRoot.querySelector('#label').textContent).toBe('Alpha');
|
|
267
|
+
expect(element.shadowRoot.querySelector('#fn').textContent).toBe('Alpha');
|
|
268
|
+
expect(element.shadowRoot.querySelector('#attr').getAttribute('title')).toBe('Ready');
|
|
269
|
+
expect(element.shadowRoot.querySelector('#field').value).toBe('one');
|
|
270
|
+
expect(
|
|
271
|
+
Array.from(element.shadowRoot.querySelectorAll('li')).map((node: any) => node.textContent)
|
|
272
|
+
).toEqual(['First']);
|
|
273
|
+
|
|
274
|
+
const labelText = element.shadowRoot.querySelector('#label').firstChild;
|
|
275
|
+
const fnText = element.shadowRoot.querySelector('#fn').firstChild;
|
|
276
|
+
const firstLi = element.shadowRoot.querySelector('li');
|
|
277
|
+
|
|
278
|
+
element.label = 'Beta';
|
|
279
|
+
element.items = [{ label: 'Second' }];
|
|
280
|
+
await nextMicrotask();
|
|
281
|
+
|
|
282
|
+
expect(element.shadowRoot.querySelector('#label').firstChild).toBe(labelText);
|
|
283
|
+
expect(element.shadowRoot.querySelector('#fn').firstChild).toBe(fnText);
|
|
284
|
+
expect(element.shadowRoot.querySelector('li')).toBe(firstLi);
|
|
285
|
+
expect(element.shadowRoot.querySelector('#label').textContent).toBe('Beta');
|
|
286
|
+
expect(element.shadowRoot.querySelector('#fn').textContent).toBe('Beta');
|
|
287
|
+
expect(element.shadowRoot.querySelector('li').textContent).toBe('Second');
|
|
288
|
+
|
|
289
|
+
element.title = null;
|
|
290
|
+
element.enabled = false;
|
|
291
|
+
element.value = 'two';
|
|
292
|
+
element.items = [{ label: 'Second' }, { label: 'Third' }];
|
|
293
|
+
await nextMicrotask();
|
|
294
|
+
|
|
295
|
+
expect(element.shadowRoot.querySelector('#label').textContent).toBe('Beta');
|
|
296
|
+
expect(element.shadowRoot.querySelector('#fn').textContent).toBe('Beta');
|
|
297
|
+
expect(element.shadowRoot.querySelector('#attr').hasAttribute('title')).toBe(false);
|
|
298
|
+
expect(element.shadowRoot.querySelector('#attr').hasAttribute('data-enabled')).toBe(false);
|
|
299
|
+
expect(element.shadowRoot.querySelector('#field').value).toBe('two');
|
|
300
|
+
expect(
|
|
301
|
+
Array.from(element.shadowRoot.querySelectorAll('li')).map((node: any) => node.textContent)
|
|
302
|
+
).toEqual(['Second', 'Third']);
|
|
303
|
+
});
|
|
304
|
+
|
|
305
|
+
it('updates template event handlers and removes them on dispose', async () => {
|
|
306
|
+
const element = document.body.appendChild(
|
|
307
|
+
document.createElement('render-event-element')
|
|
308
|
+
) as RenderEventElement;
|
|
309
|
+
const firstButton = element.shadowRoot!.querySelector('button')!;
|
|
310
|
+
|
|
311
|
+
firstButton.click();
|
|
312
|
+
expect(element.primary).toHaveBeenCalledTimes(1);
|
|
313
|
+
|
|
314
|
+
element.alternate = true;
|
|
315
|
+
await nextMicrotask();
|
|
316
|
+
firstButton.click();
|
|
317
|
+
expect(element.primary).toHaveBeenCalledTimes(1);
|
|
318
|
+
expect(element.secondary).toHaveBeenCalledTimes(1);
|
|
319
|
+
|
|
320
|
+
document.body.removeChild(element);
|
|
321
|
+
firstButton.click();
|
|
322
|
+
expect(element.secondary).toHaveBeenCalledTimes(1);
|
|
323
|
+
});
|
|
324
|
+
|
|
325
|
+
it('keeps stable event listeners attached across unrelated rerenders', async () => {
|
|
326
|
+
const element = document.body.appendChild(
|
|
327
|
+
document.createElement('render-stable-event-element')
|
|
328
|
+
) as RenderStableEventElement;
|
|
329
|
+
const button = element.shadowRoot!.querySelector('button')!;
|
|
330
|
+
const addSpy = vi.spyOn(button, 'addEventListener');
|
|
331
|
+
const removeSpy = vi.spyOn(button, 'removeEventListener');
|
|
332
|
+
|
|
333
|
+
element.count = 1;
|
|
334
|
+
await nextMicrotask();
|
|
335
|
+
|
|
336
|
+
expect(addSpy).not.toHaveBeenCalled();
|
|
337
|
+
expect(removeSpy).not.toHaveBeenCalled();
|
|
338
|
+
button.click();
|
|
339
|
+
expect(element.clicks).toBe(1);
|
|
340
|
+
});
|
|
341
|
+
|
|
342
|
+
it('replaces mounted content when the template shape changes', async () => {
|
|
343
|
+
const element = document.body.appendChild(
|
|
344
|
+
document.createElement('render-shape-element')
|
|
345
|
+
) as RenderShapeElement;
|
|
346
|
+
|
|
347
|
+
expect(element.shadowRoot!.querySelector('#summary')).toBeTruthy();
|
|
348
|
+
element.detail = true;
|
|
349
|
+
await nextMicrotask();
|
|
350
|
+
|
|
351
|
+
expect(element.shadowRoot!.querySelector('#summary')).toBeNull();
|
|
352
|
+
expect(element.shadowRoot!.querySelector('#detail')!.textContent).toBe('Detail');
|
|
353
|
+
});
|
|
354
|
+
|
|
355
|
+
it('switches renderIntoAnchor between template, primitive, node, and array values', () => {
|
|
356
|
+
const container = document.body.appendChild(document.createElement('div'));
|
|
357
|
+
const anchor = document.createComment('root');
|
|
358
|
+
container.appendChild(anchor);
|
|
359
|
+
const state: RenderState = {};
|
|
360
|
+
const itemTemplate = (items: string[]) =>
|
|
361
|
+
html`<div id="items">${items.map((item) => html`<b>${item}</b>`)}</div>`;
|
|
362
|
+
|
|
363
|
+
renderIntoAnchor(html`<span>${'first'}</span>`, anchor, state);
|
|
364
|
+
expect(container.querySelector('span')!.textContent).toBe('first');
|
|
365
|
+
|
|
366
|
+
renderIntoAnchor('plain', anchor, state);
|
|
367
|
+
expect(container.textContent).toBe('plain');
|
|
368
|
+
|
|
369
|
+
renderIntoAnchor(html`<em>${'second'}</em>`, anchor, state);
|
|
370
|
+
expect(container.querySelector('em')!.textContent).toBe('second');
|
|
371
|
+
|
|
372
|
+
const node = document.createElement('strong');
|
|
373
|
+
node.textContent = 'node';
|
|
374
|
+
renderIntoAnchor(node, anchor, state);
|
|
375
|
+
renderIntoAnchor(node, anchor, state);
|
|
376
|
+
expect(container.querySelector('strong')).toBe(node);
|
|
377
|
+
|
|
378
|
+
renderIntoAnchor(itemTemplate(['a', 'b']), anchor, state);
|
|
379
|
+
expect(Array.from(container.querySelectorAll('b')).map((item) => item.textContent)).toEqual([
|
|
380
|
+
'a',
|
|
381
|
+
'b',
|
|
382
|
+
]);
|
|
383
|
+
|
|
384
|
+
renderIntoAnchor(itemTemplate(['a']), anchor, state);
|
|
385
|
+
expect(Array.from(container.querySelectorAll('b')).map((item) => item.textContent)).toEqual([
|
|
386
|
+
'a',
|
|
387
|
+
]);
|
|
388
|
+
|
|
389
|
+
renderIntoAnchor(false, anchor, state);
|
|
390
|
+
expect(container.textContent).toBe('');
|
|
391
|
+
});
|
|
392
|
+
|
|
393
|
+
it('supports direct event and property edge cases and ignores detached anchors', () => {
|
|
394
|
+
const container = document.body.appendChild(document.createElement('div'));
|
|
395
|
+
const anchor = document.createComment('root');
|
|
396
|
+
container.appendChild(anchor);
|
|
397
|
+
const handler = { handleEvent: vi.fn() };
|
|
398
|
+
const fnHandler = vi.fn();
|
|
399
|
+
|
|
400
|
+
renderIntoAnchor(html`<button @click=${handler}>Object listener</button>`, anchor);
|
|
401
|
+
container.querySelector('button')!.click();
|
|
402
|
+
expect(handler.handleEvent).toHaveBeenCalledTimes(1);
|
|
403
|
+
|
|
404
|
+
renderIntoAnchor(html`<button @click=${fnHandler}>Function listener</button>`, anchor);
|
|
405
|
+
container.querySelector('button')!.click();
|
|
406
|
+
expect(fnHandler).toHaveBeenCalledTimes(1);
|
|
407
|
+
|
|
408
|
+
renderIntoAnchor(html`<!----><input .value=${undefined} />`, anchor);
|
|
409
|
+
expect((container.querySelector('input') as HTMLInputElement).value).toBe('');
|
|
410
|
+
|
|
411
|
+
const detachedAnchor = document.createComment('detached');
|
|
412
|
+
expect(() => renderIntoAnchor('detached', detachedAnchor)).not.toThrow();
|
|
413
|
+
});
|
|
414
|
+
});
|
|
415
|
+
|
|
416
|
+
describe('render-aware decorator reactivity', () => {
|
|
417
|
+
afterEach(() => {
|
|
418
|
+
document.body.innerHTML = '';
|
|
419
|
+
});
|
|
420
|
+
|
|
421
|
+
it('rerenders from prop, toggle, primitive state, object state, and array state changes', async () => {
|
|
422
|
+
const element = document.body.appendChild(
|
|
423
|
+
document.createElement('render-reactive-element')
|
|
424
|
+
) as any;
|
|
425
|
+
|
|
426
|
+
element.label = 'updated';
|
|
427
|
+
element.active = true;
|
|
428
|
+
element.count++;
|
|
429
|
+
element.user.name = 'Grace';
|
|
430
|
+
element.items.push({ label: 'Two' });
|
|
431
|
+
element.items[0].label = 'Updated';
|
|
432
|
+
await nextMicrotask();
|
|
433
|
+
|
|
434
|
+
expect(element.shadowRoot.querySelector('#label').textContent).toBe('updated');
|
|
435
|
+
expect(element.shadowRoot.querySelector('#active').textContent).toBe('true');
|
|
436
|
+
expect(element.shadowRoot.querySelector('#count').textContent).toBe('1');
|
|
437
|
+
expect(element.shadowRoot.querySelector('#name').textContent).toBe('Grace');
|
|
438
|
+
expect(element.shadowRoot.querySelector('#first').textContent).toBe('Updated');
|
|
439
|
+
expect(element.shadowRoot.querySelector('#size').textContent).toBe('2');
|
|
440
|
+
expect(element.watched[0]).toEqual({ old: 0, new: 1 });
|
|
441
|
+
|
|
442
|
+
element.setAttribute('label', 'from-attr');
|
|
443
|
+
element.setAttribute('active', 'false');
|
|
444
|
+
await nextMicrotask();
|
|
445
|
+
|
|
446
|
+
expect(element.shadowRoot.querySelector('#label').textContent).toBe('from-attr');
|
|
447
|
+
expect(element.shadowRoot.querySelector('#active').textContent).toBe('');
|
|
448
|
+
});
|
|
449
|
+
|
|
450
|
+
it('batches multiple render-aware changes into one microtask render', async () => {
|
|
451
|
+
const element = document.body.appendChild(
|
|
452
|
+
document.createElement('render-reactive-element')
|
|
453
|
+
) as any;
|
|
454
|
+
expect(element.renderCount).toBe(1);
|
|
455
|
+
|
|
456
|
+
element.count = 1;
|
|
457
|
+
element.label = 'queued';
|
|
458
|
+
element.user.name = 'Babbage';
|
|
459
|
+
expect(element.renderCount).toBe(1);
|
|
460
|
+
await nextMicrotask();
|
|
461
|
+
|
|
462
|
+
expect(element.renderCount).toBe(2);
|
|
463
|
+
});
|
|
464
|
+
|
|
465
|
+
it('keeps state internal and leaves unsupported objects unproxied', async () => {
|
|
466
|
+
const element = document.body.appendChild(
|
|
467
|
+
document.createElement('render-reactive-element')
|
|
468
|
+
) as any;
|
|
469
|
+
|
|
470
|
+
expect((element.constructor as any).observedAttributes).not.toContain('count');
|
|
471
|
+
expect(element.hasAttribute('count')).toBe(false);
|
|
472
|
+
expect(element.created).toBeInstanceOf(Date);
|
|
473
|
+
|
|
474
|
+
const renderCount = element.renderCount;
|
|
475
|
+
element.created.setFullYear(2027);
|
|
476
|
+
await nextMicrotask();
|
|
477
|
+
expect(element.renderCount).toBe(renderCount);
|
|
478
|
+
});
|
|
479
|
+
|
|
480
|
+
it('covers state proxy cache, proxy reassignment, deletion, and non-proxyable nodes', async () => {
|
|
481
|
+
const disconnected = document.createElement(
|
|
482
|
+
'state-edge-element'
|
|
483
|
+
) as unknown as StateEdgeElement;
|
|
484
|
+
expect(disconnected.maybe).toBeUndefined();
|
|
485
|
+
|
|
486
|
+
const element = document.body.appendChild(
|
|
487
|
+
document.createElement('state-edge-element')
|
|
488
|
+
) as unknown as StateEdgeElement;
|
|
489
|
+
|
|
490
|
+
expect(isStateName(element, 'model')).toBe(true);
|
|
491
|
+
expect(isStateName(element, 'missing')).toBe(false);
|
|
492
|
+
expect(element.shadowRoot!.querySelector('#maybe')!.textContent).toBe('undefined');
|
|
493
|
+
|
|
494
|
+
const renderCount = element.renderCount;
|
|
495
|
+
const user = element.model.user;
|
|
496
|
+
expect(element.model.user).toBe(user);
|
|
497
|
+
|
|
498
|
+
const sameModel = element.model;
|
|
499
|
+
element.model = sameModel;
|
|
500
|
+
await nextMicrotask();
|
|
501
|
+
expect(element.renderCount).toBe(renderCount);
|
|
502
|
+
|
|
503
|
+
element.spanState.textContent = 'not reactive';
|
|
504
|
+
element.mapValue.set('x', 'y');
|
|
505
|
+
await nextMicrotask();
|
|
506
|
+
expect(element.renderCount).toBe(renderCount);
|
|
507
|
+
|
|
508
|
+
delete (element.model as { count?: number }).count;
|
|
509
|
+
await nextMicrotask();
|
|
510
|
+
expect(element.shadowRoot!.querySelector('#count')!.textContent).toBe('missing');
|
|
511
|
+
expect(element.renderCount).toBe(renderCount + 1);
|
|
512
|
+
});
|
|
513
|
+
|
|
514
|
+
it('runs parent lifecycle hooks, guards duplicate lifecycle calls, and recovers after render errors', async () => {
|
|
515
|
+
const element = document.body.appendChild(
|
|
516
|
+
document.createElement('render-lifecycle-edge-element')
|
|
517
|
+
) as RenderLifecycleEdgeElement;
|
|
518
|
+
|
|
519
|
+
expect(element.connectedCalls).toBe(1);
|
|
520
|
+
(element as any).connectedCallback();
|
|
521
|
+
expect(element.connectedCalls).toBe(1);
|
|
522
|
+
|
|
523
|
+
(element as any).__stateValues.label = 'throw';
|
|
524
|
+
expect(() => (element as any).__performRender()).toThrow('render failed');
|
|
525
|
+
expect(element.renderCount).toBe(2);
|
|
526
|
+
|
|
527
|
+
(element as any).__stateValues.label = 'recovered';
|
|
528
|
+
(element as any).__performRender();
|
|
529
|
+
expect(element.shadowRoot!.querySelector('#edge')!.textContent).toBe('recovered');
|
|
530
|
+
|
|
531
|
+
element.label = 'queued';
|
|
532
|
+
document.body.removeChild(element);
|
|
533
|
+
await nextMicrotask();
|
|
534
|
+
expect(element.disconnectedCalls).toBe(1);
|
|
535
|
+
(element as any).disconnectedCallback();
|
|
536
|
+
expect(element.disconnectedCalls).toBe(1);
|
|
537
|
+
});
|
|
538
|
+
|
|
539
|
+
it('mounts static templates once for elements without render methods', () => {
|
|
540
|
+
const element = document.body.appendChild(
|
|
541
|
+
document.createElement('render-static-only-edge')
|
|
542
|
+
) as RenderStaticOnlyEdge;
|
|
543
|
+
|
|
544
|
+
expect(element.shadowRoot!.querySelector('#static-only')!.textContent).toBe('Static only');
|
|
545
|
+
(element as any).__renderStaticTemplate();
|
|
546
|
+
expect(element.shadowRoot!.querySelectorAll('#static-only')).toHaveLength(1);
|
|
547
|
+
expect(() => (element as any).__performRender()).not.toThrow();
|
|
548
|
+
});
|
|
549
|
+
|
|
550
|
+
it('disposes primitive render parts and mounts styles in light DOM render targets', () => {
|
|
551
|
+
const primitive = document.body.appendChild(
|
|
552
|
+
document.createElement('render-primitive-edge')
|
|
553
|
+
) as RenderPrimitiveEdge;
|
|
554
|
+
|
|
555
|
+
expect(primitive.shadowRoot!.textContent).toContain('plain render');
|
|
556
|
+
document.body.removeChild(primitive);
|
|
557
|
+
expect(primitive.shadowRoot!.textContent).not.toContain('plain render');
|
|
558
|
+
|
|
559
|
+
const light = document.body.appendChild(
|
|
560
|
+
document.createElement('render-light-style-edge')
|
|
561
|
+
) as RenderLightStyleEdge;
|
|
562
|
+
|
|
563
|
+
expect(light.shadowRoot).toBeNull();
|
|
564
|
+
expect(light.querySelector('style')!.textContent).toBe('.light-edge{color:red}');
|
|
565
|
+
expect(light.querySelector('.light-edge')!.textContent).toBe('Light styled');
|
|
566
|
+
});
|
|
567
|
+
|
|
568
|
+
it('initializes watched props from existing attributes when props have no own value', () => {
|
|
569
|
+
const element = document.createElement('prop-watch-attr-edge') as PropWatchAttrEdge;
|
|
570
|
+
element.setAttribute('tracked', 'from-attribute');
|
|
571
|
+
document.body.appendChild(element);
|
|
572
|
+
|
|
573
|
+
expect(element.seen).toEqual({ new: 'from-attribute' });
|
|
574
|
+
});
|
|
575
|
+
});
|
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';
|
|
2
|
+
import { CustomElement, Toggle } from 'custom-elements-ts';
|
|
3
|
+
|
|
4
|
+
@CustomElement({})
|
|
5
|
+
class ToggleElement extends HTMLElement {
|
|
6
|
+
@Toggle() disabled: any;
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
describe('toggle decorator', () => {
|
|
10
|
+
let myElementInstance: any;
|
|
11
|
+
|
|
12
|
+
beforeEach(() => {
|
|
13
|
+
const myElement = document.createElement('toggle-element');
|
|
14
|
+
myElementInstance = document.body.appendChild(myElement);
|
|
15
|
+
});
|
|
16
|
+
|
|
17
|
+
afterEach(() => {
|
|
18
|
+
document.body.innerHTML = '';
|
|
19
|
+
});
|
|
20
|
+
|
|
21
|
+
it('should return false on no attribute set', () => {
|
|
22
|
+
expect(myElementInstance.disabled).toBe(false);
|
|
23
|
+
});
|
|
24
|
+
|
|
25
|
+
it('should return true on empty attribute set', () => {
|
|
26
|
+
myElementInstance.setAttribute('disabled', '');
|
|
27
|
+
expect(myElementInstance.disabled).toBe(true);
|
|
28
|
+
});
|
|
29
|
+
|
|
30
|
+
it('should return false on remove attribute', () => {
|
|
31
|
+
myElementInstance.removeAttribute('disabled');
|
|
32
|
+
expect(myElementInstance.disabled).toBe(false);
|
|
33
|
+
});
|
|
34
|
+
|
|
35
|
+
it('should return value on attribute set', () => {
|
|
36
|
+
myElementInstance.setAttribute('disabled', 'true');
|
|
37
|
+
expect(myElementInstance.disabled).toBe(true);
|
|
38
|
+
myElementInstance.setAttribute('disabled', 'false');
|
|
39
|
+
expect(myElementInstance.disabled).toBe(false);
|
|
40
|
+
});
|
|
41
|
+
|
|
42
|
+
it('should return false on random string attribute set', () => {
|
|
43
|
+
const warn = console.warn;
|
|
44
|
+
// suppressing warn
|
|
45
|
+
console.warn = () => {};
|
|
46
|
+
myElementInstance.setAttribute('disabled', 'asd');
|
|
47
|
+
expect(myElementInstance.disabled).toBe(false);
|
|
48
|
+
console.warn = warn;
|
|
49
|
+
});
|
|
50
|
+
|
|
51
|
+
it('should return false for an invalid attribute before connection coerces it', () => {
|
|
52
|
+
const myElement = document.createElement('toggle-element') as any;
|
|
53
|
+
const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {});
|
|
54
|
+
|
|
55
|
+
myElement.setAttribute('disabled', 'asd');
|
|
56
|
+
|
|
57
|
+
expect(myElement.disabled).toBe(false);
|
|
58
|
+
expect(warnSpy).toHaveBeenCalled();
|
|
59
|
+
warnSpy.mockRestore();
|
|
60
|
+
});
|
|
61
|
+
|
|
62
|
+
it('should add attribute on empty prop set', () => {
|
|
63
|
+
myElementInstance.disabled = '';
|
|
64
|
+
expect(myElementInstance.hasAttribute('disabled')).toBe(true);
|
|
65
|
+
});
|
|
66
|
+
|
|
67
|
+
it('should remove attribute on null prop set', () => {
|
|
68
|
+
myElementInstance.disabled = null;
|
|
69
|
+
expect(myElementInstance.hasAttribute('disabled')).toBe(false);
|
|
70
|
+
});
|
|
71
|
+
|
|
72
|
+
it('should reflect prop to attribute', () => {
|
|
73
|
+
myElementInstance.disabled = true;
|
|
74
|
+
expect(myElementInstance.getAttribute('disabled')).toBe('true');
|
|
75
|
+
myElementInstance.disabled = false;
|
|
76
|
+
expect(myElementInstance.getAttribute('disabled')).toBe('false');
|
|
77
|
+
});
|
|
78
|
+
|
|
79
|
+
it('should reflect random string prop to attribute as false', () => {
|
|
80
|
+
const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {});
|
|
81
|
+
myElementInstance.disabled = 'asd';
|
|
82
|
+
expect(myElementInstance.getAttribute('disabled')).toBe('false');
|
|
83
|
+
expect(warnSpy).toHaveBeenCalled();
|
|
84
|
+
warnSpy.mockRestore();
|
|
85
|
+
});
|
|
86
|
+
|
|
87
|
+
it('should throw for unsupported type assignment', () => {
|
|
88
|
+
expect(() => {
|
|
89
|
+
myElementInstance.disabled = 123;
|
|
90
|
+
}).toThrow(TypeError);
|
|
91
|
+
});
|
|
92
|
+
});
|