opal-vite 0.2.7 → 0.2.9

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,1308 @@
1
+ # backtick_javascript: true
2
+
3
+ module OpalVite
4
+ module Concerns
5
+ module V1
6
+ # StimulusHelpers - DSL macros for reducing JavaScript backticks in Stimulus controllers
7
+ #
8
+ # This module provides Ruby-friendly methods for common Stimulus patterns,
9
+ # reducing the need for raw JavaScript backticks.
10
+ #
11
+ # Usage:
12
+ # class MyController < StimulusController
13
+ # include OpalVite::Concerns::V1::StimulusHelpers
14
+ #
15
+ # def connect
16
+ # if has_target?(:input)
17
+ # value = target_value(:input)
18
+ # target_set_html(:output, "Value: #{value}")
19
+ # end
20
+ # end
21
+ # end
22
+ module StimulusHelpers
23
+ # ===== Target Access Methods =====
24
+
25
+ # Check if a Stimulus target exists
26
+ # @param name [Symbol, String] Target name (e.g., :input, :output)
27
+ # @return [Boolean] true if target exists
28
+ def has_target?(name)
29
+ method_name = "has#{camelize(name)}Target"
30
+ `this[#{method_name}]`
31
+ end
32
+
33
+ # Get a Stimulus target element
34
+ # @param name [Symbol, String] Target name
35
+ # @return [Element, nil] The target element or nil
36
+ def get_target(name)
37
+ return nil unless has_target?(name)
38
+ method_name = "#{camelize(name, false)}Target"
39
+ `this[#{method_name}]`
40
+ end
41
+
42
+ # Get all Stimulus targets of a type
43
+ # @param name [Symbol, String] Target name
44
+ # @return [Array] Array of target elements
45
+ def get_targets(name)
46
+ method_name = "#{camelize(name, false)}Targets"
47
+ `Array.from(this[#{method_name}] || [])`
48
+ end
49
+
50
+ # Get the value of a target (input field)
51
+ # @param name [Symbol, String] Target name
52
+ # @return [String, nil] The target's value
53
+ def target_value(name)
54
+ return nil unless has_target?(name)
55
+ method_name = "#{camelize(name, false)}Target"
56
+ `this[#{method_name}].value`
57
+ end
58
+
59
+ # Set the value of a target (input field)
60
+ # @param name [Symbol, String] Target name
61
+ # @param value [String] The value to set
62
+ def target_set_value(name, value)
63
+ return unless has_target?(name)
64
+ method_name = "#{camelize(name, false)}Target"
65
+ `this[#{method_name}].value = #{value}`
66
+ end
67
+
68
+ # Get the innerHTML of a target
69
+ # @param name [Symbol, String] Target name
70
+ # @return [String, nil] The target's innerHTML
71
+ def target_html(name)
72
+ return nil unless has_target?(name)
73
+ method_name = "#{camelize(name, false)}Target"
74
+ `this[#{method_name}].innerHTML`
75
+ end
76
+
77
+ # Set the innerHTML of a target
78
+ # @param name [Symbol, String] Target name
79
+ # @param html [String] The HTML to set
80
+ def target_set_html(name, html)
81
+ return unless has_target?(name)
82
+ method_name = "#{camelize(name, false)}Target"
83
+ `this[#{method_name}].innerHTML = #{html}`
84
+ end
85
+
86
+ # Get the textContent of a target
87
+ # @param name [Symbol, String] Target name
88
+ # @return [String, nil] The target's textContent
89
+ def target_text(name)
90
+ return nil unless has_target?(name)
91
+ method_name = "#{camelize(name, false)}Target"
92
+ `this[#{method_name}].textContent`
93
+ end
94
+
95
+ # Set the textContent of a target
96
+ # @param name [Symbol, String] Target name
97
+ # @param text [String] The text to set
98
+ def target_set_text(name, text)
99
+ return unless has_target?(name)
100
+ method_name = "#{camelize(name, false)}Target"
101
+ `this[#{method_name}].textContent = #{text}`
102
+ end
103
+
104
+ # Get a data attribute from a target
105
+ # @param name [Symbol, String] Target name
106
+ # @param attr [String] The data attribute name (without 'data-' prefix)
107
+ # @return [String, nil] The attribute value
108
+ def target_data(name, attr)
109
+ return nil unless has_target?(name)
110
+ method_name = "#{camelize(name, false)}Target"
111
+ `this[#{method_name}].getAttribute('data-' + #{attr})`
112
+ end
113
+
114
+ # Set a data attribute on a target
115
+ # @param name [Symbol, String] Target name
116
+ # @param attr [String] The data attribute name (without 'data-' prefix)
117
+ # @param value [String] The value to set
118
+ def target_set_data(name, attr, value)
119
+ return unless has_target?(name)
120
+ method_name = "#{camelize(name, false)}Target"
121
+ `this[#{method_name}].setAttribute('data-' + #{attr}, #{value})`
122
+ end
123
+
124
+ # Clear a target's value (for input fields)
125
+ # @param name [Symbol, String] Target name
126
+ def target_clear(name)
127
+ target_set_value(name, '')
128
+ end
129
+
130
+ # Clear a target's innerHTML
131
+ # @param name [Symbol, String] Target name
132
+ def target_clear_html(name)
133
+ target_set_html(name, '')
134
+ end
135
+
136
+ # ===== Target Style Methods =====
137
+
138
+ # Set a style property on a target element
139
+ # @param name [Symbol, String] Target name
140
+ # @param property [String] CSS property name (e.g., 'display', 'color')
141
+ # @param value [String] CSS value
142
+ def set_target_style(name, property, value)
143
+ return unless has_target?(name)
144
+ method_name = "#{camelize(name, false)}Target"
145
+ `this[#{method_name}].style[#{property}] = #{value}`
146
+ end
147
+
148
+ # Get a style property from a target element
149
+ # @param name [Symbol, String] Target name
150
+ # @param property [String] CSS property name
151
+ # @return [String, nil] The style value
152
+ def get_target_style(name, property)
153
+ return nil unless has_target?(name)
154
+ method_name = "#{camelize(name, false)}Target"
155
+ `this[#{method_name}].style[#{property}]`
156
+ end
157
+
158
+ # Show a target element (set display to '')
159
+ # @param name [Symbol, String] Target name
160
+ def show_target(name)
161
+ set_target_style(name, 'display', '')
162
+ end
163
+
164
+ # Hide a target element (set display to 'none')
165
+ # @param name [Symbol, String] Target name
166
+ def hide_target(name)
167
+ set_target_style(name, 'display', 'none')
168
+ end
169
+
170
+ # Toggle target visibility
171
+ # @param name [Symbol, String] Target name
172
+ def toggle_target_visibility(name)
173
+ current = get_target_style(name, 'display')
174
+ if current == 'none'
175
+ show_target(name)
176
+ else
177
+ hide_target(name)
178
+ end
179
+ end
180
+
181
+ # ===== Target Class Methods =====
182
+
183
+ # Add a CSS class to a target element
184
+ # @param name [Symbol, String] Target name
185
+ # @param class_name [String] CSS class to add
186
+ def add_target_class(name, class_name)
187
+ return unless has_target?(name)
188
+ method_name = "#{camelize(name, false)}Target"
189
+ `this[#{method_name}].classList.add(#{class_name})`
190
+ end
191
+
192
+ # Remove a CSS class from a target element
193
+ # @param name [Symbol, String] Target name
194
+ # @param class_name [String] CSS class to remove
195
+ def remove_target_class(name, class_name)
196
+ return unless has_target?(name)
197
+ method_name = "#{camelize(name, false)}Target"
198
+ `this[#{method_name}].classList.remove(#{class_name})`
199
+ end
200
+
201
+ # Toggle a CSS class on a target element
202
+ # @param name [Symbol, String] Target name
203
+ # @param class_name [String] CSS class to toggle
204
+ def toggle_target_class(name, class_name)
205
+ return unless has_target?(name)
206
+ method_name = "#{camelize(name, false)}Target"
207
+ `this[#{method_name}].classList.toggle(#{class_name})`
208
+ end
209
+
210
+ # Check if a target has a CSS class
211
+ # @param name [Symbol, String] Target name
212
+ # @param class_name [String] CSS class to check
213
+ # @return [Boolean] true if target has the class
214
+ def has_target_class?(name, class_name)
215
+ return false unless has_target?(name)
216
+ method_name = "#{camelize(name, false)}Target"
217
+ `this[#{method_name}].classList.contains(#{class_name})`
218
+ end
219
+
220
+ # ===== Date/Time Methods =====
221
+
222
+ # Get current timestamp (milliseconds since epoch)
223
+ # @return [Integer] Current timestamp
224
+ def js_timestamp
225
+ `Date.now()`
226
+ end
227
+
228
+ # Get current date as ISO string
229
+ # @return [String] ISO date string
230
+ def js_iso_date
231
+ `new Date().toISOString()`
232
+ end
233
+
234
+ # Create a new Date object
235
+ # @param value [String, Integer, nil] Optional value to parse
236
+ # @return [Native] JavaScript Date object
237
+ def js_date(value = nil)
238
+ if value
239
+ `new Date(#{value})`
240
+ else
241
+ `new Date()`
242
+ end
243
+ end
244
+
245
+ # ===== RegExp Methods =====
246
+
247
+ # Create a JavaScript RegExp object
248
+ # @param pattern [String] The regex pattern
249
+ # @param flags [String] Optional flags (e.g., 'gi')
250
+ # @return [Native] JavaScript RegExp object
251
+ def js_regexp(pattern, flags = '')
252
+ `new RegExp(#{pattern}, #{flags})`
253
+ end
254
+
255
+ # Test if a string matches a regex pattern
256
+ # @param pattern [String] The regex pattern
257
+ # @param value [String] The string to test
258
+ # @param flags [String] Optional flags
259
+ # @return [Boolean] true if matches
260
+ def js_regexp_test(pattern, value, flags = '')
261
+ `new RegExp(#{pattern}, #{flags}).test(#{value})`
262
+ end
263
+
264
+ # ===== Timer Methods =====
265
+
266
+ # Set a timeout
267
+ # @param delay [Integer] Delay in milliseconds
268
+ # @yield Block to execute after delay
269
+ # @return [Integer] Timer ID
270
+ def set_timeout(delay, &block)
271
+ `setTimeout(function() { #{block.call} }, #{delay})`
272
+ end
273
+
274
+ # Set an interval
275
+ # @param delay [Integer] Interval in milliseconds
276
+ # @yield Block to execute at each interval
277
+ # @return [Integer] Timer ID
278
+ def set_interval(delay, &block)
279
+ `setInterval(function() { #{block.call} }, #{delay})`
280
+ end
281
+
282
+ # Clear a timeout
283
+ # @param timer_id [Integer] Timer ID to clear
284
+ def clear_timeout(timer_id)
285
+ `clearTimeout(#{timer_id})`
286
+ end
287
+
288
+ # Clear an interval
289
+ # @param timer_id [Integer] Timer ID to clear
290
+ def clear_interval(timer_id)
291
+ `clearInterval(#{timer_id})`
292
+ end
293
+
294
+ # ===== Body Style Methods =====
295
+
296
+ # Set document body style property
297
+ # @param property [String] CSS property name
298
+ # @param value [String] CSS value
299
+ def body_style(property, value)
300
+ `document.body.style[#{property}] = #{value}`
301
+ end
302
+
303
+ # Lock body scroll (prevent scrolling)
304
+ def lock_body_scroll
305
+ body_style('overflow', 'hidden')
306
+ end
307
+
308
+ # Unlock body scroll (restore scrolling)
309
+ def unlock_body_scroll
310
+ body_style('overflow', '')
311
+ end
312
+
313
+ # ===== Array Helper Methods =====
314
+
315
+ # Create a new JavaScript array
316
+ # @return [Native] Empty JavaScript array
317
+ def js_array
318
+ `[]`
319
+ end
320
+
321
+ # Push item to JavaScript array
322
+ # @param array [Native] JavaScript array
323
+ # @param item [Object] Item to push
324
+ def js_array_push(array, item)
325
+ `#{array}.push(#{item.to_n})`
326
+ end
327
+
328
+ # Get JavaScript array length
329
+ # @param array [Native] JavaScript array
330
+ # @return [Integer] Array length
331
+ def js_array_length(array)
332
+ `#{array}.length`
333
+ end
334
+
335
+ # Get item from JavaScript array at index
336
+ # @param array [Native] JavaScript array
337
+ # @param index [Integer] Index
338
+ # @return [Object] Item at index
339
+ def js_array_at(array, index)
340
+ `#{array}[#{index}]`
341
+ end
342
+
343
+ # ===== LocalStorage Methods =====
344
+
345
+ # Get item from localStorage
346
+ # @param key [String] Storage key
347
+ # @return [String, nil] Stored value or nil
348
+ def storage_get(key)
349
+ `localStorage.getItem(#{key})`
350
+ end
351
+
352
+ # Set item in localStorage
353
+ # @param key [String] Storage key
354
+ # @param value [String] Value to store
355
+ def storage_set(key, value)
356
+ `localStorage.setItem(#{key}, #{value})`
357
+ end
358
+
359
+ # Remove item from localStorage
360
+ # @param key [String] Storage key
361
+ def storage_remove(key)
362
+ `localStorage.removeItem(#{key})`
363
+ end
364
+
365
+ # Get JSON-parsed value from localStorage
366
+ # @param key [String] Storage key
367
+ # @param default [Object] Default value if key doesn't exist
368
+ # @return [Object] Parsed value or default
369
+ def storage_get_json(key, default = nil)
370
+ stored = storage_get(key)
371
+ return default if `#{stored} === null`
372
+ `JSON.parse(#{stored})`
373
+ end
374
+
375
+ # Set JSON-stringified value in localStorage
376
+ # @param key [String] Storage key
377
+ # @param value [Object] Value to store (will be JSON-stringified)
378
+ def storage_set_json(key, value)
379
+ `localStorage.setItem(#{key}, JSON.stringify(#{value.to_n}))`
380
+ end
381
+
382
+ # ===== Event Methods =====
383
+
384
+ # Dispatch a custom event on window
385
+ # @param name [String] Event name
386
+ # @param detail [Hash] Event detail data
387
+ def dispatch_window_event(name, detail = {})
388
+ native_detail = detail.is_a?(Hash) ? detail.to_n : detail
389
+ `
390
+ const event = new CustomEvent(#{name}, { detail: #{native_detail} });
391
+ window.dispatchEvent(event);
392
+ `
393
+ end
394
+
395
+ # Dispatch a custom event on the controller element
396
+ # @param name [String] Event name
397
+ # @param detail [Hash] Event detail data
398
+ def dispatch_event(name, detail = {})
399
+ native_detail = detail.is_a?(Hash) ? detail.to_n : detail
400
+ `
401
+ const event = new CustomEvent(#{name}, { detail: #{native_detail} });
402
+ this.element.dispatchEvent(event);
403
+ `
404
+ end
405
+
406
+ # Add window event listener
407
+ # @param name [String] Event name
408
+ # @yield Block to execute when event fires
409
+ def on_window_event(name, &block)
410
+ `window.addEventListener(#{name}, #{block})`
411
+ end
412
+
413
+ # Remove window event listener
414
+ # @param name [String] Event name
415
+ # @param handler [Native] Handler function to remove
416
+ def off_window_event(name, handler)
417
+ `window.removeEventListener(#{name}, #{handler})`
418
+ end
419
+
420
+ # Add document event listener
421
+ # @param name [String] Event name
422
+ # @yield Block to execute when event fires
423
+ def on_document_event(name, &block)
424
+ `document.addEventListener(#{name}, #{block})`
425
+ end
426
+
427
+ # Add event listener when DOM is ready
428
+ # @yield Block to execute when DOM is ready
429
+ def on_dom_ready(&block)
430
+ `document.addEventListener('DOMContentLoaded', #{block})`
431
+ end
432
+
433
+ # Add event listener to any element
434
+ # @param element [Native] DOM element
435
+ # @param name [String] Event name
436
+ # @yield Block to execute when event fires
437
+ def on_element_event(element, name, &block)
438
+ `#{element}.addEventListener(#{name}, #{block})`
439
+ end
440
+
441
+ # Remove event listener from element
442
+ # @param element [Native] DOM element
443
+ # @param name [String] Event name
444
+ # @param handler [Native] Handler function to remove
445
+ def off_element_event(element, name, handler)
446
+ `#{element}.removeEventListener(#{name}, #{handler})`
447
+ end
448
+
449
+ # Add event listener to controller's element (this.element)
450
+ # @param name [String] Event name
451
+ # @yield Block to execute when event fires
452
+ def on_controller_event(name, &block)
453
+ `
454
+ const handler = #{block};
455
+ this.element.addEventListener(#{name}, function(e) {
456
+ handler(e);
457
+ });
458
+ `
459
+ end
460
+
461
+ # Get the current event's target element
462
+ # @return [Native] Event target element
463
+ def event_target
464
+ `event.currentTarget`
465
+ end
466
+
467
+ # Get data attribute from current event target
468
+ # @param attr [String] Data attribute name (without 'data-' prefix)
469
+ # @return [String, nil] Attribute value
470
+ def event_data(attr)
471
+ `event.currentTarget.getAttribute('data-' + #{attr})`
472
+ end
473
+
474
+ # Get integer data attribute from current event target
475
+ # @param attr [String] Data attribute name (without 'data-' prefix)
476
+ # @return [Integer, nil] Parsed integer value
477
+ def event_data_int(attr)
478
+ parse_int(event_data(attr))
479
+ end
480
+
481
+ # Prevent default event behavior
482
+ def prevent_default
483
+ `event.preventDefault()`
484
+ end
485
+
486
+ # Get event key (for keyboard events)
487
+ # @return [String] Key name
488
+ def event_key
489
+ `event.key`
490
+ end
491
+
492
+ # ===== Element Methods =====
493
+
494
+ # Add class to element
495
+ # @param element [Native] DOM element
496
+ # @param class_name [String] CSS class to add
497
+ def add_class(element, class_name)
498
+ `#{element}.classList.add(#{class_name})`
499
+ end
500
+
501
+ # Remove class from element
502
+ # @param element [Native] DOM element
503
+ # @param class_name [String] CSS class to remove
504
+ def remove_class(element, class_name)
505
+ `#{element}.classList.remove(#{class_name})`
506
+ end
507
+
508
+ # Toggle class on element
509
+ # @param element [Native] DOM element
510
+ # @param class_name [String] CSS class to toggle
511
+ def toggle_class(element, class_name)
512
+ `#{element}.classList.toggle(#{class_name})`
513
+ end
514
+
515
+ # Check if element has class
516
+ # @param element [Native] DOM element
517
+ # @param class_name [String] CSS class to check
518
+ # @return [Boolean]
519
+ def has_class?(element, class_name)
520
+ `#{element}.classList.contains(#{class_name})`
521
+ end
522
+
523
+ # Set element attribute
524
+ # @param element [Native] DOM element
525
+ # @param attr [String] Attribute name
526
+ # @param value [String] Attribute value
527
+ def set_attr(element, attr, value)
528
+ `#{element}.setAttribute(#{attr}, #{value})`
529
+ end
530
+
531
+ # Get element attribute
532
+ # @param element [Native] DOM element
533
+ # @param attr [String] Attribute name
534
+ # @return [String, nil] Attribute value
535
+ def get_attr(element, attr)
536
+ `#{element}.getAttribute(#{attr})`
537
+ end
538
+
539
+ # Remove element attribute
540
+ # @param element [Native] DOM element
541
+ # @param attr [String] Attribute name
542
+ def remove_attr(element, attr)
543
+ `#{element}.removeAttribute(#{attr})`
544
+ end
545
+
546
+ # Set element style
547
+ # @param element [Native] DOM element
548
+ # @param property [String] CSS property
549
+ # @param value [String] CSS value
550
+ def set_style(element, property, value)
551
+ `#{element}.style[#{property}] = #{value}`
552
+ end
553
+
554
+ # Set element innerHTML
555
+ # @param element [Native] DOM element
556
+ # @param html [String] HTML content
557
+ def set_html(element, html)
558
+ `#{element}.innerHTML = #{html}`
559
+ end
560
+
561
+ # Set element textContent
562
+ # @param element [Native] DOM element
563
+ # @param text [String] Text content
564
+ def set_text(element, text)
565
+ `#{element}.textContent = #{text}`
566
+ end
567
+
568
+ # Get element value (for inputs)
569
+ # @param element [Native] DOM element
570
+ # @return [String] Element value
571
+ def get_value(element)
572
+ `#{element}.value`
573
+ end
574
+
575
+ # Set element value (for inputs)
576
+ # @param element [Native] DOM element
577
+ # @param value [String] Value to set
578
+ def set_value(element, value)
579
+ `#{element}.value = #{value}`
580
+ end
581
+
582
+ # Focus element
583
+ # @param element [Native] DOM element
584
+ def focus(element)
585
+ `#{element}.focus()`
586
+ end
587
+
588
+ # Check if element has attribute
589
+ # @param element [Native] DOM element
590
+ # @param attr [String] Attribute name
591
+ # @return [Boolean]
592
+ def has_attr?(element, attr)
593
+ `#{element}.hasAttribute(#{attr})`
594
+ end
595
+
596
+ # ===== DOM Creation Methods =====
597
+
598
+ # Create a new DOM element
599
+ # @param tag [String] HTML tag name
600
+ # @return [Native] Created element
601
+ def create_element(tag)
602
+ `document.createElement(#{tag})`
603
+ end
604
+
605
+ # Append child to element
606
+ # @param parent [Native] Parent element
607
+ # @param child [Native] Child element to append
608
+ def append_child(parent, child)
609
+ `#{parent}.appendChild(#{child})`
610
+ end
611
+
612
+ # Remove element from DOM
613
+ # @param element [Native] Element to remove
614
+ def remove_element(element)
615
+ `#{element}.remove()`
616
+ end
617
+
618
+ # Get next element sibling
619
+ # @param element [Native] DOM element
620
+ # @return [Native, nil] Next sibling element
621
+ def next_sibling(element)
622
+ `#{element}.nextElementSibling`
623
+ end
624
+
625
+ # Get previous element sibling
626
+ # @param element [Native] DOM element
627
+ # @return [Native, nil] Previous sibling element
628
+ def prev_sibling(element)
629
+ `#{element}.previousElementSibling`
630
+ end
631
+
632
+ # Get parent element
633
+ # @param element [Native] DOM element
634
+ # @return [Native, nil] Parent element
635
+ def parent(element)
636
+ `#{element}.parentElement`
637
+ end
638
+
639
+ # ===== DOM Query Methods =====
640
+
641
+ # Query selector on document
642
+ # @param selector [String] CSS selector
643
+ # @return [Native, nil] Element or nil
644
+ def query(selector)
645
+ `document.querySelector(#{selector})`
646
+ end
647
+
648
+ # Query selector all on document
649
+ # @param selector [String] CSS selector
650
+ # @return [Array] Array of elements
651
+ def query_all(selector)
652
+ `Array.from(document.querySelectorAll(#{selector}))`
653
+ end
654
+
655
+ # Query selector on controller element
656
+ # @param selector [String] CSS selector
657
+ # @return [Native, nil] Element or nil
658
+ def query_element(selector)
659
+ `this.element.querySelector(#{selector})`
660
+ end
661
+
662
+ # Query selector all on controller element
663
+ # @param selector [String] CSS selector
664
+ # @return [Array] Array of elements
665
+ def query_all_element(selector)
666
+ `Array.from(this.element.querySelectorAll(#{selector}))`
667
+ end
668
+
669
+ # ===== Document Methods =====
670
+
671
+ # Get document root element (html)
672
+ # @return [Native] HTML element
673
+ def doc_root
674
+ `document.documentElement`
675
+ end
676
+
677
+ # Set attribute on document root
678
+ # @param attr [String] Attribute name
679
+ # @param value [String] Attribute value
680
+ def set_root_attr(attr, value)
681
+ `document.documentElement.setAttribute(#{attr}, #{value})`
682
+ end
683
+
684
+ # Get attribute from document root
685
+ # @param attr [String] Attribute name
686
+ # @return [String, nil] Attribute value
687
+ def get_root_attr(attr)
688
+ `document.documentElement.getAttribute(#{attr})`
689
+ end
690
+
691
+ # ===== Template Methods =====
692
+
693
+ # Clone a template target's content
694
+ # @param name [Symbol, String] Template target name
695
+ # @return [Native] Cloned content
696
+ def clone_template(name)
697
+ method_name = "#{camelize(name, false)}Target"
698
+ `this[#{method_name}].content.cloneNode(true)`
699
+ end
700
+
701
+ # Get first element child from cloned template
702
+ # @param clone [Native] Cloned template content
703
+ # @return [Native, nil] First element child
704
+ def template_first_child(clone)
705
+ `#{clone}.firstElementChild`
706
+ end
707
+
708
+ # ===== Stimulus Controller Element Methods =====
709
+
710
+ # Add class to controller element
711
+ # @param class_name [String] CSS class to add
712
+ def element_add_class(class_name)
713
+ `this.element.classList.add(#{class_name})`
714
+ end
715
+
716
+ # Remove class from controller element
717
+ # @param class_name [String] CSS class to remove
718
+ def element_remove_class(class_name)
719
+ `this.element.classList.remove(#{class_name})`
720
+ end
721
+
722
+ # Toggle class on controller element
723
+ # @param class_name [String] CSS class to toggle
724
+ def element_toggle_class(class_name)
725
+ `this.element.classList.toggle(#{class_name})`
726
+ end
727
+
728
+ # ===== Utility Methods =====
729
+
730
+ # Generate a unique ID based on timestamp
731
+ # @return [Integer] Unique ID
732
+ def generate_id
733
+ js_timestamp
734
+ end
735
+
736
+ # Focus a target element
737
+ # @param name [Symbol, String] Target name
738
+ def target_focus(name)
739
+ return unless has_target?(name)
740
+ method_name = "#{camelize(name, false)}Target"
741
+ `this[#{method_name}].focus()`
742
+ end
743
+
744
+ # Blur (unfocus) a target element
745
+ # @param name [Symbol, String] Target name
746
+ def target_blur(name)
747
+ return unless has_target?(name)
748
+ method_name = "#{camelize(name, false)}Target"
749
+ `this[#{method_name}].blur()`
750
+ end
751
+
752
+ # ===== Type Conversion Methods =====
753
+
754
+ # Parse string to integer (wrapper for JavaScript parseInt)
755
+ # @param value [String, Number] Value to parse
756
+ # @param radix [Integer] Radix (default: 10)
757
+ # @return [Integer, NaN] Parsed integer
758
+ def parse_int(value, radix = 10)
759
+ `parseInt(#{value}, #{radix})`
760
+ end
761
+
762
+ # Parse string to float (wrapper for JavaScript parseFloat)
763
+ # @param value [String, Number] Value to parse
764
+ # @return [Float, NaN] Parsed float
765
+ def parse_float(value)
766
+ `parseFloat(#{value})`
767
+ end
768
+
769
+ # Check if value is NaN
770
+ # @param value [Number] Value to check
771
+ # @return [Boolean] true if NaN
772
+ def is_nan?(value)
773
+ `Number.isNaN(#{value})`
774
+ end
775
+
776
+ # Parse integer with default value (returns default if NaN)
777
+ # @param value [String, Number] Value to parse
778
+ # @param default_value [Integer] Default value if parsing fails
779
+ # @return [Integer] Parsed integer or default
780
+ def parse_int_or(value, default_value = 0)
781
+ result = parse_int(value)
782
+ is_nan?(result) ? default_value : result
783
+ end
784
+
785
+ # Parse float with default value (returns default if NaN)
786
+ # @param value [String, Number] Value to parse
787
+ # @param default_value [Float] Default value if parsing fails
788
+ # @return [Float] Parsed float or default
789
+ def parse_float_or(value, default_value = 0.0)
790
+ result = parse_float(value)
791
+ is_nan?(result) ? default_value : result
792
+ end
793
+
794
+ # ===== JavaScript Property Access Methods =====
795
+
796
+ # Get a JavaScript property from the controller (this[name])
797
+ # @param name [Symbol, String] Property name
798
+ # @return [Native] Property value
799
+ def js_prop(name)
800
+ `this[#{name.to_s}]`
801
+ end
802
+
803
+ # Set a JavaScript property on the controller (this[name] = value)
804
+ # @param name [Symbol, String] Property name
805
+ # @param value [Object] Value to set
806
+ def js_set_prop(name, value)
807
+ `this[#{name.to_s}] = #{value}`
808
+ end
809
+
810
+ # Check if controller has a JavaScript property
811
+ # @param name [Symbol, String] Property name
812
+ # @return [Boolean] true if property exists and is truthy
813
+ def js_has_prop?(name)
814
+ `!!this[#{name.to_s}]`
815
+ end
816
+
817
+ # Call a JavaScript method on the controller (this[name](...args))
818
+ # @param name [Symbol, String] Method name
819
+ # @param args [Array] Arguments to pass
820
+ # @return [Native] Method return value
821
+ def js_call(name, *args)
822
+ if args.empty?
823
+ `this[#{name.to_s}]()`
824
+ else
825
+ native_args = args.map { |a| a.respond_to?(:to_n) ? a.to_n : a }
826
+ `this[#{name.to_s}].apply(this, #{native_args})`
827
+ end
828
+ end
829
+
830
+ # Call a method on a JavaScript object
831
+ # @param obj [Native] JavaScript object
832
+ # @param method [Symbol, String] Method name
833
+ # @param args [Array] Arguments to pass
834
+ # @return [Native] Method return value
835
+ def js_call_on(obj, method, *args)
836
+ if args.empty?
837
+ `#{obj}[#{method.to_s}]()`
838
+ else
839
+ native_args = args.map { |a| a.respond_to?(:to_n) ? a.to_n : a }
840
+ `#{obj}[#{method.to_s}].apply(#{obj}, #{native_args})`
841
+ end
842
+ end
843
+
844
+ # Get a property from a JavaScript object
845
+ # @param obj [Native] JavaScript object
846
+ # @param prop [Symbol, String] Property name
847
+ # @return [Native] Property value
848
+ def js_get(obj, prop)
849
+ `#{obj}[#{prop.to_s}]`
850
+ end
851
+
852
+ # Set a property on a JavaScript object
853
+ # @param obj [Native] JavaScript object
854
+ # @param prop [Symbol, String] Property name
855
+ # @param value [Object] Value to set
856
+ def js_set(obj, prop, value)
857
+ `#{obj}[#{prop.to_s}] = #{value}`
858
+ end
859
+
860
+ # ===== JSON Methods =====
861
+
862
+ # Parse JSON string to JavaScript object
863
+ # @param json_string [String] JSON string
864
+ # @return [Native] Parsed JavaScript object
865
+ def json_parse(json_string)
866
+ `JSON.parse(#{json_string})`
867
+ end
868
+
869
+ # Stringify JavaScript object to JSON
870
+ # @param obj [Object] Object to stringify
871
+ # @return [String] JSON string
872
+ def json_stringify(obj)
873
+ native_obj = obj.respond_to?(:to_n) ? obj.to_n : obj
874
+ `JSON.stringify(#{native_obj})`
875
+ end
876
+
877
+ # ===== Console Methods =====
878
+
879
+ # Log to console
880
+ # @param args [Array] Arguments to log
881
+ def console_log(*args)
882
+ `console.log.apply(console, #{args})`
883
+ end
884
+
885
+ # Log warning to console
886
+ # @param args [Array] Arguments to log
887
+ def console_warn(*args)
888
+ `console.warn.apply(console, #{args})`
889
+ end
890
+
891
+ # Log error to console
892
+ # @param args [Array] Arguments to log
893
+ def console_error(*args)
894
+ `console.error.apply(console, #{args})`
895
+ end
896
+
897
+ # ===== String Methods =====
898
+
899
+ # Get character at index from string
900
+ # @param str [String] JavaScript string
901
+ # @param index [Integer] Character index
902
+ # @return [String] Character at index
903
+ def js_string_char_at(str, index)
904
+ `#{str}.charAt(#{index})`
905
+ end
906
+
907
+ # Get substring
908
+ # @param str [String] JavaScript string
909
+ # @param start [Integer] Start index
910
+ # @param end_idx [Integer, nil] End index (optional)
911
+ # @return [String] Substring
912
+ def js_substring(str, start, end_idx = nil)
913
+ if end_idx
914
+ `#{str}.substring(#{start}, #{end_idx})`
915
+ else
916
+ `#{str}.substring(#{start})`
917
+ end
918
+ end
919
+
920
+ # Split string
921
+ # @param str [String] JavaScript string
922
+ # @param separator [String] Separator
923
+ # @return [Native] Array of substrings
924
+ def js_split(str, separator)
925
+ `#{str}.split(#{separator})`
926
+ end
927
+
928
+ # Trim whitespace from string
929
+ # @param str [String] JavaScript string
930
+ # @return [String] Trimmed string
931
+ def js_trim(str)
932
+ `#{str}.trim()`
933
+ end
934
+
935
+ # Check if string includes substring
936
+ # @param str [String] JavaScript string
937
+ # @param search [String] Substring to search for
938
+ # @return [Boolean] true if includes
939
+ def js_includes?(str, search)
940
+ `#{str}.includes(#{search})`
941
+ end
942
+
943
+ # ===== Comparison Methods =====
944
+
945
+ # Check strict equality (===) between two JavaScript values
946
+ # @param a [Native] First value
947
+ # @param b [Native] Second value
948
+ # @return [Boolean] true if strictly equal
949
+ def js_equals?(a, b)
950
+ `#{a} === #{b}`
951
+ end
952
+
953
+ # Check loose equality (==) between two JavaScript values
954
+ # @param a [Native] First value
955
+ # @param b [Native] Second value
956
+ # @return [Boolean] true if loosely equal
957
+ def js_loose_equals?(a, b)
958
+ `#{a} == #{b}`
959
+ end
960
+
961
+ # ===== Math Methods =====
962
+
963
+ # Generate random number between 0 and 1
964
+ # @return [Float] Random number
965
+ def js_random
966
+ `Math.random()`
967
+ end
968
+
969
+ # Get minimum of two numbers
970
+ # @param a [Number] First number
971
+ # @param b [Number] Second number
972
+ # @return [Number] Minimum value
973
+ def js_min(a, b)
974
+ `Math.min(#{a}, #{b})`
975
+ end
976
+
977
+ # Get maximum of two numbers
978
+ # @param a [Number] First number
979
+ # @param b [Number] Second number
980
+ # @return [Number] Maximum value
981
+ def js_max(a, b)
982
+ `Math.max(#{a}, #{b})`
983
+ end
984
+
985
+ # Get absolute value
986
+ # @param num [Number] Number
987
+ # @return [Number] Absolute value
988
+ def js_abs(num)
989
+ `Math.abs(#{num})`
990
+ end
991
+
992
+ # Round number
993
+ # @param num [Number] Number
994
+ # @return [Integer] Rounded number
995
+ def js_round(num)
996
+ `Math.round(#{num})`
997
+ end
998
+
999
+ # Ceiling of number
1000
+ # @param num [Number] Number
1001
+ # @return [Integer] Ceiling value
1002
+ def js_ceil(num)
1003
+ `Math.ceil(#{num})`
1004
+ end
1005
+
1006
+ # Format number with fixed decimal places
1007
+ # @param num [Number] Number to format
1008
+ # @param digits [Integer] Number of decimal places
1009
+ # @return [String] Formatted number string
1010
+ def js_to_fixed(num, digits)
1011
+ `#{num}.toFixed(#{digits})`
1012
+ end
1013
+
1014
+ # Generate random integer between 0 and max (exclusive)
1015
+ # @param max [Integer] Maximum value (exclusive)
1016
+ # @return [Integer] Random integer
1017
+ def random_int(max)
1018
+ `Math.floor(Math.random() * #{max})`
1019
+ end
1020
+
1021
+ # Floor a number
1022
+ # @param num [Number] Number to floor
1023
+ # @return [Integer] Floored number
1024
+ def js_floor(num)
1025
+ `Math.floor(#{num})`
1026
+ end
1027
+
1028
+ # ===== Global Object Access =====
1029
+
1030
+ # Check if a global JavaScript object/class exists
1031
+ # @param name [String] Global name (e.g., 'Chart', 'React')
1032
+ # @return [Boolean] true if exists
1033
+ def js_global_exists?(name)
1034
+ `typeof window[#{name}] !== 'undefined'`
1035
+ end
1036
+
1037
+ # Get a global JavaScript object/class
1038
+ # @param name [String] Global name
1039
+ # @return [Native] Global object
1040
+ def js_global(name)
1041
+ `window[#{name}]`
1042
+ end
1043
+
1044
+ # Create new instance of a JavaScript class
1045
+ # @param klass [Native] JavaScript class/constructor
1046
+ # @param args [Array] Constructor arguments
1047
+ # @return [Native] New instance
1048
+ def js_new(klass, *args)
1049
+ if args.empty?
1050
+ `new klass()`
1051
+ else
1052
+ # Convert Ruby objects to native, pass JS objects as-is
1053
+ native_args = args.map { |a| `#{a} != null && typeof #{a}.$to_n === 'function' ? #{a}.$to_n() : #{a}` }
1054
+ # Use Reflect.construct for dynamic argument passing
1055
+ `Reflect.construct(#{klass}, #{native_args})`
1056
+ end
1057
+ end
1058
+
1059
+ # Define a JavaScript function on the controller (this[name] = function)
1060
+ # @param name [Symbol, String] Function name
1061
+ # @yield Block that becomes the function body
1062
+ def js_define_method(name, &block)
1063
+ `this[#{name.to_s}] = #{block}`
1064
+ end
1065
+
1066
+ # Define a JavaScript function on an object (obj[name] = function)
1067
+ # @param obj [Native] JavaScript object
1068
+ # @param name [Symbol, String] Function name
1069
+ # @yield Block that becomes the function body
1070
+ def js_define_method_on(obj, name, &block)
1071
+ `#{obj}[#{name.to_s}] = #{block}`
1072
+ end
1073
+
1074
+ # ===== Array Methods =====
1075
+
1076
+ # Get array length
1077
+ # @param arr [Native] JavaScript array
1078
+ # @return [Integer] Array length
1079
+ def js_length(arr)
1080
+ `#{arr}.length`
1081
+ end
1082
+
1083
+ # Map over array with block
1084
+ # @param arr [Native] JavaScript array
1085
+ # @yield [item] Block to execute for each item
1086
+ # @return [Native] New array with mapped values
1087
+ def js_map(arr, &block)
1088
+ `#{arr}.map(#{block})`
1089
+ end
1090
+
1091
+ # Filter array with block
1092
+ # @param arr [Native] JavaScript array
1093
+ # @yield [item] Block to execute for each item
1094
+ # @return [Native] Filtered array
1095
+ def js_filter(arr, &block)
1096
+ `#{arr}.filter(#{block})`
1097
+ end
1098
+
1099
+ # Reduce array with block
1100
+ # @param arr [Native] JavaScript array
1101
+ # @param initial [Object] Initial value
1102
+ # @yield [acc, item] Block to execute for each item
1103
+ # @return [Object] Reduced value
1104
+ def js_reduce(arr, initial, &block)
1105
+ `#{arr}.reduce(#{block}, #{initial})`
1106
+ end
1107
+
1108
+ # ForEach over array with block
1109
+ # @param arr [Native] JavaScript array
1110
+ # @yield [item, index] Block to execute for each item
1111
+ def js_each(arr, &block)
1112
+ `#{arr}.forEach(#{block})`
1113
+ end
1114
+
1115
+ # Slice array
1116
+ # @param arr [Native] JavaScript array
1117
+ # @param start [Integer] Start index
1118
+ # @param end_idx [Integer, nil] End index (optional)
1119
+ # @return [Native] Sliced array
1120
+ def js_slice(arr, start, end_idx = nil)
1121
+ if end_idx
1122
+ `#{arr}.slice(#{start}, #{end_idx})`
1123
+ else
1124
+ `#{arr}.slice(#{start})`
1125
+ end
1126
+ end
1127
+
1128
+ # ===== Object Methods =====
1129
+
1130
+ # Create empty JavaScript object
1131
+ # @return [Native] Empty JavaScript object
1132
+ def js_object
1133
+ `{}`
1134
+ end
1135
+
1136
+ # Get object keys
1137
+ # @param obj [Native] JavaScript object
1138
+ # @return [Native] Array of keys
1139
+ def js_keys(obj)
1140
+ `Object.keys(#{obj})`
1141
+ end
1142
+
1143
+ # Get object values
1144
+ # @param obj [Native] JavaScript object
1145
+ # @return [Native] Array of values
1146
+ def js_values(obj)
1147
+ `Object.values(#{obj})`
1148
+ end
1149
+
1150
+ # Get object entries
1151
+ # @param obj [Native] JavaScript object
1152
+ # @return [Native] Array of [key, value] pairs
1153
+ def js_entries(obj)
1154
+ `Object.entries(#{obj})`
1155
+ end
1156
+
1157
+ # Create Set from array and get size
1158
+ # @param arr [Native] JavaScript array
1159
+ # @return [Integer] Number of unique elements
1160
+ def js_unique_count(arr)
1161
+ `new Set(#{arr}).size`
1162
+ end
1163
+
1164
+ # ===== Fetch API =====
1165
+
1166
+ # Simple fetch that returns Promise-wrapped response
1167
+ # @param url [String] URL to fetch
1168
+ # @return [Native] Promise
1169
+ def js_fetch(url)
1170
+ `fetch(#{url})`
1171
+ end
1172
+
1173
+ # Fetch JSON from URL with callback
1174
+ # @param url [String] URL to fetch
1175
+ # @yield [data] Block to handle response data
1176
+ def fetch_json(url, &success_block)
1177
+ `
1178
+ fetch(#{url})
1179
+ .then(response => response.json())
1180
+ .then(data => #{success_block}.$call(data))
1181
+ .catch(error => console.error('Fetch error:', error))
1182
+ `
1183
+ end
1184
+
1185
+ # Fetch JSON from URL returning a Promise (for chaining)
1186
+ # @param url [String] URL to fetch
1187
+ # @return [Native] Promise that resolves to JSON data
1188
+ def fetch_json_promise(url)
1189
+ `fetch(#{url}).then(response => response.json())`
1190
+ end
1191
+
1192
+ # Fetch JSON with response validation
1193
+ # @param url [String] URL to fetch
1194
+ # @return [Native] Promise that resolves to JSON data or rejects on error
1195
+ def fetch_json_safe(url)
1196
+ `fetch(#{url}).then(function(response) { if (!response.ok) { throw new Error('Network response was not ok: ' + response.status); } return response.json(); })`
1197
+ end
1198
+
1199
+ # Fetch multiple URLs in parallel and get JSON results
1200
+ # @param urls [Array<String>] URLs to fetch
1201
+ # @return [Native] Promise that resolves to array of JSON results
1202
+ def fetch_all_json(urls)
1203
+ promises = urls.map { |url| fetch_json_promise(url) }
1204
+ `Promise.all(#{promises})`
1205
+ end
1206
+
1207
+ # Fetch JSON with success and error callbacks
1208
+ # @param url [String] URL to fetch
1209
+ # @param on_success [Proc] Success callback receiving data
1210
+ # @param on_error [Proc] Error callback receiving error
1211
+ def fetch_json_with_handlers(url, on_success:, on_error: nil)
1212
+ promise = fetch_json_safe(url)
1213
+ promise = js_then(promise) { |data| on_success.call(data) }
1214
+ if on_error
1215
+ js_catch(promise) { |error| on_error.call(error) }
1216
+ else
1217
+ js_catch(promise) { |error| console_error('Fetch error:', error) }
1218
+ end
1219
+ end
1220
+
1221
+ # ===== Promise Methods =====
1222
+
1223
+ # Create Promise.all from array of promises
1224
+ # @param promises [Array<Native>] Array of promises
1225
+ # @return [Native] Promise that resolves when all complete
1226
+ def promise_all(promises)
1227
+ `Promise.all(#{promises})`
1228
+ end
1229
+
1230
+ # Create Promise.race from array of promises
1231
+ # @param promises [Array<Native>] Array of promises
1232
+ # @return [Native] Promise that resolves when first completes
1233
+ def promise_race(promises)
1234
+ `Promise.race(#{promises})`
1235
+ end
1236
+
1237
+ # Create a resolved Promise with value
1238
+ # @param value [Object] Value to resolve with
1239
+ # @return [Native] Resolved Promise
1240
+ def promise_resolve(value)
1241
+ native_value = value.respond_to?(:to_n) ? value.to_n : value
1242
+ `Promise.resolve(#{native_value})`
1243
+ end
1244
+
1245
+ # Create a rejected Promise with error
1246
+ # @param error [Object] Error to reject with
1247
+ # @return [Native] Rejected Promise
1248
+ def promise_reject(error)
1249
+ `Promise.reject(#{error})`
1250
+ end
1251
+
1252
+ # Add then handler to promise
1253
+ # @param promise [Native] JavaScript Promise
1254
+ # @yield [value] Block to handle resolved value
1255
+ # @return [Native] New Promise
1256
+ def js_then(promise, &block)
1257
+ `#{promise}.then(#{block})`
1258
+ end
1259
+
1260
+ # Add catch handler to promise
1261
+ # @param promise [Native] JavaScript Promise
1262
+ # @yield [error] Block to handle rejection
1263
+ # @return [Native] New Promise
1264
+ def js_catch(promise, &block)
1265
+ `#{promise}.catch(#{block})`
1266
+ end
1267
+
1268
+ # Add finally handler to promise
1269
+ # @param promise [Native] JavaScript Promise
1270
+ # @yield Block to execute regardless of outcome
1271
+ # @return [Native] New Promise
1272
+ def js_finally(promise, &block)
1273
+ `#{promise}.finally(#{block})`
1274
+ end
1275
+
1276
+ private
1277
+
1278
+ # Convert snake_case to camelCase, preserving existing camelCase
1279
+ # @param name [Symbol, String] The name to convert
1280
+ # @param capitalize_first [Boolean] Whether to capitalize first letter
1281
+ # @return [String] camelCase string
1282
+ def camelize(name, capitalize_first = true)
1283
+ str = name.to_s
1284
+
1285
+ # If no underscores, assume already camelCase - just adjust first letter
1286
+ unless str.include?('_')
1287
+ if capitalize_first
1288
+ return str[0].upcase + str[1..-1].to_s
1289
+ else
1290
+ return str[0].downcase + str[1..-1].to_s
1291
+ end
1292
+ end
1293
+
1294
+ # Convert snake_case to camelCase
1295
+ parts = str.split('_')
1296
+ if capitalize_first
1297
+ parts.map(&:capitalize).join
1298
+ else
1299
+ ([parts.first] + parts[1..-1].map(&:capitalize)).join
1300
+ end
1301
+ end
1302
+ end
1303
+ end
1304
+ end
1305
+ end
1306
+
1307
+ # Alias for backward compatibility
1308
+ StimulusHelpers = OpalVite::Concerns::V1::StimulusHelpers