ultimate_turbo_modal 3.0.5 → 3.1.0

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: f50e8f99fb5d81b57e66ea6f46ee536d73ce52f4b637188450c044122b1d22a1
4
- data.tar.gz: 9b9d701149f0d326c3d775d4efca7d875982bbd2a630049dc2b4672816b84536
3
+ metadata.gz: e94fa20d1e6e7f5cf6939cb0a346af7159673c25dc287c88eaf58b08f65aab8c
4
+ data.tar.gz: 95a8f4beb5bbe52a39bca43a905e2e3bb2723d21e49e719896a318cf6302fcc0
5
5
  SHA512:
6
- metadata.gz: 2ddefb505bf8ce82a392f7338a74be53ec21c3bd70380234a6366d8662ceab4ee877c958cf676c3f7418f121981e32dab6ba10f6933bdd22fb524166e01b86a4
7
- data.tar.gz: b752b7d73e1812cff76bdab0102a5f00491373b3d2688c1a1172207f681fd3a84ac42ae00ed636564bd9c6da7609385331edae0d9997ac373294bf66736ac24d
6
+ metadata.gz: b3acb970be04442953aff94efbbf9f422c36d979a9d54993b697074b853ed2fb801f73d963f82dd7523c93f65728f05a646642398b6531a41a197bb10c98de98
7
+ data.tar.gz: 52e151ca88edc805d9d7d450c9a14ca09d58b7b3bb6ffedbdb40d59b5ea43cd60e4da12d0af70cb5a1570b5479f41601ff06c9d909747374603cd73e7a2761a3
data/CHANGELOG.md CHANGED
@@ -1,3 +1,11 @@
1
+ ## [Unreleased]
2
+
3
+ ## [3.1.0] - 2026-05-01
4
+
5
+ - Tweaked vertically centering of modals on desktop (≥640px) with a slight optical-center bias, instead of anchoring them near the top.
6
+ - Added support for opening a modal from inside a drawer. See [docs/modal-from-drawer.md](docs/modal-from-drawer.md).
7
+ - Fixed a race in `closeAllDialogs` when a stacked modal redirected off-page, which could leave a dialog open or animations in a bad state.
8
+
1
9
  ## [3.0.5] - 2026-04-22
2
10
 
3
11
  - Fixed modal being dismissed when a body-appended widget (e.g. flatpickr, Select2, Tippy) opens over its trigger between mousedown and mouseup. The browser fires `click` on the dialog (common ancestor), which was treated as a backdrop dismissal. The dialog now tracks mousedown origin and skips dismissal when the press started inside content.
data/Gemfile.lock CHANGED
@@ -1,7 +1,7 @@
1
1
  PATH
2
2
  remote: .
3
3
  specs:
4
- ultimate_turbo_modal (3.0.5)
4
+ ultimate_turbo_modal (3.1.0)
5
5
  actionpack (>= 8.0)
6
6
  activesupport (>= 8.0)
7
7
  phlex-rails (>= 2.0)
data/README.md CHANGED
@@ -203,6 +203,49 @@ Link to it the same way as a modal:
203
203
  | CSS string | Custom value, e.g. `"500px"` or `"50vw"` |
204
204
 
205
205
 
