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