@editframe/elements 0.15.0-beta.1 → 0.15.0-beta.3

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.
@@ -1,16 +1,16 @@
1
- import { EFAudio } from "./EFAudio.js";
2
-
3
1
  import { CSSStyleObserver } from "@bramus/style-observer";
4
2
  import { Task } from "@lit/task";
5
3
  import { LitElement, type PropertyValueMap, css, html } from "lit";
6
- import { customElement, property } from "lit/decorators.js";
4
+ import { customElement, property, state } from "lit/decorators.js";
7
5
  import { type Ref, createRef, ref } from "lit/directives/ref.js";
8
6
  import { EF_INTERACTIVE } from "../EF_INTERACTIVE.js";
9
7
  import { EF_RENDERING } from "../EF_RENDERING.js";
10
8
  import { TWMixin } from "../gui/TWMixin.js";
11
9
  import { CrossUpdateController } from "./CrossUpdateController.js";
10
+ import type { EFAudio } from "./EFAudio.js";
12
11
  import { EFTemporal } from "./EFTemporal.js";
13
- import { EFVideo } from "./EFVideo.js";
12
+ import type { EFVideo } from "./EFVideo.js";
13
+ import { TargetController } from "./TargetController.ts";
14
14
 
15
15
  @customElement("ef-waveform")
16
16
  export class EFWaveform extends EFTemporal(TWMixin(LitElement)) {
@@ -45,20 +45,22 @@ export class EFWaveform extends EFTemporal(TWMixin(LitElement)) {
45
45
  type: String,
46
46
  attribute: "mode",
47
47
  })
48
- mode: "roundBars" | "bars" | "bricks" | "line" | "pixel" | "wave" = "bars";
48
+ mode: "roundBars" | "bars" | "bricks" | "line" | "pixel" | "wave" | "spikes" =
49
+ "bars";
49
50
 
50
51
  @property({ type: String })
51
52
  color = "currentColor";
52
53
 
53
- @property({ type: String, attribute: "target", reflect: true })
54
- targetSelector = "";
54
+ @property({ type: String, reflect: true })
55
+ target = "";
56
+
57
+ @state()
58
+ targetElement: EFAudio | EFVideo | null = null;
55
59
 
56
60
  @property({ type: Number, attribute: "line-width" })
57
61
  lineWidth = 4;
58
62
 
59
- set target(value: string) {
60
- this.targetSelector = value;
61
- }
63
+ targetController: TargetController = new TargetController(this);
62
64
 