206
+ ## Opening a Modal from a Drawer
207
+
208
+ You can open a regular modal that stacks on top of an open drawer. This is built in — no setup or opt-in required.
209
+
210
+ Inside any drawer, link to a modal action with `data-turbo-frame="drawer-modal"`:
211
+
212
+ ```erb
213
+ <%= drawer(title: "Notifications") do %>
214
+ <p>Activity list here…</p>
215
+ <%= link_to "Edit preferences",
216
+ edit_preferences_path,
217
+ data: { turbo_frame: "drawer-modal" } %>
218
+ <% end %>
219
+ ```
220
+
221
+ The destination is rendered with the same `modal()` helper you use elsewhere:
222
+
223
+ ```erb
224
+ <%= modal(title: "Notification preferences") do %>
225
+ <p>Form here…</p>
226
+ <% end %>
227
+ ```
228
+
229
+ The gem detects the request and renders the modal into a stacked `<dialog>` that sits visually on top of the drawer.
230
+
231
+ ### Behavior
232
+
233
+ - **ESC** closes the modal first, then the drawer (native top-layer behavior).
234
+ - **Click outside** the modal closes the modal only; the drawer stays open.
235
+ - **`turbo_stream.modal(:close)`** closes the topmost dialog (the modal).
236
+ - **Form submission with same-page redirect** closes the modal smoothly; the drawer stays.
237
+ - **Form submission with a different-page redirect** closes both dialogs, then navigates.
238
+ - **Closing the drawer** (via close button, ESC after the modal closes, etc.) also tears down any modal opened from it.
239
+
240
+ ### Constraints
241
+
242
+ - Modal-from-drawer only. You cannot open a drawer from inside a modal, and you cannot stack a modal on top of another modal. The `drawer-modal` frame is only rendered inside drawers.
243
+ - Stacked modals always force `advance: false` (history is not pushed). All other modal options (overlay, padding, header, footer, etc.) work normally.
244
+ - Both backdrops are drawn when both dialogs have `overlay: true`. Pass `overlay: false` to the inner modal if you don't want the drawer to look slightly darker while the modal is open.
245
+
246
+ For a full lifecycle walkthrough and edge-case notes, see [docs/modal-from-drawer.md](docs/modal-from-drawer.md).
247
+
248
+
206
249
  ## Features and capabilities
207
250
 
208
251
  - Extremely easy to use
data/VERSION CHANGED
@@ -1 +1 @@
1
- 3.0.5
1
+ 3.1.0
@@ -1,14 +1,19 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  # Tailwind CSS v4
4
+ #
5
+ # Group selectors are scoped with the named groups `group/utmr-modal` and
6
+ # `group/utmr-drawer` so that when a modal stacks on top of an open drawer,
7
+ # the inner modal's transition styles don't pick up the outer drawer's
8
+ # `data-entered` state (which would prevent the modal from animating out).
4
9
  module UltimateTurboModal::Flavors
5
10
  class Tailwind < UltimateTurboModal::Base
6
- STYLES = "html:has(dialog#modal-container[open]) { overflow: hidden; }"
11
+ STYLES = "html:has(dialog#modal-container[open]), html:has(dialog#modal-container-stacked[open]) { overflow: hidden; }"
7
12
 
8
13
  # Modal constants
9
14
 
10
15
  MODAL_DIALOG_CLASSES = [
11
- "group",
16
+ "group/utmr-modal",
12
17
  # Dialog reset
13
18
  "fixed inset-0 p-0 m-0 border-none bg-transparent",
14
19
  "max-w-[100vw] max-h-dvh w-full h-full overflow-y-auto",
@@ -21,23 +26,23 @@ module UltimateTurboModal::Flavors
21
26
  ].join(" ")
22
27
 
23
28
  MODAL_INNER_CLASSES = [
24
- "flex min-h-full items-start justify-center pt-[10vh] sm:p-4",
29
+ "flex min-h-full items-start justify-center pt-[10vh] sm:items-center sm:pt-0 sm:p-4 sm:pb-[10vh]",
25
30
  # Transition
26
31
  "transition duration-300 ease-out",
27
- "group-data-[closing]:duration-200 group-data-[closing]:ease-in",
32
+ "group-data-[closing]/utmr-modal:duration-200 group-data-[closing]/utmr-modal:ease-in",
28
33
  # Default state (closed): faded + shifted
29
34
  "opacity-0 translate-y-4 sm:translate-y-0 sm:scale-95",
30
35
  # Entered state
31
- "group-data-[entered]:opacity-100 group-data-[entered]:translate-y-0 group-data-[entered]:scale-100"
36
+ "group-data-[entered]/utmr-modal:opacity-100 group-data-[entered]/utmr-modal:translate-y-0 group-data-[entered]/utmr-modal:scale-100"
32
37
  ].join(" ")
33
38
 
