@hyperfixi/components 2.4.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/LICENSE +20 -0
- package/dist/attrs.d.ts +15 -0
- package/dist/index.cjs +481 -0
- package/dist/index.cjs.map +1 -0
- package/dist/index.d.ts +67 -0
- package/dist/index.js +451 -0
- package/dist/index.js.map +1 -0
- package/dist/register.d.ts +41 -0
- package/dist/scan.d.ts +24 -0
- package/dist/scope-css.d.ts +48 -0
- package/dist/slots.d.ts +15 -0
- package/dist/template-ast.d.ts +43 -0
- package/dist/types.d.ts +25 -0
- package/package.json +64 -0
- package/src/attrs.test.ts +62 -0
- package/src/attrs.ts +80 -0
- package/src/index.ts +110 -0
- package/src/integration.test.ts +609 -0
- package/src/register.ts +308 -0
- package/src/scan.ts +96 -0
- package/src/scope-css.test.ts +80 -0
- package/src/scope-css.ts +87 -0
- package/src/slots.test.ts +55 -0
- package/src/slots.ts +70 -0
- package/src/template-ast.test.ts +82 -0
- package/src/template-ast.ts +147 -0
- package/src/types.ts +29 -0
|
@@ -0,0 +1,609 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* End-to-end integration — full scan + customElements.define + instantiate.
|
|
3
|
+
*
|
|
4
|
+
* Each test uses a unique tag name because customElements.define is
|
|
5
|
+
* process-wide and cannot be un-defined. The module-level `REGISTERED` set
|
|
6
|
+
* is reset per-test via `_resetRegisteredForTest` so our idempotency check
|
|
7
|
+
* doesn't short-circuit (the real registry still remembers, but happy-dom's
|
|
8
|
+
* registry is per-document here).
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
import { describe, it, expect, beforeEach, afterEach } from 'vitest';
|
|
12
|
+
import { Runtime, installPlugin, getParserExtensionRegistry } from '@hyperfixi/core';
|
|
13
|
+
import { reactivityPlugin, reactive } from '@hyperfixi/reactivity';
|
|
14
|
+
import { componentsPlugin, registerTemplateComponent } from './index';
|
|
15
|
+
import { _resetRegisteredForTest } from './register';
|
|
16
|
+
import { _resetInjectedStylesForTest } from './scope-css';
|
|
17
|
+
|
|
18
|
+
/** Drain microtasks + the setTimeout queue so reactive effects settle. */
|
|
19
|
+
async function settle(): Promise<void> {
|
|
20
|
+
for (let i = 0; i < 20; i++) await Promise.resolve();
|
|
21
|
+
await new Promise<void>(resolve => setTimeout(resolve, 0));
|
|
22
|
+
for (let i = 0; i < 20; i++) await Promise.resolve();
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
// Unique tag suffix per-test to avoid collision with customElements registry.
|
|
26
|
+
let _counter = 0;
|
|
27
|
+
function uniqueTag(prefix = 'hf-comp'): string {
|
|
28
|
+
return `${prefix}-${++_counter}`;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
describe('@hyperfixi/components — integration', () => {
|
|
32
|
+
const registry = getParserExtensionRegistry();
|
|
33
|
+
let baseline: ReturnType<typeof registry.snapshot>;
|
|
34
|
+
|
|
35
|
+
beforeEach(() => {
|
|
36
|
+
baseline = registry.snapshot();
|
|
37
|
+
_resetRegisteredForTest();
|
|
38
|
+
_resetInjectedStylesForTest();
|
|
39
|
+
});
|
|
40
|
+
|
|
41
|
+
afterEach(() => {
|
|
42
|
+
registry.restore(baseline);
|
|
43
|
+
// Clean up any test-added elements from <body>.
|
|
44
|
+
document.body.innerHTML = '';
|
|
45
|
+
_resetInjectedStylesForTest();
|
|
46
|
+
});
|
|
47
|
+
|
|
48
|
+
it('registers a template component and stamps its content on instantiation', () => {
|
|
49
|
+
const tag = uniqueTag();
|
|
50
|
+
document.body.innerHTML = `
|
|
51
|
+
<template component="${tag}">
|
|
52
|
+
<p>hello from component</p>
|
|
53
|
+
</template>
|
|
54
|
+
`;
|
|
55
|
+
|
|
56
|
+
const runtime = new Runtime();
|
|
57
|
+
installPlugin(runtime, componentsPlugin);
|
|
58
|
+
const registered = componentsPlugin.scan(document);
|
|
59
|
+
expect(registered).toBeGreaterThanOrEqual(1);
|
|
60
|
+
|
|
61
|
+
// Instantiate and attach to the DOM so connectedCallback fires.
|
|
62
|
+
const instance = document.createElement(tag);
|
|
63
|
+
document.body.appendChild(instance);
|
|
64
|
+
|
|
65
|
+
expect(instance.innerHTML).toContain('hello from component');
|
|
66
|
+
});
|
|
67
|
+
|
|
68
|
+
it('interpolates ${attrs.*} values from component attributes', () => {
|
|
69
|
+
const tag = uniqueTag();
|
|
70
|
+
document.body.innerHTML = `
|
|
71
|
+
<template component="${tag}">
|
|
72
|
+
<p>count: \${attrs.initialCount}, flag: \${attrs.isOn}</p>
|
|
73
|
+
</template>
|
|
74
|
+
`;
|
|
75
|
+
const runtime = new Runtime();
|
|
76
|
+
installPlugin(runtime, componentsPlugin);
|
|
77
|
+
componentsPlugin.scan(document);
|
|
78
|
+
|
|
79
|
+
const instance = document.createElement(tag);
|
|
80
|
+
instance.setAttribute('initial-count', '7');
|
|
81
|
+
instance.setAttribute('is-on', 'true');
|
|
82
|
+
document.body.appendChild(instance);
|
|
83
|
+
|
|
84
|
+
expect(instance.innerHTML).toContain('count: 7');
|
|
85
|
+
expect(instance.innerHTML).toContain('flag: true');
|
|
86
|
+
});
|
|
87
|
+
|
|
88
|
+
it('substitutes default slot content from children', () => {
|
|
89
|
+
const tag = uniqueTag();
|
|
90
|
+
document.body.innerHTML = `
|
|
91
|
+
<template component="${tag}">
|
|
92
|
+
<div class="wrapper"><slot/></div>
|
|
93
|
+
</template>
|
|
94
|
+
`;
|
|
95
|
+
const runtime = new Runtime();
|
|
96
|
+
installPlugin(runtime, componentsPlugin);
|
|
97
|
+
componentsPlugin.scan(document);
|
|
98
|
+
|
|
99
|
+
const instance = document.createElement(tag);
|
|
100
|
+
instance.innerHTML = '<span>user content</span>';
|
|
101
|
+
document.body.appendChild(instance);
|
|
102
|
+
|
|
103
|
+
expect(instance.querySelector('.wrapper')).toBeTruthy();
|
|
104
|
+
expect(instance.querySelector('.wrapper')!.innerHTML).toContain('<span>user content</span>');
|
|
105
|
+
});
|
|
106
|
+
|
|
107
|
+
it('substitutes named slots', () => {
|
|
108
|
+
const tag = uniqueTag();
|
|
109
|
+
document.body.innerHTML = `
|
|
110
|
+
<template component="${tag}">
|
|
111
|
+
<header><slot name="title"/></header>
|
|
112
|
+
<main><slot/></main>
|
|
113
|
+
</template>
|
|
114
|
+
`;
|
|
115
|
+
const runtime = new Runtime();
|
|
116
|
+
installPlugin(runtime, componentsPlugin);
|
|
117
|
+
componentsPlugin.scan(document);
|
|
118
|
+
|
|
119
|
+
const instance = document.createElement(tag);
|
|
120
|
+
instance.innerHTML = '<h1 slot="title">My Title</h1><p>Body text</p>';
|
|
121
|
+
document.body.appendChild(instance);
|
|
122
|
+
|
|
123
|
+
const header = instance.querySelector('header');
|
|
124
|
+
const main = instance.querySelector('main');
|
|
125
|
+
expect(header).toBeTruthy();
|
|
126
|
+
expect(main).toBeTruthy();
|
|
127
|
+
expect(header!.innerHTML).toContain('<h1>My Title</h1>'); // slot= stripped
|
|
128
|
+
expect(main!.innerHTML).toContain('<p>Body text</p>');
|
|
129
|
+
});
|
|
130
|
+
|
|
131
|
+
it('rejects tag names without a dash', () => {
|
|
132
|
+
const errors: unknown[] = [];
|
|
133
|
+
const origError = console.error;
|
|
134
|
+
console.error = (...args: unknown[]) => {
|
|
135
|
+
errors.push(args);
|
|
136
|
+
};
|
|
137
|
+
try {
|
|
138
|
+
document.body.innerHTML = '<template component="nodash"><p>bad</p></template>';
|
|
139
|
+
const runtime = new Runtime();
|
|
140
|
+
installPlugin(runtime, componentsPlugin);
|
|
141
|
+
const count = componentsPlugin.scan(document);
|
|
142
|
+
expect(count).toBe(0);
|
|
143
|
+
expect(errors.length).toBeGreaterThanOrEqual(1);
|
|
144
|
+
expect(String(errors[0])).toMatch(/dash/);
|
|
145
|
+
} finally {
|
|
146
|
+
console.error = origError;
|
|
147
|
+
}
|
|
148
|
+
});
|
|
149
|
+
|
|
150
|
+
it('is idempotent for re-registration of the same tag', () => {
|
|
151
|
+
const tag = uniqueTag();
|
|
152
|
+
document.body.innerHTML = `
|
|
153
|
+
<template component="${tag}"><p>once</p></template>
|
|
154
|
+
`;
|
|
155
|
+
const runtime = new Runtime();
|
|
156
|
+
installPlugin(runtime, componentsPlugin);
|
|
157
|
+
const first = componentsPlugin.scan(document);
|
|
158
|
+
const second = componentsPlugin.scan(document);
|
|
159
|
+
expect(first).toBeGreaterThanOrEqual(1);
|
|
160
|
+
expect(second).toBe(0); // already registered
|
|
161
|
+
});
|
|
162
|
+
|
|
163
|
+
it('registerTemplateComponent can be called directly (no plugin install)', () => {
|
|
164
|
+
const tag = uniqueTag();
|
|
165
|
+
document.body.innerHTML = `
|
|
166
|
+
<template component="${tag}"><p>direct</p></template>
|
|
167
|
+
`;
|
|
168
|
+
const t = document.querySelector('template')!;
|
|
169
|
+
const ok = registerTemplateComponent(t as HTMLTemplateElement);
|
|
170
|
+
expect(ok).toBe(true);
|
|
171
|
+
|
|
172
|
+
const instance = document.createElement(tag);
|
|
173
|
+
document.body.appendChild(instance);
|
|
174
|
+
expect(instance.innerHTML).toContain('direct');
|
|
175
|
+
});
|
|
176
|
+
|
|
177
|
+
describe('v2: reactive ^var rendering', () => {
|
|
178
|
+
it("interpolates ${^var} from the host element's caret-var storage", async () => {
|
|
179
|
+
const tag = uniqueTag();
|
|
180
|
+
document.body.innerHTML = `
|
|
181
|
+
<template component="${tag}">
|
|
182
|
+
<span class="readout">Count: \${^count}</span>
|
|
183
|
+
</template>
|
|
184
|
+
`;
|
|
185
|
+
const runtime = new Runtime();
|
|
186
|
+
installPlugin(runtime, reactivityPlugin);
|
|
187
|
+
installPlugin(runtime, componentsPlugin);
|
|
188
|
+
componentsPlugin.scan(document);
|
|
189
|
+
|
|
190
|
+
const instance = document.createElement(tag);
|
|
191
|
+
document.body.appendChild(instance);
|
|
192
|
+
|
|
193
|
+
// Seed ^count on the host directly (no init script in this test).
|
|
194
|
+
reactive.writeCaret(instance, 'count', 7, instance);
|
|
195
|
+
await settle();
|
|
196
|
+
|
|
197
|
+
expect(instance.innerHTML).toContain('Count: 7');
|
|
198
|
+
});
|
|
199
|
+
|
|
200
|
+
it('re-renders when a tracked ^var is updated', async () => {
|
|
201
|
+
const tag = uniqueTag();
|
|
202
|
+
document.body.innerHTML = `
|
|
203
|
+
<template component="${tag}">
|
|
204
|
+
<span class="readout">Count: \${^count}</span>
|
|
205
|
+
</template>
|
|
206
|
+
`;
|
|
207
|
+
const runtime = new Runtime();
|
|
208
|
+
installPlugin(runtime, reactivityPlugin);
|
|
209
|
+
installPlugin(runtime, componentsPlugin);
|
|
210
|
+
componentsPlugin.scan(document);
|
|
211
|
+
|
|
212
|
+
const instance = document.createElement(tag);
|
|
213
|
+
document.body.appendChild(instance);
|
|
214
|
+
|
|
215
|
+
reactive.writeCaret(instance, 'count', 0, instance);
|
|
216
|
+
await settle();
|
|
217
|
+
expect(instance.innerHTML).toContain('Count: 0');
|
|
218
|
+
|
|
219
|
+
reactive.writeCaret(instance, 'count', 42, instance);
|
|
220
|
+
await settle();
|
|
221
|
+
expect(instance.innerHTML).toContain('Count: 42');
|
|
222
|
+
});
|
|
223
|
+
|
|
224
|
+
it('runs an init script from `_=` on <template>', async () => {
|
|
225
|
+
const tag = uniqueTag();
|
|
226
|
+
// The init script seeds ^count to 5 once on connectedCallback.
|
|
227
|
+
const tmpl = document.createElement('template');
|
|
228
|
+
tmpl.setAttribute('component', tag);
|
|
229
|
+
tmpl.setAttribute('_', 'set ^count to 5');
|
|
230
|
+
tmpl.innerHTML = '<span class="readout">v=${^count}</span>';
|
|
231
|
+
document.body.appendChild(tmpl);
|
|
232
|
+
|
|
233
|
+
const runtime = new Runtime();
|
|
234
|
+
installPlugin(runtime, reactivityPlugin);
|
|
235
|
+
installPlugin(runtime, componentsPlugin);
|
|
236
|
+
componentsPlugin.scan(document);
|
|
237
|
+
|
|
238
|
+
const instance = document.createElement(tag);
|
|
239
|
+
document.body.appendChild(instance);
|
|
240
|
+
await settle();
|
|
241
|
+
|
|
242
|
+
// After init runs and effect re-renders, ^count = 5 should appear.
|
|
243
|
+
expect(instance.innerHTML).toContain('v=5');
|
|
244
|
+
});
|
|
245
|
+
|
|
246
|
+
it('preserves init script from <script type="text/hyperscript-template" _="...">', async () => {
|
|
247
|
+
const tag = uniqueTag();
|
|
248
|
+
const script = document.createElement('script');
|
|
249
|
+
script.setAttribute('type', 'text/hyperscript-template');
|
|
250
|
+
script.setAttribute('component', tag);
|
|
251
|
+
script.setAttribute('_', 'set ^value to "hello"');
|
|
252
|
+
script.textContent = '<span class="readout">${^value}</span>';
|
|
253
|
+
document.body.appendChild(script);
|
|
254
|
+
|
|
255
|
+
const runtime = new Runtime();
|
|
256
|
+
installPlugin(runtime, reactivityPlugin);
|
|
257
|
+
installPlugin(runtime, componentsPlugin);
|
|
258
|
+
componentsPlugin.scan(document);
|
|
259
|
+
|
|
260
|
+
const instance = document.createElement(tag);
|
|
261
|
+
document.body.appendChild(instance);
|
|
262
|
+
await settle();
|
|
263
|
+
|
|
264
|
+
expect(instance.innerHTML).toContain('hello');
|
|
265
|
+
});
|
|
266
|
+
|
|
267
|
+
it('full click-counter scenario: init + button click + reactive re-render', async () => {
|
|
268
|
+
const tag = uniqueTag();
|
|
269
|
+
const tmpl = document.createElement('template');
|
|
270
|
+
tmpl.setAttribute('component', tag);
|
|
271
|
+
tmpl.setAttribute('_', 'set ^count to 0');
|
|
272
|
+
tmpl.innerHTML =
|
|
273
|
+
'<button _="on click increment ^count">+</button>' +
|
|
274
|
+
'<span class="readout">Clicks: ${^count}</span>';
|
|
275
|
+
document.body.appendChild(tmpl);
|
|
276
|
+
|
|
277
|
+
const runtime = new Runtime();
|
|
278
|
+
installPlugin(runtime, reactivityPlugin);
|
|
279
|
+
installPlugin(runtime, componentsPlugin);
|
|
280
|
+
componentsPlugin.scan(document);
|
|
281
|
+
|
|
282
|
+
const instance = document.createElement(tag);
|
|
283
|
+
document.body.appendChild(instance);
|
|
284
|
+
await settle();
|
|
285
|
+
|
|
286
|
+
// Initial render after init: count = 0
|
|
287
|
+
expect(instance.innerHTML).toContain('Clicks: 0');
|
|
288
|
+
|
|
289
|
+
// Simulate clicks. Re-query the button after each settle — the
|
|
290
|
+
// reactive re-render replaces innerHTML, so the previous reference
|
|
291
|
+
// is detached. Settle between clicks — rapid synchronous clicks race
|
|
292
|
+
// with the async hyperscript event handler chain (a pre-existing
|
|
293
|
+
// runtime behavior, not specific to components).
|
|
294
|
+
instance.querySelector('button')!.click();
|
|
295
|
+
await settle();
|
|
296
|
+
expect(instance.innerHTML).toContain('Clicks: 1');
|
|
297
|
+
|
|
298
|
+
instance.querySelector('button')!.click();
|
|
299
|
+
await settle();
|
|
300
|
+
expect(instance.innerHTML).toContain('Clicks: 2');
|
|
301
|
+
|
|
302
|
+
instance.querySelector('button')!.click();
|
|
303
|
+
await settle();
|
|
304
|
+
expect(instance.innerHTML).toContain('Clicks: 3');
|
|
305
|
+
});
|
|
306
|
+
|
|
307
|
+
it('init script can read attrs.X as a hyperscript local', async () => {
|
|
308
|
+
const tag = uniqueTag();
|
|
309
|
+
const tmpl = document.createElement('template');
|
|
310
|
+
tmpl.setAttribute('component', tag);
|
|
311
|
+
tmpl.setAttribute('_', 'set ^title to attrs.title');
|
|
312
|
+
tmpl.innerHTML = '<h3>${^title}</h3>';
|
|
313
|
+
document.body.appendChild(tmpl);
|
|
314
|
+
|
|
315
|
+
const runtime = new Runtime();
|
|
316
|
+
installPlugin(runtime, reactivityPlugin);
|
|
317
|
+
installPlugin(runtime, componentsPlugin);
|
|
318
|
+
componentsPlugin.scan(document);
|
|
319
|
+
|
|
320
|
+
const instance = document.createElement(tag);
|
|
321
|
+
instance.setAttribute('title', 'Hello, attrs!');
|
|
322
|
+
document.body.appendChild(instance);
|
|
323
|
+
await settle();
|
|
324
|
+
|
|
325
|
+
expect(instance.innerHTML).toContain('Hello, attrs!');
|
|
326
|
+
});
|
|
327
|
+
|
|
328
|
+
it("descendants' _= attributes do not see attrs (design choice — copy via ^vars)", async () => {
|
|
329
|
+
const tag = uniqueTag();
|
|
330
|
+
const tmpl = document.createElement('template');
|
|
331
|
+
tmpl.setAttribute('component', tag);
|
|
332
|
+
// Init copies attrs.label into ^label so descendants can read it.
|
|
333
|
+
tmpl.setAttribute('_', 'set ^label to attrs.label');
|
|
334
|
+
tmpl.innerHTML = '<button _="on click set my textContent to ^label">click</button>';
|
|
335
|
+
document.body.appendChild(tmpl);
|
|
336
|
+
|
|
337
|
+
const runtime = new Runtime();
|
|
338
|
+
installPlugin(runtime, reactivityPlugin);
|
|
339
|
+
installPlugin(runtime, componentsPlugin);
|
|
340
|
+
componentsPlugin.scan(document);
|
|
341
|
+
|
|
342
|
+
const instance = document.createElement(tag);
|
|
343
|
+
instance.setAttribute('label', 'Saved!');
|
|
344
|
+
document.body.appendChild(instance);
|
|
345
|
+
await settle();
|
|
346
|
+
|
|
347
|
+
const button = instance.querySelector('button')!;
|
|
348
|
+
button.click();
|
|
349
|
+
await settle();
|
|
350
|
+
expect(button.textContent).toBe('Saved!');
|
|
351
|
+
});
|
|
352
|
+
|
|
353
|
+
it("nested components don't leak ^vars across the dom-scope boundary", async () => {
|
|
354
|
+
const outerTag = uniqueTag('outer-counter');
|
|
355
|
+
const innerTag = uniqueTag('inner-counter');
|
|
356
|
+
|
|
357
|
+
const outerTmpl = document.createElement('template');
|
|
358
|
+
outerTmpl.setAttribute('component', outerTag);
|
|
359
|
+
outerTmpl.setAttribute('_', 'set ^count to 99');
|
|
360
|
+
outerTmpl.innerHTML = `<span class="outer">outer:\${^count}</span><${innerTag}></${innerTag}>`;
|
|
361
|
+
document.body.appendChild(outerTmpl);
|
|
362
|
+
|
|
363
|
+
const innerTmpl = document.createElement('template');
|
|
364
|
+
innerTmpl.setAttribute('component', innerTag);
|
|
365
|
+
// Note: NO init script — inner doesn't define ^count.
|
|
366
|
+
innerTmpl.innerHTML = '<span class="inner">inner:${^count}</span>';
|
|
367
|
+
document.body.appendChild(innerTmpl);
|
|
368
|
+
|
|
369
|
+
const runtime = new Runtime();
|
|
370
|
+
installPlugin(runtime, reactivityPlugin);
|
|
371
|
+
installPlugin(runtime, componentsPlugin);
|
|
372
|
+
componentsPlugin.scan(document);
|
|
373
|
+
|
|
374
|
+
const outer = document.createElement(outerTag);
|
|
375
|
+
document.body.appendChild(outer);
|
|
376
|
+
await settle();
|
|
377
|
+
|
|
378
|
+
// Outer renders with count=99.
|
|
379
|
+
expect(outer.querySelector('.outer')!.textContent).toBe('outer:99');
|
|
380
|
+
// Inner doesn't define ^count and the boundary blocks the walk —
|
|
381
|
+
// ${^count} resolves to undefined, which renders as empty string.
|
|
382
|
+
expect(outer.querySelector('.inner')!.textContent).toBe('inner:');
|
|
383
|
+
});
|
|
384
|
+
|
|
385
|
+
it('isolated state: each instance has its own ^var storage', async () => {
|
|
386
|
+
const tag = uniqueTag();
|
|
387
|
+
const tmpl = document.createElement('template');
|
|
388
|
+
tmpl.setAttribute('component', tag);
|
|
389
|
+
tmpl.setAttribute('_', 'set ^count to 0');
|
|
390
|
+
tmpl.innerHTML =
|
|
391
|
+
'<button _="on click increment ^count">+</button>' +
|
|
392
|
+
'<span class="readout">${^count}</span>';
|
|
393
|
+
document.body.appendChild(tmpl);
|
|
394
|
+
|
|
395
|
+
const runtime = new Runtime();
|
|
396
|
+
installPlugin(runtime, reactivityPlugin);
|
|
397
|
+
installPlugin(runtime, componentsPlugin);
|
|
398
|
+
componentsPlugin.scan(document);
|
|
399
|
+
|
|
400
|
+
const a = document.createElement(tag);
|
|
401
|
+
const b = document.createElement(tag);
|
|
402
|
+
document.body.appendChild(a);
|
|
403
|
+
document.body.appendChild(b);
|
|
404
|
+
await settle();
|
|
405
|
+
|
|
406
|
+
a.querySelector('button')!.click();
|
|
407
|
+
await settle();
|
|
408
|
+
a.querySelector('button')!.click();
|
|
409
|
+
await settle();
|
|
410
|
+
|
|
411
|
+
expect(a.querySelector('.readout')!.textContent).toBe('2');
|
|
412
|
+
expect(b.querySelector('.readout')!.textContent).toBe('0');
|
|
413
|
+
});
|
|
414
|
+
});
|
|
415
|
+
|
|
416
|
+
describe('v2.1: scoped CSS (@scope lifting)', () => {
|
|
417
|
+
it('lifts <style> blocks out of the template into <head> wrapped in @scope', () => {
|
|
418
|
+
const tag = uniqueTag();
|
|
419
|
+
document.body.innerHTML = `
|
|
420
|
+
<template component="${tag}">
|
|
421
|
+
<style>.btn { color: red; }</style>
|
|
422
|
+
<button class="btn">click</button>
|
|
423
|
+
</template>
|
|
424
|
+
`;
|
|
425
|
+
const runtime = new Runtime();
|
|
426
|
+
installPlugin(runtime, componentsPlugin);
|
|
427
|
+
componentsPlugin.scan(document);
|
|
428
|
+
|
|
429
|
+
const headStyle = document.head.querySelector(`style[data-component="${tag}"]`);
|
|
430
|
+
expect(headStyle).toBeTruthy();
|
|
431
|
+
expect(headStyle!.textContent).toContain(`@scope (${tag})`);
|
|
432
|
+
expect(headStyle!.textContent).toContain('.btn { color: red; }');
|
|
433
|
+
|
|
434
|
+
// Instance HTML should not include the original <style>.
|
|
435
|
+
const instance = document.createElement(tag);
|
|
436
|
+
document.body.appendChild(instance);
|
|
437
|
+
expect(instance.innerHTML).not.toContain('<style');
|
|
438
|
+
expect(instance.innerHTML).toContain('<button');
|
|
439
|
+
});
|
|
440
|
+
|
|
441
|
+
it('dedupes head injection when the same component is registered twice', () => {
|
|
442
|
+
const tag = uniqueTag();
|
|
443
|
+
document.body.innerHTML = `
|
|
444
|
+
<template component="${tag}"><style>.x{color:red}</style><p>p</p></template>
|
|
445
|
+
`;
|
|
446
|
+
const runtime = new Runtime();
|
|
447
|
+
installPlugin(runtime, componentsPlugin);
|
|
448
|
+
componentsPlugin.scan(document);
|
|
449
|
+
componentsPlugin.scan(document); // second scan — already registered
|
|
450
|
+
|
|
451
|
+
const matches = document.head.querySelectorAll(`style[data-component="${tag}"]`);
|
|
452
|
+
expect(matches.length).toBe(1);
|
|
453
|
+
});
|
|
454
|
+
|
|
455
|
+
it('emits distinct head styles for distinct components', () => {
|
|
456
|
+
const a = uniqueTag('hf-a');
|
|
457
|
+
const b = uniqueTag('hf-b');
|
|
458
|
+
document.body.innerHTML = `
|
|
459
|
+
<template component="${a}"><style>.a{}</style><p/></template>
|
|
460
|
+
<template component="${b}"><style>.b{}</style><p/></template>
|
|
461
|
+
`;
|
|
462
|
+
const runtime = new Runtime();
|
|
463
|
+
installPlugin(runtime, componentsPlugin);
|
|
464
|
+
componentsPlugin.scan(document);
|
|
465
|
+
|
|
466
|
+
expect(document.head.querySelector(`style[data-component="${a}"]`)).toBeTruthy();
|
|
467
|
+
expect(document.head.querySelector(`style[data-component="${b}"]`)).toBeTruthy();
|
|
468
|
+
});
|
|
469
|
+
|
|
470
|
+
it('does nothing when the template has no <style>', () => {
|
|
471
|
+
const tag = uniqueTag();
|
|
472
|
+
document.body.innerHTML = `<template component="${tag}"><p>plain</p></template>`;
|
|
473
|
+
const runtime = new Runtime();
|
|
474
|
+
installPlugin(runtime, componentsPlugin);
|
|
475
|
+
componentsPlugin.scan(document);
|
|
476
|
+
expect(document.head.querySelector(`style[data-component="${tag}"]`)).toBeNull();
|
|
477
|
+
});
|
|
478
|
+
});
|
|
479
|
+
|
|
480
|
+
describe('v2: template directives (#if / #for / #else / #end)', () => {
|
|
481
|
+
it('renders #if branch when condition is truthy', async () => {
|
|
482
|
+
const tag = uniqueTag();
|
|
483
|
+
const tmpl = document.createElement('template');
|
|
484
|
+
tmpl.setAttribute('component', tag);
|
|
485
|
+
tmpl.innerHTML =
|
|
486
|
+
'<header>${attrs.title}</header>\n' +
|
|
487
|
+
'#if attrs.showBadge\n' +
|
|
488
|
+
' <span class="badge">VIP</span>\n' +
|
|
489
|
+
'#end';
|
|
490
|
+
document.body.appendChild(tmpl);
|
|
491
|
+
|
|
492
|
+
const runtime = new Runtime();
|
|
493
|
+
installPlugin(runtime, reactivityPlugin);
|
|
494
|
+
installPlugin(runtime, componentsPlugin);
|
|
495
|
+
componentsPlugin.scan(document);
|
|
496
|
+
|
|
497
|
+
const a = document.createElement(tag);
|
|
498
|
+
a.setAttribute('title', 'Hello');
|
|
499
|
+
a.setAttribute('show-badge', 'true');
|
|
500
|
+
document.body.appendChild(a);
|
|
501
|
+
await settle();
|
|
502
|
+
expect(a.innerHTML).toContain('Hello');
|
|
503
|
+
expect(a.innerHTML).toContain('VIP');
|
|
504
|
+
|
|
505
|
+
const b = document.createElement(tag);
|
|
506
|
+
b.setAttribute('title', 'Hi');
|
|
507
|
+
// show-badge omitted → coerces falsy
|
|
508
|
+
document.body.appendChild(b);
|
|
509
|
+
await settle();
|
|
510
|
+
expect(b.innerHTML).toContain('Hi');
|
|
511
|
+
expect(b.innerHTML).not.toContain('VIP');
|
|
512
|
+
});
|
|
513
|
+
|
|
514
|
+
it('renders #else branch when #if condition is falsy', async () => {
|
|
515
|
+
const tag = uniqueTag();
|
|
516
|
+
const tmpl = document.createElement('template');
|
|
517
|
+
tmpl.setAttribute('component', tag);
|
|
518
|
+
tmpl.innerHTML =
|
|
519
|
+
'#if attrs.online\n' +
|
|
520
|
+
' <span class="status">online</span>\n' +
|
|
521
|
+
'#else\n' +
|
|
522
|
+
' <span class="status">offline</span>\n' +
|
|
523
|
+
'#end';
|
|
524
|
+
document.body.appendChild(tmpl);
|
|
525
|
+
|
|
526
|
+
const runtime = new Runtime();
|
|
527
|
+
installPlugin(runtime, reactivityPlugin);
|
|
528
|
+
installPlugin(runtime, componentsPlugin);
|
|
529
|
+
componentsPlugin.scan(document);
|
|
530
|
+
|
|
531
|
+
const offline = document.createElement(tag);
|
|
532
|
+
document.body.appendChild(offline);
|
|
533
|
+
await settle();
|
|
534
|
+
expect(offline.innerHTML).toContain('offline');
|
|
535
|
+
expect(offline.innerHTML).not.toContain('>online<');
|
|
536
|
+
});
|
|
537
|
+
|
|
538
|
+
it('renders #for over an iterable from a ^var', async () => {
|
|
539
|
+
const tag = uniqueTag();
|
|
540
|
+
const tmpl = document.createElement('template');
|
|
541
|
+
tmpl.setAttribute('component', tag);
|
|
542
|
+
tmpl.setAttribute('_', 'set ^items to ["alpha","beta","gamma"]');
|
|
543
|
+
tmpl.innerHTML =
|
|
544
|
+
'<ul>\n' + '#for item in ^items\n' + ' <li>${item}</li>\n' + '#end\n' + '</ul>';
|
|
545
|
+
document.body.appendChild(tmpl);
|
|
546
|
+
|
|
547
|
+
const runtime = new Runtime();
|
|
548
|
+
installPlugin(runtime, reactivityPlugin);
|
|
549
|
+
installPlugin(runtime, componentsPlugin);
|
|
550
|
+
componentsPlugin.scan(document);
|
|
551
|
+
|
|
552
|
+
const instance = document.createElement(tag);
|
|
553
|
+
document.body.appendChild(instance);
|
|
554
|
+
await settle();
|
|
555
|
+
expect(instance.innerHTML).toContain('<li>alpha</li>');
|
|
556
|
+
expect(instance.innerHTML).toContain('<li>beta</li>');
|
|
557
|
+
expect(instance.innerHTML).toContain('<li>gamma</li>');
|
|
558
|
+
});
|
|
559
|
+
|
|
560
|
+
it('renders #for #else when iterable is empty', async () => {
|
|
561
|
+
const tag = uniqueTag();
|
|
562
|
+
const tmpl = document.createElement('template');
|
|
563
|
+
tmpl.setAttribute('component', tag);
|
|
564
|
+
tmpl.setAttribute('_', 'set ^items to []');
|
|
565
|
+
tmpl.innerHTML =
|
|
566
|
+
'<ul>\n' +
|
|
567
|
+
'#for item in ^items\n' +
|
|
568
|
+
' <li>${item}</li>\n' +
|
|
569
|
+
'#else\n' +
|
|
570
|
+
' <li class="empty">no items</li>\n' +
|
|
571
|
+
'#end\n' +
|
|
572
|
+
'</ul>';
|
|
573
|
+
document.body.appendChild(tmpl);
|
|
574
|
+
|
|
575
|
+
const runtime = new Runtime();
|
|
576
|
+
installPlugin(runtime, reactivityPlugin);
|
|
577
|
+
installPlugin(runtime, componentsPlugin);
|
|
578
|
+
componentsPlugin.scan(document);
|
|
579
|
+
|
|
580
|
+
const instance = document.createElement(tag);
|
|
581
|
+
document.body.appendChild(instance);
|
|
582
|
+
await settle();
|
|
583
|
+
expect(instance.innerHTML).toContain('no items');
|
|
584
|
+
});
|
|
585
|
+
});
|
|
586
|
+
|
|
587
|
+
it('watch() registers dynamically-added templates', async () => {
|
|
588
|
+
const runtime = new Runtime();
|
|
589
|
+
installPlugin(runtime, componentsPlugin);
|
|
590
|
+
const stop = componentsPlugin.watch();
|
|
591
|
+
|
|
592
|
+
try {
|
|
593
|
+
const tag = uniqueTag();
|
|
594
|
+
const tmpl = document.createElement('template');
|
|
595
|
+
tmpl.setAttribute('component', tag);
|
|
596
|
+
tmpl.innerHTML = '<p>dynamic</p>';
|
|
597
|
+
document.body.appendChild(tmpl);
|
|
598
|
+
|
|
599
|
+
// Give the MutationObserver microtask a chance to fire.
|
|
600
|
+
await new Promise<void>(resolve => setTimeout(resolve, 10));
|
|
601
|
+
|
|
602
|
+
const instance = document.createElement(tag);
|
|
603
|
+
document.body.appendChild(instance);
|
|
604
|
+
expect(instance.innerHTML).toContain('dynamic');
|
|
605
|
+
} finally {
|
|
606
|
+
stop();
|
|
607
|
+
}
|
|
608
|
+
});
|
|
609
|
+
});
|