ripple 0.2.170 → 0.2.171

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,4 +1,71 @@
1
- import { flushSync, track, effect, bindValue, bindChecked } from 'ripple';
1
+ import {
2
+ flushSync,
3
+ track,
4
+ effect,
5
+ bindValue,
6
+ bindChecked,
7
+ bindIndeterminate,
8
+ bindGroup,
9
+ bindClientWidth,
10
+ bindClientHeight,
11
+ bindOffsetWidth,
12
+ bindOffsetHeight,
13
+ bindContentRect,
14
+ bindContentBoxSize,
15
+ bindBorderBoxSize,
16
+ bindDevicePixelContentBoxSize,
17
+ bindInnerHTML,
18
+ bindInnerText,
19
+ bindTextContent,
20
+ bindNode,
21
+ } from 'ripple';
22
+
23
+ // Mock ResizeObserver for testing
24
+ const resizeObserverCallbacks = new Map<any, ResizeObserverCallback>();
25
+ const observedElements = new Map();
26
+
27
+ function createMockResizeObserver(callback: ResizeObserverCallback) {
28
+ const instance = {
29
+ observe(element: Element, options?: ResizeObserverOptions) {
30
+ observedElements.set(element, options);
31
+ },
32
+ unobserve(element: Element) {
33
+ observedElements.delete(element);
34
+ },
35
+ disconnect() {
36
+ observedElements.clear();
37
+ },
38
+ };
39
+
40
+ resizeObserverCallbacks.set(instance, callback);
41
+ return instance;
42
+ }
43
+
44
+ function triggerResize(element: Element, entry: Partial<ResizeObserverEntry>) {
45
+ const defaultEntry: ResizeObserverEntry = {
46
+ target: element,
47
+ contentRect: entry.contentRect || new DOMRectReadOnly(0, 0, 100, 100),
48
+ borderBoxSize: entry.borderBoxSize || [],
49
+ contentBoxSize: entry.contentBoxSize || [],
50
+ devicePixelContentBoxSize: entry.devicePixelContentBoxSize || [],
51
+ ...entry,
52
+ } as ResizeObserverEntry;
53
+
54
+ // Trigger all callbacks for this element
55
+ for (const [instance, callback] of resizeObserverCallbacks) {
56
+ callback([defaultEntry], instance as any);
57
+ }
58
+ }
59
+
60
+ // Setup ResizeObserver mock
61
+ beforeAll(() => {
62
+ (global as any).ResizeObserver = createMockResizeObserver;
63
+ });
64
+
65
+ afterAll(() => {
66
+ resizeObserverCallbacks.clear();
67
+ observedElements.clear();
68
+ });
2
69
 