34
- MODAL_CONTENT_CLASSES = "relative transform max-h-screen overflow-hidden rounded-lg bg-white text-left shadow-lg transition-all sm:my-8 sm:max-w-3xl dark:bg-gray-800 dark:text-white"
35
- MODAL_MAIN_CLASSES = "group-data-[padding=true]:p-4 group-data-[padding=true]:pt-2 overflow-y-auto max-h-[75vh]"
36
- MODAL_HEADER_CLASSES = "flex justify-between items-center w-full py-4 rounded-t dark:border-gray-600 group-data-[header-divider=true]:border-b group-data-[header=false]:absolute"
39
+ MODAL_CONTENT_CLASSES = "relative transform max-h-screen overflow-hidden rounded-lg bg-white text-left shadow-lg transition-all sm:max-w-3xl dark:bg-gray-800 dark:text-white"
40
+ MODAL_MAIN_CLASSES = "group-data-[padding=true]/utmr-modal:p-4 group-data-[padding=true]/utmr-modal:pt-2 overflow-y-auto max-h-[75vh]"
41
+ MODAL_HEADER_CLASSES = "flex justify-between items-center w-full py-4 rounded-t dark:border-gray-600 group-data-[header-divider=true]/utmr-modal:border-b group-data-[header=false]/utmr-modal:absolute"
37
42
  MODAL_TITLE_CLASSES = "pl-4"
38
- MODAL_TITLE_H_CLASSES = "group-data-[title=false]:hidden text-lg font-semibold text-gray-900 dark:text-white"
39
- MODAL_FOOTER_CLASSES = "flex p-4 rounded-b dark:border-gray-600 group-data-[footer-divider=true]:border-t"
40
- MODAL_CLOSE_CLASSES = "mr-4 group-data-[close-button=false]:hidden"
43
+ MODAL_TITLE_H_CLASSES = "group-data-[title=false]/utmr-modal:hidden text-lg font-semibold text-gray-900 dark:text-white"
44
+ MODAL_FOOTER_CLASSES = "flex p-4 rounded-b dark:border-gray-600 group-data-[footer-divider=true]/utmr-modal:border-t"
45
+ MODAL_CLOSE_CLASSES = "mr-4 group-data-[close-button=false]/utmr-modal:hidden"
41
46
  MODAL_CLOSE_SR_CLASSES = "sr-only"
42
47
  MODAL_CLOSE_BUTTON_CLASSES = "text-gray-400 bg-transparent hover:bg-gray-200 hover:text-gray-900 rounded-lg text-sm p-1.5 ml-auto inline-flex items-center dark:hover:bg-gray-600 dark:hover:text-white"
43
48
  MODAL_CLOSE_ICON_CLASSES = "w-5 h-5"
@@ -45,7 +50,7 @@ module UltimateTurboModal::Flavors
45
50
  # Drawer constants
46
51
 