63
65
  connectedCallback() {
64
66
  super.connectedCallback();
@@ -136,8 +138,6 @@ export class EFWaveform extends EFTemporal(TWMixin(LitElement)) {
136
138
  ctx.reset();
137
139
 
138
140
  // Scale all drawing operations by dpr
139
- ctx.scale(dpr, dpr);
140
-
141
141
  return ctx;
142
142
  }
143
143
 
@@ -145,31 +145,25 @@ export class EFWaveform extends EFTemporal(TWMixin(LitElement)) {
145
145
  const canvas = ctx.canvas;
146
146
  const waveWidth = canvas.width;
147
147
  const waveHeight = canvas.height;
148
- const baseline = waveHeight / 4;
149
148
 
150
- // Calculate bar width with padding
151
149
  const totalBars = frequencyData.length;
152
- const paddingInner = 0.5; // 50% padding between bars
153
- const paddingOuter = 0.01; // 1% padding on edges
154
- const availableWidth = waveWidth * (1 - 2 * paddingOuter);
150
+ const paddingInner = 0.5;
151
+ const paddingOuter = 0.01;
152
+ const availableWidth = waveWidth;
155
153
  const barWidth =
156
154
  availableWidth / (totalBars + (totalBars - 1) * paddingInner);
157
155
 
158
156
  ctx.clearRect(0, 0, waveWidth, waveHeight);
159
-
160
- // Create a single Path2D object for all bars
161
157
  const path = new Path2D();
162
158
 
163
159
  frequencyData.forEach((value, i) => {
164
- const normalizedValue = value / 255;
165
- const height = normalizedValue * (waveHeight / 2);
160
+ const normalizedValue = Math.min((value / 255) * 2, 1);
161
+ const barHeight = normalizedValue * waveHeight;
162
+ const y = (waveHeight - barHeight) / 2;
166
163
  const x = waveWidth * paddingOuter + i * (barWidth * (1 + paddingInner));
167
- const y = baseline - height;
168
-
169
- path.rect(x, y, barWidth, height * 2);
164
+ path.rect(x, y, barWidth, barHeight);
170
165
  });
171
166
 
172
- // Single fill operation for all bars
173
167
  ctx.fill(path);
174
168
  }
175
169
 
@@ -185,11 +179,16 @@ export class EFWaveform extends EFTemporal(TWMixin(LitElement)) {
185
179
 
186
180
  const columnWidth = waveWidth / frequencyData.length;
187
181
  const boxSize = columnWidth * 0.9;
182
+ const verticalGap = boxSize * 0.2; // Add spacing between bricks
183
+ const maxBricks = Math.floor(waveHeight / (boxSize + verticalGap)); // Account for gaps in height calculation
184
+
188
185
  frequencyData.forEach((value, i) => {
189
- const brickHeight = (value / 255) * waveHeight;
190
- for (let j = 0; j <= brickHeight; j++) {
186
+ const normalizedValue = Math.min((value / 255) * 2, 1);
187
+ const brickCount = Math.floor(normalizedValue * maxBricks);
188
+
189
+ for (let j = 0; j < brickCount; j++) {
191
190
  const x = columnWidth * i;
192
- const y = waveHeight - (j * columnWidth + boxSize);
191
+ const y = waveHeight - (j + 1) * (boxSize + verticalGap); // Include gap in position calculation
193
192
  path.rect(x, y, boxSize, boxSize);
194
193
  }
195
194
  });
@@ -204,13 +203,12 @@ export class EFWaveform extends EFTemporal(TWMixin(LitElement)) {
204
203
  const canvas = ctx.canvas;
205
204
  const waveWidth = canvas.width;
206
205
  const waveHeight = canvas.height;
207
- const baseline = waveHeight / 4;
208
206
 
209
207
  // Similar padding calculation as drawBars
210
208
  const totalBars = frequencyData.length;
211
209
  const paddingInner = 0.5;
212
210
  const paddingOuter = 0.01;
213
- const availableWidth = waveWidth * (1 - 2 * paddingOuter);
211
+ const availableWidth = waveWidth;
214
212
  const barWidth =
215
213
  availableWidth / (totalBars + (totalBars - 1) * paddingInner);
216
214
 
@@ -220,13 +218,13 @@ export class EFWaveform extends EFTemporal(TWMixin(LitElement)) {
220
218
  const path = new Path2D();
221
219
 
222
220
  frequencyData.forEach((value, i) => {
223
- const normalizedValue = value / 255;
224
- const height = normalizedValue * (waveHeight / 2);
221
+ const normalizedValue = Math.min((value / 255) * 2, 1);
222
+ const height = normalizedValue * waveHeight; // Use full wave height like in drawBars
225
223
  const x = waveWidth * paddingOuter + i * (barWidth * (1 + paddingInner));
226
- const y = baseline - height;
224
+ const y = (waveHeight - height) / 2; // Center vertically
227
225
 
228
226
  // Add rounded rectangle to path
229
- path.roundRect(x, y, barWidth, height * 2, barWidth / 2);
227
+ path.roundRect(x, y, barWidth, height, barWidth / 2);
230
228
  });
231
229
 
232
230
  // Single fill operation for all bars
@@ -239,9 +237,9 @@ export class EFWaveform extends EFTemporal(TWMixin(LitElement)) {
239
237
  ) {
240
238
  const canvas = ctx.canvas;
241
239
  const waveWidth = canvas.width;
242
- const waveHeight = canvas.height / 2;
243
- const barWidth = (waveWidth / frequencyData.length) * 0.8;
240
+ const waveHeight = canvas.height;
244
241
  const baseline = waveHeight / 2;
242
+ const barWidth = (waveWidth / frequencyData.length) * 0.8;
245
243
 
246
244
  ctx.clearRect(0, 0, waveWidth, waveHeight);
247
245
 
@@ -272,7 +270,7 @@ export class EFWaveform extends EFTemporal(TWMixin(LitElement)) {
272
270
  protected drawLine(ctx: CanvasRenderingContext2D, frequencyData: Uint8Array) {
273
271
  const canvas = ctx.canvas;
274
272
  const waveWidth = canvas.width;
275
- const waveHeight = canvas.height / 2;
273
+ const waveHeight = canvas.height;
276
274
 
277
275
  ctx.clearRect(0, 0, waveWidth, waveHeight);
278
276
 
@@ -300,24 +298,21 @@ export class EFWaveform extends EFTemporal(TWMixin(LitElement)) {
300
298
  ) {
301
299
  const canvas = ctx.canvas;
302
300
  const waveWidth = canvas.width;
303
- const waveHeight = canvas.height / 2;
301
+ const waveHeight = canvas.height;
304
302
  const baseline = waveHeight / 2;
305
303
  const barWidth = waveWidth / frequencyData.length;
306
304
 
307
305
  ctx.clearRect(0, 0, waveWidth, waveHeight);
308
-
309
- // Create a single Path2D object for all pixels
310
306
  const path = new Path2D();
311
307
 
312
308
  frequencyData.forEach((value, i) => {
309
+ const normalizedValue = Math.min((value / 255) * 2, 1); // Updated normalization
313
310
  const x = i * (waveWidth / frequencyData.length);
314
- const barHeight = (value / 255) * baseline;
311
+ const barHeight = normalizedValue * (waveHeight / 2); // Half height since we extend both ways
315
312
  const y = baseline - barHeight;
316
-
317
- path.rect(x, y, barWidth, barHeight * 2);
313
+ path.rect(x, y, barWidth, barHeight * 2); // Double height to extend both ways
318
314
  });
319
315
 
320
- // Single fill operation for all pixels
321
316
  ctx.fill(path);
322
317
  }
323
318
 
@@ -325,54 +320,147 @@ export class EFWaveform extends EFTemporal(TWMixin(LitElement)) {
325
320
  const canvas = ctx.canvas;
326
321
  const waveWidth = canvas.width;
327
322
  const waveHeight = canvas.height;
328
- const baseline = canvas.height / 4;
323
+ const paddingOuter = 0.01;
324
+ const availableWidth = waveWidth * (1 - 2 * paddingOuter);
325
+ const startX = waveWidth * paddingOuter;
326
+
327
+ ctx.clearRect(0, 0, waveWidth, waveHeight);
328
+ const path = new Path2D();
329
+
330
+ // Draw top curve
331
+ const firstValue = Math.min(((frequencyData[0] ?? 0) / 255) * 2, 1);
332
+ const firstY = (waveHeight - firstValue * waveHeight) / 2;
333
+ path.moveTo(startX, firstY);
334
+
335
+ // Draw top half
336
+ frequencyData.forEach((value, i) => {
337
+ const normalizedValue = Math.min((value / 255) * 2, 1);
338
+ const x = startX + (i / (frequencyData.length - 1)) * availableWidth;
339
+ const barHeight = normalizedValue * waveHeight;
340
+ const y = (waveHeight - barHeight) / 2;
341
+
342
+ if (i === 0) {
343
+ path.moveTo(x, y);
344
+ } else {
345
+ const prevX =
346
+ startX + ((i - 1) / (frequencyData.length - 1)) * availableWidth;
347
+ const prevValue = Math.min(((frequencyData[i - 1] ?? 0) / 255) * 2, 1);
348
+ const prevBarHeight = prevValue * waveHeight;
349
+ const prevY = (waveHeight - prevBarHeight) / 2;
350
+ const xc = (prevX + x) / 2;
351
+ const yc = (prevY + y) / 2;
352
+ path.quadraticCurveTo(prevX, prevY, xc, yc);
353
+ }
354
+ });
355
+
356
+ // Draw bottom half
357
+ for (let i = frequencyData.length - 1; i >= 0; i--) {
358
+ const normalizedValue = Math.min(((frequencyData[i] ?? 0) / 255) * 2, 1);
359
+ const x = startX + (i / (frequencyData.length - 1)) * availableWidth;
360
+ const barHeight = normalizedValue * waveHeight;
361
+ const y = (waveHeight + barHeight) / 2;
362
+
363
+ if (i === frequencyData.length - 1) {
364
+ path.lineTo(x, y);
365
+ } else {
366
+ const nextX =
367
+ startX + ((i + 1) / (frequencyData.length - 1)) * availableWidth;
368
+ const nextValue = Math.min(((frequencyData[i + 1] ?? 0) / 255) * 2, 1);
369
+ const nextBarHeight = nextValue * waveHeight;
370
+ const nextY = (waveHeight + nextBarHeight) / 2;
371
+ const xc = (nextX + x) / 2;
372
+ const yc = (nextY + y) / 2;
373
+ path.quadraticCurveTo(nextX, nextY, xc, yc);
374
+ }
375
+ }
376
+
377
+ // Close the path with a smooth curve back to start
378
+ const lastY = (waveHeight + firstValue * waveHeight) / 2;
379
+ const controlX = startX;
380
+ const controlY = (lastY + firstY) / 2;
381
+ path.quadraticCurveTo(controlX, controlY, startX, firstY);
382
+
383
+ ctx.fill(path);
384
+ }
385
+
386
+ protected drawSpikes(
387
+ ctx: CanvasRenderingContext2D,
388
+ frequencyData: Uint8Array,
389
+ ) {
390
+ const canvas = ctx.canvas;
391
+ const waveWidth = canvas.width;
392
+ const waveHeight = canvas.height;
393
+ const paddingOuter = 0.01;
394
+ const availableWidth = waveWidth * (1 - 2 * paddingOuter);
395
+ const startX = waveWidth * paddingOuter;
329
396
 
330
397
  ctx.clearRect(0, 0, waveWidth, waveHeight);
331
398
  const path = new Path2D();
332
- path.moveTo(0, baseline);
333
399
 
334
400
  // Draw top curve
401
+ const firstValue = (frequencyData[0] ?? 0) / 255;
402
+ const firstY = (waveHeight - firstValue * waveHeight) / 2;
403
+ path.moveTo(startX, firstY);
404
+
405
+ // Draw top half
335
406
  frequencyData.forEach((value, i) => {
336
- const normalizedValue = value / 255;
337
- const x = (i / (frequencyData.length - 1)) * waveWidth;
338
- const y = baseline - normalizedValue * (waveHeight / 2);
407
+ const normalizedValue = Math.min((value / 255) * 2, 1);
408
+ const x = startX + (i / (frequencyData.length - 1)) * availableWidth;
409
+ const barHeight = normalizedValue * (waveHeight / 2);
410
+ const y = (waveHeight - barHeight * 2) / 2;
339
411
 
340
412
  if (i === 0) {
341
413
  path.moveTo(x, y);
342
414
  } else {
343
- const prevX = ((i - 1) / (frequencyData.length - 1)) * waveWidth;
415
+ const prevX =
416
+ startX + ((i - 1) / (frequencyData.length - 1)) * availableWidth;
344
417
  const prevValue = (frequencyData[i - 1] ?? 0) / 255;
345
- const prevY = baseline - prevValue * (waveHeight / 2);
418
+ const prevBarHeight = prevValue * (waveHeight / 2);
419
+ const prevY = (waveHeight - prevBarHeight * 2) / 2;
346
420
  const xc = (prevX + x) / 2;
347
421
  const yc = (prevY + y) / 2;
348
422
  path.quadraticCurveTo(prevX, prevY, xc, yc);
349
423
  }
350
424
  });
351
425
 
352
- // Draw bottom curve
426
+ // Draw bottom half
353
427
  for (let i = frequencyData.length - 1; i >= 0; i--) {
354
- const normalizedValue = (frequencyData[i] ?? 0) / 255;
355
- const x = (i / (frequencyData.length - 1)) * waveWidth;
356
- const y = baseline + normalizedValue * (waveHeight / 2);
428
+ const normalizedValue = Math.min(((frequencyData[i] ?? 0) / 255) * 2, 1);
429
+ const x = startX + (i / (frequencyData.length - 1)) * availableWidth;
430
+ const barHeight = normalizedValue * (waveHeight / 2);
431
+ const y = (waveHeight + barHeight * 2) / 2;
357
432
 
358
433
  if (i === frequencyData.length - 1) {
359
434
  path.lineTo(x, y);
360
435
  } else {
361
- const nextX = ((i + 1) / (frequencyData.length - 1)) * waveWidth;
436
+ const nextX =
437
+ startX + ((i + 1) / (frequencyData.length - 1)) * availableWidth;
362
438
  const nextValue = (frequencyData[i + 1] ?? 0) / 255;
363
- const nextY = baseline + nextValue * (waveHeight / 2);
439
+ const nextBarHeight = nextValue * (waveHeight / 2);
440
+ const nextY = (waveHeight + nextBarHeight * 2) / 2;
364
441
  const xc = (nextX + x) / 2;
365
442
  const yc = (nextY + y) / 2;
366
443
  path.quadraticCurveTo(nextX, nextY, xc, yc);
367
444
  }
368
445
  }
369
446
 
447
+ // Close the path with a smooth curve
448
+ const lastY = (waveHeight + firstValue * waveHeight) / 2;
449
+ const controlX = startX;
450
+ const controlY = (lastY + firstY) / 2;
451
+ path.quadraticCurveTo(controlX, controlY, startX, firstY);
452
+
370
453
  ctx.fill(path);
371
454
  }
372
455
 
373
456
  frameTask = new Task(this, {
374
457
  autoRun: EF_INTERACTIVE,
375
- args: () => [this.targetElement?.frequencyDataTask.status] as const,
458
+ args: () => {
459
+ return [
460
+ this.targetElement,
461
+ this.targetElement?.frequencyDataTask.value,
462
+ ] as const;
463
+ },
376
464
  task: async () => {
377
465
  if (!this.targetElement) return;
378
466
  await this.targetElement.frequencyDataTask.taskComplete;
@@ -383,11 +471,15 @@ export class EFWaveform extends EFTemporal(TWMixin(LitElement)) {
383
471
  const frequencyData = this.targetElement.frequencyDataTask.value;
384
472
  if (!frequencyData) return;
385
473
 
474
+ ctx.save();
386
475
  if (this.color === "currentColor") {
387
476
  const computedStyle = getComputedStyle(this);
388
477
  const currentColor = computedStyle.color;
389
478
  ctx.strokeStyle = currentColor;
390
479
  ctx.fillStyle = currentColor;
480
+ } else {
481
+ ctx.strokeStyle = this.color;
482
+ ctx.fillStyle = this.color;
391
483
  }
392
484
 
393
485
  switch (this.mode) {
@@ -406,10 +498,15 @@ export class EFWaveform extends EFTemporal(TWMixin(LitElement)) {
406
498
  case "wave":
407
499
  this.drawWave(ctx, frequencyData);
408
500
  break;
501
+ case "spikes":
502
+ this.drawSpikes(ctx, frequencyData);
503
+ break;
409
504
  case "roundBars":
410
505
  this.drawRoundBars(ctx, frequencyData);
411
506
  break;
412
507
  }
508
+
509
+ ctx.restore();
413
510
  },
414
511
  });
415
512
 
@@ -418,14 +515,6 @@ export class EFWaveform extends EFTemporal(TWMixin(LitElement)) {
418
515
  return this.targetElement.durationMs;
419
516
  }
420
517
 
421
- get targetElement() {
422
- const target = document.getElementById(this.targetSelector ?? "");
423
- if (target instanceof EFAudio || target instanceof EFVideo) {
424
- return target;
425
- }
426
- return null;
427
- }
428
-
429
518
  protected updated(changedProperties: PropertyValueMap<this>): void {
430
519
  super.updated(changedProperties);
431
520
 
@@ -436,3 +525,9 @@ export class EFWaveform extends EFTemporal(TWMixin(LitElement)) {
436
525
  }
437
526
  }
438
527
  }
528
+
529
+ declare global {
530
+ interface HTMLElementTagNameMap {
531
+ "ef-waveform": EFWaveform & Element;
532
+ }
533
+ }
@@ -0,0 +1,229 @@
1
+ import { LitElement, html } from "lit";
2
+ import { customElement, property, state } from "lit/decorators.js";
3
+ import { afterEach, describe, expect, test } from "vitest";
4
+ import { EFTargetable, TargetController } from "./TargetController.ts";
5
+
6
+ let id = 0;
7
+
8
+ const nextId = () => {
9
+ return `targetable-test-${id++}`;
10
+ };
11
+
12
+ @customElement("targetable-test")
13
+ class TargetableTest extends EFTargetable(LitElement) {
14
+ @property()
15
+ value = "initial";
16
+
17
+ render() {
18
+ return html`<div>${this.value}</div>`;
19
+ }
20
+ }
21
+
22
+ @customElement("targeter-test")
23
+ class TargeterTest extends LitElement {
24
+ // @ts-expect-error this controller is needed, but never referenced
25
+ private targetController: TargetController = new TargetController(this);
26
+
27
+ @state()
28
+ targetElement: Element | null = null;
29
+
30
+ @property()
31
+ target = "";
32
+
33
+ render() {
34
+ const target = this.targetElement;
35
+ return html`
36
+ <div>
37
+ ${target ? html`Found: ${target.tagName}` : html`Finding target...`}
38
+ </div>
39
+ `;
40
+ }
41
+ }
42
+
43
+ describe("target", () => {
44
+ afterEach(() => {
45
+ // Clean up all test elements from the document body
46
+ document.body.innerHTML = "";
47
+ });
48
+
49
+ test("should be able to get the target element", async () => {
50
+ const target = document.createElement("targetable-test");
51
+ const element = document.createElement("targeter-test");
52
+ document.body.appendChild(target);
53
+ document.body.appendChild(element);
54
+
55
+ const id = nextId();
56
+ target.id = id;
57
+ element.target = id;
58
+
59
+ await element.updateComplete;
60
+ expect(element.targetElement).toBe(target);
61
+ });
62
+
63
+ test("should update when document changes", async () => {
64
+ const target = document.createElement("targetable-test");
65
+ const element = document.createElement("targeter-test");
66
+ document.body.appendChild(element);
67
+
68
+ const id = nextId();
69
+ element.target = id;
70
+
71
+ expect(element.targetElement).toBe(null);
72
+
73
+ target.id = id;
74
+ document.body.appendChild(target);
75
+ await element.updateComplete;
76
+ expect(element.targetElement).toBe(target);
77
+ });
78
+
79
+ test("should update when attribute changes", async () => {
80
+ const target = document.createElement("targetable-test");
81
+ const element = document.createElement("targeter-test");
82
+ document.body.appendChild(element);
83
+ document.body.appendChild(target);
84
+
85
+ const id = nextId();
86
+ target.id = id;
87
+ element.target = id;
88
+
89
+ await element.updateComplete;
90
+ expect(element.targetElement).toBe(target);
91
+
92
+ target.id = nextId();
93
+ await element.updateComplete;
94
+ expect(element.targetElement).toBe(null);
95
+ });
96
+
97
+ test("should update when target is set before id exists", async () => {
98
+ const target = document.createElement("targetable-test");
99
+ const element = document.createElement("targeter-test");
100
+ document.body.appendChild(target);
101
+ document.body.appendChild(element);
102
+
103
+ const id = nextId();
104
+ element.target = id;
105
+ expect(element.targetElement).toBe(null);
106
+
107
+ target.id = id;
108
+ await element.updateComplete;
109
+ expect(element.targetElement).toBe(target);
110
+ });
111
+
112
+ test("should update when target changes to match existing id", async () => {
113
+ const target = document.createElement("targetable-test");
114
+ const element = document.createElement("targeter-test");
115
+ document.body.appendChild(target);
116
+ document.body.appendChild(element);
117
+
118
+ const id = nextId();
119
+ target.id = id;
120
+ expect(element.targetElement).toBe(null);
121
+
122
+ element.target = id;
123
+ await element.updateComplete;
124
+ expect(element.targetElement).toBe(target);
125
+ });
126
+
127
+ test("should handle target being cleared", async () => {
128
+ const target = document.createElement("targetable-test");
129
+ const element = document.createElement("targeter-test");
130
+ document.body.appendChild(target);
131
+ document.body.appendChild(element);
132
+
133
+ const id = nextId();
134
+ target.id = id;
135
+ element.target = id;
136
+
137
+ await element.updateComplete;
138
+ expect(element.targetElement).toBe(target);
139
+
140
+ element.target = "";
141
+ await element.updateComplete;
142
+ expect(element.targetElement).toBe(null);
143
+ });
144
+
145
+ test("should handle multiple elements targeting the same id", async () => {
146
+ const target = document.createElement("targetable-test");
147
+ const element1 = document.createElement("targeter-test");
148
+ const element2 = document.createElement("targeter-test");
149
+ document.body.appendChild(target);
150
+ document.body.appendChild(element1);
151
+ document.body.appendChild(element2);
152
+
153
+ const id = nextId();
154
+ target.id = id;
155
+ element1.target = id;
156
+ element2.target = id;
157
+
158
+ await Promise.all([element1.updateComplete, element2.updateComplete]);
159
+ expect(element1.targetElement).toBe(target);
160
+ expect(element2.targetElement).toBe(target);
161
+ });
162
+
163
+ test("should handle element removal from DOM", async () => {
164
+ const target = document.createElement("targetable-test");
165
+ const element = document.createElement("targeter-test");
166
+ document.body.appendChild(target);
167
+ document.body.appendChild(element);
168
+
169
+ const id = nextId();
170
+ target.id = id;
171
+ element.target = id;
172
+
173
+ await element.updateComplete;
174
+ expect(element.targetElement).toBe(target);
175
+
176
+ document.body.removeChild(target);
177
+ await element.updateComplete;
178
+ expect(element.targetElement).toBe(null);
179
+ });
180
+
181
+ test("should handle rapid target id changes", async () => {
182
+ const target = document.createElement("targetable-test");
183
+ const element = document.createElement("targeter-test");
184
+ document.body.appendChild(target);
185
+ document.body.appendChild(element);
186
+
187
+ const id1 = nextId();
188
+ const id2 = nextId();
189
+ const id3 = nextId();
190
+
191
+ target.id = id1;
192
+ element.target = id1;
193
+ await element.updateComplete;
194
+ expect(element.targetElement).toBe(target);
195
+
196
+ target.id = id2;
197
+ target.id = id3; // Immediately change again
198
+ await element.updateComplete;
199
+ expect(element.targetElement).toBe(null);
200
+ });
201
+
202
+ test("should not trigger unnecessary updates when setting same id multiple times", async () => {
203
+ const target = document.createElement("targetable-test");
204
+ const element = document.createElement("targeter-test");
205
+ document.body.appendChild(target);
206
+ document.body.appendChild(element);
207
+
208
+ const id = nextId();
209
+ target.id = id;
210
+ element.target = id;
211
+
212
+ await element.updateComplete;
213
+ expect(element.targetElement).toBe(target);
214
+
215
+ // Set the same ID again
216
+ target.id = id;
217
+ await element.updateComplete;
218
+
219
+ // The target element should remain stable
220
+ expect(element.targetElement).toBe(target);
221
+ });
222
+ });
223
+
224
+ declare global {
225
+ interface HTMLElementTagNameMap {
226
+ "targetable-test": TargetableTest & Element;
227
+ "targeter-test": TargeterTest & Element;
228
+ }
229
+ }