@pyreon/runtime-dom 0.12.7 → 0.12.8

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.
@@ -0,0 +1,714 @@
1
+ /**
2
+ * Real-World Integration Tests
3
+ *
4
+ * Tests that simulate real application patterns — multiple features combined.
5
+ * Todo app, form validation, tab component, nested context.
6
+ */
7
+ import type { VNodeChild } from '@pyreon/core'
8
+ import { createContext, For, Fragment, h, onMount, provide, Show, useContext } from '@pyreon/core'
9
+ import { signal } from '@pyreon/reactivity'
10
+ import { mount } from '../index'
11
+
12
+ // ─── Helpers ────────────────────────────────────────────────────────────────
13
+
14
+ function container(): HTMLElement {
15
+ const el = document.createElement('div')
16
+ document.body.appendChild(el)
17
+ return el
18
+ }
19
+
20
+ afterEach(() => {
21
+ document.body.innerHTML = ''
22
+ })
23
+
24
+ // ─── Todo App Pattern ───────────────────────────────────────────────────────
25
+
26
+ describe('real-world — todo app pattern', () => {
27
+ type Todo = {
28
+ id: number
29
+ text: ReturnType<typeof signal<string>>
30
+ completed: ReturnType<typeof signal<boolean>>
31
+ }
32
+
33
+ function createTodo(id: number, text: string, completed = false): Todo {
34
+ return { id, text: signal(text), completed: signal(completed) }
35
+ }
36
+
37
+ const TodoItem = (props: {
38
+ text: () => string
39
+ completed: () => boolean
40
+ onToggle: () => void
41
+ onDelete: () => void
42
+ }) =>
43
+ h('li', { class: () => (props.completed() ? 'done' : 'pending') },
44
+ h('span', { class: 'todo-text' }, () => props.text()),
45
+ h('button', { class: 'toggle', onClick: props.onToggle }, 'toggle'),
46
+ h('button', { class: 'delete', onClick: props.onDelete }, 'delete'),
47
+ )
48
+
49
+ test('render list of todos with For', () => {
50
+ const el = container()
51
+ const todos = signal<Todo[]>([
52
+ createTodo(1, 'Buy milk'),
53
+ createTodo(2, 'Write tests', true),
54
+ createTodo(3, 'Ship feature'),
55
+ ])
56
+
57
+ mount(
58
+ h('ul', null,
59
+ For({
60
+ each: todos,
61
+ by: (t: Todo) => t.id,
62
+ children: (t: Todo) =>
63
+ h(TodoItem, {
64
+ text: () => t.text(),
65
+ completed: () => t.completed(),
66
+ onToggle: () => t.completed.update((c) => !c),
67
+ onDelete: () => todos.update((list) => list.filter((i) => i.id !== t.id)),
68
+ }),
69
+ }),
70
+ ),
71
+ el,
72
+ )
73
+
74
+ expect(el.querySelectorAll('li').length).toBe(3)
75
+ expect(el.querySelectorAll('.todo-text')[0]?.textContent).toBe('Buy milk')
76
+ expect(el.querySelectorAll('.todo-text')[1]?.textContent).toBe('Write tests')
77
+ expect(el.querySelectorAll('li')[1]?.className).toBe('done')
78
+ })
79
+
80
+ test('add a todo -> appears in DOM', () => {
81
+ const el = container()
82
+ const todos = signal<Todo[]>([
83
+ createTodo(1, 'Existing'),
84
+ ])
85
+
86
+ mount(
87
+ h('ul', null,
88
+ For({
89
+ each: todos,
90
+ by: (t: Todo) => t.id,
91
+ children: (t: Todo) =>
92
+ h(TodoItem, {
93
+ text: () => t.text(),
94
+ completed: () => t.completed(),
95
+ onToggle: () => t.completed.update((c) => !c),
96
+ onDelete: () => todos.update((list) => list.filter((i) => i.id !== t.id)),
97
+ }),
98
+ }),
99
+ ),
100
+ el,
101
+ )
102
+
103
+ expect(el.querySelectorAll('li').length).toBe(1)
104
+
105
+ // Add new todo
106
+ todos.update((list) => [...list, createTodo(2, 'New todo')])
107
+ expect(el.querySelectorAll('li').length).toBe(2)
108
+ expect(el.querySelectorAll('.todo-text')[1]?.textContent).toBe('New todo')
109
+ })
110
+
111
+ test('toggle todo complete -> class changes on that item only', () => {
112
+ const el = container()
113
+ const todo1 = createTodo(1, 'First')
114
+ const todo2 = createTodo(2, 'Second')
115
+ const todos = signal<Todo[]>([todo1, todo2])
116
+
117
+ mount(
118
+ h('ul', null,
119
+ For({
120
+ each: todos,
121
+ by: (t: Todo) => t.id,
122
+ children: (t: Todo) =>
123
+ h(TodoItem, {
124
+ text: () => t.text(),
125
+ completed: () => t.completed(),
126
+ onToggle: () => t.completed.update((c) => !c),
127
+ onDelete: () => todos.update((list) => list.filter((i) => i.id !== t.id)),
128
+ }),
129
+ }),
130
+ ),
131
+ el,
132
+ )
133
+
134
+ // Both start as pending
135
+ expect(el.querySelectorAll('li')[0]?.className).toBe('pending')
136
+ expect(el.querySelectorAll('li')[1]?.className).toBe('pending')
137
+
138
+ // Toggle first todo via click
139
+ el.querySelectorAll('.toggle')[0]?.dispatchEvent(new Event('click', { bubbles: true }))
140
+ expect(el.querySelectorAll('li')[0]?.className).toBe('done')
141
+ expect(el.querySelectorAll('li')[1]?.className).toBe('pending')
142
+ })
143
+
144
+ test('delete todo -> removed from DOM, others unchanged', () => {
145
+ const el = container()
146
+ const todos = signal<Todo[]>([
147
+ createTodo(1, 'Keep'),
148
+ createTodo(2, 'Delete me'),
149
+ createTodo(3, 'Also keep'),
150
+ ])
151
+
152
+ mount(
153
+ h('ul', null,
154
+ For({
155
+ each: todos,
156
+ by: (t: Todo) => t.id,
157
+ children: (t: Todo) =>
158
+ h(TodoItem, {
159
+ text: () => t.text(),
160
+ completed: () => t.completed(),
161
+ onToggle: () => t.completed.update((c) => !c),
162
+ onDelete: () => todos.update((list) => list.filter((i) => i.id !== t.id)),
163
+ }),
164
+ }),
165
+ ),
166
+ el,
167
+ )
168
+
169
+ expect(el.querySelectorAll('li').length).toBe(3)
170
+
171
+ // Delete second item via click
172
+ el.querySelectorAll('.delete')[1]?.dispatchEvent(new Event('click', { bubbles: true }))
173
+ expect(el.querySelectorAll('li').length).toBe(2)
174
+ expect(el.querySelectorAll('.todo-text')[0]?.textContent).toBe('Keep')
175
+ expect(el.querySelectorAll('.todo-text')[1]?.textContent).toBe('Also keep')
176
+ })
177
+
178
+ test('edit todo text -> only that text node updates', () => {
179
+ const el = container()
180
+ const todo1 = createTodo(1, 'Original text')
181
+ const todo2 = createTodo(2, 'Other text')
182
+ const todos = signal<Todo[]>([todo1, todo2])
183
+
184
+ mount(
185
+ h('ul', null,
186
+ For({
187
+ each: todos,
188
+ by: (t: Todo) => t.id,
189
+ children: (t: Todo) =>
190
+ h(TodoItem, {
191
+ text: () => t.text(),
192
+ completed: () => t.completed(),
193
+ onToggle: () => t.completed.update((c) => !c),
194
+ onDelete: () => todos.update((list) => list.filter((i) => i.id !== t.id)),
195
+ }),
196
+ }),
197
+ ),
198
+ el,
199
+ )
200
+
201
+ // Edit first todo's text
202
+ todo1.text.set('Edited text')
203
+ expect(el.querySelectorAll('.todo-text')[0]?.textContent).toBe('Edited text')
204
+ // Second todo unchanged
205
+ expect(el.querySelectorAll('.todo-text')[1]?.textContent).toBe('Other text')
206
+ })
207
+ })
208
+
209
+ // ─── Form with Validation ───────────────────────────────────────────────────
210
+
211
+ describe('real-world — form with validation', () => {
212
+ test('render inputs with signals and display updates', () => {
213
+ const el = container()
214
+ const username = signal('')
215
+ const email = signal('')
216
+
217
+ const Form = () =>
218
+ h('form', null,
219
+ h('input', {
220
+ type: 'text',
221
+ class: 'username',
222
+ value: () => username(),
223
+ onInput: (e: Event) => username.set((e.target as HTMLInputElement).value),
224
+ }),
225
+ h('input', {
226
+ type: 'email',
227
+ class: 'email',
228
+ value: () => email(),
229
+ onInput: (e: Event) => email.set((e.target as HTMLInputElement).value),
230
+ }),
231
+ h('p', { class: 'preview' }, () => `${username()} <${email()}>`),
232
+ )
233
+
234
+ mount(h(Form, null), el)
235
+
236
+ expect(el.querySelector('.preview')!.textContent).toBe(' <>')
237
+
238
+ // Simulate typing
239
+ username.set('alice')
240
+ expect(el.querySelector('.preview')!.textContent).toBe('alice <>')
241
+
242
+ email.set('alice@example.com')
243
+ expect(el.querySelector('.preview')!.textContent).toBe('alice <alice@example.com>')
244
+ })
245
+
246
+ test('show/hide error message based on validation signal', () => {
247
+ const el = container()
248
+ const value = signal('')
249
+ const touched = signal(false)
250
+ const hasError = () => touched() && value().length < 3
251
+
252
+ const Field = () =>
253
+ h('div', null,
254
+ h('input', {
255
+ type: 'text',
256
+ value: () => value(),
257
+ onInput: (e: Event) => value.set((e.target as HTMLInputElement).value),
258
+ onBlur: () => touched.set(true),
259
+ }),
260
+ h(Show, { when: hasError },
261
+ h('span', { class: 'error' }, 'Must be at least 3 characters'),
262
+ ),
263
+ )
264
+
265
+ mount(h(Field, null), el)
266
+
267
+ // No error initially (not touched)
268
+ expect(el.querySelector('.error')).toBeNull()
269
+
270
+ // Touch the field with short value
271
+ touched.set(true)
272
+ expect(el.querySelector('.error')).not.toBeNull()
273
+ expect(el.querySelector('.error')!.textContent).toBe('Must be at least 3 characters')
274
+
275
+ // Fix the error
276
+ value.set('valid')
277
+ expect(el.querySelector('.error')).toBeNull()
278
+
279
+ // Make it invalid again
280
+ value.set('ab')
281
+ expect(el.querySelector('.error')).not.toBeNull()
282
+ })
283
+
284
+ test('form submission tracking', () => {
285
+ const el = container()
286
+ const submitting = signal(false)
287
+ const submitted = signal(false)
288
+
289
+ const Form = () =>
290
+ h('div', null,
291
+ h('button', {
292
+ class: 'submit',
293
+ disabled: () => submitting(),
294
+ onClick: () => {
295
+ submitting.set(true)
296
+ // Simulate async submit
297
+ submitted.set(true)
298
+ submitting.set(false)
299
+ },
300
+ }, () => (submitting() ? 'Submitting...' : 'Submit')),
301
+ h(Show, { when: submitted },
302
+ h('p', { class: 'success' }, 'Form submitted successfully!'),
303
+ ),
304
+ )
305
+
306
+ mount(h(Form, null), el)
307
+
308
+ expect(el.querySelector('.success')).toBeNull()
309
+ expect(el.querySelector('.submit')!.textContent).toBe('Submit')
310
+
311
+ // Click submit
312
+ el.querySelector('.submit')!.dispatchEvent(new Event('click', { bubbles: true }))
313
+ expect(el.querySelector('.success')).not.toBeNull()
314
+ expect(el.querySelector('.submit')!.textContent).toBe('Submit')
315
+ })
316
+ })
317
+
318
+ // ─── Tab Component ──────────────────────────────────────────────────────────
319
+
320
+ describe('real-world — tab component', () => {
321
+ test('render tabs and switch between them', () => {
322
+ const el = container()
323
+ const activeTab = signal(0)
324
+
325
+ const TabContent0 = () => h('div', { class: 'tab-content-0' }, 'First tab content')
326
+ const TabContent1 = () => h('div', { class: 'tab-content-1' }, 'Second tab content')
327
+ const TabContent2 = () => h('div', { class: 'tab-content-2' }, 'Third tab content')
328
+
329
+ const Tabs = () =>
330
+ h('div', null,
331
+ h('div', { class: 'tab-bar' },
332
+ h('button', { class: 'tab-0', onClick: () => activeTab.set(0) }, 'Tab 1'),
333
+ h('button', { class: 'tab-1', onClick: () => activeTab.set(1) }, 'Tab 2'),
334
+ h('button', { class: 'tab-2', onClick: () => activeTab.set(2) }, 'Tab 3'),
335
+ ),
336
+ h(Show, { when: () => activeTab() === 0 }, h(TabContent0, null)),
337
+ h(Show, { when: () => activeTab() === 1 }, h(TabContent1, null)),
338
+ h(Show, { when: () => activeTab() === 2 }, h(TabContent2, null)),
339
+ )
340
+
341
+ mount(h(Tabs, null), el)
342
+
343
+ // First tab visible by default
344
+ expect(el.querySelector('.tab-content-0')).not.toBeNull()
345
+ expect(el.querySelector('.tab-content-1')).toBeNull()
346
+ expect(el.querySelector('.tab-content-2')).toBeNull()
347
+
348
+ // Switch to second tab
349
+ el.querySelector('.tab-1')!.dispatchEvent(new Event('click', { bubbles: true }))
350
+ expect(el.querySelector('.tab-content-0')).toBeNull()
351
+ expect(el.querySelector('.tab-content-1')).not.toBeNull()
352
+ expect(el.querySelector('.tab-content-2')).toBeNull()
353
+
354
+ // Switch to third tab
355
+ el.querySelector('.tab-2')!.dispatchEvent(new Event('click', { bubbles: true }))
356
+ expect(el.querySelector('.tab-content-0')).toBeNull()
357
+ expect(el.querySelector('.tab-content-1')).toBeNull()
358
+ expect(el.querySelector('.tab-content-2')).not.toBeNull()
359
+ })
360
+
361
+ test('signal in active tab is reactive', () => {
362
+ const el = container()
363
+ const activeTab = signal(0)
364
+ const counter = signal(0)
365
+
366
+ const CounterTab = () =>
367
+ h('div', { class: 'counter-tab' },
368
+ h('span', { class: 'count' }, () => String(counter())),
369
+ h('button', { class: 'increment', onClick: () => counter.update((n) => n + 1) }, '+'),
370
+ )
371
+
372
+ const OtherTab = () => h('div', { class: 'other-tab' }, 'Other content')
373
+
374
+ const Tabs = () =>
375
+ h('div', null,
376
+ h('button', { class: 'switch', onClick: () => activeTab.update((t) => (t === 0 ? 1 : 0)) }, 'switch'),
377
+ h(Show, { when: () => activeTab() === 0 }, h(CounterTab, null)),
378
+ h(Show, { when: () => activeTab() === 1 }, h(OtherTab, null)),
379
+ )
380
+
381
+ mount(h(Tabs, null), el)
382
+
383
+ // Counter starts at 0
384
+ expect(el.querySelector('.count')!.textContent).toBe('0')
385
+
386
+ // Increment works
387
+ el.querySelector('.increment')!.dispatchEvent(new Event('click', { bubbles: true }))
388
+ expect(el.querySelector('.count')!.textContent).toBe('1')
389
+
390
+ // Switch to other tab
391
+ el.querySelector('.switch')!.dispatchEvent(new Event('click', { bubbles: true }))
392
+ expect(el.querySelector('.counter-tab')).toBeNull()
393
+ expect(el.querySelector('.other-tab')).not.toBeNull()
394
+
395
+ // Switch back — counter remounts with current signal value
396
+ el.querySelector('.switch')!.dispatchEvent(new Event('click', { bubbles: true }))
397
+ expect(el.querySelector('.counter-tab')).not.toBeNull()
398
+ expect(el.querySelector('.count')!.textContent).toBe('1')
399
+ })
400
+
401
+ test('previous tab unmounts when switching', () => {
402
+ const el = container()
403
+ const activeTab = signal(0)
404
+ let mountCount = 0
405
+ let unmountCount = 0
406
+
407
+ const TrackedTab = () => {
408
+ onMount(() => {
409
+ mountCount++
410
+ return () => { unmountCount++ }
411
+ })
412
+ return h('div', { class: 'tracked' }, 'tracked content')
413
+ }
414
+
415
+ const Tabs = () =>
416
+ h('div', null,
417
+ h('button', { class: 'switch', onClick: () => activeTab.update((t) => (t === 0 ? 1 : 0)) }, 'switch'),
418
+ h(Show, { when: () => activeTab() === 0 }, h(TrackedTab, null)),
419
+ h(Show, { when: () => activeTab() === 1 }, h('div', null, 'other')),
420
+ )
421
+
422
+ mount(h(Tabs, null), el)
423
+ expect(mountCount).toBe(1)
424
+ expect(unmountCount).toBe(0)
425
+
426
+ // Switch away — tracked tab unmounts
427
+ el.querySelector('.switch')!.dispatchEvent(new Event('click', { bubbles: true }))
428
+ expect(unmountCount).toBe(1)
429
+
430
+ // Switch back — tracked tab mounts again
431
+ el.querySelector('.switch')!.dispatchEvent(new Event('click', { bubbles: true }))
432
+ expect(mountCount).toBe(2)
433
+ })
434
+ })
435
+
436
+ // ─── Nested Context ─────────────────────────────────────────────────────────
437
+
438
+ describe('real-world — nested context', () => {
439
+ const ThemeCtx = createContext<string>('light')
440
+
441
+ test('parent provides context, child reads it', () => {
442
+ const el = container()
443
+ let childTheme: string | undefined
444
+
445
+ const Child = () => {
446
+ childTheme = useContext(ThemeCtx)
447
+ return h('span', null, childTheme)
448
+ }
449
+
450
+ const Parent = () => {
451
+ provide(ThemeCtx, 'dark')
452
+ return h(Child, null)
453
+ }
454
+
455
+ mount(h(Parent, null), el)
456
+ expect(childTheme).toBe('dark')
457
+ expect(el.querySelector('span')!.textContent).toBe('dark')
458
+ })
459
+
460
+ test('deeply nested child reads ancestor context', () => {
461
+ const el = container()
462
+ let deepTheme: string | undefined
463
+
464
+ const DeepChild = () => {
465
+ deepTheme = useContext(ThemeCtx)
466
+ return h('span', { class: 'deep' }, deepTheme)
467
+ }
468
+
469
+ const Middle = () => h('div', { class: 'middle' }, h(DeepChild, null))
470
+
471
+ const Root = () => {
472
+ provide(ThemeCtx, 'blue')
473
+ return h('div', null, h(Middle, null))
474
+ }
475
+
476
+ mount(h(Root, null), el)
477
+ expect(deepTheme).toBe('blue')
478
+ expect(el.querySelector('.deep')!.textContent).toBe('blue')
479
+ })
480
+
481
+ test('nested providers override parent context', () => {
482
+ const el = container()
483
+ let innerTheme: string | undefined
484
+ let outerTheme: string | undefined
485
+
486
+ const InnerChild = () => {
487
+ innerTheme = useContext(ThemeCtx)
488
+ return h('span', { class: 'inner' }, innerTheme)
489
+ }
490
+
491
+ const OuterChild = () => {
492
+ outerTheme = useContext(ThemeCtx)
493
+ return h('span', { class: 'outer' }, outerTheme)
494
+ }
495
+
496
+ const InnerProvider = () => {
497
+ provide(ThemeCtx, 'red')
498
+ return h(InnerChild, null)
499
+ }
500
+
501
+ const OuterProvider = () => {
502
+ provide(ThemeCtx, 'green')
503
+ return h(Fragment, null,
504
+ h(OuterChild, null),
505
+ h(InnerProvider, null),
506
+ )
507
+ }
508
+
509
+ mount(h(OuterProvider, null), el)
510
+ expect(outerTheme).toBe('green')
511
+ expect(innerTheme).toBe('red')
512
+ expect(el.querySelector('.outer')!.textContent).toBe('green')
513
+ expect(el.querySelector('.inner')!.textContent).toBe('red')
514
+ })
515
+
516
+ test('sibling providers isolate context', () => {
517
+ const el = container()
518
+ let themeA: string | undefined
519
+ let themeB: string | undefined
520
+
521
+ const ChildA = () => {
522
+ themeA = useContext(ThemeCtx)
523
+ return h('span', { class: 'a' }, themeA)
524
+ }
525
+
526
+ const ChildB = () => {
527
+ themeB = useContext(ThemeCtx)
528
+ return h('span', { class: 'b' }, themeB)
529
+ }
530
+
531
+ const ProviderA = () => {
532
+ provide(ThemeCtx, 'alpha')
533
+ return h(ChildA, null)
534
+ }
535
+
536
+ const ProviderB = () => {
537
+ provide(ThemeCtx, 'beta')
538
+ return h(ChildB, null)
539
+ }
540
+
541
+ mount(h(Fragment, null, h(ProviderA, null), h(ProviderB, null)), el)
542
+
543
+ expect(themeA).toBe('alpha')
544
+ expect(themeB).toBe('beta')
545
+ expect(el.querySelector('.a')!.textContent).toBe('alpha')
546
+ expect(el.querySelector('.b')!.textContent).toBe('beta')
547
+ })
548
+
549
+ test('context with Show — context survives reactive boundary', async () => {
550
+ const el = container()
551
+ let childTheme: string | undefined
552
+ const visible = signal(false)
553
+
554
+ const Child = () => {
555
+ childTheme = useContext(ThemeCtx)
556
+ return h('span', { class: 'themed' }, childTheme)
557
+ }
558
+
559
+ const App = () => {
560
+ provide(ThemeCtx, 'purple')
561
+ return h('div', null,
562
+ h(Show, { when: visible }, h(Child, null)),
563
+ )
564
+ }
565
+
566
+ mount(h(App, null), el)
567
+ expect(el.querySelector('.themed')).toBeNull()
568
+
569
+ // Show child — context should be available
570
+ visible.set(true)
571
+ await new Promise((r) => setTimeout(r, 50))
572
+ expect(childTheme).toBe('purple')
573
+ expect(el.querySelector('.themed')!.textContent).toBe('purple')
574
+ })
575
+ })
576
+
577
+ // ─── Complex Composition ────────────────────────────────────────────────────
578
+
579
+ describe('real-world — complex composition', () => {
580
+ test('counter with derived display and reset', () => {
581
+ const el = container()
582
+ const count = signal(0)
583
+
584
+ const Counter = () =>
585
+ h('div', null,
586
+ h('span', { class: 'value' }, () => String(count())),
587
+ h('span', { class: 'doubled' }, () => String(count() * 2)),
588
+ h('span', { class: 'label' }, () => (count() === 0 ? 'zero' : count() > 0 ? 'positive' : 'negative')),
589
+ h('button', { class: 'inc', onClick: () => count.update((n) => n + 1) }, '+'),
590
+ h('button', { class: 'dec', onClick: () => count.update((n) => n - 1) }, '-'),
591
+ h('button', { class: 'reset', onClick: () => count.set(0) }, 'reset'),
592
+ )
593
+
594
+ mount(h(Counter, null), el)
595
+
596
+ expect(el.querySelector('.value')!.textContent).toBe('0')
597
+ expect(el.querySelector('.doubled')!.textContent).toBe('0')
598
+ expect(el.querySelector('.label')!.textContent).toBe('zero')
599
+
600
+ // Increment
601
+ el.querySelector('.inc')!.dispatchEvent(new Event('click', { bubbles: true }))
602
+ expect(el.querySelector('.value')!.textContent).toBe('1')
603
+ expect(el.querySelector('.doubled')!.textContent).toBe('2')
604
+ expect(el.querySelector('.label')!.textContent).toBe('positive')
605
+
606
+ // Increment again
607
+ el.querySelector('.inc')!.dispatchEvent(new Event('click', { bubbles: true }))
608
+ expect(el.querySelector('.value')!.textContent).toBe('2')
609
+ expect(el.querySelector('.doubled')!.textContent).toBe('4')
610
+
611
+ // Decrement 3 times
612
+ el.querySelector('.dec')!.dispatchEvent(new Event('click', { bubbles: true }))
613
+ el.querySelector('.dec')!.dispatchEvent(new Event('click', { bubbles: true }))
614
+ el.querySelector('.dec')!.dispatchEvent(new Event('click', { bubbles: true }))
615
+ expect(el.querySelector('.value')!.textContent).toBe('-1')
616
+ expect(el.querySelector('.label')!.textContent).toBe('negative')
617
+
618
+ // Reset
619
+ el.querySelector('.reset')!.dispatchEvent(new Event('click', { bubbles: true }))
620
+ expect(el.querySelector('.value')!.textContent).toBe('0')
621
+ expect(el.querySelector('.label')!.textContent).toBe('zero')
622
+ })
623
+
624
+ test('filterable list with search', () => {
625
+ const el = container()
626
+ type Item = { id: number; name: string }
627
+
628
+ const allItems: Item[] = [
629
+ { id: 1, name: 'Apple' },
630
+ { id: 2, name: 'Banana' },
631
+ { id: 3, name: 'Cherry' },
632
+ { id: 4, name: 'Apricot' },
633
+ ]
634
+
635
+ const query = signal('')
636
+ const filtered = () =>
637
+ allItems.filter((i) => i.name.toLowerCase().includes(query().toLowerCase()))
638
+
639
+ const SearchList = () =>
640
+ h('div', null,
641
+ h('input', {
642
+ class: 'search',
643
+ value: () => query(),
644
+ onInput: (e: Event) => query.set((e.target as HTMLInputElement).value),
645
+ }),
646
+ h('ul', null,
647
+ For({
648
+ each: filtered,
649
+ by: (i: Item) => i.id,
650
+ children: (i: Item) => h('li', null, i.name),
651
+ }),
652
+ ),
653
+ h('span', { class: 'count' }, () => `${filtered().length} results`),
654
+ )
655
+
656
+ mount(h(SearchList, null), el)
657
+
658
+ expect(el.querySelectorAll('li').length).toBe(4)
659
+ expect(el.querySelector('.count')!.textContent).toBe('4 results')
660
+
661
+ // Filter to "ap" — matches Apple and Apricot
662
+ query.set('ap')
663
+ expect(el.querySelectorAll('li').length).toBe(2)
664
+ expect(el.querySelector('.count')!.textContent).toBe('2 results')
665
+
666
+ // Filter to "ban" — matches Banana
667
+ query.set('ban')
668
+ expect(el.querySelectorAll('li').length).toBe(1)
669
+ expect(el.querySelectorAll('li')[0]?.textContent).toBe('Banana')
670
+
671
+ // Clear filter
672
+ query.set('')
673
+ expect(el.querySelectorAll('li').length).toBe(4)
674
+ })
675
+
676
+ test('dynamic class list based on multiple signals', () => {
677
+ const el = container()
678
+ const active = signal(false)
679
+ const disabled = signal(false)
680
+ const size = signal<'sm' | 'md' | 'lg'>('md')
681
+
682
+ const Button = () =>
683
+ h('button', {
684
+ class: () => [
685
+ 'btn',
686
+ active() && 'btn-active',
687
+ disabled() && 'btn-disabled',
688
+ `btn-${size()}`,
689
+ ].filter(Boolean).join(' '),
690
+ disabled: () => disabled(),
691
+ }, 'Click')
692
+
693
+ mount(h(Button, null), el)
694
+
695
+ const btn = el.querySelector('button')!
696
+ expect(btn.className).toBe('btn btn-md')
697
+
698
+ active.set(true)
699
+ expect(btn.className).toBe('btn btn-active btn-md')
700
+
701
+ size.set('lg')
702
+ expect(btn.className).toBe('btn btn-active btn-lg')
703
+
704
+ disabled.set(true)
705
+ expect(btn.className).toBe('btn btn-active btn-disabled btn-lg')
706
+ expect(btn.disabled).toBe(true)
707
+
708
+ active.set(false)
709
+ disabled.set(false)
710
+ size.set('sm')
711
+ expect(btn.className).toBe('btn btn-sm')
712
+ expect(btn.disabled).toBe(false)
713
+ })
714
+ })