opal-vite 0.3.2 → 0.3.4

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.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 5491e78891b38d4e302b86941b5bce99574452f0e361bad6c109711f9e0e247d
4
- data.tar.gz: 70c7080aa2c98fc641c28e63c077aed1a601705a0c3b380031c0e822b7a6f1be
3
+ metadata.gz: d450e521fc2311f795d94b0a92a4d7b8f6e2b676d6320309fb8b3f69696e27ad
4
+ data.tar.gz: '05863ede7a7b4e8f44beceb2d3a7d0438d8e75102fde5dc496acb2251254d126'
5
5
  SHA512:
6
- metadata.gz: 5951f4e205e1c6415062830331007db5592a51d8672f025a02cbee24ac4eef2239895c5f6ceb35e2a7c3b96187be54e80e85c032671e45640b815011e3fc98b7
7
- data.tar.gz: c36245a857819c721caa5a5ab98f5beaeea97babf49168018abe84a104864696c19c3f34bd7393c9a2076b81599c3702babbd208e35a619239ae7336955a6905
6
+ metadata.gz: 390e78cf526a75c960387caaf4844923fe68814f271e11394a536d198cdcae1fbdaaca31290ddc58b60e6c0a4fe5b0d4d2598669ef246b040f3342b21892b51d
7
+ data.tar.gz: a15d5f251a90702fd67d93639b40a939d073fa43b008ec29693c44f2a32875e04d955b372547e74e4fb1c3355a200cb20012ffa7bb556a7fa0fb42c8618a7284
@@ -1,5 +1,5 @@
1
1
  module Opal
2
2
  module Vite
3
- VERSION = "0.3.2"
3
+ VERSION = "0.3.4"
4
4
  end
5
5
  end
