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,783 @@
|
|
|
1
|
+
import { Signal, isSignal, signalTargetCollected } from './signal';
|
|
2
|
+
|
|
3
|
+
export type PrimitiveTemplateValue = string | number | boolean | null | undefined;
|
|
4
|
+
export type TemplateEventHandler = (event: any) => void;
|
|
5
|
+
|
|
6
|
+
export type TemplateValue =
|
|
7
|
+
| PrimitiveTemplateValue
|
|
8
|
+
| Node
|
|
9
|
+
| TemplateResult
|
|
10
|
+
| TemplateValue[]
|
|
11
|
+
| MappedTemplates<unknown>
|
|
12
|
+
| Signal<unknown>
|
|
13
|
+
| (() => TemplateValue)
|
|
14
|
+
| TemplateEventHandler
|
|
15
|
+
| EventListenerObject;
|
|
16
|
+
|
|
17
|
+
/** Minimal WeakRef typing so the library can keep targeting ES5 lib. */
|
|
18
|
+
declare const WeakRef:
|
|
19
|
+
| (new <T extends object>(target: T) => { deref(): T | undefined })
|
|
20
|
+
| undefined;
|
|
21
|
+
|
|
22
|
+
const createRef = <T extends object>(target: T): { deref(): T | undefined } =>
|
|
23
|
+
typeof WeakRef === 'function' ? new WeakRef(target) : { deref: () => target };
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* Subscribes a DOM write to a signal and performs the initial write. The
|
|
27
|
+
* subscriber holds the node behind a WeakRef: once the node is garbage
|
|
28
|
+
* collected, the next notification unsubscribes it automatically (see
|
|
29
|
+
* signalTargetCollected), so wholesale-discarded subtrees never leak
|
|
30
|
+
* subscriptions.
|
|
31
|
+
*/
|
|
32
|
+
const bindSignal = <N extends object>(
|
|
33
|
+
sig: Signal<unknown>,
|
|
34
|
+
target: N,
|
|
35
|
+
write: (node: N, value: unknown) => void
|
|
36
|
+
): (() => void) => {
|
|
37
|
+
write(target, sig.value);
|
|
38
|
+
const ref = createRef(target);
|
|
39
|
+
const observer = (value: unknown) => {
|
|
40
|
+
const node = ref.deref();
|
|
41
|
+
if (!node) {
|
|
42
|
+
throw signalTargetCollected;
|
|
43
|
+
}
|
|
44
|
+
write(node, value);
|
|
45
|
+
};
|
|
46
|
+
sig.subscribe(observer);
|
|
47
|
+
return () => sig.unsubscribe(observer);
|
|
48
|
+
};
|
|
49
|
+
|
|
50
|
+
const writeSignalText = (node: Text, value: unknown): void => {
|
|
51
|
+
node.nodeValue = value === null || value === undefined ? '' : String(value);
|
|
52
|
+
};
|
|
53
|
+
|
|
54
|
+
const applyAttribute = (element: Element, name: string, value: unknown): void => {
|
|
55
|
+
if (value === false || value === null || value === undefined) {
|
|
56
|
+
element.removeAttribute(name);
|
|
57
|
+
} else {
|
|
58
|
+
element.setAttribute(name, String(value));
|
|
59
|
+
}
|
|
60
|
+
};
|
|
61
|
+
|
|
62
|
+
/**
|
|
63
|
+
* A lazily-mapped list, as returned by `map()`. Handing the list and the
|
|
64
|
+
* mapping function to the renderer (instead of an array of TemplateResults)
|
|
65
|
+
* lets it skip rows whose item is identical (`===`) to the item the row was
|
|
66
|
+
* built from — no template call, no part updates, no visit.
|
|
67
|
+
*/
|
|
68
|
+
export interface MappedTemplates<T> {
|
|
69
|
+
readonly items: readonly T[];
|
|
70
|
+
readonly fn: (item: T, index: number) => TemplateValue;
|
|
71
|
+
readonly __customElementsTsMapped: true;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
export const map = <T>(
|
|
75
|
+
items: readonly T[],
|
|
76
|
+
fn: (item: T, index: number) => TemplateValue
|
|
77
|
+
): MappedTemplates<T> => ({
|
|
78
|
+
items,
|
|
79
|
+
fn,
|
|
80
|
+
__customElementsTsMapped: true,
|
|
81
|
+
});
|
|
82
|
+
|
|
83
|
+
const isMappedTemplates = (value: unknown): value is MappedTemplates<unknown> => {
|
|
84
|
+
return Boolean(
|
|
85
|
+
value &&
|
|
86
|
+
typeof value === 'object' &&
|
|
87
|
+
(value as MappedTemplates<unknown>).__customElementsTsMapped === true
|
|
88
|
+
);
|
|
89
|
+
};
|
|
90
|
+
|
|
91
|
+
export interface TemplateResult {
|
|
92
|
+
readonly strings: TemplateStringsArray;
|
|
93
|
+
readonly values: TemplateValue[];
|
|
94
|
+
readonly __customElementsTsTemplateResult: true;
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
export interface TemplateInstance {
|
|
98
|
+
readonly strings: TemplateStringsArray;
|
|
99
|
+
readonly nodes: ChildNode[];
|
|
100
|
+
update(values: TemplateValue[]): void;
|
|
101
|
+
dispose(): void;
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
interface Part {
|
|
105
|
+
update(value: TemplateValue): void;
|
|
106
|
+
dispose(): void;
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
export interface RenderState {
|
|
110
|
+
instance?: TemplateInstance;
|
|
111
|
+
part?: ChildPart;
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
type PartKind = 'child' | 'attr' | 'prop' | 'event';
|
|
115
|
+
|
|
116
|
+
interface PartDescriptor {
|
|
117
|
+
kind: PartKind;
|
|
118
|
+
/** Attribute / property / event name. Empty string for child parts. */
|
|
119
|
+
name: string;
|
|
120
|
+
/** childNodes index path from the template root to the part's node. */
|
|
121
|
+
path: number[];
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
interface ParsedTemplate {
|
|
125
|
+
/** Pristine parsed content, cloned for each instance. */
|
|
126
|
+
content: DocumentFragment;
|
|
127
|
+
/** Part descriptors indexed by expression position. */
|
|
128
|
+
parts: PartDescriptor[];
|
|
129
|
+
/**
|
|
130
|
+
* True when the template has exactly one root element; instances then
|
|
131
|
+
* clone that element directly, skipping a DocumentFragment per clone.
|
|
132
|
+
*/
|
|
133
|
+
singleRoot: boolean;
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
const templateCache = new WeakMap<TemplateStringsArray, ParsedTemplate>();
|
|
137
|
+
|
|
138
|
+
export const html = (
|
|
139
|
+
strings: TemplateStringsArray,
|
|
140
|
+
...values: TemplateValue[]
|
|
141
|
+
): TemplateResult => ({
|
|
142
|
+
strings,
|
|
143
|
+
values,
|
|
144
|
+
__customElementsTsTemplateResult: true,
|
|
145
|
+
});
|
|
146
|
+
|
|
147
|
+
export const isTemplateResult = (value: unknown): value is TemplateResult => {
|
|
148
|
+
return Boolean(
|
|
149
|
+
value &&
|
|
150
|
+
typeof value === 'object' &&
|
|
151
|
+
(value as TemplateResult).__customElementsTsTemplateResult === true
|
|
152
|
+
);
|
|
153
|
+
};
|
|
154
|
+
|
|
155
|
+
export const renderIntoAnchor = (
|
|
156
|
+
value: TemplateValue,
|
|
157
|
+
anchor: Comment,
|
|
158
|
+
state: RenderState = {},
|
|
159
|
+
host?: unknown
|
|
160
|
+
): RenderState => {
|
|
161
|
+
if (isTemplateResult(value)) {
|
|
162
|
+
if (state.part) {
|
|
163
|
+
state.part.dispose();
|
|
164
|
+
state.part = undefined;
|
|
165
|
+
}
|
|
166
|
+
if (state.instance && state.instance.strings === value.strings) {
|
|
167
|
+
state.instance.update(value.values);
|
|
168
|
+
return state;
|
|
169
|
+
}
|
|
170
|
+
if (state.instance) {
|
|
171
|
+
state.instance.dispose();
|
|
172
|
+
}
|
|
173
|
+
state.instance = createTemplateInstance(value, host);
|
|
174
|
+
insertAfter(anchor, state.instance.nodes);
|
|
175
|
+
return state;
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
if (state.instance) {
|
|
179
|
+
state.instance.dispose();
|
|
180
|
+
state.instance = undefined;
|
|
181
|
+
}
|
|
182
|
+
if (!state.part) {
|
|
183
|
+
state.part = new ChildPart(anchor, host);
|
|
184
|
+
}
|
|
185
|
+
state.part.update(value);
|
|
186
|
+
return state;
|
|
187
|
+
};
|
|
188
|
+
|
|
189
|
+
const createTemplateInstance = (result: TemplateResult, host?: unknown): TemplateInstance => {
|
|
190
|
+
const parsed = getParsedTemplate(result.strings);
|
|
191
|
+
|
|
192
|
+
let root: Node;
|
|
193
|
+
let nodes: ChildNode[];
|
|
194
|
+
if (parsed.singleRoot) {
|
|
195
|
+
root = parsed.content.firstChild!.cloneNode(true);
|
|
196
|
+
nodes = [root as ChildNode];
|
|
197
|
+
} else {
|
|
198
|
+
root = parsed.content.cloneNode(true);
|
|
199
|
+
nodes = Array.from(root.childNodes);
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
// Resolve each precompiled index path directly to its node — no tree
|
|
203
|
+
// walking or attribute scanning per instance.
|
|
204
|
+
const descriptors = parsed.parts;
|
|
205
|
+
const count = descriptors.length;
|
|
206
|
+
const parts: Part[] = new Array(count);
|
|
207
|
+
for (let i = 0; i < count; i++) {
|
|
208
|
+
const d = descriptors[i];
|
|
209
|
+
let node: Node = root;
|
|
210
|
+
const path = d.path;
|
|
211
|
+
for (let j = 0; j < path.length; j++) {
|
|
212
|
+
node = node.childNodes[path[j]];
|
|
213
|
+
}
|
|
214
|
+
if (d.kind === 'child') {
|
|
215
|
+
parts[i] = new ChildPart(node as Comment, host);
|
|
216
|
+
} else if (d.kind === 'event') {
|
|
217
|
+
parts[i] = new EventPart(node as Element, d.name, host);
|
|
218
|
+
} else if (d.kind === 'prop') {
|
|
219
|
+
parts[i] = new PropertyPart(node as Element, d.name);
|
|
220
|
+
} else {
|
|
221
|
+
parts[i] = new AttributePart(node as Element, d.name);
|
|
222
|
+
}
|
|
223
|
+
}
|
|
224
|
+
const values = result.values;
|
|
225
|
+
for (let i = 0; i < count; i++) {
|
|
226
|
+
parts[i].update(values[i]);
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
return {
|
|
230
|
+
strings: result.strings,
|
|
231
|
+
nodes,
|
|
232
|
+
update(newValues: TemplateValue[]) {
|
|
233
|
+
for (let i = 0; i < count; i++) {
|
|
234
|
+
parts[i].update(newValues[i]);
|
|
235
|
+
}
|
|
236
|
+
},
|
|
237
|
+
dispose() {
|
|
238
|
+
parts.forEach((part) => part.dispose());
|
|
239
|
+
nodes.forEach((node) => node.parentNode?.removeChild(node));
|
|
240
|
+
},
|
|
241
|
+
};
|
|
242
|
+
};
|
|
243
|
+
|
|
244
|
+
const getParsedTemplate = (strings: TemplateStringsArray): ParsedTemplate => {
|
|
245
|
+
const cached = templateCache.get(strings);
|
|
246
|
+
if (cached) {
|
|
247
|
+
return cached;
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
const markers: string[] = [];
|
|
251
|
+
let parsedHtml = '';
|
|
252
|
+
for (let index = 0; index < strings.length - 1; index++) {
|
|
253
|
+
parsedHtml += strings[index];
|
|
254
|
+
const marker = `__custom_elements_ts_marker_${index}__`;
|
|
255
|
+
markers.push(marker);
|
|
256
|
+
parsedHtml += isAttributePosition(strings[index]) ? marker : `<!--${marker}-->`;
|
|
257
|
+
}
|
|
258
|
+
parsedHtml += strings[strings.length - 1];
|
|
259
|
+
|
|
260
|
+
const template = document.createElement('template');
|
|
261
|
+
template.innerHTML = parsedHtml;
|
|
262
|
+
const content = template.content;
|
|
263
|
+
|
|
264
|
+
// Whitespace-only text nodes inside table-structure elements are never
|
|
265
|
+
// rendered; removing them once here makes every clone smaller and faster.
|
|
266
|
+
stripTableWhitespace(content);
|
|
267
|
+
|
|
268
|
+
// Discover parts once at parse time and record index paths to their nodes.
|
|
269
|
+
const markerToIndex = new Map(markers.map((marker, index) => [marker, index]));
|
|
270
|
+
const descriptors: PartDescriptor[] = new Array(markers.length);
|
|
271
|
+
const walker = document.createTreeWalker(
|
|
272
|
+
content,
|
|
273
|
+
NodeFilter.SHOW_ELEMENT | NodeFilter.SHOW_COMMENT
|
|
274
|
+
);
|
|
275
|
+
let current = walker.nextNode();
|
|
276
|
+
while (current) {
|
|
277
|
+
if (current.nodeType === Node.COMMENT_NODE) {
|
|
278
|
+
const index = markerToIndex.get(current.nodeValue || '');
|
|
279
|
+
if (index !== undefined) {
|
|
280
|
+
(current as Comment).textContent = '';
|
|
281
|
+
descriptors[index] = { kind: 'child', name: '', path: getNodePath(current, content) };
|
|
282
|
+
}
|
|
283
|
+
} else {
|
|
284
|
+
const element = current as Element;
|
|
285
|
+
let elementPath: number[] | undefined;
|
|
286
|
+
Array.from(element.attributes).forEach((attribute) => {
|
|
287
|
+
const index = markerToIndex.get(attribute.value);
|
|
288
|
+
if (index === undefined) {
|
|
289
|
+
return;
|
|
290
|
+
}
|
|
291
|
+
const name = attribute.name;
|
|
292
|
+
element.removeAttribute(name);
|
|
293
|
+
elementPath ||= getNodePath(element, content);
|
|
294
|
+
const kind: PartKind = name.startsWith('@')
|
|
295
|
+
? 'event'
|
|
296
|
+
: name.startsWith('.')
|
|
297
|
+
? 'prop'
|
|
298
|
+
: 'attr';
|
|
299
|
+
descriptors[index] = {
|
|
300
|
+
kind,
|
|
301
|
+
name: kind === 'attr' ? name : name.slice(1),
|
|
302
|
+
path: elementPath,
|
|
303
|
+
};
|
|
304
|
+
});
|
|
305
|
+
}
|
|
306
|
+
current = walker.nextNode();
|
|
307
|
+
}
|
|
308
|
+
|
|
309
|
+
const singleRoot =
|
|
310
|
+
content.childNodes.length === 1 && content.firstChild!.nodeType === Node.ELEMENT_NODE;
|
|
311
|
+
if (singleRoot) {
|
|
312
|
+
// Paths become relative to the root element instead of the fragment.
|
|
313
|
+
for (const descriptor of descriptors) {
|
|
314
|
+
descriptor?.path.shift();
|
|
315
|
+
}
|
|
316
|
+
}
|
|
317
|
+
|
|
318
|
+
const parsed: ParsedTemplate = { content, parts: descriptors, singleRoot };
|
|
319
|
+
templateCache.set(strings, parsed);
|
|
320
|
+
return parsed;
|
|
321
|
+
};
|
|
322
|
+
|
|
323
|
+
const getNodePath = (node: Node, root: Node): number[] => {
|
|
324
|
+
const path: number[] = [];
|
|
325
|
+
let current: Node = node;
|
|
326
|
+
while (current !== root) {
|
|
327
|
+
const parent = current.parentNode!;
|
|
328
|
+
path.push(Array.prototype.indexOf.call(parent.childNodes, current));
|
|
329
|
+
current = parent;
|
|
330
|
+
}
|
|
331
|
+
return path.reverse();
|
|
332
|
+
};
|
|
333
|
+
|
|
334
|
+
/** Elements whose whitespace-only text children are never rendered. */
|
|
335
|
+
const tableTags = ['TABLE', 'THEAD', 'TBODY', 'TFOOT', 'TR'];
|
|
336
|
+
|
|
337
|
+
const stripTableWhitespace = (el: Node): void => {
|
|
338
|
+
const isTable =
|
|
339
|
+
el.nodeType === Node.ELEMENT_NODE && tableTags.indexOf((el as Element).tagName) !== -1;
|
|
340
|
+
let child = el.firstChild;
|
|
341
|
+
while (child) {
|
|
342
|
+
const next = child.nextSibling;
|
|
343
|
+
if (child.nodeType === Node.ELEMENT_NODE) {
|
|
344
|
+
stripTableWhitespace(child);
|
|
345
|
+
} else if (isTable && child.nodeType === Node.TEXT_NODE && !child.nodeValue!.trim()) {
|
|
346
|
+
(child as ChildNode).remove();
|
|
347
|
+
}
|
|
348
|
+
child = next;
|
|
349
|
+
}
|
|
350
|
+
};
|
|
351
|
+
|
|
352
|
+
const isAttributePosition = (text: string): boolean => {
|
|
353
|
+
const lastOpen = text.lastIndexOf('<');
|
|
354
|
+
const lastClose = text.lastIndexOf('>');
|
|
355
|
+
if (lastOpen < lastClose) {
|
|
356
|
+
return false;
|
|
357
|
+
}
|
|
358
|
+
return /[^\s<>"'=/]+\s*=\s*["']?$/.test(text);
|
|
359
|
+
};
|
|
360
|
+
|
|
361
|
+
class ChildPart implements Part {
|
|
362
|
+
private kind: 'empty' | 'text' | 'node' | 'template' | 'array' = 'empty';
|
|
363
|
+
private nodes: ChildNode[] = [];
|
|
364
|
+
private templateInstance?: TemplateInstance;
|
|
365
|
+
private arrayItems: Array<{ anchor: Comment; part: ChildPart }> = [];
|
|
366
|
+
/** Items a map() list was last rendered from, aligned with arrayItems. */
|
|
367
|
+
private lastItems: unknown[] | null = null;
|
|
368
|
+
private boundSignal?: Signal<unknown>;
|
|
369
|
+
private unbindSignal?: () => void;
|
|
370
|
+
|
|
371
|
+
constructor(
|
|
372
|
+
private anchor: Comment,
|
|
373
|
+
private host?: unknown
|
|
374
|
+
) {}
|
|
375
|
+
|
|
376
|
+
update(value: TemplateValue): void {
|
|
377
|
+
const resolved = resolveValue(value);
|
|
378
|
+
if (this.boundSignal) {
|
|
379
|
+
if (resolved === this.boundSignal) {
|
|
380
|
+
// Same signal as last render: the text node updates itself.
|
|
381
|
+
return;
|
|
382
|
+
}
|
|
383
|
+
this.detachSignal();
|
|
384
|
+
}
|
|
385
|
+
if (isSignal(resolved)) {
|
|
386
|
+
this.updateSignal(resolved);
|
|
387
|
+
return;
|
|
388
|
+
}
|
|
389
|
+
if (resolved === null || resolved === undefined || resolved === false) {
|
|
390
|
+
this.clear();
|
|
391
|
+
return;
|
|
392
|
+
}
|
|
393
|
+
if (isMappedTemplates(resolved)) {
|
|
394
|
+
this.updateMapped(resolved);
|
|
395
|
+
return;
|
|
396
|
+
}
|
|
397
|
+
if (Array.isArray(resolved)) {
|
|
398
|
+
this.lastItems = null;
|
|
399
|
+
if (resolved.length === 0) {
|
|
400
|
+
// Clearing a list routes through clear() so it can take the
|
|
401
|
+
// whole-parent fast path instead of removing items one by one.
|
|
402
|
+
this.clear();
|
|
403
|
+
this.kind = 'array';
|
|
404
|
+
return;
|
|
405
|
+
}
|
|
406
|
+
this.updateArray(resolved);
|
|
407
|
+
return;
|
|
408
|
+
}
|
|
409
|
+
if (isTemplateResult(resolved)) {
|
|
410
|
+
this.updateTemplate(resolved);
|
|
411
|
+
return;
|
|
412
|
+
}
|
|
413
|
+
if (resolved instanceof Node) {
|
|
414
|
+
this.updateNode(resolved);
|
|
415
|
+
return;
|
|
416
|
+
}
|
|
417
|
+
this.updateText(String(resolved));
|
|
418
|
+
}
|
|
419
|
+
|
|
420
|
+
dispose(): void {
|
|
421
|
+
this.clear();
|
|
422
|
+
}
|
|
423
|
+
|
|
424
|
+
private updateSignal(sig: Signal<unknown>): void {
|
|
425
|
+
this.clear();
|
|
426
|
+
const text = document.createTextNode('');
|
|
427
|
+
insertAfter(this.anchor, [text]);
|
|
428
|
+
this.nodes = [text];
|
|
429
|
+
this.kind = 'text';
|
|
430
|
+
this.unbindSignal = bindSignal(sig, text, writeSignalText);
|
|
431
|
+
this.boundSignal = sig;
|
|
432
|
+
}
|
|
433
|
+
|
|
434
|
+
private detachSignal(): void {
|
|
435
|
+
this.unbindSignal?.();
|
|
436
|
+
this.unbindSignal = undefined;
|
|
437
|
+
this.boundSignal = undefined;
|
|
438
|
+
}
|
|
439
|
+
|
|
440
|
+
private clear(): void {
|
|
441
|
+
this.detachSignal();
|
|
442
|
+
this.lastItems = null;
|
|
443
|
+
if (this.fastClear()) {
|
|
444
|
+
// The subtree was discarded wholesale; just drop references. Event
|
|
445
|
+
// listeners and nested parts are garbage-collected with their nodes.
|
|
446
|
+
this.templateInstance = undefined;
|
|
447
|
+
this.arrayItems = [];
|
|
448
|
+
this.nodes = [];
|
|
449
|
+
this.kind = 'empty';
|
|
450
|
+
return;
|
|
451
|
+
}
|
|
452
|
+
this.templateInstance?.dispose();
|
|
453
|
+
this.templateInstance = undefined;
|
|
454
|
+
this.arrayItems.forEach((item) => {
|
|
455
|
+
item.part.dispose();
|
|
456
|
+
item.anchor.parentNode?.removeChild(item.anchor);
|
|
457
|
+
});
|
|
458
|
+
this.arrayItems = [];
|
|
459
|
+
this.nodes.forEach((node) => node.parentNode?.removeChild(node));
|
|
460
|
+
this.nodes = [];
|
|
461
|
+
this.kind = 'empty';
|
|
462
|
+
}
|
|
463
|
+
|
|
464
|
+
/**
|
|
465
|
+
* When this part's content spans its whole parent (the anchor is the
|
|
466
|
+
* parent's first child and the content ends the parent), the parent can be
|
|
467
|
+
* emptied with one textContent write instead of removing every node
|
|
468
|
+
* individually — much faster for large lists.
|
|
469
|
+
*/
|
|
470
|
+
private fastClear(): boolean {
|
|
471
|
+
if (this.kind === 'empty') {
|
|
472
|
+
return false;
|
|
473
|
+
}
|
|
474
|
+
const parent = this.anchor.parentNode;
|
|
475
|
+
if (!parent || this.anchor.previousSibling) {
|
|
476
|
+
return false;
|
|
477
|
+
}
|
|
478
|
+
const end = this.getEndNode();
|
|
479
|
+
if (end === this.anchor || end.nextSibling) {
|
|
480
|
+
return false;
|
|
481
|
+
}
|
|
482
|
+
parent.textContent = '';
|
|
483
|
+
parent.appendChild(this.anchor);
|
|
484
|
+
return true;
|
|
485
|
+
}
|
|
486
|
+
|
|
487
|
+
private updateText(value: string): void {
|
|
488
|
+
if (this.kind === 'text' && this.nodes[0]?.nodeType === Node.TEXT_NODE) {
|
|
489
|
+
if (this.nodes[0].nodeValue !== value) {
|
|
490
|
+
this.nodes[0].nodeValue = value;
|
|
491
|
+
}
|
|
492
|
+
return;
|
|
493
|
+
}
|
|
494
|
+
this.clear();
|
|
495
|
+
const text = document.createTextNode(value);
|
|
496
|
+
insertAfter(this.anchor, [text]);
|
|
497
|
+
this.nodes = [text];
|
|
498
|
+
this.kind = 'text';
|
|
499
|
+
}
|
|
500
|
+
|
|
501
|
+
private updateNode(value: Node): void {
|
|
502
|
+
if (this.kind === 'node' && this.nodes[0] === value) {
|
|
503
|
+
return;
|
|
504
|
+
}
|
|
505
|
+
this.clear();
|
|
506
|
+
insertAfter(this.anchor, [value as ChildNode]);
|
|
507
|
+
this.nodes = [value as ChildNode];
|
|
508
|
+
this.kind = 'node';
|
|
509
|
+
}
|
|
510
|
+
|
|
511
|
+
private updateTemplate(value: TemplateResult): void {
|
|
512
|
+
if (this.kind === 'template' && this.templateInstance?.strings === value.strings) {
|
|
513
|
+
this.templateInstance.update(value.values);
|
|
514
|
+
return;
|
|
515
|
+
}
|
|
516
|
+
this.clear();
|
|
517
|
+
this.templateInstance = createTemplateInstance(value, this.host);
|
|
518
|
+
insertAfter(this.anchor, this.templateInstance.nodes);
|
|
519
|
+
this.nodes = this.templateInstance.nodes;
|
|
520
|
+
this.kind = 'template';
|
|
521
|
+
}
|
|
522
|
+
|
|
523
|
+
private updateMapped(mapped: MappedTemplates<unknown>): void {
|
|
524
|
+
const items = mapped.items as unknown[];
|
|
525
|
+
const fn = mapped.fn as (item: unknown, index: number) => TemplateValue;
|
|
526
|
+
const length = items.length;
|
|
527
|
+
|
|
528
|
+
if (length === 0) {
|
|
529
|
+
this.clear();
|
|
530
|
+
this.kind = 'array';
|
|
531
|
+
this.lastItems = [];
|
|
532
|
+
return;
|
|
533
|
+
}
|
|
534
|
+
if (this.kind !== 'array') {
|
|
535
|
+
this.clear();
|
|
536
|
+
this.kind = 'array';
|
|
537
|
+
}
|
|
538
|
+
|
|
539
|
+
const last = this.lastItems;
|
|
540
|
+
// Identity skips are only valid while lastItems mirrors arrayItems.
|
|
541
|
+
const canSkip = last !== null && last.length === this.arrayItems.length;
|
|
542
|
+
|
|
543
|
+
// Pure contiguous removal: when identity shows the tail simply shifted
|
|
544
|
+
// left, detach just the removed rows' DOM and keep every other row
|
|
545
|
+
// untouched — instead of rewriting every row after the removal point.
|
|
546
|
+
if (canSkip && last!.length > length) {
|
|
547
|
+
let start = 0;
|
|
548
|
+
while (start < length && last![start] === items[start]) {
|
|
549
|
+
start++;
|
|
550
|
+
}
|
|
551
|
+
const removed = last!.length - length;
|
|
552
|
+
let isShift = true;
|
|
553
|
+
for (let i = start; i < length; i++) {
|
|
554
|
+
if (last![i + removed] !== items[i]) {
|
|
555
|
+
isShift = false;
|
|
556
|
+
break;
|
|
557
|
+
}
|
|
558
|
+
}
|
|
559
|
+
if (isShift) {
|
|
560
|
+
const dropped = this.arrayItems.splice(start, removed);
|
|
561
|
+
for (let i = 0; i < dropped.length; i++) {
|
|
562
|
+
dropped[i].part.dispose();
|
|
563
|
+
dropped[i].anchor.parentNode?.removeChild(dropped[i].anchor);
|
|
564
|
+
}
|
|
565
|
+
last!.splice(start, removed);
|
|
566
|
+
return;
|
|
567
|
+
}
|
|
568
|
+
}
|
|
569
|
+
|
|
570
|
+
while (this.arrayItems.length > length) {
|
|
571
|
+
const item = this.arrayItems.pop()!;
|
|
572
|
+
item.part.dispose();
|
|
573
|
+
item.anchor.parentNode?.removeChild(item.anchor);
|
|
574
|
+
}
|
|
575
|
+
|
|
576
|
+
for (let index = 0; index < length; index++) {
|
|
577
|
+
let entry = this.arrayItems[index];
|
|
578
|
+
if (!entry) {
|
|
579
|
+
const itemAnchor = document.createComment('');
|
|
580
|
+
insertAfter(this.getEndNode(), [itemAnchor]);
|
|
581
|
+
entry = { anchor: itemAnchor, part: new ChildPart(itemAnchor, this.host) };
|
|
582
|
+
this.arrayItems[index] = entry;
|
|
583
|
+
} else if (canSkip && last![index] === items[index]) {
|
|
584
|
+
// The row still holds the item it was built from: nothing to do.
|
|
585
|
+
continue;
|
|
586
|
+
}
|
|
587
|
+
entry.part.update(fn(items[index], index));
|
|
588
|
+
}
|
|
589
|
+
|
|
590
|
+
// Remember the items so the next render can identity-skip unchanged rows.
|
|
591
|
+
let copy = this.lastItems;
|
|
592
|
+
if (copy === null || copy.length !== length) {
|
|
593
|
+
copy = new Array(length);
|
|
594
|
+
this.lastItems = copy;
|
|
595
|
+
}
|
|
596
|
+
for (let index = 0; index < length; index++) {
|
|
597
|
+
copy[index] = items[index];
|
|
598
|
+
}
|
|
599
|
+
}
|
|
600
|
+
|
|
601
|
+
private updateArray(values: TemplateValue[]): void {
|
|
602
|
+
if (this.kind !== 'array') {
|
|
603
|
+
this.clear();
|
|
604
|
+
this.kind = 'array';
|
|
605
|
+
}
|
|
606
|
+
|
|
607
|
+
while (this.arrayItems.length > values.length) {
|
|
608
|
+
const item = this.arrayItems.pop()!;
|
|
609
|
+
item.part.dispose();
|
|
610
|
+
item.anchor.parentNode?.removeChild(item.anchor);
|
|
611
|
+
}
|
|
612
|
+
|
|
613
|
+
for (let index = 0; index < values.length; index++) {
|
|
614
|
+
let item = this.arrayItems[index];
|
|
615
|
+
if (!item) {
|
|
616
|
+
const itemAnchor = document.createComment('custom-elements-ts-array-item');
|
|
617
|
+
insertAfter(this.getEndNode(), [itemAnchor]);
|
|
618
|
+
item = {
|
|
619
|
+
anchor: itemAnchor,
|
|
620
|
+
part: new ChildPart(itemAnchor, this.host),
|
|
621
|
+
};
|
|
622
|
+
this.arrayItems[index] = item;
|
|
623
|
+
}
|
|
624
|
+
item.part.update(values[index]);
|
|
625
|
+
}
|
|
626
|
+
}
|
|
627
|
+
|
|
628
|
+
private getEndNode(): ChildNode {
|
|
629
|
+
const lastItem = this.arrayItems[this.arrayItems.length - 1];
|
|
630
|
+
if (lastItem) {
|
|
631
|
+
return lastItem.part.getEndNode();
|
|
632
|
+
}
|
|
633
|
+
return this.nodes[this.nodes.length - 1] || this.anchor;
|
|
634
|
+
}
|
|
635
|
+
}
|
|
636
|
+
|
|
637
|
+
class AttributePart implements Part {
|
|
638
|
+
private currentValue: TemplateValue | typeof noValue = noValue;
|
|
639
|
+
private unbindSignal?: () => void;
|
|
640
|
+
|
|
641
|
+
constructor(
|
|
642
|
+
private element: Element,
|
|
643
|
+
private name: string
|
|
644
|
+
) {}
|
|
645
|
+
|
|
646
|
+
update(value: TemplateValue): void {
|
|
647
|
+
const resolved = resolveValue(value);
|
|
648
|
+
if (Object.is(this.currentValue, resolved)) {
|
|
649
|
+
return;
|
|
650
|
+
}
|
|
651
|
+
if (this.unbindSignal) {
|
|
652
|
+
this.unbindSignal();
|
|
653
|
+
this.unbindSignal = undefined;
|
|
654
|
+
}
|
|
655
|
+
this.currentValue = resolved;
|
|
656
|
+
if (isSignal(resolved)) {
|
|
657
|
+
const name = this.name;
|
|
658
|
+
this.unbindSignal = bindSignal(resolved, this.element, (el, v) =>
|
|
659
|
+
applyAttribute(el, name, v)
|
|
660
|
+
);
|
|
661
|
+
return;
|
|
662
|
+
}
|
|
663
|
+
applyAttribute(this.element, this.name, resolved);
|
|
664
|
+
}
|
|
665
|
+
|
|
666
|
+
dispose(): void {
|
|
667
|
+
if (this.unbindSignal) {
|
|
668
|
+
this.unbindSignal();
|
|
669
|
+
this.unbindSignal = undefined;
|
|
670
|
+
}
|
|
671
|
+
this.currentValue = noValue;
|
|
672
|
+
this.element.removeAttribute(this.name);
|
|
673
|
+
}
|
|
674
|
+
}
|
|
675
|
+
|
|
676
|
+
class PropertyPart implements Part {
|
|
677
|
+
private currentValue: TemplateValue | typeof noValue = noValue;
|
|
678
|
+
private unbindSignal?: () => void;
|
|
679
|
+
|
|
680
|
+
constructor(
|
|
681
|
+
private element: Element,
|
|
682
|
+
private name: string
|
|
683
|
+
) {}
|
|
684
|
+
|
|
685
|
+
update(value: TemplateValue): void {
|
|
686
|
+
const resolved = resolveValue(value);
|
|
687
|
+
if (Object.is(this.currentValue, resolved)) {
|
|
688
|
+
return;
|
|
689
|
+
}
|
|
690
|
+
if (this.unbindSignal) {
|
|
691
|
+
this.unbindSignal();
|
|
692
|
+
this.unbindSignal = undefined;
|
|
693
|
+
}
|
|
694
|
+
this.currentValue = resolved;
|
|
695
|
+
if (isSignal(resolved)) {
|
|
696
|
+
const name = this.name;
|
|
697
|
+
this.unbindSignal = bindSignal(resolved, this.element, (el, v) => {
|
|
698
|
+
(el as any)[name] = v === null || v === undefined ? '' : v;
|
|
699
|
+
});
|
|
700
|
+
return;
|
|
701
|
+
}
|
|
702
|
+
(this.element as any)[this.name] = resolved === null || resolved === undefined ? '' : resolved;
|
|
703
|
+
}
|
|
704
|
+
|
|
705
|
+
dispose(): void {
|
|
706
|
+
if (this.unbindSignal) {
|
|
707
|
+
this.unbindSignal();
|
|
708
|
+
this.unbindSignal = undefined;
|
|
709
|
+
}
|
|
710
|
+
this.currentValue = noValue;
|
|
711
|
+
(this.element as any)[this.name] = '';
|
|
712
|
+
}
|
|
713
|
+
}
|
|
714
|
+
|
|
715
|
+
class EventPart implements Part {
|
|
716
|
+
private currentValue: TemplateValue | typeof noValue = noValue;
|
|
717
|
+
private listener?: EventListenerOrEventListenerObject;
|
|
718
|
+
|
|
719
|
+
constructor(
|
|
720
|
+
private element: Element,
|
|
721
|
+
private eventName: string,
|
|
722
|
+
private host?: unknown
|
|
723
|
+
) {}
|
|
724
|
+
|
|
725
|
+
update(value: TemplateValue): void {
|
|
726
|
+
if (Object.is(this.currentValue, value)) {
|
|
727
|
+
return;
|
|
728
|
+
}
|
|
729
|
+
this.currentValue = value;
|
|
730
|
+
if (this.listener) {
|
|
731
|
+
this.element.removeEventListener(this.eventName, this.listener);
|
|
732
|
+
this.listener = undefined;
|
|
733
|
+
}
|
|
734
|
+
if (typeof value === 'function') {
|
|
735
|
+
const handler = value as unknown as (event: Event) => void;
|
|
736
|
+
this.listener = ((event: Event) =>
|
|
737
|
+
handler.call(this.host || this.element, event)) as EventListener;
|
|
738
|
+
this.element.addEventListener(this.eventName, this.listener);
|
|
739
|
+
} else if (isEventListenerObject(value)) {
|
|
740
|
+
this.listener = value;
|
|
741
|
+
this.element.addEventListener(this.eventName, this.listener);
|
|
742
|
+
}
|
|
743
|
+
}
|
|
744
|
+
|
|
745
|
+
dispose(): void {
|
|
746
|
+
this.currentValue = noValue;
|
|
747
|
+
if (this.listener) {
|
|
748
|
+
this.element.removeEventListener(this.eventName, this.listener);
|
|
749
|
+
this.listener = undefined;
|
|
750
|
+
}
|
|
751
|
+
}
|
|
752
|
+
}
|
|
753
|
+
|
|
754
|
+
const resolveValue = (value: TemplateValue): TemplateValue => {
|
|
755
|
+
if (typeof value === 'function' && value.length === 0) {
|
|
756
|
+
return (value as () => TemplateValue)();
|
|
757
|
+
}
|
|
758
|
+
return value;
|
|
759
|
+
};
|
|
760
|
+
|
|
761
|
+
const isEventListenerObject = (value: unknown): value is EventListenerObject => {
|
|
762
|
+
return Boolean(
|
|
763
|
+
value &&
|
|
764
|
+
typeof value === 'object' &&
|
|
765
|
+
typeof (value as EventListenerObject).handleEvent === 'function'
|
|
766
|
+
);
|
|
767
|
+
};
|
|
768
|
+
|
|
769
|
+
const noValue = Symbol('custom-elements-ts-no-value');
|
|
770
|
+
|
|
771
|
+
const insertAfter = (anchor: ChildNode, nodes: ChildNode[]) => {
|
|
772
|
+
let reference = anchor.nextSibling;
|
|
773
|
+
const parent = anchor.parentNode;
|
|
774
|
+
if (!parent) {
|
|
775
|
+
return;
|
|
776
|
+
}
|
|
777
|
+
nodes.forEach((node) => {
|
|
778
|
+
parent.insertBefore(node, reference);
|
|
779
|
+
reference = node.nextSibling;
|
|
780
|
+
});
|
|
781
|
+
};
|
|
782
|
+
|
|
783
|
+
export { ChildPart };
|