3
70
  describe('use value()', () => {
4
71
  it('should update value on input', () => {
@@ -48,6 +115,29 @@ describe('use value()', () => {
48
115
  expect(logs).toEqual(['text changed', 'foo', 'text changed', 'Hello']);
49
116
  });
50
117
 
118
+ it('should update text input element when tracked value changes', () => {
119
+ component App() {
120
+ const text = track('initial');
121
+
122
+ <div>
123
+ <input type="text" {ref bindValue(text)} />
124
+ <button onClick={() => (@text = 'updated')}>{'Update'}</button>
125
+ </div>
126
+ }
127
+ render(App);
128
+ flushSync();
129
+
130
+ const input = container.querySelector('input') as HTMLInputElement;
131
+ const button = container.querySelector('button') as HTMLButtonElement;
132
+
133
+ expect(input.value).toBe('initial');
134
+
135
+ button.click();
136
+ flushSync();
137
+
138
+ expect(input.value).toBe('updated');
139
+ });
140
+
51
141
  it('should update checked on input', () => {
52
142
  const logs: string[] = [];
53
143
 
@@ -72,6 +162,78 @@ describe('use value()', () => {
72
162
  expect(logs).toEqual(['checked changed', false, 'checked changed', true]);
73
163
  });
74
164
 
165
+ it('should update checkbox element when tracked value changes', () => {
166
+ component App() {
167
+ const value = track(false);
168
+
169
+ <div>
170
+ <input type="checkbox" {ref bindChecked(value)} />
171
+ <button onClick={() => (@value = true)}>{'Check'}</button>
172
+ </div>
173
+ }
174
+ render(App);
175
+ flushSync();
176
+
177
+ const input = container.querySelector('input') as HTMLInputElement;
178
+ const button = container.querySelector('button') as HTMLButtonElement;
179
+
180
+ expect(input.checked).toBe(false);
181
+
182
+ button.click();
183
+ flushSync();
184
+
185
+ expect(input.checked).toBe(true);
186
+ });
187
+
188
+ it('should update indeterminate on input', () => {
189
+ const logs: string[] = [];
190
+
191
+ component App() {
192
+ const value = track(false);
193
+
194
+ effect(() => {
195
+ logs.push('indeterminate changed', @value);
196
+ });
197
+
198
+ <input type="checkbox" {ref bindIndeterminate(value)} />
199
+ }
200
+ render(App);
201
+ flushSync();
202
+
203
+ const input = container.querySelector('input') as HTMLInputElement;
204
+ expect(input.indeterminate).toBe(false);
205
+
206
+ input.indeterminate = true;
207
+ input.dispatchEvent(new Event('change', { bubbles: true }));
208
+ flushSync();
209
+
210
+ expect(input.indeterminate).toBe(true);
211
+ expect(logs).toEqual(['indeterminate changed', false, 'indeterminate changed', true]);
212
+ });
213
+
214
+ it('should update checkbox indeterminate element when tracked value changes', () => {
215
+ component App() {
216
+ const value = track(false);
217
+
218
+ <div>
219
+ <input type="checkbox" {ref bindIndeterminate(value)} />
220
+ <button onClick={() => (@value = true)}>{'Set Indeterminate'}</button>
221
+ </div>
222
+ }
223
+ render(App);
224
+ flushSync();
225
+
226
+ const input = container.querySelector('input') as HTMLInputElement;
227
+ const button = container.querySelector('button') as HTMLButtonElement;
228
+
229
+ expect(input.indeterminate).toBe(false);
230
+
231
+ button.click();
232
+ flushSync();
233
+
234
+ expect(input.indeterminate).toBe(true);
235
+ });
236
+
75
237
  it('should update select value on change', () => {
76
238
  const logs: string[] = [];
77
239
 
@@ -100,4 +262,1113 @@ describe('use value()', () => {
100
262
  expect(select.value).toBe('3');
101
263
  expect(logs).toEqual(['select changed', '2', 'select changed', '3']);
102
264
  });
265
+
266
+ it('should update select element when tracked value changes', () => {
267
+ component App() {
268
+ const select = track('1');
269
+
270
+ <div>
271
+ <select {ref bindValue(select)}>
272
+ <option value="1">{'One'}</option>
273
+ <option value="2">{'Two'}</option>
274
+ <option value="3">{'Three'}</option>
275
+ </select>
276
+ <button onClick={() => (@select = '3')}>{'Update'}</button>
277
+ </div>
278
+ }
279
+
280
+ render(App);
281
+ flushSync();
282
+
283
+ const selectEl = container.querySelector('select') as HTMLSelectElement;
284
+ const button = container.querySelector('button') as HTMLButtonElement;
285
+
286
+ expect(selectEl.value).toBe('1');
287
+
288
+ button.click();
289
+ flushSync();
290
+
291
+ expect(selectEl.value).toBe('3');
292
+ });
293
+
294
+ it('should bind checkbox group', () => {
295
+ const logs: string[] = [];
296
+
297
+ component App() {
298
+ const selected = track(['b']);
299
+
300
+ effect(() => {
301
+ logs.push('selected changed', JSON.stringify(@selected));
302
+ });
303
+
304
+ <div>
305
+ <input type="checkbox" value="a" {ref bindGroup(selected)} />
306
+ <input type="checkbox" value="b" {ref bindGroup(selected)} />
307
+ <input type="checkbox" value="c" {ref bindGroup(selected)} />
308
+ </div>
309
+ }
310
+
311
+ render(App);
312
+ flushSync();
313
+
314
+ const inputs = container.querySelectorAll('input') as NodeListOf<HTMLInputElement>;
315
+ expect(inputs[0].checked).toBe(false);
316
+ expect(inputs[1].checked).toBe(true);
317
+ expect(inputs[2].checked).toBe(false);
318
+
319
+ // Check first checkbox
320
+ inputs[0].checked = true;
321
+ inputs[0].dispatchEvent(new Event('change', { bubbles: true }));
322
+ flushSync();
323
+
324
+ expect(logs).toContain('selected changed');
325
+ expect(logs[logs.length - 1]).toBe(JSON.stringify(['b', 'a']));
326
+
327
+ // Uncheck second checkbox
328
+ inputs[1].checked = false;
329
+ inputs[1].dispatchEvent(new Event('change', { bubbles: true }));
330
+ flushSync();
331
+
332
+ expect(logs[logs.length - 1]).toBe(JSON.stringify(['a']));
333
+
334
+ // Check third checkbox
335
+ inputs[2].checked = true;
336
+ inputs[2].dispatchEvent(new Event('change', { bubbles: true }));
337
+ flushSync();
338
+
339
+ expect(logs[logs.length - 1]).toBe(JSON.stringify(['a', 'c']));
340
+ });
341
+
342
+ it('should bind radio group', () => {
343
+ const logs: string[] = [];
344
+
345
+ component App() {
346
+ const selected = track('b');
347
+
348
+ effect(() => {
349
+ logs.push('selected changed', @selected);
350
+ });
351
+
352
+ <div>
353
+ <input type="radio" name="test" value="a" {ref bindGroup(selected)} />
354
+ <input type="radio" name="test" value="b" {ref bindGroup(selected)} />
355
+ <input type="radio" name="test" value="c" {ref bindGroup(selected)} />
356
+ </div>
357
+ }
358
+
359
+ render(App);
360
+ flushSync();
361
+
362
+ const inputs = container.querySelectorAll('input') as NodeListOf<HTMLInputElement>;
363
+ expect(inputs[0].checked).toBe(false);
364
+ expect(inputs[1].checked).toBe(true);
365
+ expect(inputs[2].checked).toBe(false);
366
+
367
+ // Select first radio
368
+ inputs[0].checked = true;
369
+ inputs[0].dispatchEvent(new Event('change', { bubbles: true }));
370
+ flushSync();
371
+
372
+ expect(logs).toContain('selected changed');
373
+ expect(logs[logs.length - 1]).toBe('a');
374
+ expect(inputs[0].checked).toBe(true);
375
+ expect(inputs[1].checked).toBe(false);
376
+ expect(inputs[2].checked).toBe(false);
377
+
378
+ // Select third radio
379
+ inputs[2].checked = true;
380
+ inputs[2].dispatchEvent(new Event('change', { bubbles: true }));
381
+ flushSync();
382
+
383
+ expect(logs[logs.length - 1]).toBe('c');
384
+ expect(inputs[0].checked).toBe(false);
385
+ expect(inputs[1].checked).toBe(false);
386
+ expect(inputs[2].checked).toBe(true);
387
+ });
388
+
389
+ it('should update checkbox group from tracked value change', () => {
390
+ component App() {
391
+ const selected = track(['a']);
392
+
393
+ <div>
394
+ <input type="checkbox" value="a" {ref bindGroup(selected)} />
395
+ <input type="checkbox" value="b" {ref bindGroup(selected)} />
396
+ <input type="checkbox" value="c" {ref bindGroup(selected)} />
397
+ <button onClick={() => (@selected = ['b', 'c'])}>{'Update'}</button>
398
+ </div>
399
+ }
400
+
401
+ render(App);
402
+ flushSync();
403
+
404
+ const inputs = container.querySelectorAll('input') as NodeListOf<HTMLInputElement>;
405
+ const button = container.querySelector('button') as HTMLButtonElement;
406
+
407
+ expect(inputs[0].checked).toBe(true);
408
+ expect(inputs[1].checked).toBe(false);
409
+ expect(inputs[2].checked).toBe(false);
410
+
411
+ button.click();
412
+ flushSync();
413
+
414
+ expect(inputs[0].checked).toBe(false);
415
+ expect(inputs[1].checked).toBe(true);
416
+ expect(inputs[2].checked).toBe(true);
417
+ });
418
+
419
+ it('should update radio group from tracked value change', () => {
420
+ component App() {
421
+ const selected = track('a');
422
+
423
+ <div>
424
+ <input type="radio" name="test" value="a" {ref bindGroup(selected)} />
425
+ <input type="radio" name="test" value="b" {ref bindGroup(selected)} />
426
+ <input type="radio" name="test" value="c" {ref bindGroup(selected)} />
427
+ <button onClick={() => (@selected = 'c')}>{'Update'}</button>
428
+ </div>
429
+ }
430
+
431
+ render(App);
432
+ flushSync();
433
+
434
+ const inputs = container.querySelectorAll('input') as NodeListOf<HTMLInputElement>;
435
+ const button = container.querySelector('button') as HTMLButtonElement;
436
+
437
+ expect(inputs[0].checked).toBe(true);
438
+ expect(inputs[1].checked).toBe(false);
439
+ expect(inputs[2].checked).toBe(false);
440
+
441
+ button.click();
442
+ flushSync();
443
+
444
+ expect(inputs[0].checked).toBe(false);
445
+ expect(inputs[1].checked).toBe(false);
446
+ expect(inputs[2].checked).toBe(true);
447
+ });
448
+
449
+ it('should handle checkbox group with initial empty array', () => {
450
+ component App() {
451
+ const selected = track([]);
452
+
453
+ <div>
454
+ <input type="checkbox" value="a" {ref bindGroup(selected)} />
455
+ <input type="checkbox" value="b" {ref bindGroup(selected)} />
456
+ </div>
457
+ }
458
+
459
+ render(App);
460
+ flushSync();
461
+
462
+ const inputs = container.querySelectorAll('input') as NodeListOf<HTMLInputElement>;
463
+
464
+ expect(inputs[0].checked).toBe(false);
465
+ expect(inputs[1].checked).toBe(false);
466
+
467
+ inputs[0].checked = true;
468
+ inputs[0].dispatchEvent(new Event('change', { bubbles: true }));
469
+ flushSync();
470
+
471
+ expect(inputs[0].checked).toBe(true);
472
+ expect(inputs[1].checked).toBe(false);
473
+ });
474
+
475
+ it('should handle number input type', () => {
476
+ component App() {
477
+ const value = track(42);
478
+
479
+ <input type="number" {ref bindValue(value)} />
480
+ }
481
+
482
+ render(App);
483
+ flushSync();
484
+
485
+ const input = container.querySelector('input') as HTMLInputElement;
486
+ expect(input.value).toBe('42');
487
+
488
+ input.value = '100';
489
+ input.dispatchEvent(new Event('input', { bubbles: true }));
490
+ flushSync();
491
+
492
+ expect(input.value).toBe('100');
493
+ });
494
+
495
+ it('should update number input element when tracked value changes', () => {
496
+ component App() {
497
+ const value = track(10);
498
+
499
+ <div>
500
+ <input type="number" {ref bindValue(value)} />
501
+ <button onClick={() => (@value = 99)}>{'Update'}</button>
502
+ </div>
503
+ }
504
+
505
+ render(App);
506
+ flushSync();
507
+
508
+ const input = container.querySelector('input') as HTMLInputElement;
509
+ const button = container.querySelector('button') as HTMLButtonElement;
510
+
511
+ expect(input.value).toBe('10');
512
+
513
+ button.click();
514
+ flushSync();
515
+
516
+ expect(input.value).toBe('99');
517
+ });
518
+
519
+ it('should handle range input type', () => {
520
+ component App() {
521
+ const value = track(50);
522
+
523
+ <input type="range" min="0" max="100" {ref bindValue(value)} />
524
+ }
525
+
526
+ render(App);
527
+ flushSync();
528
+
529
+ const input = container.querySelector('input') as HTMLInputElement;
530
+ expect(input.value).toBe('50');
531
+
532
+ input.value = '75';
533
+ input.dispatchEvent(new Event('input', { bubbles: true }));
534
+ flushSync();
535
+
536
+ expect(input.value).toBe('75');
537
+ });
538
+
539
+ it('should update range input element when tracked value changes', () => {
540
+ component App() {
541
+ const value = track(25);
542
+
543
+ <div>
544
+ <input type="range" min="0" max="100" {ref bindValue(value)} />
545
+ <button onClick={() => (@value = 80)}>{'Update'}</button>
546
+ </div>
547
+ }
548
+
549
+ render(App);
550
+ flushSync();
551
+
552
+ const input = container.querySelector('input') as HTMLInputElement;
553
+ const button = container.querySelector('button') as HTMLButtonElement;
554
+
555
+ expect(input.value).toBe('25');
556
+
557
+ button.click();
558
+ flushSync();
559
+
560
+ expect(input.value).toBe('80');
561
+ });
562
+
563
+ it('should handle empty number input as null', () => {
564
+ component App() {
565
+ const value = track(null);
566
+
567
+ <input type="number" {ref bindValue(value)} />
568
+ }
569
+
570
+ render(App);
571
+ flushSync();
572
+
573
+ const input = container.querySelector('input') as HTMLInputElement;
574
+ expect(input.value).toBe('');
575
+
576
+ input.value = '';
577
+ input.dispatchEvent(new Event('input', { bubbles: true }));
578
+ flushSync();
579
+
580
+ expect(input.value).toBe('');
581
+ });
582
+
583
+ it('should handle date input type', () => {
584
+ component App() {
585
+ const value = track('2025-11-14');
586
+
587
+ <input type="date" {ref bindValue(value)} />
588
+ }
589
+
590
+ render(App);
591
+ flushSync();
592
+
593
+ const input = container.querySelector('input') as HTMLInputElement;
594
+ expect(input.value).toBe('2025-11-14');
595
+
596
+ input.value = '2025-12-25';
597
+ input.dispatchEvent(new Event('input', { bubbles: true }));
598
+ flushSync();
599
+
600
+ expect(input.value).toBe('2025-12-25');
601
+ });
602
+
603
+ it('should update date input element when tracked value changes', () => {
604
+ component App() {
605
+ const value = track('2025-01-01');
606
+
607
+ <div>
608
+ <input type="date" {ref bindValue(value)} />
609
+ <button onClick={() => (@value = '2025-12-31')}>{'Update'}</button>
610
+ </div>
611
+ }
612
+
613
+ render(App);
614
+ flushSync();
615
+
616
+ const input = container.querySelector('input') as HTMLInputElement;
617
+ const button = container.querySelector('button') as HTMLButtonElement;
618
+
619
+ expect(input.value).toBe('2025-01-01');
620
+
621
+ button.click();
622
+ flushSync();
623
+
624
+ expect(input.value).toBe('2025-12-31');
625
+ });
626
+
627
+ it('should handle select with multiple attribute', () => {
628
+ component App() {
629
+ const selected = track(['2', '3']);
630
+
631
+ <select multiple {ref bindValue(selected)}>
632
+ <option value="1">{'One'}</option>
633
+ <option value="2">{'Two'}</option>
634
+ <option value="3">{'Three'}</option>
635
+ <option value="4">{'Four'}</option>
636
+ </select>
637
+ }
638
+
639
+ render(App);
640
+ flushSync();
641
+
642
+ const select = container.querySelector('select') as HTMLSelectElement;
643
+ const options = select.options;
644
+
645
+ expect(options[0].selected).toBe(false);
646
+ expect(options[1].selected).toBe(true);
647
+ expect(options[2].selected).toBe(true);
648
+ expect(options[3].selected).toBe(false);
649
+
650
+ // Change selection
651
+ options[0].selected = true;
652
+ options[1].selected = false;
653
+ select.dispatchEvent(new Event('change', { bubbles: true }));
654
+ flushSync();
655
+
656
+ expect(options[0].selected).toBe(true);
657
+ expect(options[1].selected).toBe(false);
658
+ expect(options[2].selected).toBe(true);
659
+ });
660
+
661
+ it('should update multiple select element when tracked value changes', () => {
662
+ component App() {
663
+ const selected = track(['1']);
664
+
665
+ <div>
666
+ <select multiple {ref bindValue(selected)}>
667
+ <option value="1">{'One'}</option>
668
+ <option value="2">{'Two'}</option>
669
+ <option value="3">{'Three'}</option>
670
+ <option value="4">{'Four'}</option>
671
+ </select>
672
+ <button onClick={() => (@selected = ['2', '4'])}>{'Update'}</button>
673
+ </div>
674
+ }
675
+
676
+ render(App);
677
+ flushSync();
678
+
679
+ const select = container.querySelector('select') as HTMLSelectElement;
680
+ const button = container.querySelector('button') as HTMLButtonElement;
681
+ const options = select.options;
682
+
683
+ expect(options[0].selected).toBe(true);
684
+ expect(options[1].selected).toBe(false);
685
+ expect(options[2].selected).toBe(false);
686
+ expect(options[3].selected).toBe(false);
687
+
688
+ button.click();
689
+ flushSync();
690
+
691
+ expect(options[0].selected).toBe(false);
692
+ expect(options[1].selected).toBe(true);
693
+ expect(options[2].selected).toBe(false);
694
+ expect(options[3].selected).toBe(true);
695
+ });
696
+
697
+ it('should handle select without initial value and fall back to first option', () => {
698
+ component App() {
699
+ const selected = track(undefined);
700
+
701
+ <select {ref bindValue(selected)}>
702
+ <option value="1">{'One'}</option>
703
+ <option value="2">{'Two'}</option>
704
+ </select>
705
+ }
706
+
707
+ render(App);
708
+ flushSync();
709
+
710
+ const select = container.querySelector('select') as HTMLSelectElement;
711
+ // Should pick up first option when undefined
712
+ expect(select.selectedIndex).toBeGreaterThanOrEqual(0);
713
+ });
714
+
715
+ it('should handle select with disabled options', () => {
716
+ component App() {
717
+ const selected = track(undefined);
718
+
719
+ <select {ref bindValue(selected)}>
720
+ <option value="1" disabled>{'One'}</option>
721
+ <option value="2">{'Two'}</option>
722
+ </select>
723
+ }
724
+
725
+ render(App);
726
+ flushSync();
727
+
728
+ const select = container.querySelector('select') as HTMLSelectElement;
729
+ // Should fall back to first non-disabled option
730
+ expect(select.options[1].selected).toBe(true);
731
+ });
732
+ });
733
+
734
+ describe('bindClientWidth and bindClientHeight', () => {
735
+ it('should bind element clientWidth', () => {
736
+ const logs: number[] = [];
737
+
738
+ component App() {
739
+ const width = track(0);
740
+
741
+ effect(() => {
742
+ logs.push(@width);
743
+ });
744
+
745
+ <div {ref bindClientWidth(width)} />
746
+ }
747
+
748
+ render(App);
749
+ flushSync();
750
+
751
+ const div = container.querySelector('div') as HTMLDivElement;
752
+
753
+ Object.defineProperty(div, 'clientWidth', {
754
+ configurable: true,
755
+ get: () => 200,
756
+ });
757
+
758
+ triggerResize(div, {
759
+ contentRect: new DOMRectReadOnly(0, 0, 200, 100),
760
+ });
761
+ flushSync();
762
+
763
+ expect(logs[logs.length - 1]).toBe(200);
764
+ });
765
+
766
+ it('should bind element clientHeight', () => {
767
+ const logs: number[] = [];
768
+
769
+ component App() {
770
+ const height = track(0);
771
+
772
+ effect(() => {
773
+ logs.push(@height);
774
+ });
775
+
776
+ <div {ref bindClientHeight(height)} />
777
+ }
778
+
779
+ render(App);
780
+ flushSync();
781
+
782
+ const div = container.querySelector('div') as HTMLDivElement;
783
+
784
+ Object.defineProperty(div, 'clientHeight', {
785
+ configurable: true,
786
+ get: () => 150,
787
+ });
788
+
789
+ triggerResize(div, {
790
+ contentRect: new DOMRectReadOnly(0, 0, 100, 150),
791
+ });
792
+ flushSync();
793
+
794
+ expect(logs[logs.length - 1]).toBe(150);
795
+ });
796
+ });
797
+
798
+ describe('bindOffsetWidth and bindOffsetHeight', () => {
799
+ it('should bind element offsetWidth', () => {
800
+ const logs: number[] = [];
801
+
802
+ component App() {
803
+ const width = track(0);
804
+
805
+ effect(() => {
806
+ logs.push(@width);
807
+ });
808
+
809
+ <div {ref bindOffsetWidth(width)} />
810
+ }
811
+
812
+ render(App);
813
+ flushSync();
814
+
815
+ const div = container.querySelector('div') as HTMLDivElement;
816
+
817
+ Object.defineProperty(div, 'offsetWidth', {
818
+ configurable: true,
819
+ get: () => 250,
820
+ });
821
+
822
+ triggerResize(div, {
823
+ contentRect: new DOMRectReadOnly(0, 0, 250, 100),
824
+ });
825
+ flushSync();
826
+
827
+ expect(logs[logs.length - 1]).toBe(250);
828
+ });
829
+
830
+ it('should bind element offsetHeight', () => {
831
+ const logs: number[] = [];
832
+
833
+ component App() {
834
+ const height = track(0);
835
+
836
+ effect(() => {
837
+ logs.push(@height);
838
+ });
839
+
840
+ <div {ref bindOffsetHeight(height)} />
841
+ }
842
+
843
+ render(App);
844
+ flushSync();
845
+
846
+ const div = container.querySelector('div') as HTMLDivElement;
847
+
848
+ Object.defineProperty(div, 'offsetHeight', {
849
+ configurable: true,
850
+ get: () => 175,
851
+ });
852
+
853
+ triggerResize(div, {
854
+ contentRect: new DOMRectReadOnly(0, 0, 100, 175),
855
+ });
856
+ flushSync();
857
+
858
+ expect(logs[logs.length - 1]).toBe(175);
859
+ });
860
+ });
861
+
862
+ describe('bindContentRect', () => {
863
+ it('should bind element contentRect', () => {
864
+ const logs: DOMRectReadOnly[] = [];
865
+
866
+ component App() {
867
+ const rect = track(null);
868
+
869
+ effect(() => {
870
+ if (@rect) logs.push(@rect);
871
+ });
872
+
873
+ <div {ref bindContentRect(rect)} />
874
+ }
875
+
876
+ render(App);
877
+ flushSync();
878
+
879
+ const div = container.querySelector('div') as HTMLDivElement;
880
+
881
+ const mockRect = new DOMRectReadOnly(10, 20, 300, 200);
882
+ triggerResize(div, {
883
+ contentRect: mockRect,
884
+ });
885
+ flushSync();
886
+
887
+ expect(logs.length).toBeGreaterThan(0);
888
+ const lastRect = logs[logs.length - 1];
889
+ expect(lastRect.width).toBe(300);
890
+ expect(lastRect.height).toBe(200);
891
+ });
892
+ });
893
+
894
+ describe('bindContentBoxSize', () => {
895
+ it('should bind element contentBoxSize', () => {
896
+ const logs: any[] = [];
897
+
898
+ component App() {
899
+ const boxSize = track(null);
900
+
901
+ effect(() => {
902
+ if (@boxSize) logs.push(@boxSize);
903
+ });
904
+
905
+ <div {ref bindContentBoxSize(boxSize)} />
906
+ }
907
+
908
+ render(App);
909
+ flushSync();
910
+
911
+ const div = container.querySelector('div') as HTMLDivElement;
912
+
913
+ const mockBoxSize = [
914
+ { blockSize: 200, inlineSize: 300 },
915
+ ];
916
+ triggerResize(div, {
917
+ contentBoxSize: mockBoxSize as any,
918
+ });
919
+ flushSync();
920
+
921
+ expect(logs.length).toBeGreaterThan(0);
922
+ expect(logs[logs.length - 1]).toBe(mockBoxSize);
923
+ });
924
+ });
925
+
926
+ describe('bindBorderBoxSize', () => {
927
+ it('should bind element borderBoxSize', () => {
928
+ const logs: any[] = [];
929
+
930
+ component App() {
931
+ const boxSize = track(null);
932
+
933
+ effect(() => {
934
+ if (@boxSize) logs.push(@boxSize);
935
+ });
936
+
937
+ <div {ref bindBorderBoxSize(boxSize)} />
938
+ }
939
+
940
+ render(App);
941
+ flushSync();
942
+
943
+ const div = container.querySelector('div') as HTMLDivElement;
944
+
945
+ const mockBoxSize = [
946
+ { blockSize: 220, inlineSize: 320 },
947
+ ];
948
+ triggerResize(div, {
949
+ borderBoxSize: mockBoxSize as any,
950
+ });
951
+ flushSync();
952
+
953
+ expect(logs.length).toBeGreaterThan(0);
954
+ expect(logs[logs.length - 1]).toBe(mockBoxSize);
955
+ });
956
+ });
957
+
958
+ describe('bindDevicePixelContentBoxSize', () => {
959
+ it('should bind element devicePixelContentBoxSize', () => {
960
+ const logs: any[] = [];
961
+
962
+ component App() {
963
+ const boxSize = track(null);
964
+
965
+ effect(() => {
966
+ if (@boxSize) logs.push(@boxSize);
967
+ });
968
+
969
+ <div {ref bindDevicePixelContentBoxSize(boxSize)} />
970
+ }
971
+
972
+ render(App);
973
+ flushSync();
974
+
975
+ const div = container.querySelector('div') as HTMLDivElement;
976
+
977
+ const mockBoxSize = [
978
+ { blockSize: 400, inlineSize: 600 },
979
+ ];
980
+ triggerResize(div, {
981
+ devicePixelContentBoxSize: mockBoxSize as any,
982
+ });
983
+ flushSync();
984
+
985
+ expect(logs.length).toBeGreaterThan(0);
986
+ expect(logs[logs.length - 1]).toBe(mockBoxSize);
987
+ });
988
+ });
989
+
990
+ describe('bindInnerHTML', () => {
991
+ it('should bind element innerHTML', () => {
992
+ const logs: string[] = [];
993
+
994
+ component App() {
995
+ const html = track('<strong>Hello</strong>');
996
+
997
+ effect(() => {
998
+ logs.push(@html);
999
+ });
1000
+
1001
+ <div contenteditable="true" {ref bindInnerHTML(html)} />
1002
+ }
1003
+
1004
+ render(App);
1005
+ flushSync();
1006
+
1007
+ const div = container.querySelector('div') as HTMLDivElement;
1008
+ expect(div.innerHTML).toBe('<strong>Hello</strong>');
1009
+
1010
+ div.innerHTML = '<em>World</em>';
1011
+ div.dispatchEvent(new Event('input', { bubbles: true }));
1012
+ flushSync();
1013
+
1014
+ expect(logs[logs.length - 1]).toBe('<em>World</em>');
1015
+ });
1016
+
1017
+ it('should update innerHTML when tracked value changes', () => {
1018
+ component App() {
1019
+ const html = track('<p>Initial</p>');
1020
+
1021
+ <div>
1022
+ <div contenteditable="true" {ref bindInnerHTML(html)} />
1023
+ <button onClick={() => (@html = '<p>Updated</p>')}>{'Update'}</button>
1024
+ </div>
1025
+ }
1026
+
1027
+ render(App);
1028
+ flushSync();
1029
+
1030
+ const div = container.querySelector('div[contenteditable]') as HTMLDivElement;
1031
+ const button = container.querySelector('button') as HTMLButtonElement;
1032
+
1033
+ expect(div.innerHTML).toBe('<p>Initial</p>');
1034
+
1035
+ button.click();
1036
+ flushSync();
1037
+
1038
+ expect(div.innerHTML).toBe('<p>Updated</p>');
1039
+ });
1040
+
1041
+ it('should handle null innerHTML value', () => {
1042
+ component App() {
1043
+ const html = track(null);
1044
+
1045
+ <div contenteditable="true" {ref bindInnerHTML(html)} />
1046
+ }
1047
+
1048
+ render(App);
1049
+ flushSync();
1050
+
1051
+ const div = container.querySelector('div') as HTMLDivElement;
1052
+ // Should set to current innerHTML when null
1053
+ expect(div.innerHTML).toBeDefined();
1054
+ });
1055
+ });
1056
+
1057
+ describe('bindInnerText', () => {
1058
+ it('should bind element innerText', () => {
1059
+ const logs: string[] = [];
1060
+
1061
+ component App() {
1062
+ const text = track('Hello World');
1063
+
1064
+ effect(() => {
1065
+ logs.push(@text);
1066
+ });
1067
+
1068
+ <div contenteditable="true" {ref bindInnerText(text)} />
1069
+ }
1070
+
1071
+ render(App);
1072
+ flushSync();
1073
+
1074
+ const div = container.querySelector('div') as HTMLDivElement;
1075
+ expect(div.innerText).toBe('Hello World');
1076
+
1077
+ div.innerText = 'Goodbye World';
1078
+ div.dispatchEvent(new Event('input', { bubbles: true }));
1079
+ flushSync();
1080
+
1081
+ expect(logs[logs.length - 1]).toBe('Goodbye World');
1082
+ });
1083
+
1084
+ it('should update innerText when tracked value changes', () => {
1085
+ component App() {
1086
+ const text = track('Before');
1087
+
1088
+ <div>
1089
+ <div contenteditable="true" {ref bindInnerText(text)} />
1090
+ <button onClick={() => (@text = 'After')}>{'Update'}</button>
1091
+ </div>
1092
+ }
1093
+
1094
+ render(App);
1095
+ flushSync();
1096
+
1097
+ const div = container.querySelector('div[contenteditable]') as HTMLDivElement;
1098
+ const button = container.querySelector('button') as HTMLButtonElement;
1099
+
1100
+ expect(div.innerText).toBe('Before');
1101
+
1102
+ button.click();
1103
+ flushSync();
1104
+
1105
+ expect(div.innerText).toBe('After');
1106
+ });
1107
+ });
1108
+
1109
+ describe('bindTextContent', () => {
1110
+ it('should bind element textContent', () => {
1111
+ const logs: string[] = [];
1112
+
1113
+ component App() {
1114
+ const text = track('Sample text');
1115
+
1116
+ effect(() => {
1117
+ logs.push(@text);
1118
+ });
1119
+
1120
+ <div contenteditable="true" {ref bindTextContent(text)} />
1121
+ }
1122
+
1123
+ render(App);
1124
+ flushSync();
1125
+
1126
+ const div = container.querySelector('div') as HTMLDivElement;
1127
+ expect(div.textContent).toBe('Sample text');
1128
+
1129
+ div.textContent = 'Modified text';
1130
+ div.dispatchEvent(new Event('input', { bubbles: true }));
1131
+ flushSync();
1132
+
1133
+ expect(logs[logs.length - 1]).toBe('Modified text');
1134
+ });
1135
+
1136
+ it('should update textContent when tracked value changes', () => {
1137
+ component App() {
1138
+ const text = track('Start');
1139
+
1140
+ <div>
1141
+ <div contenteditable="true" {ref bindTextContent(text)} />
1142
+ <button onClick={() => (@text = 'End')}>{'Update'}</button>
1143
+ </div>
1144
+ }
1145
+
1146
+ render(App);
1147
+ flushSync();
1148
+
1149
+ const div = container.querySelector('div[contenteditable]') as HTMLDivElement;
1150
+ const button = container.querySelector('button') as HTMLButtonElement;
1151
+
1152
+ expect(div.textContent).toBe('Start');
1153
+
1154
+ button.click();
1155
+ flushSync();
1156
+
1157
+ expect(div.textContent).toBe('End');
1158
+ });
1159
+
1160
+ it('should handle null textContent value', () => {
1161
+ component App() {
1162
+ const text = track(null);
1163
+
1164
+ <div contenteditable="true" {ref bindTextContent(text)} />
1165
+ }
1166
+
1167
+ render(App);
1168
+ flushSync();
1169
+
1170
+ const div = container.querySelector('div') as HTMLDivElement;
1171
+ // Should set to current textContent when null
1172
+ expect(div.textContent).toBeDefined();
1173
+ });
1174
+ });
1175
+
1176
+ describe('bindNode', () => {
1177
+ it('should update tracked value with element reference', () => {
1178
+ let capturedNode: HTMLElement | null = null;
1179
+
1180
+ component App() {
1181
+ const nodeRef = track(null);
1182
+
1183
+ effect(() => {
1184
+ capturedNode = @nodeRef;
1185
+ });
1186
+
1187
+ <div {ref bindNode(nodeRef)} />
1188
+ }
1189
+
1190
+ render(App);
1191
+ flushSync();
1192
+
1193
+ const div = container.querySelector('div') as HTMLDivElement;
1194
+ expect(capturedNode).toBe(div);
1195
+ });
1196
+
1197
+ it('should allow access to bound element', () => {
1198
+ component App() {
1199
+ const inputRef = track(null);
1200
+
1201
+ <div>
1202
+ <input type="text" {ref bindNode(inputRef)} />
1203
+ <button
1204
+ onClick={() => {
1205
+ if (@inputRef) {
1206
+ @inputRef.value = 'Set by ref';
1207
+ }
1208
+ }}
1209
+ >
1210
+ {'Set Value'}
1211
+ </button>
1212
+ </div>
1213
+ }
1214
+
1215
+ render(App);
1216
+ flushSync();
1217
+
1218
+ const input = container.querySelector('input') as HTMLInputElement;
1219
+ const button = container.querySelector('button') as HTMLButtonElement;
1220
+
1221
+ expect(input.value).toBe('');
1222
+
1223
+ button.click();
1224
+ flushSync();
1225
+
1226
+ expect(input.value).toBe('Set by ref');
1227
+ });
1228
+ });
1229
+
1230
+ describe('error handling', () => {
1231
+ it('should throw error when bindValue receives non-tracked object', () => {
1232
+ expect(() => {
1233
+ component App() {
1234
+ <input type="text" {ref bindValue({ value: 'not tracked' })} />
1235
+ }
1236
+ render(App);
1237
+ }).toThrow('bindValue() argument is not a tracked object');
1238
+ });
1239
+
1240
+ it('should throw error when bindChecked receives non-tracked object', () => {
1241
+ expect(() => {
1242
+ component App() {
1243
+ <input type="checkbox" {ref bindChecked({ value: false })} />
1244
+ }
1245
+ render(App);
1246
+ }).toThrow('bindChecked() argument is not a tracked object');
1247
+ });
1248
+
1249
+ it('should throw error when bindIndeterminate receives non-tracked object', () => {
1250
+ expect(() => {
1251
+ component App() {
1252
+ <input type="checkbox" {ref bindIndeterminate({ value: false })} />
1253
+ }
1254
+ render(App);
1255
+ }).toThrow('bindIndeterminate() argument is not a tracked object');
1256
+ });
1257
+
1258
+ it('should throw error when bindGroup receives non-tracked object', () => {
1259
+ expect(() => {
1260
+ component App() {
1261
+ <input type="checkbox" value="a" {ref bindGroup({ value: [] })} />
1262
+ }
1263
+ render(App);
1264
+ }).toThrow('bindGroup() argument is not a tracked object');
1265
+ });
1266
+
1267
+ it('should throw error when bindClientWidth receives non-tracked object', () => {
1268
+ expect(() => {
1269
+ component App() {
1270
+ <div {ref bindClientWidth({ value: 0 })} />
1271
+ }
1272
+ render(App);
1273
+ }).toThrow('bindClientWidth() argument is not a tracked object');
1274
+ });
1275
+
1276
+ it('should throw error when bindClientHeight receives non-tracked object', () => {
1277
+ expect(() => {
1278
+ component App() {
1279
+ <div {ref bindClientHeight({ value: 0 })} />
1280
+ }
1281
+ render(App);
1282
+ }).toThrow('bindClientHeight() argument is not a tracked object');
1283
+ });
1284
+
1285
+ it('should throw error when bindOffsetWidth receives non-tracked object', () => {
1286
+ expect(() => {
1287
+ component App() {
1288
+ <div {ref bindOffsetWidth({ value: 0 })} />
1289
+ }
1290
+ render(App);
1291
+ }).toThrow('bindOffsetWidth() argument is not a tracked object');
1292
+ });
1293
+
1294
+ it('should throw error when bindOffsetHeight receives non-tracked object', () => {
1295
+ expect(() => {
1296
+ component App() {
1297
+ <div {ref bindOffsetHeight({ value: 0 })} />
1298
+ }
1299
+ render(App);
1300
+ }).toThrow('bindOffsetHeight() argument is not a tracked object');
1301
+ });
1302
+
1303
+ it('should throw error when bindContentRect receives non-tracked object', () => {
1304
+ expect(() => {
1305
+ component App() {
1306
+ <div {ref bindContentRect({ value: null })} />
1307
+ }
1308
+ render(App);
1309
+ }).toThrow('bindContentRect() argument is not a tracked object');
1310
+ });
1311
+
1312
+ it('should throw error when bindContentBoxSize receives non-tracked object', () => {
1313
+ expect(() => {
1314
+ component App() {
1315
+ <div {ref bindContentBoxSize({ value: null })} />
1316
+ }
1317
+ render(App);
1318
+ }).toThrow('bindContentBoxSize() argument is not a tracked object');
1319
+ });
1320
+
1321
+ it('should throw error when bindBorderBoxSize receives non-tracked object', () => {
1322
+ expect(() => {
1323
+ component App() {
1324
+ <div {ref bindBorderBoxSize({ value: null })} />
1325
+ }
1326
+ render(App);
1327
+ }).toThrow('bindBorderBoxSize() argument is not a tracked object');
1328
+ });
1329
+
1330
+ it('should throw error when bindDevicePixelContentBoxSize receives non-tracked object', () => {
1331
+ expect(() => {
1332
+ component App() {
1333
+ <div {ref bindDevicePixelContentBoxSize({ value: null })} />
1334
+ }
1335
+ render(App);
1336
+ }).toThrow('bindDevicePixelContentBoxSize() argument is not a tracked object');
1337
+ });
1338
+
1339
+ it('should throw error when bindInnerHTML receives non-tracked object', () => {
1340
+ expect(() => {
1341
+ component App() {
1342
+ <div {ref bindInnerHTML({ value: '' })} />
1343
+ }
1344
+ render(App);
1345
+ }).toThrow('bindInnerHTML() argument is not a tracked object');
1346
+ });
1347
+
1348
+ it('should throw error when bindInnerText receives non-tracked object', () => {
1349
+ expect(() => {
1350
+ component App() {
1351
+ <div {ref bindInnerText({ value: '' })} />
1352
+ }
1353
+ render(App);
1354
+ }).toThrow('bindInnerText() argument is not a tracked object');
1355
+ });
1356
+
1357
+ it('should throw error when bindTextContent receives non-tracked object', () => {
1358
+ expect(() => {
1359
+ component App() {
1360
+ <div {ref bindTextContent({ value: '' })} />
1361
+ }
1362
+ render(App);
1363
+ }).toThrow('bindTextContent() argument is not a tracked object');
1364
+ });
1365
+
1366
+ it('should throw error when bindNode receives non-tracked object', () => {
1367
+ expect(() => {
1368
+ component App() {
1369
+ <div {ref bindNode({ value: null })} />
1370
+ }
1371
+ render(App);
1372
+ }).toThrow('bindNode() argument is not a tracked object');
1373
+ });
103
1374
  });