@@ -0,0 +1,379 @@
1
+ # backtick_javascript: true
2
+
3
+ module OpalVite
4
+ module Concerns
5
+ module V1
6
+ # ActionCableHelpers - Ruby-friendly DSL for ActionCable WebSocket communication
7
+ #
8
+ # This module provides methods for connecting to and interacting with
9
+ # Rails ActionCable channels from Opal/Stimulus controllers.
10
+ #
11
+ # Prerequisites:
12
+ # - @rails/actioncable package must be installed (npm install @rails/actioncable)
13
+ # - ActionCable consumer must be imported in your JavaScript entry point
14
+ #
15
+ # Usage:
16
+ # # In your main.js:
17
+ # import { createConsumer } from "@rails/actioncable"
18
+ # window.ActionCable = { createConsumer }
19
+ #
20
+ # # In your Opal controller:
21
+ # class ChatController < StimulusController
22
+ # include OpalVite::Concerns::V1::ActionCableHelpers
23
+ #
24
+ # def connect
25
+ # cable_connect("/cable")
26
+ # subscribe_to("ChatChannel", room_id: 1) do |subscription|
27
+ # on_cable_received(subscription) do |data|
28
+ # append_message(data)
29
+ # end
30
+ # end
31
+ # end
32
+ #
33
+ # def disconnect
34
+ # cable_disconnect
35
+ # end
36
+ #
37
+ # def send_message
38
+ # cable_perform(:speak, message: target_value(:input))
39
+ # end
40
+ # end
41
+ module ActionCableHelpers
42
+ # ===== Consumer Management =====
43
+
44
+ # Create and store an ActionCable consumer
45
+ # @param url [String] WebSocket URL (e.g., "/cable" or "wss://example.com/cable")
46
+ # @return [Native] ActionCable consumer instance
47
+ # @example
48
+ # cable_connect("/cable")
49
+ # cable_connect("wss://api.example.com/cable")
50
+ def cable_connect(url = "/cable")
51
+ @_cable_consumer = `window.ActionCable.createConsumer(#{url})`
52
+ end
53
+
54
+ # Get the current ActionCable consumer
55
+ # @return [Native, nil] Consumer instance or nil
56
+ def cable_consumer
57
+ @_cable_consumer
58
+ end
59
+
60
+ # Disconnect and cleanup the ActionCable consumer
61
+ def cable_disconnect
62
+ return unless @_cable_consumer
63
+ `#{@_cable_consumer}.disconnect()`
64
+ @_cable_subscriptions = nil
65
+ @_cable_consumer = nil
66
+ end
67
+
68
+ # Check if ActionCable is connected
69
+ # @return [Boolean] true if connected
70
+ def cable_connected?
71
+ return false unless @_cable_consumer
72
+ `#{@_cable_consumer}.connection.isOpen()`
73
+ end
74
+
75
+ # ===== Subscription Management =====
76
+
77
+ # Subscribe to an ActionCable channel
78
+ # @param channel_name [String] Channel class name (e.g., "ChatChannel")
79
+ # @param params [Hash] Channel parameters (e.g., room_id: 1)
80
+ # @yield [subscription] Block that receives the subscription for setup
81
+ # @return [Native] Subscription instance
82
+ # @example
83
+ # subscribe_to("ChatChannel", room_id: 1) do |subscription|
84
+ # on_cable_connected(subscription) { puts "Connected!" }
85
+ # on_cable_received(subscription) { |data| handle_data(data) }
86
+ # end
87
+ def subscribe_to(channel_name, params = {}, &setup_block)
88
+ raise "Cable not connected. Call cable_connect first." unless @_cable_consumer
89
+
90
+ @_cable_subscriptions ||= {}
91
+ subscription_params = { channel: channel_name }.merge(params)
92
+ native_params = subscription_params.to_n
93
+
94
+ # Create subscription with empty callbacks (will be set via helpers)
95
+ subscription = `#{@_cable_consumer}.subscriptions.create(#{native_params}, {
96
+ connected: function() {},
97
+ disconnected: function() {},
98
+ received: function(data) {},
99
+ rejected: function() {}
100
+ })`
101
+
102
+ # Store subscription with a key
103
+ key = cable_subscription_key(channel_name, params)
104
+ @_cable_subscriptions[key] = subscription
105
+
106
+ # Yield for setting up callbacks
107
+ setup_block.call(subscription) if setup_block
108
+
109
+ subscription
110
+ end
111
+
112
+ # Unsubscribe from a channel
113
+ # @param channel_name [String] Channel class name
114
+ # @param params [Hash] Channel parameters
115
+ def unsubscribe_from(channel_name, params = {})
116
+ return unless @_cable_subscriptions
117
+ key = cable_subscription_key(channel_name, params)
118
+ subscription = @_cable_subscriptions.delete(key)
119
+ `#{subscription}.unsubscribe()` if subscription
120
+ end
121
+
122
+ # Get a subscription by channel name and params
123
+ # @param channel_name [String] Channel class name
124
+ # @param params [Hash] Channel parameters
125
+ # @return [Native, nil] Subscription instance or nil
126
+ def get_subscription(channel_name, params = {})
127
+ return nil unless @_cable_subscriptions
128
+ key = cable_subscription_key(channel_name, params)
129
+ @_cable_subscriptions[key]
130
+ end
131
+
132
+ # ===== Subscription Callbacks =====
133
+
134
+ # Set the connected callback for a subscription
135
+ # @param subscription [Native] Subscription instance
136
+ # @yield Block to execute when connected
137
+ def on_cable_connected(subscription, &block)
138
+ `#{subscription}.connected = function() { #{block.call} }`
139
+ end
140
+
141
+ # Set the disconnected callback for a subscription
142
+ # @param subscription [Native] Subscription instance
143
+ # @yield Block to execute when disconnected
144
+ def on_cable_disconnected(subscription, &block)
145
+ `#{subscription}.disconnected = function() { #{block.call} }`
146
+ end
147
+
148
+ # Set the received callback for a subscription
149
+ # @param subscription [Native] Subscription instance
150
+ # @yield [data] Block that receives incoming data
151
+ def on_cable_received(subscription, &block)
152
+ `#{subscription}.received = function(data) { #{block.call(`data`)} }`
153
+ end
154
+
155
+ # Set the rejected callback for a subscription
156
+ # @param subscription [Native] Subscription instance
157
+ # @yield Block to execute when subscription is rejected
158
+ def on_cable_rejected(subscription, &block)
159
+ `#{subscription}.rejected = function() { #{block.call} }`
160
+ end
161
+
162
+ # ===== Sending Data =====
163
+
164
+ # Perform an action on the default/first subscription
165
+ # @param action [Symbol, String] Action name (server-side method)
166
+ # @param data [Hash] Data to send
167
+ # @example
168
+ # cable_perform(:speak, message: "Hello!")
169
+ # cable_perform(:typing, user_id: current_user_id)
170
+ def cable_perform(action, data = {})
171
+ subscription = default_subscription
172
+ raise "No active subscription" unless subscription
173
+ perform_on(subscription, action, data)
174
+ end
175
+
176
+ # Perform an action on a specific subscription
177
+ # @param subscription [Native] Subscription instance
178
+ # @param action [Symbol, String] Action name
179
+ # @param data [Hash] Data to send
180
+ def perform_on(subscription, action, data = {})
181
+ native_data = data.to_n
182
+ `#{subscription}.perform(#{action.to_s}, #{native_data})`
183
+ end
184
+
185
+ # Send raw data on a subscription
186
+ # @param subscription [Native] Subscription instance
187
+ # @param data [Hash] Data to send
188
+ def cable_send(subscription, data)
189
+ native_data = data.to_n
190
+ `#{subscription}.send(#{native_data})`
191
+ end
192
+
193
+ # ===== Convenience Methods =====
194
+
195
+ # Subscribe and setup all callbacks in one call
196
+ # @param channel_name [String] Channel class name
197
+ # @param params [Hash] Channel parameters
198
+ # @param on_connected [Proc] Connected callback
199
+ # @param on_disconnected [Proc] Disconnected callback
200
+ # @param on_received [Proc] Received callback (receives data)
201
+ # @param on_rejected [Proc] Rejected callback
202
+ # @return [Native] Subscription instance
203
+ def cable_subscribe(channel_name, params: {}, on_connected: nil, on_disconnected: nil, on_received: nil, on_rejected: nil)
204
+ raise "Cable not connected. Call cable_connect first." unless @_cable_consumer
205
+
206
+ @_cable_subscriptions ||= {}
207
+ subscription_params = { channel: channel_name }.merge(params)
208
+ native_params = subscription_params.to_n
209
+
210
+ # Create subscription with callbacks directly
211
+ subscription = `#{@_cable_consumer}.subscriptions.create(#{native_params}, {
212
+ connected: function() {
213
+ #{on_connected.call if on_connected}
214
+ },
215
+ disconnected: function() {
216
+ #{on_disconnected.call if on_disconnected}
217
+ },
218
+ received: function(data) {
219
+ #{on_received.call(`data`) if on_received}
220
+ },
221
+ rejected: function() {
222
+ #{on_rejected.call if on_rejected}
223
+ }
224
+ })`
225
+
226
+ # Store subscription with a key
227
+ key = cable_subscription_key(channel_name, params)
228
+ @_cable_subscriptions[key] = subscription
229
+
230
+ subscription
231
+ end
232
+
233
+ # Quick subscription setup for simple channels
234
+ # @param channel_name [String] Channel class name
235
+ # @param params [Hash] Channel parameters
236
+ # @yield [data] Block that handles received data
237
+ # @return [Native] Subscription instance
238
+ # @example
239
+ # quick_subscribe("NotificationChannel") do |data|
240
+ # show_notification(data["message"])
241
+ # end
242
+ def quick_subscribe(channel_name, params = {}, &on_received)
243
+ subscribe_to(channel_name, params) do |subscription|
244
+ on_cable_received(subscription) { |data| on_received.call(data) } if on_received
245
+ end
246
+ end
247
+
248
+ # ===== Broadcast Helpers =====
249
+ # These methods help handle specific broadcast patterns
250
+
251
+ # Handle a broadcast that contains HTML to insert
252
+ # @param data [Native] Received data object
253
+ # @param target_selector [String] CSS selector for target element
254
+ # @param position [Symbol] Insert position (:append, :prepend, :replace, :before, :after)
255
+ def handle_html_broadcast(data, target_selector, position = :append)
256
+ html = `#{data}.html || #{data}.content || #{data}.body || ""`
257
+ return if `#{html} === ""`
258
+
259
+ target = `document.querySelector(#{target_selector})`
260
+ return unless `#{target}`
261
+
262
+ case position
263
+ when :append
264
+ `#{target}.insertAdjacentHTML('beforeend', #{html})`
265
+ when :prepend
266
+ `#{target}.insertAdjacentHTML('afterbegin', #{html})`
267
+ when :replace
268
+ `#{target}.innerHTML = #{html}`
269
+ when :before
270
+ `#{target}.insertAdjacentHTML('beforebegin', #{html})`
271
+ when :after
272
+ `#{target}.insertAdjacentHTML('afterend', #{html})`
273
+ end
274
+ end
275
+
276
+ # Handle a broadcast that updates a specific element
277
+ # @param data [Native] Received data with id and content
278
+ # @param id_key [String] Key in data containing element ID
279
+ # @param content_key [String] Key in data containing new content
280
+ def handle_update_broadcast(data, id_key = "id", content_key = "content")
281
+ element_id = `#{data}[#{id_key}]`
282
+ content = `#{data}[#{content_key}]`
283
+ return if `#{element_id} === undefined || #{content} === undefined`
284
+
285
+ element = `document.getElementById(#{element_id})`
286
+ `#{element}.innerHTML = #{content}` if `#{element}`
287
+ end
288
+
289
+ # Handle a broadcast that removes an element
290
+ # @param data [Native] Received data with id
291
+ # @param id_key [String] Key in data containing element ID
292
+ def handle_remove_broadcast(data, id_key = "id")
293
+ element_id = `#{data}[#{id_key}]`
294
+ return if `#{element_id} === undefined`
295
+
296
+ element = `document.getElementById(#{element_id})`
297
+ `#{element}.remove()` if `#{element}`
298
+ end
299
+
300
+ # ===== Data Extraction Helpers =====
301
+
302
+ # Extract a value from received ActionCable data
303
+ # @param data [Native] Received data object
304
+ # @param key [String, Symbol] Key to extract
305
+ # @param default [Object] Default value if key doesn't exist
306
+ # @return [Object] Extracted value or default
307
+ def cable_data(data, key, default = nil)
308
+ key_s = key.to_s
309
+ value = `#{data}[#{key_s}]`
310
+ `#{value} === undefined` ? default : value
311
+ end
312
+
313
+ # Extract and parse a JSON string from received data
314
+ # @param data [Native] Received data object
315
+ # @param key [String, Symbol] Key containing JSON string
316
+ # @return [Native] Parsed JSON object or nil
317
+ def cable_data_json(data, key)
318
+ json_str = cable_data(data, key)
319
+ return nil if json_str.nil?
320
+ `JSON.parse(#{json_str})`
321
+ rescue
322
+ nil
323
+ end
324
+
325
+ # Check if received data has a specific key
326
+ # @param data [Native] Received data object
327
+ # @param key [String, Symbol] Key to check
328
+ # @return [Boolean] true if key exists
329
+ def cable_data_has?(data, key)
330
+ key_s = key.to_s
331
+ `#{data}.hasOwnProperty(#{key_s})`
332
+ end
333
+
334
+ # Get data type from received data (for routing actions)
335
+ # @param data [Native] Received data object
336
+ # @param type_key [String] Key containing type/action name
337
+ # @return [String, nil] Type value
338
+ def cable_data_type(data, type_key = "type")
339
+ cable_data(data, type_key)
340
+ end
341
+
342
+ # Route incoming data based on type/action
343
+ # @param data [Native] Received data object
344
+ # @param handlers [Hash] Map of type => handler proc
345
+ # @param type_key [String] Key containing type/action name
346
+ # @example
347
+ # on_cable_received(subscription) do |data|
348
+ # cable_route(data, {
349
+ # "message" => -> { handle_message(data) },
350
+ # "typing" => -> { handle_typing(data) },
351
+ # "presence" => -> { handle_presence(data) }
352
+ # })
353
+ # end
354
+ def cable_route(data, handlers, type_key = "type")
355
+ data_type = cable_data_type(data, type_key)
356
+ return unless data_type
357
+ handler = handlers[data_type] || handlers[data_type.to_sym]
358
+ handler&.call
359
+ end
360
+
361
+ private
362
+
363
+ # Generate a unique key for subscription storage
364
+ def cable_subscription_key(channel_name, params)
365
+ "#{channel_name}:#{params.to_a.sort.map { |k, v| "#{k}=#{v}" }.join(',')}"
366
+ end
367
+
368
+ # Get the default (first) subscription
369
+ def default_subscription
370
+ return nil unless @_cable_subscriptions
371
+ @_cable_subscriptions.values.first
372
+ end
373
+ end
374
+ end
375
+ end
376
+ end
377
+
378
+ # Alias for backward compatibility
379
+ ActionCableHelpers = OpalVite::Concerns::V1::ActionCableHelpers
@@ -0,0 +1,234 @@
1
+ # backtick_javascript: true
2
+
3
+ module OpalVite
4
+ module Concerns
5
+ module V1
6
+ # DebugHelpers concern - provides debugging utilities for Opal applications
7
+ # Outputs structured debug information to browser console
8
+ module DebugHelpers
9
+ # Log a debug message with optional data
10
+ # @param message [String] The message to log
11
+ # @param data [Object] Optional data to include
12
+ def debug_log(message, data = nil)
13
+ return unless debug_enabled?
14
+
15
+ if data
16
+ `console.log('[DEBUG] ' + #{message}, #{data.to_n})`
17
+ else
18
+ `console.log('[DEBUG] ' + #{message})`
19
+ end
20
+ end
21
+
22
+ # Log a warning message
23
+ # @param message [String] The warning message
24
+ # @param data [Object] Optional data to include
25
+ def debug_warn(message, data = nil)
26
+ return unless debug_enabled?
27
+
28
+ if data
29
+ `console.warn('[WARN] ' + #{message}, #{data.to_n})`
30
+ else
31
+ `console.warn('[WARN] ' + #{message})`
32
+ end
33
+ end
34
+
35
+ # Log an error message
36
+ # @param message [String] The error message
37
+ # @param error [Object] Optional error object or data
38
+ def debug_error(message, error = nil)
39
+ if error
40
+ `console.error('[ERROR] ' + #{message}, #{error.to_n})`
41
+ else
42
+ `console.error('[ERROR] ' + #{message})`
43
+ end
44
+ end
45
+
46
+ # Log a group of related debug messages
47
+ # @param label [String] Group label
48
+ # @yield Block containing debug_log calls
49
+ def debug_group(label)
50
+ return yield unless debug_enabled?
51
+
52
+ `console.group('[DEBUG] ' + #{label})`
53
+ yield
54
+ `console.groupEnd()`
55
+ end
56
+
57
+ # Log a collapsed group of related debug messages
58
+ # @param label [String] Group label
59
+ # @yield Block containing debug_log calls
60
+ def debug_group_collapsed(label)
61
+ return yield unless debug_enabled?
62
+
63
+ `console.groupCollapsed('[DEBUG] ' + #{label})`
64
+ yield
65
+ `console.groupEnd()`
66
+ end
67
+
68
+ # Start a performance timer
69
+ # @param label [String] Timer label
70
+ def debug_time(label)
71
+ return unless debug_enabled?
72
+ `console.time('[TIMER] ' + #{label})`
73
+ end
74
+
75
+ # End a performance timer and log the elapsed time
76
+ # @param label [String] Timer label (must match the one used in debug_time)
77
+ def debug_time_end(label)
78
+ return unless debug_enabled?
79
+ `console.timeEnd('[TIMER] ' + #{label})`
80
+ end
81
+
82
+ # Log a table of data (useful for arrays/objects)
83
+ # @param data [Array, Hash] Data to display as table
84
+ def debug_table(data)
85
+ return unless debug_enabled?
86
+ `console.table(#{data.to_n})`
87
+ end
88
+
89
+ # Log object inspection with Ruby-style formatting
90
+ # @param obj [Object] Object to inspect
91
+ # @param label [String] Optional label
92
+ def debug_inspect(obj, label = nil)
93
+ return unless debug_enabled?
94
+
95
+ inspected = obj.inspect
96
+ if label
97
+ `console.log('[INSPECT] ' + #{label} + ':', #{inspected})`
98
+ else
99
+ `console.log('[INSPECT]', #{inspected})`
100
+ end
101
+ end
102
+
103
+ # Log the current call stack
104
+ # @param message [String] Optional message
105
+ def debug_trace(message = nil)
106
+ return unless debug_enabled?
107
+
108
+ if message
109
+ `console.trace('[TRACE] ' + #{message})`
110
+ else
111
+ `console.trace('[TRACE]')`
112
+ end
113
+ end
114
+
115
+ # Assert a condition and log error if false
116
+ # @param condition [Boolean] Condition to check
117
+ # @param message [String] Message to show if assertion fails
118
+ def debug_assert(condition, message = 'Assertion failed')
119
+ `console.assert(#{condition}, '[ASSERT] ' + #{message})`
120
+ end
121
+
122
+ # Count and log how many times this is called with the given label
123
+ # @param label [String] Counter label
124
+ def debug_count(label = 'default')
125
+ return unless debug_enabled?
126
+ `console.count('[COUNT] ' + #{label})`
127
+ end
128
+
129
+ # Reset the counter for the given label
130
+ # @param label [String] Counter label
131
+ def debug_count_reset(label = 'default')
132
+ return unless debug_enabled?
133
+ `console.countReset('[COUNT] ' + #{label})`
134
+ end
135
+
136
+ # Check if debugging is enabled
137
+ # Override this method to control debug output
138
+ # @return [Boolean] true if debugging is enabled
139
+ def debug_enabled?
140
+ # Check for debug flag in multiple places
141
+ return @debug_enabled unless @debug_enabled.nil?
142
+
143
+ # Check window.OPAL_DEBUG or localStorage debug setting
144
+ @debug_enabled = `
145
+ (typeof window !== 'undefined' &&
146
+ (window.OPAL_DEBUG === true ||
147
+ (typeof localStorage !== 'undefined' &&
148
+ localStorage.getItem('opal_debug') === 'true')))
149
+ `
150
+ end
151
+
152
+ # Enable debugging
153
+ def debug_enable!
154
+ @debug_enabled = true
155
+ `
156
+ if (typeof window !== 'undefined') {
157
+ window.OPAL_DEBUG = true;
158
+ if (typeof localStorage !== 'undefined') {
159
+ localStorage.setItem('opal_debug', 'true');
160
+ }
161
+ }
162
+ `
163
+ debug_log('Debug mode enabled')
164
+ end
165
+
166
+ # Disable debugging
167
+ def debug_disable!
168
+ debug_log('Debug mode disabled')
169
+ @debug_enabled = false
170
+ `
171
+ if (typeof window !== 'undefined') {
172
+ window.OPAL_DEBUG = false;
173
+ if (typeof localStorage !== 'undefined') {
174
+ localStorage.removeItem('opal_debug');
175
+ }
176
+ }
177
+ `
178
+ end
179
+
180
+ # Measure execution time of a block
181
+ # @param label [String] Label for the measurement
182
+ # @yield Block to measure
183
+ # @return [Object] Return value of the block
184
+ def debug_measure(label)
185
+ return yield unless debug_enabled?
186
+
187
+ start_time = `performance.now()`
188
+ result = yield
189
+ end_time = `performance.now()`
190
+ duration = end_time - start_time
191
+
192
+ `console.log('[PERF] ' + #{label} + ': ' + #{duration.round(2)} + 'ms')`
193
+ result
194
+ end
195
+
196
+ # Log Stimulus controller connection info
197
+ # @param controller [Object] Stimulus controller instance
198
+ def debug_stimulus_connect(controller = nil)
199
+ return unless debug_enabled?
200
+
201
+ ctrl = controller || self
202
+ name = ctrl.class.respond_to?(:stimulus_name) ? ctrl.class.stimulus_name : ctrl.class.name
203
+ `console.log('[STIMULUS] Connected:', #{name})`
204
+ end
205
+
206
+ # Log Stimulus controller disconnection info
207
+ # @param controller [Object] Stimulus controller instance
208
+ def debug_stimulus_disconnect(controller = nil)
209
+ return unless debug_enabled?
210
+
211
+ ctrl = controller || self
212
+ name = ctrl.class.respond_to?(:stimulus_name) ? ctrl.class.stimulus_name : ctrl.class.name
213
+ `console.log('[STIMULUS] Disconnected:', #{name})`
214
+ end
215
+
216
+ # Log Stimulus action info
217
+ # @param action_name [String] Name of the action
218
+ # @param event [Object] Event object
219
+ def debug_stimulus_action(action_name, event = nil)
220
+ return unless debug_enabled?
221
+
222
+ if event
223
+ `console.log('[STIMULUS] Action:', #{action_name}, 'Event:', #{event.to_n})`
224
+ else
225
+ `console.log('[STIMULUS] Action:', #{action_name})`
226
+ end
227
+ end
228
+ end
229
+ end
230
+ end
231
+ end
232
+
233
+ # Alias for backward compatibility
234
+ DebugHelpers = OpalVite::Concerns::V1::DebugHelpers
@@ -0,0 +1,472 @@
1
+ # backtick_javascript: true
2
+
3
+ module OpalVite
4
+ module Concerns
5
+ module V1
6
+ # TurboHelpers - Ruby-friendly DSL for Hotwire Turbo integration
7
+ #
8
+ # This module provides methods for interacting with Turbo Drive, Frames, and Streams
9
+ # from Opal/Stimulus controllers.
10
+ #
11
+ # Prerequisites:
12
+ # - @hotwired/turbo package must be installed (npm install @hotwired/turbo)
13
+ # - Turbo must be imported in your JavaScript entry point
14
+ #
15
+ # Usage:
16
+ # # In your main.js:
17
+ # import * as Turbo from "@hotwired/turbo"
18
+ # window.Turbo = Turbo
19
+ #
20
+ # # In your Opal controller:
21
+ # class NavigationController < StimulusController
22
+ # include OpalVite::Concerns::V1::TurboHelpers
23
+ #
24
+ # def navigate_to(event)
25
+ # turbo_visit("/dashboard")
26
+ # end
27
+ #
28
+ # def refresh_frame
29
+ # reload_turbo_frame("notifications")
30
+ # end
31
+ # end
32
+ module TurboHelpers
33
+ # ===== Turbo Drive =====
34
+
35
+ # Navigate to a URL using Turbo Drive
36
+ # @param url [String] URL to navigate to
37
+ # @param options [Hash] Visit options
38
+ # @option options [String] :action ("advance") "advance", "replace", or "restore"
39
+ # @option options [String] :frame Target frame ID (for frame navigation)
40
+ # @example
41
+ # turbo_visit("/users/1")
42
+ # turbo_visit("/login", action: "replace")
43
+ # turbo_visit("/modal-content", frame: "modal")
44
+ def turbo_visit(url, options = {})
45
+ native_options = options.to_n
46
+ if options.empty?
47
+ `window.Turbo.visit(#{url})`
48
+ else
49
+ `window.Turbo.visit(#{url}, #{native_options})`
50
+ end
51
+ end
52
+
53
+ # Navigate to a URL replacing the current history entry
54
+ # @param url [String] URL to navigate to
55
+ def turbo_replace(url)
56
+ turbo_visit(url, action: "replace")
57
+ end
58
+
59
+ # Clear the Turbo Drive cache
60
+ def turbo_clear_cache
61
+ `window.Turbo.cache.clear()`
62
+ end
63
+
64
+ # Enable Turbo Drive (if previously disabled)
65
+ def turbo_enable
66
+ `document.documentElement.removeAttribute('data-turbo')`
67
+ end
68
+
69
+ # Disable Turbo Drive globally
70
+ def turbo_disable
71
+ `document.documentElement.setAttribute('data-turbo', 'false')`
72
+ end
73
+
74
+ # Check if Turbo Drive is enabled
75
+ # @return [Boolean] true if enabled
76
+ def turbo_enabled?
77
+ `document.documentElement.getAttribute('data-turbo') !== 'false'`
78
+ end
79
+
80
+ # Set Turbo Drive progress bar delay
81
+ # @param delay [Integer] Delay in milliseconds
82
+ def turbo_progress_delay(delay)
83
+ `window.Turbo.setProgressBarDelay(#{delay})`
84
+ end
85
+
86
+ # ===== Turbo Frames =====
87
+
88
+ # Get a Turbo Frame element by ID
89
+ # @param frame_id [String] Frame ID
90
+ # @return [Native, nil] Frame element or nil
91
+ def get_turbo_frame(frame_id)
92
+ frame = `document.querySelector('turbo-frame#' + #{frame_id})`
93
+ `#{frame} === null` ? nil : frame
94
+ end
95
+
96
+ # Reload a Turbo Frame
97
+ # @param frame_id [String] Frame ID to reload
98
+ # @param url [String, nil] Optional URL to load (uses src attribute if nil)
99
+ def reload_turbo_frame(frame_id, url = nil)
100
+ frame = get_turbo_frame(frame_id)
101
+ return unless frame
102
+
103
+ if url
104
+ `#{frame}.src = #{url}`
105
+ else
106
+ # Trigger reload by re-setting src
107
+ current_src = `#{frame}.src`
108
+ `#{frame}.src = #{current_src}`
109
+ end
110
+ end
111
+
112
+ # Set the src attribute of a Turbo Frame
113
+ # @param frame_id [String] Frame ID
114
+ # @param url [String] URL to load
115
+ def set_frame_src(frame_id, url)
116
+ frame = get_turbo_frame(frame_id)
117
+ `#{frame}.src = #{url}` if frame
118
+ end
119
+
120
+ # Disable a Turbo Frame (prevent loading)
121
+ # @param frame_id [String] Frame ID
122
+ def disable_turbo_frame(frame_id)
123
+ frame = get_turbo_frame(frame_id)
124
+ `#{frame}.setAttribute('disabled', '')` if frame
125
+ end
126
+
127
+ # Enable a Turbo Frame
128
+ # @param frame_id [String] Frame ID
129
+ def enable_turbo_frame(frame_id)
130
+ frame = get_turbo_frame(frame_id)
131
+ `#{frame}.removeAttribute('disabled')` if frame
132
+ end
133
+
134
+ # Check if a Turbo Frame is loading
135
+ # @param frame_id [String] Frame ID
136
+ # @return [Boolean] true if loading
137
+ def frame_loading?(frame_id)
138
+ frame = get_turbo_frame(frame_id)
139
+ return false unless frame
140
+ `#{frame}.hasAttribute('busy')`
141
+ end
142
+
143
+ # Wait for a Turbo Frame to finish loading
144
+ # @param frame_id [String] Frame ID
145
+ # @yield Block to execute when loaded
146
+ def on_frame_loaded(frame_id, &block)
147
+ frame = get_turbo_frame(frame_id)
148
+ return unless frame
149
+
150
+ if frame_loading?(frame_id)
151
+ `#{frame}.addEventListener('turbo:frame-load', function handler() {
152
+ #{frame}.removeEventListener('turbo:frame-load', handler);
153
+ #{block.call};
154
+ })`
155
+ else
156
+ block.call
157
+ end
158
+ end
159
+
160
+ # Get the target frame from current event
161
+ # @return [Native, nil] Frame element from event
162
+ def event_turbo_frame
163
+ `event.target.closest('turbo-frame')`
164
+ end
165
+
166
+ # ===== Turbo Streams =====
167
+
168
+ # Render a Turbo Stream action
169
+ # @param action [Symbol, String] Stream action (:append, :prepend, :replace, :update, :remove, :before, :after)
170
+ # @param target [String] Target element ID
171
+ # @param html [String] HTML content (not needed for :remove)
172
+ # @example
173
+ # turbo_stream(:append, "messages", "<div>New message</div>")
174
+ # turbo_stream(:remove, "message_1")
175
+ def turbo_stream(action, target, html = nil)
176
+ action_s = action.to_s
177
+ template_content = html ? "<template>#{html}</template>" : ""
178
+
179
+ stream_html = %{<turbo-stream action="#{action_s}" target="#{target}">#{template_content}</turbo-stream>}
180
+ render_turbo_stream(stream_html)
181
+ end
182
+
183
+ # Render raw Turbo Stream HTML
184
+ # @param stream_html [String] Full turbo-stream element HTML
185
+ def render_turbo_stream(stream_html)
186
+ `window.Turbo.renderStreamMessage(#{stream_html})`
187
+ end
188
+
189
+ # Append content to target element via Turbo Stream
190
+ # @param target [String] Target element ID
191
+ # @param html [String] HTML content to append
192
+ def turbo_append(target, html)
193
+ turbo_stream(:append, target, html)
194
+ end
195
+
196
+ # Prepend content to target element via Turbo Stream
197
+ # @param target [String] Target element ID
198
+ # @param html [String] HTML content to prepend
199
+ def turbo_prepend(target, html)
200
+ turbo_stream(:prepend, target, html)
201
+ end
202
+
203
+ # Replace target element via Turbo Stream
204
+ # @param target [String] Target element ID
205
+ # @param html [String] HTML content to replace with
206
+ def turbo_replace_element(target, html)
207
+ turbo_stream(:replace, target, html)
208
+ end
209
+
210
+ # Update target element's content via Turbo Stream
211
+ # @param target [String] Target element ID
212
+ # @param html [String] HTML content to update with
213
+ def turbo_update(target, html)
214
+ turbo_stream(:update, target, html)
215
+ end
216
+
217
+ # Remove target element via Turbo Stream
218
+ # @param target [String] Target element ID
219
+ def turbo_remove(target)
220
+ turbo_stream(:remove, target)
221
+ end
222
+
223
+ # Insert content before target element via Turbo Stream
224
+ # @param target [String] Target element ID
225
+ # @param html [String] HTML content to insert
226
+ def turbo_before(target, html)
227
+ turbo_stream(:before, target, html)
228
+ end
229
+
230
+ # Insert content after target element via Turbo Stream
231
+ # @param target [String] Target element ID
232
+ # @param html [String] HTML content to insert
233
+ def turbo_after(target, html)
234
+ turbo_stream(:after, target, html)
235
+ end
236
+
237
+ # Create multiple Turbo Stream operations
238
+ # @yield Block that builds stream operations
239
+ # @example
240
+ # turbo_streams do |s|
241
+ # s.append("messages", "<div>Message 1</div>")
242
+ # s.prepend("notifications", "<div>Alert!</div>")
243
+ # s.remove("loading-indicator")
244
+ # end
245
+ def turbo_streams(&block)
246
+ builder = TurboStreamBuilder.new
247
+ block.call(builder)
248
+ builder.render
249
+ end
250
+
251
+ # ===== Turbo Events =====
252
+
253
+ # Listen for Turbo Drive events
254
+ # @param event_name [String] Event name (without 'turbo:' prefix)
255
+ # @yield [event] Block to execute when event fires
256
+ # @example
257
+ # on_turbo("before-visit") { |e| validate_form }
258
+ # on_turbo("load") { init_components }
259
+ def on_turbo(event_name, &block)
260
+ full_name = event_name.start_with?("turbo:") ? event_name : "turbo:#{event_name}"
261
+ `document.addEventListener(#{full_name}, function(event) { #{block.call(`event`)} })`
262
+ end
263
+
264
+ # Listen for turbo:before-visit - cancel navigation
265
+ # @yield [event] Block to execute (call event.preventDefault to cancel)
266
+ def on_turbo_before_visit(&block)
267
+ on_turbo("before-visit", &block)
268
+ end
269
+
270
+ # Listen for turbo:visit - navigation started
271
+ # @yield [event] Block to execute
272
+ def on_turbo_visit(&block)
273
+ on_turbo("visit", &block)
274
+ end
275
+
276
+ # Listen for turbo:load - page fully loaded
277
+ # @yield [event] Block to execute
278
+ def on_turbo_load(&block)
279
+ on_turbo("load", &block)
280
+ end
281
+
282
+ # Listen for turbo:render - page rendered
283
+ # @yield [event] Block to execute
284
+ def on_turbo_render(&block)
285
+ on_turbo("render", &block)
286
+ end
287
+
288
+ # Listen for turbo:before-fetch-request - before fetch
289
+ # @yield [event] Block to execute
290
+ def on_turbo_before_fetch(&block)
291
+ on_turbo("before-fetch-request", &block)
292
+ end
293
+
294
+ # Listen for turbo:submit-start - form submission started
295
+ # @yield [event] Block to execute
296
+ def on_turbo_submit_start(&block)
297
+ on_turbo("submit-start", &block)
298
+ end
299
+
300
+ # Listen for turbo:submit-end - form submission ended
301
+ # @yield [event] Block to execute
302
+ def on_turbo_submit_end(&block)
303
+ on_turbo("submit-end", &block)
304
+ end
305
+
306
+ # Listen for turbo:frame-load - frame loaded
307
+ # @yield [event] Block to execute
308
+ def on_turbo_frame_load(&block)
309
+ on_turbo("frame-load", &block)
310
+ end
311
+
312
+ # Listen for turbo:before-stream-render - before stream renders
313
+ # @yield [event] Block to execute
314
+ def on_turbo_before_stream(&block)
315
+ on_turbo("before-stream-render", &block)
316
+ end
317
+
318
+ # ===== Form Helpers =====
319
+
320
+ # Submit a form via Turbo
321
+ # @param form_element [Native] Form element
322
+ def turbo_submit(form_element)
323
+ el = form_element.respond_to?(:to_n) ? form_element.to_n : form_element
324
+ `#{el}.requestSubmit()`
325
+ end
326
+
327
+ # Submit a form with custom target frame
328
+ # @param form_element [Native] Form element
329
+ # @param frame_id [String] Target frame ID
330
+ def turbo_submit_to_frame(form_element, frame_id)
331
+ el = form_element.respond_to?(:to_n) ? form_element.to_n : form_element
332
+ `#{el}.setAttribute('data-turbo-frame', #{frame_id})`
333
+ `#{el}.requestSubmit()`
334
+ end
335
+
336
+ # Disable Turbo on a specific form
337
+ # @param form_element [Native] Form element
338
+ def disable_turbo_form(form_element)
339
+ el = form_element.respond_to?(:to_n) ? form_element.to_n : form_element
340
+ `#{el}.setAttribute('data-turbo', 'false')`
341
+ end
342
+
343
+ # Enable Turbo on a specific form
344
+ # @param form_element [Native] Form element
345
+ def enable_turbo_form(form_element)
346
+ el = form_element.respond_to?(:to_n) ? form_element.to_n : form_element
347
+ `#{el}.removeAttribute('data-turbo')`
348
+ end
349
+
350
+ # ===== Stream over SSE =====
351
+
352
+ # Connect to a Turbo Stream over SSE endpoint
353
+ # @param url [String] SSE endpoint URL
354
+ # @return [Native] EventSource instance
355
+ # @example
356
+ # source = turbo_stream_from("/notifications/stream")
357
+ # # Turbo will automatically process incoming streams
358
+ def turbo_stream_from(url)
359
+ `window.Turbo.connectStreamSource(new EventSource(#{url}))`
360
+ end
361
+
362
+ # Disconnect a Turbo Stream SSE source
363
+ # @param source [Native] EventSource to disconnect
364
+ def turbo_stream_disconnect(source)
365
+ `window.Turbo.disconnectStreamSource(#{source})`
366
+ `#{source}.close()`
367
+ end
368
+
369
+ # ===== Confirmation Dialog =====
370
+
371
+ # Set custom Turbo confirmation handler
372
+ # @yield [message, element, submitter] Block that returns true/false
373
+ # @example
374
+ # turbo_confirm_method do |message, element, submitter|
375
+ # `window.confirm(message)`
376
+ # end
377
+ def turbo_confirm_method(&block)
378
+ `window.Turbo.setConfirmMethod(function(message, element, submitter) {
379
+ return #{block.call(`message`, `element`, `submitter`)};
380
+ })`
381
+ end
382
+
383
+ # ===== Morphing (Turbo 8+) =====
384
+
385
+ # Refresh the page using Turbo's morph feature
386
+ # @param options [Hash] Refresh options
387
+ # @option options [Boolean] :request_id Custom request ID
388
+ def turbo_refresh(options = {})
389
+ if options.empty?
390
+ `window.Turbo.visit(window.location.href, { action: 'replace' })`
391
+ else
392
+ native_options = options.merge(action: "replace").to_n
393
+ `window.Turbo.visit(window.location.href, #{native_options})`
394
+ end
395
+ end
396
+
397
+ # ===== Utility Methods =====
398
+
399
+ # Check if Turbo is available
400
+ # @return [Boolean] true if Turbo is loaded
401
+ def turbo_available?
402
+ `typeof window.Turbo !== 'undefined'`
403
+ end
404
+
405
+ # Get current Turbo Drive visit location
406
+ # @return [String, nil] Current URL or nil
407
+ def turbo_current_url
408
+ `window.Turbo.navigator.location?.href`
409
+ end
410
+
411
+ # Cancel a pending Turbo visit
412
+ def turbo_cancel_visit
413
+ `window.Turbo.navigator.stop()`
414
+ end
415
+
416
+ # Add loading class during Turbo navigation
417
+ # @param element [Native] Element to add class to
418
+ # @param class_name [String] Class name to toggle
419
+ def turbo_loading_class(element, class_name = "turbo-loading")
420
+ el = element.respond_to?(:to_n) ? element.to_n : element
421
+
422
+ on_turbo("before-fetch-request") { `#{el}.classList.add(#{class_name})` }
423
+ on_turbo("before-fetch-response") { `#{el}.classList.remove(#{class_name})` }
424
+ end
425
+ end
426
+
427
+ # Builder for creating multiple Turbo Stream operations
428
+ class TurboStreamBuilder
429
+ def initialize
430
+ @streams = []
431
+ end
432
+
433
+ def append(target, html)
434
+ @streams << %{<turbo-stream action="append" target="#{target}"><template>#{html}</template></turbo-stream>}
435
+ end
436
+
437
+ def prepend(target, html)
438
+ @streams << %{<turbo-stream action="prepend" target="#{target}"><template>#{html}</template></turbo-stream>}
439
+ end
440
+
441
+ def replace(target, html)
442
+ @streams << %{<turbo-stream action="replace" target="#{target}"><template>#{html}</template></turbo-stream>}
443
+ end
444
+
445
+ def update(target, html)
446
+ @streams << %{<turbo-stream action="update" target="#{target}"><template>#{html}</template></turbo-stream>}
447
+ end
448
+
449
+ def remove(target)
450
+ @streams << %{<turbo-stream action="remove" target="#{target}"></turbo-stream>}
451
+ end
452
+
453
+ def before(target, html)
454
+ @streams << %{<turbo-stream action="before" target="#{target}"><template>#{html}</template></turbo-stream>}
455
+ end
456
+
457
+ def after(target, html)
458
+ @streams << %{<turbo-stream action="after" target="#{target}"><template>#{html}</template></turbo-stream>}
459
+ end
460
+
461
+ def render
462
+ @streams.each do |stream_html|
463
+ `window.Turbo.renderStreamMessage(#{stream_html})`
464
+ end
465
+ end
466
+ end
467
+ end
468
+ end
469
+ end
470
+
471
+ # Alias for backward compatibility
472
+ TurboHelpers = OpalVite::Concerns::V1::TurboHelpers
@@ -8,3 +8,6 @@ require 'opal_vite/concerns/v1/vue_helpers'
8
8
  require 'opal_vite/concerns/v1/react_helpers'
9
9
  require 'opal_vite/concerns/v1/uri_helpers'
10
10
  require 'opal_vite/concerns/v1/base64_helpers'
11
+ require 'opal_vite/concerns/v1/debug_helpers'
12
+ require 'opal_vite/concerns/v1/action_cable_helpers'
13
+ require 'opal_vite/concerns/v1/turbo_helpers'
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: opal-vite
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.3.2
4
+ version: 0.3.4
5
5
  platform: ruby
6
6
  authors:
7
7
  - stofu1234
@@ -120,13 +120,16 @@ files:
120
120
  - opal/opal_vite/concerns/storable.rb
121
121
  - opal/opal_vite/concerns/toastable.rb
122
122
  - opal/opal_vite/concerns/v1.rb
123
+ - opal/opal_vite/concerns/v1/action_cable_helpers.rb
123
124
  - opal/opal_vite/concerns/v1/base64_helpers.rb
125
+ - opal/opal_vite/concerns/v1/debug_helpers.rb
124
126
  - opal/opal_vite/concerns/v1/dom_helpers.rb
125
127
  - opal/opal_vite/concerns/v1/js_proxy_ex.rb
126
128
  - opal/opal_vite/concerns/v1/react_helpers.rb
127
129
  - opal/opal_vite/concerns/v1/stimulus_helpers.rb
128
130
  - opal/opal_vite/concerns/v1/storable.rb
129
131
  - opal/opal_vite/concerns/v1/toastable.rb
132
+ - opal/opal_vite/concerns/v1/turbo_helpers.rb
130
133
  - opal/opal_vite/concerns/v1/uri_helpers.rb
131
134
  - opal/opal_vite/concerns/v1/vue_helpers.rb
132
135
  - opal/opal_vite/concerns/vue_helpers.rb