47
52
  DRAWER_DIALOG_CLASSES = [
48
- "group",
53
+ "group/utmr-drawer",
49
54
  # Dialog reset
50
55
  "fixed inset-0 p-0 m-0 border-none bg-transparent",
51
56
  "max-w-[100vw] max-h-dvh w-full h-full overflow-y-auto",
@@ -75,30 +80,30 @@ module UltimateTurboModal::Flavors
75
80
  DRAWER_PANEL_CLASSES = [
76
81
  "absolute inset-y-0",
77
82
  # Position based on direction
78
- "group-data-[drawer=left]:left-0 group-data-[drawer=right]:right-0",
83
+ "group-data-[drawer=left]/utmr-drawer:left-0 group-data-[drawer=right]/utmr-drawer:right-0",
79
84
  # Width (size variable + gutter)
80
85
  "w-[min(var(--utmr-w),calc(100vw_-_var(--utmr-gutter)))]",
81
86
  # Default: translated off-screen
82
87
  "[translate:var(--utmr-hide)]",
83
88
  # Entered: in place
84
- "group-data-[entered]:[translate:0]",
89
+ "group-data-[entered]/utmr-drawer:[translate:0]",
85
90
  # Closing: back off-screen
86
- "group-data-[closing]:[translate:var(--utmr-hide)]",
91
+ "group-data-[closing]/utmr-drawer:[translate:var(--utmr-hide)]",
87
92
  # Transition
88
93
  "transition-[translate] duration-250 ease-in-out sm:duration-400",
89
94
  "will-change-[translate]",
90
95
  # Hidden before animation ready
91
- "group-[&:not([data-enter-ready]):not([data-entered])]:invisible"
96
+ "group-[&:not([data-enter-ready]):not([data-entered])]/utmr-drawer:invisible"
92
97
  ].join(" ")
93
98
 
94
- DRAWER_CONTENT_CLASSES = "relative flex h-full w-full flex-col bg-white group-data-[padding=true]:pt-6 shadow-xl dark:bg-gray-800 dark:text-white"
99
+ DRAWER_CONTENT_CLASSES = "relative flex h-full w-full flex-col bg-white group-data-[padding=true]/utmr-drawer:pt-6 shadow-xl dark:bg-gray-800 dark:text-white"
95
100
 
96
- DRAWER_HEADER_CLASSES = "flex items-start justify-between w-full px-4 sm:px-6 group-data-[header-divider=true]:pb-4 group-data-[header-divider=true]:border-b group-data-[header-divider=true]:border-gray-200 dark:group-data-[header-divider=true]:border-gray-600 group-data-[header=false]:hidden"
101
+ DRAWER_HEADER_CLASSES = "flex items-start justify-between w-full px-4 sm:px-6 group-data-[header-divider=true]/utmr-drawer:pb-4 group-data-[header-divider=true]/utmr-drawer:border-b group-data-[header-divider=true]/utmr-drawer:border-gray-200 dark:group-data-[header-divider=true]/utmr-drawer:border-gray-600 group-data-[header=false]/utmr-drawer:hidden"
97
102
  DRAWER_TITLE_CLASSES = ""
98
- DRAWER_TITLE_H_CLASSES = "group-data-[title=false]:hidden text-base font-semibold text-gray-900 dark:text-white"
99
- DRAWER_MAIN_CLASSES = "relative group-data-[padding=true]:mt-6 flex-1 overflow-y-auto group-data-[padding=true]:px-4 group-data-[padding=true]:sm:px-6 group-data-[padding=true]:pb-6"
100
- DRAWER_FOOTER_CLASSES = "flex shrink-0 px-4 py-4 sm:px-6 group-data-[footer-divider=true]:border-t group-data-[footer-divider=true]:border-gray-200 dark:group-data-[footer-divider=true]:border-gray-600"
101
- DRAWER_CLOSE_CLASSES = "ml-3 flex h-7 items-center group-data-[close-button=false]:hidden"
103
+ DRAWER_TITLE_H_CLASSES = "group-data-[title=false]/utmr-drawer:hidden text-base font-semibold text-gray-900 dark:text-white"
104
+ DRAWER_MAIN_CLASSES = "relative group-data-[padding=true]/utmr-drawer:mt-6 flex-1 overflow-y-auto group-data-[padding=true]/utmr-drawer:px-4 group-data-[padding=true]/utmr-drawer:sm:px-6 group-data-[padding=true]/utmr-drawer:pb-6"
105
+ DRAWER_FOOTER_CLASSES = "flex shrink-0 px-4 py-4 sm:px-6 group-data-[footer-divider=true]/utmr-drawer:border-t group-data-[footer-divider=true]/utmr-drawer:border-gray-200 dark:group-data-[footer-divider=true]/utmr-drawer:border-gray-600"
106
+ DRAWER_CLOSE_CLASSES = "ml-3 flex h-7 items-center group-data-[close-button=false]/utmr-drawer:hidden"
102
107
  DRAWER_CLOSE_BUTTON_CLASSES = "relative rounded-md text-gray-400 hover:text-gray-500 focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-indigo-600"
103
108
  DRAWER_CLOSE_SR_CLASSES = MODAL_CLOSE_SR_CLASSES
104
109
  DRAWER_CLOSE_ICON_CLASSES = "size-6"
@@ -5,7 +5,7 @@
5
5
  # Set STYLES to a CSS string if you need additional inline styles.
6
6
  module UltimateTurboModal::Flavors
7
7
  class Vanilla < UltimateTurboModal::Base
8
- STYLES = "html:has(dialog#modal-container[open]) { overflow: hidden; }"
8
+ STYLES = "html:has(dialog#modal-container[open]), html:has(dialog#modal-container-stacked[open]) { overflow: hidden; }"
9
9
 
10
10
  MODAL_DIALOG_CLASSES = "modal-container"
11
11
  MODAL_INNER_CLASSES = "modal-inner"
@@ -39,6 +39,9 @@ class UltimateTurboModal::Base < Phlex::HTML
39
39
  request: nil, title: nil
40
40
  )
