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.
Files changed (82) hide show
  1. package/.eslintrc.json +47 -0
  2. package/.github/workflows/ci.yml +49 -0
  3. package/.prettierrc +7 -0
  4. package/LICENSE +20 -0
  5. package/README.md +279 -37
  6. package/assets/readme-header.png +0 -0
  7. package/assets/social-preview.jpg +0 -0
  8. package/demos/counter/counter.element.html +1 -0
  9. package/demos/counter/counter.element.scss +234 -0
  10. package/demos/counter/counter.element.ts +68 -0
  11. package/demos/counter/index.html +205 -0
  12. package/demos/counter/index.ts +1 -0
  13. package/demos/site/code-example/code-example.element.scss +168 -0
  14. package/demos/site/code-example/code-example.element.ts +88 -0
  15. package/demos/site/event-log/event-log.element.scss +179 -0
  16. package/demos/site/event-log/event-log.element.ts +123 -0
  17. package/demos/site/favicon.svg +14 -0
  18. package/demos/site/index.html +368 -0
  19. package/demos/site/index.ts +19 -0
  20. package/demos/site/llms.txt +53 -0
  21. package/demos/site/message/message.element.scss +75 -0
  22. package/demos/site/message/message.element.ts +76 -0
  23. package/demos/site/og-image.png +0 -0
  24. package/demos/site/scroll-restoration.ts +28 -0
  25. package/demos/site/source-toggle/source-toggle.ts +88 -0
  26. package/demos/site/source-viewer/source-viewer.element.scss +292 -0
  27. package/demos/site/source-viewer/source-viewer.element.ts +138 -0
  28. package/demos/site/source-viewer/sources.generated.ts +11 -0
  29. package/demos/site/styles/site.css +1135 -0
  30. package/demos/site/styles/tokens.css +56 -0
  31. package/demos/site/toast/toast.element.scss +110 -0
  32. package/demos/site/toast/toast.element.ts +63 -0
  33. package/demos/todo-dashboard/index.html +141 -0
  34. package/demos/todo-dashboard/index.ts +4 -0
  35. package/demos/todo-dashboard/todo-dashboard.element.scss +1150 -0
  36. package/demos/todo-dashboard/todo-dashboard.element.ts +333 -0
  37. package/demos/todo-dashboard/todo-filters.element.ts +54 -0
  38. package/demos/todo-dashboard/todo-item.element.ts +127 -0
  39. package/demos/todo-dashboard/todo-stats.element.ts +189 -0
  40. package/package.json +73 -24
  41. package/src/custom-element.ts +206 -0
  42. package/src/index.ts +8 -0
  43. package/src/listen.ts +70 -0
  44. package/src/prop.ts +92 -0
  45. package/src/signal.ts +66 -0
  46. package/src/state.ts +141 -0
  47. package/src/template-runtime.ts +783 -0
  48. package/src/toggle.ts +66 -0
  49. package/src/tsconfig.json +24 -0
  50. package/src/util.ts +33 -0
  51. package/src/watch.ts +14 -0
  52. package/tests/basic.spec.ts +70 -0
  53. package/tests/custom-element.spec.ts +77 -0
  54. package/tests/dispatch.spec.ts +52 -0
  55. package/tests/init.spec.ts +94 -0
  56. package/tests/listen.spec.ts +118 -0
  57. package/tests/map-shallow-state.spec.ts +184 -0
  58. package/tests/prop.spec.ts +118 -0
  59. package/tests/signal.spec.ts +117 -0
  60. package/tests/templating-runtime.spec.ts +575 -0
  61. package/tests/toggle.spec.ts +92 -0
  62. package/tests/watch.spec.ts +183 -0
  63. package/tools/build.js +128 -0
  64. package/tools/bundle.js +167 -0
  65. package/tools/generate-sources.js +76 -0
  66. package/tools/rollup-config.js +70 -0
  67. package/tools/start.js +194 -0
  68. package/tsconfig.json +38 -0
  69. package/vite.config.mts +30 -0
  70. package/bundles/custom-elements-ts.umd.js +0 -359
  71. package/bundles/custom-elements-ts.umd.js.map +0 -1
  72. package/esm2015/custom-elements-ts.js +0 -283
  73. package/esm2015/custom-elements-ts.js.map +0 -1
  74. package/esm5/custom-element.d.ts +0 -12
  75. package/esm5/custom-elements-ts.js +0 -344
  76. package/esm5/custom-elements-ts.js.map +0 -1
  77. package/esm5/index.d.ts +0 -5
  78. package/esm5/listen.d.ts +0 -17
  79. package/esm5/prop.d.ts +0 -2
  80. package/esm5/toggle.d.ts +0 -1
  81. package/esm5/util.d.ts +0 -4
  82. 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 };