zephyr_rb 1.0.0 → 1.0.1b

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.
data/src/components.rb ADDED
@@ -0,0 +1,934 @@
1
+ # frozen_string_literal: true
2
+ require "js"
3
+
4
+ # =============================================================================
5
+ # BUTTON COMPONENT
6
+ # =============================================================================
7
+ ZephyrWasm.component('x-button') do
8
+ observed_attributes :label, :disabled, :variant
9
+
10
+ on_connect do
11
+ set_state(:label, self['label'] || '')
12
+ set_state(:disabled, self['disabled'] == 'true')
13
+ set_state(:variant, self['variant'] || 'default')
14
+ end
15
+
16
+ template do |b|
17
+ comp = self
18
+ label = comp.state[:label] || comp['label'] || ''
19
+ disabled = comp.state[:disabled]
20
+ variant = comp.state[:variant] || 'default'
21
+
22
+ bg_color = case variant
23
+ when 'primary' then '#4f46e5'
24
+ when 'danger' then '#ef4444'
25
+ when 'success' then '#10b981'
26
+ else '#ffffff'
27
+ end
28
+
29
+ text_color = variant == 'default' ? '#000000' : '#ffffff'
30
+ border = variant == 'default' ? '1px solid #e5e7eb' : 'none'
31
+
32
+ b.button(
33
+ disabled: disabled,
34
+ style: {
35
+ padding: '6px 10px',
36
+ border_radius: '8px',
37
+ border: border,
38
+ background: bg_color,
39
+ color: text_color,
40
+ cursor: disabled ? 'not-allowed' : 'pointer',
41
+ opacity: disabled ? '0.6' : '1',
42
+ font_size: '14px',
43
+ font_weight: '500'
44
+ },
45
+ on_click: ->(e) {
46
+ return if comp.state[:disabled]
47
+ event = JS.global[:CustomEvent].new(
48
+ 'button-click',
49
+ { bubbles: true, composed: true }.to_js
50
+ )
51
+ comp.element.call(:dispatchEvent, event)
52
+ }
53
+ ) { b.text(label) }
54
+ end
55
+ end
56
+
57
+ # =============================================================================
58
+ # INPUT COMPONENT
59
+ # =============================================================================
60
+ ZephyrWasm.component('x-input') do
61
+ observed_attributes :value, :type, :placeholder, :required, :disabled, :error, :label
62
+
63
+ on_connect do
64
+ set_state(:value, self['value'] || '')
65
+ set_state(:type, self['type'] || 'text')
66
+ set_state(:error, self['error'])
67
+ end
68
+
69
+ template do |b|
70
+ comp = self
71
+ value = comp.state[:value] || ''
72
+ input_type = comp.state[:type] || 'text'
73
+ placeholder = comp['placeholder'] || ''
74
+ disabled = comp.hasAttribute('disabled')
75
+ error = comp.state[:error] || comp['error']
76
+ label_text = comp['label']
77
+
78
+ b.div(style: { display: 'block', margin_bottom: error ? '4px' : '12px' }) do
79
+ # Label
80
+ if label_text
81
+ b.tag(:label, style: {
82
+ display: 'block',
83
+ margin_bottom: '4px',
84
+ font_size: '14px',
85
+ font_weight: '500',
86
+ color: '#374151'
87
+ }) { b.text(label_text) }
88
+ end
89
+
90
+ # Input
91
+ b.tag(:input,
92
+ type: input_type,
93
+ value: value,
94
+ placeholder: placeholder,
95
+ disabled: disabled,
96
+ style: {
97
+ width: '100%',
98
+ padding: '8px 12px',
99
+ border: error ? '1px solid #ef4444' : '1px solid #e5e7eb',
100
+ border_radius: '6px',
101
+ font_size: '14px',
102
+ outline: 'none',
103
+ background: disabled ? '#f9fafb' : 'white',
104
+ color: disabled ? '#9ca3af' : 'black',
105
+ box_sizing: 'border-box'
106
+ },
107
+ on_input: ->(e) {
108
+ new_value = e[:target][:value].to_s
109
+ comp.set_state(:value, new_value)
110
+ comp['value'] = new_value
111
+
112
+ event = JS.global[:CustomEvent].new(
113
+ 'input-change',
114
+ { bubbles: true, composed: true, detail: { value: new_value }.to_js }.to_js
115
+ )
116
+ comp.element.call(:dispatchEvent, event)
117
+ }
118
+ )
119
+
120
+ # Error message
121
+ if error && !error.empty?
122
+ b.div(style: {
123
+ font_size: '12px',
124
+ color: '#ef4444',
125
+ margin_top: '4px'
126
+ }) { b.text(error) }
127
+ end
128
+ end
129
+ end
130
+ end
131
+
132
+ # =============================================================================
133
+ # SELECT COMPONENT
134
+ # =============================================================================
135
+ ZephyrWasm.component('x-select') do
136
+ observed_attributes :value, :options, :placeholder, :disabled, :label
137
+
138
+ on_connect do
139
+ set_state(:value, self['value'] || '')
140
+ set_state(:options, parse_options)
141
+ end
142
+
143
+ def parse_options
144
+ options_attr = self['options']
145
+ return [] unless options_attr
146
+
147
+ begin
148
+ JSON.parse(options_attr)
149
+ rescue
150
+ []
151
+ end
152
+ end
153
+
154
+ template do |b|
155
+ comp = self
156
+ value = comp.state[:value] || ''
157
+ options = comp.state[:options] || []
158
+ placeholder = comp['placeholder']
159
+ disabled = comp.hasAttribute('disabled')
160
+ label_text = comp['label']
161
+
162
+ b.div(style: { display: 'block', margin_bottom: '12px' }) do
163
+ # Label
164
+ if label_text
165
+ b.tag(:label, style: {
166
+ display: 'block',
167
+ margin_bottom: '4px',
168
+ font_size: '14px',
169
+ font_weight: '500',
170
+ color: '#374151'
171
+ }) { b.text(label_text) }
172
+ end
173
+
174
+ # Select
175
+ b.tag(:select,
176
+ disabled: disabled,
177
+ style: {
178
+ width: '100%',
179
+ padding: '8px 12px',
180
+ border: '1px solid #e5e7eb',
181
+ border_radius: '6px',
182
+ font_size: '14px',
183
+ outline: 'none',
184
+ background: disabled ? '#f9fafb' : 'white',
185
+ color: disabled ? '#9ca3af' : 'black',
186
+ cursor: disabled ? 'not-allowed' : 'pointer',
187
+ box_sizing: 'border-box'
188
+ },
189
+ on_change: ->(e) {
190
+ new_value = e[:target][:value].to_s
191
+ comp.set_state(:value, new_value)
192
+ comp['value'] = new_value
193
+
194
+ event = JS.global[:CustomEvent].new(
195
+ 'select-change',
196
+ { bubbles: true, composed: true, detail: { value: new_value }.to_js }.to_js
197
+ )
198
+ comp.element.call(:dispatchEvent, event)
199
+ }
200
+ ) do
201
+ # Placeholder option
202
+ if placeholder
203
+ b.tag(:option, value: '', disabled: true, selected: value.empty?) do
204
+ b.text(placeholder)
205
+ end
206
+ end
207
+
208
+ # Options
209
+ options.each do |opt|
210
+ opt_value = opt['value'] || opt[:value]
211
+ opt_label = opt['label'] || opt[:label] || opt_value
212
+ b.tag(:option, value: opt_value, selected: opt_value == value) do
213
+ b.text(opt_label)
214
+ end
215
+ end
216
+ end
217
+ end
218
+ end
219
+ end
220
+
221
+ # =============================================================================
222
+ # CHECKBOX COMPONENT
223
+ # =============================================================================
224
+ ZephyrWasm.component('x-checkbox') do
225
+ observed_attributes :checked, :label, :disabled
226
+
227
+ on_connect do
228
+ set_state(:checked, self['checked'] == 'true')
229
+ end
230
+
231
+ template do |b|
232
+ comp = self
233
+ checked = comp.state[:checked]
234
+ label_text = comp['label'] || ''
235
+ disabled = comp.hasAttribute('disabled')
236
+
237
+ b.div(style: {
238
+ display: 'flex',
239
+ align_items: 'center',
240
+ gap: '8px',
241
+ margin_bottom: '12px'
242
+ }) do
243
+ b.tag(:input,
244
+ type: 'checkbox',
245
+ checked: checked,
246
+ disabled: disabled,
247
+ style: {
248
+ width: '16px',
249
+ height: '16px',
250
+ cursor: disabled ? 'not-allowed' : 'pointer'
251
+ },
252
+ on_change: ->(e) {
253
+ new_checked = !!e[:target][:checked]
254
+ comp.set_state(:checked, new_checked)
255
+ comp['checked'] = new_checked.to_s
256
+
257
+ event = JS.global[:CustomEvent].new(
258
+ 'checkbox-change',
259
+ { bubbles: true, composed: true, detail: { checked: new_checked }.to_js }.to_js
260
+ )
261
+ comp.element.call(:dispatchEvent, event)
262
+ }
263
+ )
264
+
265
+ if label_text && !label_text.empty?
266
+ b.tag(:label, style: {
267
+ font_size: '14px',
268
+ color: disabled ? '#9ca3af' : '#374151',
269
+ cursor: disabled ? 'not-allowed' : 'pointer',
270
+ user_select: 'none'
271
+ }) { b.text(label_text) }
272
+ end
273
+ end
274
+ end
275
+ end
276
+
277
+ # =============================================================================
278
+ # TEXTAREA COMPONENT
279
+ # =============================================================================
280
+ ZephyrWasm.component('x-textarea') do
281
+ observed_attributes :value, :placeholder, :rows, :disabled, :label
282
+
283
+ on_connect do
284
+ set_state(:value, self['value'] || '')
285
+ end
286
+
287
+ template do |b|
288
+ comp = self
289
+ value = comp.state[:value] || ''
290
+ placeholder = comp['placeholder'] || ''
291
+ rows = (comp['rows'] || '3').to_i
292
+ disabled = comp.hasAttribute('disabled')
293
+ label_text = comp['label']
294
+
295
+ b.div(style: { display: 'block', margin_bottom: '12px' }) do
296
+ # Label
297
+ if label_text
298
+ b.tag(:label, style: {
299
+ display: 'block',
300
+ margin_bottom: '4px',
301
+ font_size: '14px',
302
+ font_weight: '500',
303
+ color: '#374151'
304
+ }) { b.text(label_text) }
305
+ end
306
+
307
+ # Textarea
308
+ b.tag(:textarea,
309
+ placeholder: placeholder,
310
+ disabled: disabled,
311
+ style: {
312
+ width: '100%',
313
+ padding: '8px 12px',
314
+ border: '1px solid #e5e7eb',
315
+ border_radius: '6px',
316
+ font_size: '14px',
317
+ outline: 'none',
318
+ background: disabled ? '#f9fafb' : 'white',
319
+ color: disabled ? '#9ca3af' : 'black',
320
+ font_family: 'inherit',
321
+ resize: 'vertical',
322
+ min_height: "#{rows * 24}px",
323
+ box_sizing: 'border-box'
324
+ },
325
+ on_input: ->(e) {
326
+ new_value = e[:target][:value].to_s
327
+ comp.set_state(:value, new_value)
328
+ comp['value'] = new_value
329
+
330
+ event = JS.global[:CustomEvent].new(
331
+ 'textarea-change',
332
+ { bubbles: true, composed: true, detail: { value: new_value }.to_js }.to_js
333
+ )
334
+ comp.element.call(:dispatchEvent, event)
335
+ }
336
+ ) { b.text(value) }
337
+ end
338
+ end
339
+ end
340
+
341
+ # =============================================================================
342
+ # CARD COMPONENT
343
+ # =============================================================================
344
+ ZephyrWasm.component('x-card') do
345
+ observed_attributes :variant, :elevated, :bordered
346
+
347
+ on_connect do
348
+ set_state(:variant, self['variant'] || 'default')
349
+ end
350
+
351
+ template do |b|
352
+ comp = self
353
+ variant = comp.state[:variant] || 'default'
354
+ elevated = comp.hasAttribute('elevated')
355
+ bordered = comp.hasAttribute('bordered')
356
+
357
+ border_style = case variant
358
+ when 'primary' then '2px solid #3b82f6'
359
+ when 'success' then '2px solid #10b981'
360
+ when 'warning' then '2px solid #f59e0b'
361
+ when 'danger' then '2px solid #ef4444'
362
+ else bordered ? '1px solid #e5e7eb' : 'none'
363
+ end
364
+
365
+ bg_color = case variant
366
+ when 'primary' then '#eff6ff'
367
+ when 'success' then '#ecfdf5'
368
+ when 'warning' then '#fffbeb'
369
+ when 'danger' then '#fef2f2'
370
+ else '#ffffff'
371
+ end
372
+
373
+ b.div(style: {
374
+ background: bg_color,
375
+ border: border_style,
376
+ border_radius: '8px',
377
+ padding: '20px',
378
+ box_shadow: elevated ? '0 4px 6px -1px rgba(0, 0, 0, 0.1)' : 'none',
379
+ transition: 'all 0.2s ease'
380
+ }) do
381
+ # Render slotted content
382
+ b.tag(:slot)
383
+ end
384
+ end
385
+ end
386
+
387
+ # =============================================================================
388
+ # TABS COMPONENT
389
+ # =============================================================================
390
+ ZephyrWasm.component('x-tabs') do
391
+ observed_attributes :active
392
+
393
+ on_connect do
394
+ set_state(:active, self['active'] || 'tab1')
395
+ set_state(:tabs, [
396
+ { id: 'tab1', label: 'Tab 1', content: 'Content for Tab 1' },
397
+ { id: 'tab2', label: 'Tab 2', content: 'Content for Tab 2' },
398
+ { id: 'tab3', label: 'Tab 3', content: 'Content for Tab 3' }
399
+ ])
400
+ end
401
+
402
+ template do |b|
403
+ comp = self
404
+ active = comp.state[:active]
405
+ tabs = comp.state[:tabs] || []
406
+
407
+ b.div(style: { display: 'flex', flex_direction: 'column', width: '100%' }) do
408
+ # Tab list
409
+ b.div(
410
+ role: 'tablist',
411
+ style: {
412
+ display: 'flex',
413
+ border_bottom: '1px solid #e5e7eb',
414
+ background: '#f9fafb'
415
+ }
416
+ ) do
417
+ tabs.each do |tab|
418
+ is_active = tab[:id] == active
419
+ b.button(
420
+ role: 'tab',
421
+ style: {
422
+ background: is_active ? 'white' : 'transparent',
423
+ border: 'none',
424
+ border_bottom: is_active ? '2px solid #4f46e5' : '2px solid transparent',
425
+ padding: '12px 16px',
426
+ cursor: 'pointer',
427
+ font_size: '14px',
428
+ font_weight: '500',
429
+ color: is_active ? '#111827' : '#6b7280',
430
+ transition: 'all 0.15s ease'
431
+ },
432
+ on_click: ->(_e) {
433
+ comp.set_state(:active, tab[:id])
434
+ comp['active'] = tab[:id]
435
+
436
+ event = JS.global[:CustomEvent].new(
437
+ 'tab-change',
438
+ { bubbles: true, composed: true, detail: { tab: tab[:id] }.to_js }.to_js
439
+ )
440
+ comp.element.call(:dispatchEvent, event)
441
+ }
442
+ ) { b.text(tab[:label]) }
443
+ end
444
+ end
445
+
446
+ # Tab content
447
+ b.div(style: { padding: '20px', background: 'white' }) do
448
+ active_tab = tabs.find { |t| t[:id] == active }
449
+ if active_tab
450
+ b.div { b.text(active_tab[:content]) }
451
+ end
452
+ end
453
+ end
454
+ end
455
+ end
456
+
457
+ # =============================================================================
458
+ # ACCORDION COMPONENT
459
+ # =============================================================================
460
+ ZephyrWasm.component('x-accordion') do
461
+ observed_attributes :expanded
462
+
463
+ on_connect do
464
+ set_state(:expanded, (self['expanded'] || '').split(',').map(&:strip))
465
+ set_state(:sections, [
466
+ { id: 'section1', title: 'Section 1', content: 'Content for section 1' },
467
+ { id: 'section2', title: 'Section 2', content: 'Content for section 2' },
468
+ { id: 'section3', title: 'Section 3', content: 'Content for section 3' }
469
+ ])
470
+ end
471
+
472
+ template do |b|
473
+ comp = self
474
+ expanded = comp.state[:expanded] || []
475
+ sections = comp.state[:sections] || []
476
+
477
+ b.div(style: {
478
+ border: '1px solid #e5e7eb',
479
+ border_radius: '8px',
480
+ overflow: 'hidden'
481
+ }) do
482
+ sections.each_with_index do |section, index|
483
+ is_expanded = expanded.include?(section[:id])
484
+
485
+ # Header
486
+ b.button(
487
+ style: {
488
+ width: '100%',
489
+ background: is_expanded ? 'white' : '#f9fafb',
490
+ border: 'none',
491
+ border_bottom: index < sections.length - 1 ? '1px solid #e5e7eb' : 'none',
492
+ padding: '16px 20px',
493
+ text_align: 'left',
494
+ cursor: 'pointer',
495
+ font_size: '14px',
496
+ font_weight: '500',
497
+ display: 'flex',
498
+ justify_content: 'space-between',
499
+ align_items: 'center',
500
+ transition: 'background 0.15s ease'
501
+ },
502
+ on_click: ->(_e) {
503
+ new_expanded = expanded.dup
504
+ if is_expanded
505
+ new_expanded.delete(section[:id])
506
+ else
507
+ new_expanded << section[:id]
508
+ end
509
+ comp.set_state(:expanded, new_expanded)
510
+ comp['expanded'] = new_expanded.join(',')
511
+ }
512
+ ) do
513
+ b.span { b.text(section[:title]) }
514
+ b.span(style: {
515
+ transform: is_expanded ? 'rotate(180deg)' : 'rotate(0deg)',
516
+ transition: 'transform 0.2s ease'
517
+ }) { b.text('▼') }
518
+ end
519
+
520
+ # Content
521
+ if is_expanded
522
+ b.div(style: {
523
+ padding: '20px',
524
+ border_bottom: index < sections.length - 1 ? '1px solid #e5e7eb' : 'none'
525
+ }) { b.text(section[:content]) }
526
+ end
527
+ end
528
+ end
529
+ end
530
+ end
531
+
532
+ # =============================================================================
533
+ # DIALOG/MODAL COMPONENT
534
+ # =============================================================================
535
+ ZephyrWasm.component('x-dialog') do
536
+ observed_attributes :open, :title
537
+
538
+ on_connect do
539
+ set_state(:open, self['open'] == 'true')
540
+ end
541
+
542
+ template do |b|
543
+ comp = self
544
+ is_open = comp.state[:open]
545
+ title = comp['title'] || 'Dialog'
546
+
547
+ if is_open
548
+ # Backdrop
549
+ b.div(
550
+ style: {
551
+ position: 'fixed',
552
+ top: '0',
553
+ left: '0',
554
+ right: '0',
555
+ bottom: '0',
556
+ background: 'rgba(0, 0, 0, 0.5)',
557
+ display: 'flex',
558
+ align_items: 'center',
559
+ justify_content: 'center',
560
+ z_index: '1000'
561
+ },
562
+ on_click: ->(e) {
563
+ if e[:target] == e[:currentTarget]
564
+ comp.set_state(:open, false)
565
+ comp['open'] = 'false'
566
+ end
567
+ }
568
+ ) do
569
+ # Dialog
570
+ b.div(
571
+ role: 'dialog',
572
+ style: {
573
+ background: 'white',
574
+ border_radius: '8px',
575
+ box_shadow: '0 25px 50px -12px rgba(0, 0, 0, 0.25)',
576
+ max_width: '90vw',
577
+ max_height: '90vh',
578
+ width: '600px',
579
+ display: 'flex',
580
+ flex_direction: 'column'
581
+ },
582
+ on_click: ->(e) { e.call(:stopPropagation) }
583
+ ) do
584
+ # Header
585
+ b.div(style: {
586
+ padding: '16px 20px',
587
+ border_bottom: '1px solid #e5e7eb',
588
+ display: 'flex',
589
+ justify_content: 'space-between',
590
+ align_items: 'center'
591
+ }) do
592
+ b.tag(:h2, style: {
593
+ margin: '0',
594
+ font_size: '18px',
595
+ font_weight: '600'
596
+ }) { b.text(title) }
597
+
598
+ b.button(
599
+ style: {
600
+ background: 'none',
601
+ border: 'none',
602
+ font_size: '18px',
603
+ cursor: 'pointer',
604
+ padding: '4px',
605
+ border_radius: '4px'
606
+ },
607
+ on_click: ->(_e) {
608
+ comp.set_state(:open, false)
609
+ comp['open'] = 'false'
610
+ }
611
+ ) { b.text('×') }
612
+ end
613
+
614
+ # Content
615
+ b.div(style: {
616
+ padding: '20px',
617
+ overflow_y: 'auto',
618
+ flex: '1'
619
+ }) do
620
+ b.tag(:slot)
621
+ end
622
+ end
623
+ end
624
+ end
625
+ end
626
+ end
627
+
628
+ # =============================================================================
629
+ # BREADCRUMB COMPONENT
630
+ # =============================================================================
631
+ ZephyrWasm.component('x-breadcrumb') do
632
+ observed_attributes :path
633
+
634
+ on_connect do
635
+ set_state(:items, parse_path)
636
+ end
637
+
638
+ def parse_path
639
+ path_attr = self['path']
640
+ return [] unless path_attr
641
+
642
+ begin
643
+ JSON.parse(path_attr)
644
+ rescue
645
+ path_attr.split(',').map { |item| { label: item.strip } }
646
+ end
647
+ end
648
+
649
+ template do |b|
650
+ comp = self
651
+ items = comp.state[:items] || []
652
+
653
+ b.tag(:nav, style: { padding: '8px 0' }) do
654
+ b.tag(:ol, style: {
655
+ display: 'flex',
656
+ align_items: 'center',
657
+ list_style: 'none',
658
+ margin: '0',
659
+ padding: '0',
660
+ gap: '8px'
661
+ }) do
662
+ items.each_with_index do |item, index|
663
+ is_last = index == items.length - 1
664
+
665
+ b.tag(:li, style: {
666
+ display: 'flex',
667
+ align_items: 'center',
668
+ gap: '8px'
669
+ }) do
670
+ if item['href'] && !is_last
671
+ b.tag(:a,
672
+ href: item['href'],
673
+ style: {
674
+ color: '#4f46e5',
675
+ text_decoration: 'none',
676
+ padding: '4px 8px',
677
+ border_radius: '4px'
678
+ }
679
+ ) { b.text(item['label'] || item[:label]) }
680
+ else
681
+ b.span(style: {
682
+ color: is_last ? '#374151' : '#6b7280',
683
+ font_weight: is_last ? '500' : '400',
684
+ padding: '4px 8px'
685
+ }) { b.text(item['label'] || item[:label]) }
686
+ end
687
+
688
+ unless is_last
689
+ b.span(style: { color: '#9ca3af' }) { b.text('/') }
690
+ end
691
+ end
692
+ end
693
+ end
694
+ end
695
+ end
696
+ end
697
+
698
+ # =============================================================================
699
+ # VIRTUAL LIST COMPONENT (Simplified)
700
+ # =============================================================================
701
+ ZephyrWasm.component('x-virtual-list') do
702
+ observed_attributes 'item-count', 'item-height'
703
+
704
+ on_connect do
705
+ item_count = (self['item-count'] || '0').to_i
706
+ item_height = (self['item-height'] || '24').to_i
707
+
708
+ set_state(:item_count, item_count)
709
+ set_state(:item_height, item_height)
710
+ set_state(:scroll_top, 0)
711
+ end
712
+
713
+ template do |b|
714
+ comp = self
715
+ item_count = comp.state[:item_count] || 0
716
+ item_height = comp.state[:item_height] || 24
717
+ scroll_top = comp.state[:scroll_top] || 0
718
+
719
+ viewport_height = 400
720
+ start_index = [0, (scroll_top / item_height).floor - 3].max
721
+ visible_count = [(viewport_height / item_height).ceil + 6, item_count - start_index].min
722
+
723
+ b.div(
724
+ style: {
725
+ overflow: 'auto',
726
+ height: '400px',
727
+ position: 'relative'
728
+ },
729
+ on_scroll: ->(e) {
730
+ comp.set_state(:scroll_top, e[:target][:scrollTop].to_i)
731
+ }
732
+ ) do
733
+ b.div(style: {
734
+ height: "#{item_count * item_height}px",
735
+ position: 'relative'
736
+ }) do
737
+ visible_count.times do |i|
738
+ index = start_index + i
739
+ break if index >= item_count
740
+
741
+ b.div(style: {
742
+ position: 'absolute',
743
+ top: "#{index * item_height}px",
744
+ left: '0',
745
+ right: '0',
746
+ height: "#{item_height}px",
747
+ padding: '8px',
748
+ border_bottom: '1px solid #eee',
749
+ background: index % 2 == 0 ? '#f9f9f9' : 'white'
750
+ }) { b.text("Item #{index}") }
751
+ end
752
+ end
753
+ end
754
+ end
755
+ end
756
+
757
+ # Counter component - demonstrates state management
758
+ ZephyrWasm.component('x-counter') do
759
+ observed_attributes :initial
760
+
761
+ on_connect do
762
+ count = (self['initial'] || '0').to_i
763
+ set_state(:count, count)
764
+ end
765
+
766
+ template do |b|
767
+ comp = self
768
+
769
+ b.div(class: 'counter-container') do
770
+ b.button(
771
+ class: 'btn btn-decrement',
772
+ on_click: ->(_e) {
773
+ current = comp.state[:count] || 0
774
+ comp.set_state(:count, current - 1)
775
+ }
776
+ ) { b.text('-') }
777
+
778
+ b.span(class: 'counter-value') { b.text(comp.state[:count] || 0) }
779
+
780
+ b.button(
781
+ class: 'btn btn-increment',
782
+ on_click: ->(_e) {
783
+ current = comp.state[:count] || 0
784
+ comp.set_state(:count, current + 1)
785
+ }
786
+ ) { b.text('+') }
787
+ end
788
+ end
789
+ end
790
+
791
+ # Toggle button component
792
+ ZephyrWasm.component('x-toggle') do
793
+ observed_attributes :label, :checked
794
+
795
+ on_connect do
796
+ set_state(:checked, self['checked'] == 'true')
797
+ end
798
+
799
+ template do |b|
800
+ comp = self
801
+ btn_class = comp.state[:checked] ? 'toggle active' : 'toggle'
802
+ label_text = comp['label'] || 'Toggle'
803
+
804
+ b.div(class: 'toggle-wrapper') do
805
+ b.button(
806
+ class: btn_class,
807
+ on_click: ->(_e) {
808
+ new_state = !comp.state[:checked]
809
+ comp.set_state(:checked, new_state)
810
+ comp['checked'] = new_state.to_s
811
+
812
+ event = JS.global[:CustomEvent].new(
813
+ 'toggle-change',
814
+ { bubbles: true, detail: { checked: new_state }.to_js }.to_js
815
+ )
816
+ comp.element.call(:dispatchEvent, event)
817
+ }
818
+ ) { b.text(label_text) }
819
+ end
820
+ end
821
+ end
822
+
823
+ # Todo list component - demonstrates list rendering
824
+ ZephyrWasm.component('x-todo-list') do
825
+ on_connect do
826
+ set_state(:todos, [])
827
+ set_state(:input_value, '')
828
+ end
829
+
830
+ template do |b|
831
+ comp = self
832
+
833
+ b.div(class: 'todo-list') do
834
+ b.div(class: 'todo-input-group') do
835
+ b.tag(:input,
836
+ type: 'text',
837
+ placeholder: 'Enter a task...',
838
+ value: comp.state[:input_value] || '',
839
+ on_input: ->(e) {
840
+ comp.set_state(:input_value, e[:target][:value].to_s)
841
+ }
842
+ )
843
+
844
+ b.button(
845
+ class: 'btn btn-primary',
846
+ on_click: ->(_e) {
847
+ value = comp.state[:input_value]&.strip
848
+ if value && !value.empty?
849
+ todos = (comp.state[:todos] || []).dup
850
+ todos << { id: JS.global[:Date].new.call(:getTime), text: value, done: false }
851
+ comp.set_state(:todos, todos)
852
+ comp.set_state(:input_value, '')
853
+ end
854
+ }
855
+ ) { b.text('Add') }
856
+ end
857
+
858
+ b.tag(:ul, class: 'todo-items') do
859
+ todos = comp.state[:todos] || []
860
+ b.render_each(todos) do |todo|
861
+ b.tag(:li, class: todo[:done] ? 'done' : '') do
862
+ b.tag(:input,
863
+ type: 'checkbox',
864
+ checked: !!todo[:done],
865
+ on_change: ->(e) {
866
+ todos = (comp.state[:todos] || []).map { |t|
867
+ if t[:id] == todo[:id]
868
+ { **t, done: !!e[:target][:checked] }
869
+ else
870
+ t
871
+ end
872
+ }
873
+ comp.set_state(:todos, todos)
874
+ }
875
+ )
876
+ b.span { b.text(todo[:text]) }
877
+ b.button(
878
+ class: 'btn-delete',
879
+ on_click: ->(_e) {
880
+ todos = (comp.state[:todos] || []).reject { |t| t[:id] == todo[:id] }
881
+ comp.set_state(:todos, todos)
882
+ }
883
+ ) { b.text('×') }
884
+ end
885
+ end
886
+ end
887
+ end
888
+ end
889
+ end
890
+
891
+ # Tabs component - demonstrates more complex UI
892
+ ZephyrWasm.component('x-tabs') do
893
+ observed_attributes :active
894
+
895
+ on_connect do
896
+ set_state(:active, self['active'] || 'tab1')
897
+ set_state(:tabs, [
898
+ { id: 'tab1', label: 'Tab 1', content: 'Content 1' },
899
+ { id: 'tab2', label: 'Tab 2', content: 'Content 2' },
900
+ { id: 'tab3', label: 'Tab 3', content: 'Content 3' }
901
+ ])
902
+ end
903
+
904
+ template do |b|
905
+ comp = self
906
+
907
+ b.div(class: 'tabs-container') do
908
+ # Tab list
909
+ b.div(class: 'tab-list', role: 'tablist') do
910
+ tabs = comp.state[:tabs] || []
911
+ active = comp.state[:active]
912
+ b.render_each(tabs) do |tab|
913
+ b.button(
914
+ class: tab[:id] == active ? 'tab-button active' : 'tab-button',
915
+ role: 'tab',
916
+ on_click: ->(_e) {
917
+ comp.set_state(:active, tab[:id])
918
+ comp['active'] = tab[:id]
919
+ }
920
+ ) { b.text(tab[:label]) }
921
+ end
922
+ end
923
+
924
+ # Tab content
925
+ b.div(class: 'tab-content') do
926
+ tabs = comp.state[:tabs] || []
927
+ active_tab = tabs.find { |t| t[:id] == comp.state[:active] }
928
+ if active_tab
929
+ b.div(class: 'tab-panel') { b.text(active_tab[:content]) }
930
+ end
931
+ end
932
+ end
933
+ end
934
+ end