41
41
  @drawer = drawer_position
42
+ @request = request
43
+
44
+ raise ArgumentError, "Cannot render a drawer into the drawer-modal frame (drawers cannot be opened from inside another drawer or modal)" if drawer? && stacked?
42
45
 
43
46
  if drawer?
44
47
  cfg = UltimateTurboModal.configuration.drawer_config
@@ -60,11 +63,15 @@ class UltimateTurboModal::Base < Phlex::HTML
60
63
  @overlay = overlay.nil? ? cfg.overlay : overlay
61
64
  @padding = padding.nil? ? cfg.padding : padding
62
65
 
66
+ if stacked?
67
+ @advance = false
68
+ @advance_url = nil
69
+ end
70
+
63
71
  @allowed_click_outside_selector = allowed_click_outside_selector
64
72
  @close_button_data_action = close_button_data_action
65
73
  @close_button_sr_label = close_button_sr_label
66
74
  @content_div_data = content_div_data
67
- @request = request
68
75
  @title = title
69
76
 
70
77
  self.class.include_turbo_helpers
@@ -84,7 +91,7 @@ class UltimateTurboModal::Base < Phlex::HTML
84
91
 
85
92
  def view_template(&block)
86
93
  if turbo_frame?
87
- turbo_frame_tag("modal") do
94
+ turbo_frame_tag(turbo_frame_name) do
88
95
  drawer? ? render_drawer(&block) : render_modal(&block)
89
96
  end
90
97
  else
@@ -92,6 +99,10 @@ class UltimateTurboModal::Base < Phlex::HTML
92
99
  end
93
100
  end
94
101
 
102
+ def turbo_frame_name
103
+ stacked? ? "drawer-modal" : "modal"
104
+ end
105
+
95
106
  def title(&block)
96
107
  @title_block = block
97
108
  end
@@ -145,6 +156,16 @@ class UltimateTurboModal::Base < Phlex::HTML
145
156
 
146
157
  def drawer? = !!@drawer
147
158
 
159
+ def stacked? = request&.headers&.[]("Turbo-Frame") == "drawer-modal"
160
+
161
+ def dialog_id = stacked? ? "modal-container-stacked" : "modal-container"
162
+
163
+ def inner_id = stacked? ? "modal-inner-stacked" : "modal-inner"
164
+
165
+ # Suffix inner ids so they don't collide with the drawer's ids when the
166
+ # stacked modal is rendered inside the drawer's DOM.
167
+ def scoped_id(name) = stacked? ? "#{name}-stacked" : name
168
+
148
169
  def drawer_position = @drawer || :right
149
170
 
150
171
  def overlay? = !!@overlay
@@ -262,11 +283,11 @@ class UltimateTurboModal::Base < Phlex::HTML
262
283
  inline_style = "--utmr-w: #{@drawer_size}"
263
284
  end
264
285
 
