opal-vite 0.3.3 → 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: '009e15bfd7bc8509c7e6dc12d945c6138bf14c86b50c933c246fea83516fa893'
4
- data.tar.gz: 2e44cfd7e86f661d3b10c355d08a63303f75fd4ff28c3b63471fb8a7f9252fa5
3
+ metadata.gz: d450e521fc2311f795d94b0a92a4d7b8f6e2b676d6320309fb8b3f69696e27ad
4
+ data.tar.gz: '05863ede7a7b4e8f44beceb2d3a7d0438d8e75102fde5dc496acb2251254d126'
5
5
  SHA512:
6
- metadata.gz: 5d0220cc638f3865580bb29cc72a2a40a24c3b0c03ed477f985ac19b229a81d5ae083b58c02aefd4a6ab2a02287af7c80182e85d1d35df6056f6060548743d05
7
- data.tar.gz: 5ec45efb48c602f941bc104ea94eb464ce9cc2bac24ee24bac7ea4453c5481fedd368418dd8b077a4f1ca424766f2f52b29e7430e9ab56b4621ad70a52fc4b95
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.3"
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,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
@@ -9,3 +9,5 @@ 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
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.3
4
+ version: 0.3.4
5
5
  platform: ruby
6
6
  authors:
7
7
  - stofu1234
@@ -120,6 +120,7 @@ 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
124
125
  - opal/opal_vite/concerns/v1/debug_helpers.rb
125
126
  - opal/opal_vite/concerns/v1/dom_helpers.rb
@@ -128,6 +129,7 @@ files:
128
129
  - opal/opal_vite/concerns/v1/stimulus_helpers.rb
129
130
  - opal/opal_vite/concerns/v1/storable.rb
130
131
  - opal/opal_vite/concerns/v1/toastable.rb
132
+ - opal/opal_vite/concerns/v1/turbo_helpers.rb
131
133
  - opal/opal_vite/concerns/v1/uri_helpers.rb
132
134
  - opal/opal_vite/concerns/v1/vue_helpers.rb
133
135
  - opal/opal_vite/concerns/vue_helpers.rb