265
- dialog(id: "modal-container",
286
+ dialog(id: dialog_id,
266
287
  class: dialog_classes,
267
288
  style: inline_style,
268
289
  aria: {
269
- labelledby: "modal-title-h"
290
+ labelledby: scoped_id("modal-title-h")
270
291
  },
271
292
  data: data_attributes, &block)
272
293
  end
@@ -274,14 +295,14 @@ class UltimateTurboModal::Base < Phlex::HTML
274
295
  ## Modal-specific elements
275
296
 
276
297
  def modal_inner(&block)
277
- maybe_turbo_frame("modal-inner") do
278
- div(id: "modal-inner", class: self.class::MODAL_INNER_CLASSES, &block)
298
+ maybe_turbo_frame(inner_id) do
299
+ div(id: inner_id, class: self.class::MODAL_INNER_CLASSES, &block)
279
300
  end
280
301
  end
281
302
 
282
303
  def modal_content(&block)
283
304
  data = (content_div_data || {}).merge({modal_target: "content"})
284
- div(id: "modal-content", class: self.class::MODAL_CONTENT_CLASSES, data: data, &block)
305
+ div(id: scoped_id("modal-content"), class: self.class::MODAL_CONTENT_CLASSES, data: data, &block)
285
306
  end
286
307
 
287
308
  def modal_main(&block) = render_main(&block)
@@ -294,7 +315,12 @@ class UltimateTurboModal::Base < Phlex::HTML
294
315
 
295
316
  def drawer_wrapper(&block)
296
317
  maybe_turbo_frame("modal-inner") do
297
- div(id: "drawer-wrapper", class: self.class::DRAWER_WRAPPER_CLASSES, &block)
318
+ div(id: "drawer-wrapper", class: self.class::DRAWER_WRAPPER_CLASSES) do
319
+ yield
320
+ # Empty frame so links inside the drawer can target a stacked modal
321
+ # via data-turbo-frame="drawer-modal". Always rendered.
322
+ turbo_frame_tag("drawer-modal")
323
+ end
298
324
  end
299
325
  end
300
326
 
@@ -320,34 +346,34 @@ class UltimateTurboModal::Base < Phlex::HTML
320
346
  ## Shared rendering
321
347
 
322
348
  def render_main(&block)
323
- div(id: "modal-main", class: classes_for("MAIN_CLASSES"), &block)
349
+ div(id: scoped_id("modal-main"), class: classes_for("MAIN_CLASSES"), &block)
324
350
  end
325
351
 
326
352
  def render_header
327
- div(id: "modal-header", class: classes_for("HEADER_CLASSES")) do
353
+ div(id: scoped_id("modal-header"), class: classes_for("HEADER_CLASSES")) do
328
354
  render_title
329
355
  drawer? ? drawer_close : modal_close
330
356
  end
331
357
  end
332
358
 
333
359
  def render_title
334
- div(id: "modal-title", class: classes_for("TITLE_CLASSES")) do
360
+ div(id: scoped_id("modal-title"), class: classes_for("TITLE_CLASSES")) do
335
361
  if @title_block.present?
336
362
  render @title_block
337
363
  else
338
- h3(id: "modal-title-h", class: classes_for("TITLE_H_CLASSES")) { @title }
364
+ h3(id: scoped_id("modal-title-h"), class: classes_for("TITLE_H_CLASSES")) { @title }
339
365
  end
340
366
  end
341
367
  end
342
368
 
343
369
  def render_footer
344
- div(id: "modal-footer", class: classes_for("FOOTER_CLASSES")) do
370
+ div(id: scoped_id("modal-footer"), class: classes_for("FOOTER_CLASSES")) do
345
371
  render @footer
346
372
  end
347
373
  end
348
374
 
349
375
  def render_close
350
- div(id: "modal-close", class: classes_for("CLOSE_CLASSES")) do
376
+ div(id: scoped_id("modal-close"), class: classes_for("CLOSE_CLASSES")) do
351
377
  close_button_tag(classes_for("CLOSE_BUTTON_CLASSES")) do
352
378
  yield if block_given?
353
379
  close_icon_svg(classes_for("CLOSE_ICON_CLASSES"))
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: ultimate_turbo_modal
3
3
  version: !ruby/object:Gem::Version
4
- version: 3.0.5
4
+ version: 3.1.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Carl Mercier