ultimate_turbo_modal 3.2.1 → 3.4.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.
@@ -8,7 +8,7 @@
8
8
  # `data-entered` state (which would prevent the modal from animating out).
9
9
  module UltimateTurboModal::Flavors
10
10
  class Tailwind < UltimateTurboModal::Base
11
- STYLES = "html:has(dialog#modal-container[open]), html:has(dialog#modal-container-stacked[open]) { overflow: hidden; }"
11
+ STYLES = "html:has(dialog.utmr[open]) { overflow: hidden; }"
12
12
 
13
13
  # Modal constants
14
14
 
@@ -25,9 +25,11 @@ module UltimateTurboModal::Flavors
25
25
  "data-[closing]:backdrop:duration-200 data-[closing]:backdrop:ease-in"
26
26
  ].join(" ")
27
27
 
28
- MODAL_INNER_CLASSES = [
29
- "flex min-h-full items-start justify-center pt-[10vh] sm:items-center sm:pt-0 sm:p-4 sm:pb-[10vh]",
30
- # Transition
28
+ # The enter/leave animation, shared by the modal and the confirm dialog.
29
+ # Tailwind resolves conflicts by CSS source order rather than by the order
30
+ # classes appear in the string, so the two cannot share a layout line -- but
31
+ # they must not drift apart on the animation.
32
+ MODAL_TRANSITION_CLASSES = [
31
33
  "transition duration-300 ease-out",
32
34
  "group-data-[closing]/utmr-modal:duration-200 group-data-[closing]/utmr-modal:ease-in",
33
35
  # Default state (closed): faded + shifted
@@ -36,6 +38,11 @@ module UltimateTurboModal::Flavors
36
38
  "group-data-[entered]/utmr-modal:opacity-100 group-data-[entered]/utmr-modal:translate-y-0 group-data-[entered]/utmr-modal:scale-100"
37
39
  ].join(" ")
38
40
 
41
+ MODAL_INNER_CLASSES = [
42
+ "flex min-h-full items-start justify-center pt-[10vh] sm:items-center sm:pt-0 sm:p-4 sm:pb-[10vh]",
43
+ MODAL_TRANSITION_CLASSES
44
+ ].join(" ")
45
+
39
46
  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
47
  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
48
  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"
@@ -47,6 +54,73 @@ module UltimateTurboModal::Flavors
47
54
  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"
48
55
  MODAL_CLOSE_ICON_CLASSES = "w-5 h-5"
49
56
 
57
+ # Confirm dialog constants
58
+ #
59
+ # The confirm dialog renders through the modal's markup, so it inherits the
60
+ # dialog, backdrop and transition classes above. These constants only
61
+ # narrow the card and retune the spacing for what is a short prompt rather
62
+ # than a page of content. Any slot left undefined falls back to MODAL_*.
63
+
64
+ # Same transition as a modal, with a real gutter so the full-width card
65
+ # never touches the screen edge on mobile.
66
+ CONFIRM_INNER_CLASSES = [
67
+ "flex min-h-full items-start justify-center p-4 pt-[10vh] sm:items-center sm:pt-4 sm:pb-[10vh]",
68
+ MODAL_TRANSITION_CLASSES
69
+ ].join(" ")
70
+
71
+ # Sized rather than shrink-to-fit, so a one-word prompt and a three-line one
72
+ # come out the same. The floor is written as `min(20rem, 100%)` so it can
73
+ # never push the card wider than the space it has on a narrow screen.
74
+ CONFIRM_CONTENT_CLASSES = [
75
+ "relative w-full transform overflow-hidden rounded-lg text-left shadow-xl transition-all",
76
+ "min-w-[min(20rem,100%)] sm:max-w-md",
77
+ "bg-white dark:bg-gray-800 dark:text-white"
78
+ ].join(" ")
79
+
80
+ CONFIRM_HEADER_CLASSES = [
81
+ "flex items-start justify-between gap-4 w-full px-6 pt-6",
82
+ "dark:border-gray-600",
83
+ "group-data-[header-divider=true]/utmr-modal:border-b group-data-[header-divider=true]/utmr-modal:pb-4",
84
+ "group-data-[header=false]/utmr-modal:hidden"
85
+ ].join(" ")
86
+
87
+ CONFIRM_TITLE_CLASSES = "min-w-0"
88
+ CONFIRM_TITLE_H_CLASSES = "group-data-[title=false]/utmr-modal:hidden text-base font-semibold text-gray-900 dark:text-white"
89
+
90
+ CONFIRM_MAIN_CLASSES = "group-data-[padding=true]/utmr-modal:px-6 group-data-[padding=true]/utmr-modal:pt-2"
91
+ CONFIRM_BODY_CLASSES = "text-sm/6 text-gray-500 dark:text-gray-400"
92
+
93
+ CONFIRM_FOOTER_CLASSES = [
94
+ "px-6 pt-6 pb-6",
95
+ "dark:border-gray-600 group-data-[footer-divider=true]/utmr-modal:border-t"
96
+ ].join(" ")
97
+
98
+ # Stacked and full-width on mobile (thumb-friendly), inline and right
99
+ # aligned from `sm` up. Reversed so the primary action sits on top.
100
+ CONFIRM_ACTIONS_CLASSES = "flex w-full flex-col-reverse gap-2 sm:flex-row sm:justify-end sm:gap-3"
101
+
102
+ CONFIRM_BUTTON_CLASSES = [
103
+ "inline-flex w-full sm:w-auto justify-center items-center rounded-md",
104
+ "px-4 py-2.5 sm:py-2 text-sm font-semibold transition-colors",
105
+ "focus-visible:outline-2 focus-visible:outline-offset-2"
106
+ ].join(" ")
107
+
108
+ CONFIRM_CANCEL_CLASSES = [
109
+ CONFIRM_BUTTON_CLASSES,
110
+ "bg-white text-gray-900 ring-1 ring-inset ring-gray-300 hover:bg-gray-50",
111
+ "dark:bg-gray-700 dark:text-gray-100 dark:ring-gray-600 dark:hover:bg-gray-600",
112
+ "focus-visible:outline-gray-400"
113
+ ].join(" ")
114
+
115
+ CONFIRM_ACCEPT_CLASSES = [
116
+ CONFIRM_BUTTON_CLASSES,
117
+ "text-white shadow-sm bg-indigo-600 hover:bg-indigo-500 focus-visible:outline-indigo-600",
118
+ # Destructive variant, set from `variant: :danger`
119
+ "group-data-[utmr-confirm-variant=danger]/utmr-modal:bg-red-600",
120
+ "group-data-[utmr-confirm-variant=danger]/utmr-modal:hover:bg-red-500",
121
+ "group-data-[utmr-confirm-variant=danger]/utmr-modal:focus-visible:outline-red-600"
122
+ ].join(" ")
123
+
50
124
  # Drawer constants
51
125
 
52
126
  DRAWER_DIALOG_CLASSES = [
@@ -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]), html:has(dialog#modal-container-stacked[open]) { overflow: hidden; }"
8
+ STYLES = "html:has(dialog.utmr[open]) { overflow: hidden; }"
9
9
 
10
10
  MODAL_DIALOG_CLASSES = "modal-container"
11
11
  MODAL_INNER_CLASSES = "modal-inner"
@@ -20,6 +20,19 @@ module UltimateTurboModal::Flavors
20
20
  MODAL_CLOSE_SR_CLASSES = "sr-only"
21
21
  MODAL_CLOSE_ICON_CLASSES = "modal-close-icon"
22
22
 
23
+ # Confirm dialog constants
24
+ # Slots without a CONFIRM_* constant (dialog, inner) fall back to MODAL_*.
25
+ CONFIRM_CONTENT_CLASSES = "confirm-content"
26
+ CONFIRM_HEADER_CLASSES = "confirm-header"
27
+ CONFIRM_TITLE_CLASSES = "confirm-title"
28
+ CONFIRM_TITLE_H_CLASSES = "confirm-title-h"
29
+ CONFIRM_MAIN_CLASSES = "confirm-main"
30
+ CONFIRM_BODY_CLASSES = "confirm-body"
31
+ CONFIRM_FOOTER_CLASSES = "confirm-footer"
32
+ CONFIRM_ACTIONS_CLASSES = "confirm-actions"
33
+ CONFIRM_CANCEL_CLASSES = "confirm-cancel"
34
+ CONFIRM_ACCEPT_CLASSES = "confirm-accept"
35
+
23
36
  # Drawer constants
24
37
  DRAWER_DIALOG_CLASSES = "drawer-container"
25
38
  DRAWER_WRAPPER_CLASSES = "drawer-wrapper"
@@ -7,6 +7,7 @@ UltimateTurboModal.configure do |config|
7
7
  # config.modal do |m|
8
8
  # m.advance = false
9
9
  # m.close_button = true
10
+ # m.close_on_submit = true
10
11
  # m.header = true
11
12
  # m.header_divider = true
12
13
  # m.footer_divider = true
@@ -16,7 +17,9 @@ UltimateTurboModal.configure do |config|
16
17
 
17
18
  # config.drawer do |d|
18
19
  # d.position = :right
20
+ # d.advance = false
19
21
  # d.close_button = true
22
+ # d.close_on_submit = true
20
23
  # d.header = true
21
24
  # d.header_divider = false
22
25
  # d.footer_divider = true
@@ -24,4 +27,19 @@ UltimateTurboModal.configure do |config|
24
27
  # d.overlay = true
25
28
  # d.size = :md
26
29
  # end
30
+
31
+ # Turbo Confirm support. Enable it by adding `<%= modal_confirm_template %>`
32
+ # to your layout; these are the dialog's defaults.
33
+ # config.confirm do |c|
34
+ # c.enabled = true
35
+ # c.title = "Are you sure?"
36
+ # c.accept_label = "OK"
37
+ # c.cancel_label = "Cancel"
38
+ # c.close_button = false
39
+ # c.header = true
40
+ # c.header_divider = false
41
+ # c.footer_divider = false
42
+ # c.padding = true
43
+ # c.overlay = true
44
+ # end
27
45
  end
@@ -76,6 +76,8 @@ module UltimateTurboModal
76
76
  return
77
77
  end
78
78
 
79
+ add_vanilla_stylesheet if flavor == "vanilla"
80
+
79
81
  template_rel = "flavors/#{flavor}.rb"
80
82
  template_abs = File.join(self.class.source_root, template_rel)
81
83
 
@@ -4,14 +4,17 @@ class UltimateTurboModal::Base < Phlex::HTML
4
4
  prepend Phlex::DeferredRenderWithMainContent
5
5
 
6
6
  attr_accessor :request, :allowed_click_outside_selector, :content_div_data
7
+ CONFIRM_TEMPLATE_ID = "utmr-confirm-template"
7
8
  VALID_DRAWER_SIZES = %i[xs sm md lg xl 2xl full].freeze
8
9
  VALID_DRAWER_POSITIONS = %i[right left].freeze
9
10
 
10
- # @param advance [Boolean, String] Whether to update the browser history when opening and closing the modal (modal-only, ignored for drawers)
11
+ # @param advance [Boolean, String] Whether to update the browser history when opening and closing the modal or drawer
11
12
  # @param allowed_click_outside_selector [String] CSS selectors for elements that are allowed to be clicked outside of the modal without dismissing the modal
12
13
  # @param close_button [Boolean] Whether to show a close button
13
14
  # @param close_button_data_action [String] `data-action` attribute for the close button
14
15
  # @param close_button_sr_label [String] Close button label for screen readers
16
+ # @param close_on_submit [Boolean] Whether to dismiss on a successful, non-redirecting form submission
17
+ # @param confirm [Boolean] Internal: render the Turbo Confirm dialog template. Use the `modal_confirm_template` view helper instead.
15
18
  # @param drawer_position [Symbol, false] Internal: drawer position (:right, :left) or false for standard modal. Use the `drawer()` view helper instead.
16
19
  # @param footer_divider [Boolean] Whether to show a divider between the main content and the footer
17
20
  # @param header [Boolean] Whether to show a modal header
@@ -28,6 +31,8 @@ class UltimateTurboModal::Base < Phlex::HTML
28
31
  close_button: nil,
29
32
  close_button_data_action: "modal#hideModal",
30
33
  close_button_sr_label: "Close modal",
34
+ close_on_submit: nil,
35
+ confirm: false,
31
36
  drawer_position: false,
32
37
  footer_divider: nil,
33
38
  header: nil,
@@ -36,27 +41,34 @@ class UltimateTurboModal::Base < Phlex::HTML
36
41
  padding: nil,
37
42
  size: nil,
38
43
  content_div_data: nil,
44
+ accept_label: nil,
45
+ cancel_label: nil,
39
46
  request: nil, title: nil
40
47
  )
41
48
  @drawer = drawer_position
49
+ @confirm = confirm
42
50
  @request = request
43
51
 
44
52
  raise ArgumentError, "Cannot render a drawer into the drawer-modal frame (drawers cannot be opened from inside another drawer or modal)" if drawer? && stacked?
45
53
 
46
- if drawer?
54
+ if confirm?
55
+ cfg = UltimateTurboModal.configuration.confirm_config
56
+ @drawer_size = nil
57
+ @accept_label = accept_label || cfg.accept_label
58
+ @cancel_label = cancel_label || cfg.cancel_label
59
+ title ||= cfg.title
60
+ elsif drawer?
47
61
  cfg = UltimateTurboModal.configuration.drawer_config
48
- @advance = false
49
- @advance_url = nil
50
- @close_button = close_button.nil? ? cfg.close_button : close_button
51
62
  @drawer_size = self.class.validate_drawer_size!(size || cfg.size)
52
63
  else
53
64
  cfg = UltimateTurboModal.configuration.modal_config
54
- adv = advance.nil? ? cfg.advance : advance
55
- @advance = !!adv
56
- @advance_url = (adv.present? && adv.is_a?(String)) ? adv : nil
57
- @close_button = close_button.nil? ? cfg.close_button : close_button
58
65
  @drawer_size = nil
59
66
  end
67
+ adv = advance.nil? ? cfg.advance : advance
68
+ @advance = !!adv
69
+ @advance_url = (adv.present? && adv.is_a?(String)) ? adv : nil
70
+ @close_button = close_button.nil? ? cfg.close_button : close_button
71
+ @close_on_submit = close_on_submit.nil? ? cfg.close_on_submit : close_on_submit
60
72
  @footer_divider = footer_divider.nil? ? cfg.footer_divider : footer_divider
61
73
  @header = header.nil? ? cfg.header : header
62
74
  @header_divider = header_divider.nil? ? cfg.header_divider : header_divider
@@ -90,7 +102,11 @@ class UltimateTurboModal::Base < Phlex::HTML
90
102
  end
91
103
 
92
104
  def view_template(&block)
93
- if turbo_frame?
105
+ if confirm?
106
+ # Inert until the JS clones it, so it can live in the layout on every
107
+ # page without a dialog (or its <style> tag) ever being active.
108
+ template(id: CONFIRM_TEMPLATE_ID) { render_confirm }
109
+ elsif turbo_frame?
94
110
  turbo_frame_tag(turbo_frame_name) do
95
111
  drawer? ? render_drawer(&block) : render_modal(&block)
96
112
  end
@@ -103,12 +119,16 @@ class UltimateTurboModal::Base < Phlex::HTML
103
119
  stacked? ? "drawer-modal" : "modal"
104
120
  end
105
121
 
122
+ # Both return nil so `<%= m.title do %>` writes nothing to the ERB buffer.
123
+ # The assignment's own value is a Proc, and ERB would print it.
106
124
  def title(&block)
107
125
  @title_block = block
126
+ nil
108
127
  end
109
128
 
110
129
  def footer(&block)
111
130
  @footer = block
131
+ nil
112
132
  end
113
133
 
114
134
  class << self
@@ -134,13 +154,18 @@ class UltimateTurboModal::Base < Phlex::HTML
134
154
 
135
155
  def close_button? = !!@close_button
136
156
 
157
+ def close_on_submit? = !!@close_on_submit
158
+
159
+ def confirm? = !!@confirm
160
+
137
161
  def title_block? = !!@title_block
138
162
 
139
163
  def title? = !!@title
140
164
 
141
165
  def header? = !!@header
142
166
 
143
- def footer? = @footer.present?
167
+ # A confirm's footer holds its own buttons rather than a user block.
168
+ def footer? = @footer.present? || confirm?
144
169
 
145
170
  def header_divider? = !!@header_divider && (@title_block.present? || title?)
146
171
 
@@ -166,13 +191,22 @@ class UltimateTurboModal::Base < Phlex::HTML
166
191
  frame == "drawer-modal" || frame == "modal-inner-stacked"
167
192
  end
168
193
 
169
- def dialog_id = stacked? ? "modal-container-stacked" : "modal-container"
194
+ def dialog_id = scoped_id("modal-container")
170
195
 
171
- def inner_id = stacked? ? "modal-inner-stacked" : "modal-inner"
196
+ def inner_id = scoped_id("modal-inner")
172
197
 
173
198
  # Suffix inner ids so they don't collide with the drawer's ids when the
174
- # stacked modal is rendered inside the drawer's DOM.
175
- def scoped_id(name) = stacked? ? "#{name}-stacked" : name
199
+ # stacked modal is rendered inside the drawer's DOM, or with an open
200
+ # modal's ids when the confirm dialog is layered over it.
201
+ def scoped_id(name) = "#{name}#{id_suffix}"
202
+
203
+ def id_suffix
204
+ return "-confirm" if confirm?
205
+ # A layout renders for Turbo Frame requests too, so this has to be checked
206
+ # after confirm? -- otherwise a confirm template rendered during a stacked
207
+ # request would claim the stacked modal's ids.
208
+ stacked? ? "-stacked" : ""
209
+ end
176
210
 
177
211
  def drawer_position = @drawer || :right
178
212
 
@@ -250,11 +284,42 @@ class UltimateTurboModal::Base < Phlex::HTML
250
284
  raw(safe(str))
251
285
  end
252
286
 
287
+ # Confirm dialogs render through the modal's markup, so every slot falls back
288
+ # to its MODAL_* class unless the flavor defines a CONFIRM_* override.
253
289
  def classes_for(suffix)
290
+ if confirm?
291
+ override = confirm_classes_for(suffix)
292
+ return override unless override.nil?
293
+ end
294
+
254
295
  prefix = drawer? ? "DRAWER" : "MODAL"
255
296
  self.class.const_get("#{prefix}_#{suffix}")
256
297
  end
257
298
 
299
+ # CONFIRM_* constants are optional. A flavor file written before confirm
300
+ # support returns nil here, which means shared slots fall back to MODAL_* and
301
+ # the confirm-only slots (body, actions, buttons) render unstyled rather than
302
+ # raising. `warn_missing_confirm_classes` is what tells the developer why.
303
+ def confirm_classes_for(suffix)
304
+ const = :"CONFIRM_#{suffix}"
305
+ self.class.const_defined?(const) ? self.class.const_get(const) : nil
306
+ end
307
+
308
+ # An unstyled confirm dialog looks like a bug in the gem rather than a stale
309
+ # flavor file, so say which it is. Once per flavor class, in development only.
310
+ def warn_missing_confirm_classes
311
+ return unless defined?(Rails) && Rails.env.local?
312
+ return if self.class.const_defined?(:CONFIRM_ACTIONS_CLASSES)
313
+ return if self.class.instance_variable_get(:@utmr_confirm_classes_warned)
314
+
315
+ self.class.instance_variable_set(:@utmr_confirm_classes_warned, true)
316
+ Rails.logger&.warn(
317
+ "[UltimateTurboModal] #{self.class.name} defines no CONFIRM_* classes, so the " \
318
+ "confirm dialog will render unstyled. Run `rails g ultimate_turbo_modal:update` " \
319
+ "to refresh your flavor file."
320
+ )
321
+ end
322
+
258
323
  def custom_drawer_size?
259
324
  @drawer_size.present? && !VALID_DRAWER_SIZES.include?(@drawer_size.to_s.to_sym)
260
325
  end
@@ -263,22 +328,35 @@ class UltimateTurboModal::Base < Phlex::HTML
263
328
  data_attributes = {
264
329
  controller: "modal",
265
330
  modal_target: "container",
266
- modal_advance_url_value: advance_url,
267
- modal_allowed_click_outside_selector_value: allowed_click_outside_selector,
268
- action: "turbo:submit-end->modal#submitEnd cancel->modal#cancelEvent mousedown->modal#dialogMousedown click->modal#dialogClicked",
331
+ action: dialog_actions,
269
332
  padding: padding?.to_s,
270
333
  title: title?.to_s,
271
334
  header: header?.to_s,
272
335
  close_button: close_button?.to_s,
273
336
  header_divider: header_divider?.to_s,
274
- footer_divider: footer_divider?.to_s
337
+ footer_divider: footer_divider?.to_s,
338
+ overlay: overlay?.to_s
275
339
  }
276
340
 
341
+ role = nil
342
+ aria_attributes = {labelledby: scoped_id("modal-title-h")}
343
+
344
+ if confirm?
345
+ # Everything the controller reads from those three values is driven by an
346
+ # action a confirm doesn't bind, so they would only ever mislead a reader.
347
+ data_attributes[:utmr_confirm] = ""
348
+ role = "alertdialog"
349
+ aria_attributes[:describedby] = scoped_id("modal-body")
350
+ else
351
+ data_attributes[:modal_advance_url_value] = advance_url
352
+ data_attributes[:modal_allowed_click_outside_selector_value] = allowed_click_outside_selector
353
+ data_attributes[:modal_close_on_submit_value] = close_on_submit?.to_s
354
+ end
355
+
277
356
  if drawer?
278
357
  data_attributes[:drawer] = drawer_position.to_s
279
358
  data_attributes[:drawer_size] = (@drawer_size.presence || "md").to_s
280
359
  end
281
- data_attributes[:overlay] = overlay?.to_s
282
360
 
283
361
  if defined?(Rails) && Rails.env.local?
284
362
  data_attributes[:utmr_version] = UltimateTurboModal::VERSION
@@ -294,17 +372,29 @@ class UltimateTurboModal::Base < Phlex::HTML
294
372
  dialog(id: dialog_id,
295
373
  class: dialog_classes,
296
374
  style: inline_style,
297
- aria: {
298
- labelledby: scoped_id("modal-title-h")
299
- },
375
+ role: role,
376
+ aria: aria_attributes,
300
377
  data: data_attributes, &block)
301
378
  end
302
379
 
380
+ # A confirm is a decision, so a stray click on the backdrop must not answer it
381
+ # for the user. Leaving the outside-click actions off is the whole of it --
382
+ # Escape and the Cancel button still dismiss.
383
+ def dialog_actions
384
+ return "cancel->modal#cancelEvent" if confirm?
385
+
386
+ ["turbo:submit-end->modal#submitEnd",
387
+ "cancel->modal#cancelEvent",
388
+ "mousedown->modal#dialogMousedown",
389
+ "click->modal#dialogClicked"].join(" ")
390
+ end
391
+
303
392
  ## Modal-specific elements
304
393
 
305
394
  def modal_inner(&block)
306
395
  maybe_turbo_frame(inner_id) do
307
- div(id: inner_id, class: self.class::MODAL_INNER_CLASSES, &block)
396
+ div(id: inner_id, class: self.class::MODAL_INNER_CLASSES,
397
+ data: {modal_target: "transition"}, &block)
308
398
  end
309
399
  end
310
400
 
@@ -333,7 +423,8 @@ class UltimateTurboModal::Base < Phlex::HTML
333
423
  end
334
424
 
335
425
  def drawer_panel(&block)
336
- div(id: "drawer-panel", class: self.class::DRAWER_PANEL_CLASSES, data: {modal_target: "content"}, &block)
426
+ div(id: "drawer-panel", class: self.class::DRAWER_PANEL_CLASSES,
427
+ data: {modal_target: "content transition"}, &block)
337
428
  end
338
429
 
339
430
  def drawer_content(&block)
@@ -351,6 +442,51 @@ class UltimateTurboModal::Base < Phlex::HTML
351
442
  end
352
443
  end
353
444
 
445
+ ## Confirm-specific elements
446
+
447
+ def render_confirm
448
+ warn_missing_confirm_classes
449
+ # Unlike the modal and drawer templates, the styles go *inside* the dialog:
450
+ # the JS clones this one node out of the <template>, and the scroll-lock
451
+ # rule has to come along with it and leave with it.
452
+ dialog_element do
453
+ styles
454
+ confirm_inner do
455
+ confirm_content do
456
+ render_header
457
+ confirm_main
458
+ confirm_footer
459
+ end
460
+ end
461
+ end
462
+ end
463
+
464
+ def confirm_inner(&block)
465
+ div(id: inner_id, class: classes_for("INNER_CLASSES"), data: {modal_target: "transition"}, &block)
466
+ end
467
+
468
+ def confirm_content(&block)
469
+ div(id: scoped_id("modal-content"), class: classes_for("CONTENT_CLASSES"), data: {modal_target: "content"}, &block)
470
+ end
471
+
472
+ # Left empty: the JavaScript fills it from `data-turbo-confirm`.
473
+ def confirm_main
474
+ render_main do
475
+ div(id: scoped_id("modal-body"), class: confirm_classes_for("BODY_CLASSES"))
476
+ end
477
+ end
478
+
479
+ def confirm_footer
480
+ div(id: scoped_id("modal-footer"), class: classes_for("FOOTER_CLASSES")) do
481
+ div(class: confirm_classes_for("ACTIONS_CLASSES")) do
482
+ button(type: "button", class: confirm_classes_for("CANCEL_CLASSES"),
483
+ data: {utmr_confirm_action: "cancel"}) { @cancel_label }
484
+ button(type: "button", class: confirm_classes_for("ACCEPT_CLASSES"),
485
+ data: {utmr_confirm_action: "accept"}) { @accept_label }
486
+ end
487
+ end
488
+ end
489
+
354
490
  ## Shared rendering
355
491
 
356
492
  def render_main(&block)
@@ -360,10 +496,20 @@ class UltimateTurboModal::Base < Phlex::HTML
360
496
  def render_header
361
497
  div(id: scoped_id("modal-header"), class: classes_for("HEADER_CLASSES")) do
362
498
  render_title
363
- drawer? ? drawer_close : modal_close
499
+ render_header_close
364
500
  end
365
501
  end
366
502
 
503
+ # Modals and drawers always render the close button and let the flavor hide it
504
+ # through `data-close-button`. A confirm leaves it out of the markup entirely
505
+ # instead, which keeps it out of the tab order and the accessibility tree --
506
+ # it has its own Cancel button, so it is off by default.
507
+ def render_header_close
508
+ return if confirm? && !close_button?
509
+
510
+ drawer? ? drawer_close : modal_close
511
+ end
512
+
367
513
  def render_title
368
514
  div(id: scoped_id("modal-title"), class: classes_for("TITLE_CLASSES")) do
369
515
  if @title_block.present?
@@ -14,7 +14,7 @@ module UltimateTurboModal
14
14
  :allowed_click_outside_selector, :allowed_click_outside_selector=, to: :configuration
15
15
 
16
16
  class Configuration
17
- attr_reader :flavor, :modal_config, :drawer_config
17
+ attr_reader :flavor, :modal_config, :drawer_config, :confirm_config
18
18
  attr_accessor :allowed_click_outside_selector
19
19
 
20
20
  def initialize
@@ -22,6 +22,7 @@ module UltimateTurboModal
22
22
  @allowed_click_outside_selector = []
23
23
  @modal_config = ModalConfig.new
24
24
  @drawer_config = DrawerConfig.new
25
+ @confirm_config = ConfirmConfig.new
25
26
  end
26
27
 
27
28
  def modal
@@ -34,6 +35,11 @@ module UltimateTurboModal
34
35
  @drawer_config
35
36
  end
36
37
 
38
+ def confirm
39
+ yield(@confirm_config) if block_given?
40
+ @confirm_config
41
+ end
42
+
37
43
  def flavor=(flavor)
38
44
  raise ArgumentError, "Value must be a symbol." unless flavor.is_a?(Symbol) || flavor.is_a?(String)
39
45
  @flavor = flavor.to_sym
@@ -41,7 +47,7 @@ module UltimateTurboModal
41
47
 
42
48
  # Shared base for modal and drawer configuration
43
49
  class BaseConfig
44
- attr_reader :close_button, :header, :header_divider, :footer_divider, :padding, :overlay
50
+ attr_reader :advance, :close_button, :close_on_submit, :header, :header_divider, :footer_divider, :padding, :overlay
45
51
 
46
52
  def self.boolean_option(name)
47
53
  define_method(:"#{name}=") do |value|
@@ -51,11 +57,20 @@ module UltimateTurboModal
51
57
  end
52
58
 
53
59
  boolean_option :close_button
60
+ boolean_option :close_on_submit
54
61
  boolean_option :header
55
62
  boolean_option :header_divider
56
63
  boolean_option :footer_divider
57
64
  boolean_option :overlay
58
65
 
66
+ def advance=(value)
67
+ if [true, false].include?(value) || value.is_a?(String)
68
+ @advance = value
69
+ else
70
+ raise ArgumentError, "Value must be a boolean or a String."
71
+ end
72
+ end
73
+
59
74
  def padding=(padding)
60
75
  if [true, false].include?(padding) || padding.is_a?(String)
61
76
  @padding = padding
@@ -66,26 +81,51 @@ module UltimateTurboModal
66
81
  end
67
82
 
68
83
  class ModalConfig < BaseConfig
69
- attr_reader :advance
70
-
71
84
  def initialize
72
85
  @advance = false
73
86
  @close_button = true
87
+ @close_on_submit = true
74
88
  @header = true
75
89
  @header_divider = true
76
90
  @footer_divider = true
77
91
  @padding = true
78
92
  @overlay = true
79
93
  end
94
+ end
80
95
 
81
- boolean_option :advance
96
+ # Defaults for the Turbo Confirm dialog. Every value here is a fallback:
97
+ # anything supplied per-element through `data-turbo-confirm` wins.
98
+ class ConfirmConfig < BaseConfig
99
+ attr_accessor :title, :accept_label, :cancel_label
100
+ attr_reader :enabled
101
+
102
+ boolean_option :enabled
103
+
104
+ # `advance` and `close_on_submit` are left unset: a confirm never pushes a
105
+ # URL and holds no form, and the dialog binds neither behavior.
106
+ def initialize
107
+ @enabled = true
108
+ # A confirm has its own Cancel button, and dividers chop up what is
109
+ # only ever a line or two of text.
110
+ @close_button = false
111
+ @header = true
112
+ @header_divider = false
113
+ @footer_divider = false
114
+ @padding = true
115
+ @overlay = true
116
+ @title = "Are you sure?"
117
+ @accept_label = "OK"
118
+ @cancel_label = "Cancel"
119
+ end
82
120
  end
83
121
 
84
122
  class DrawerConfig < BaseConfig
85
123
  attr_reader :size, :position
86
124
 
87
125
  def initialize
126
+ @advance = false
88
127
  @close_button = true
128
+ @close_on_submit = true
89
129
  @header = true
90
130
  @header_divider = false
91
131
  @footer_divider = true
@@ -12,5 +12,36 @@ module UltimateTurboModal::Helpers
12
12
  size = UltimateTurboModal::Base.validate_drawer_size!(size || cfg.size)
13
13
  modal(drawer_position: position, size: size, **options, &block)
14
14
  end
15
+
16
+ # Renders the inert <template> the Turbo Confirm dialog is cloned from.
17
+ # Place it once in your layout: its presence is what enables the feature.
18
+ # Renders nothing when `config.confirm.enabled` is false, which turns the
19
+ # feature off without touching the layout.
20
+ def modal_confirm_template(**options)
21
+ return unless UltimateTurboModal.configuration.confirm_config.enabled
22
+
23
+ render(UltimateTurboModal.confirm(request:, **options))
24
+ end
25
+
26
+ # Builds the `data` attributes for an element that should prompt before
27
+ # acting. The options are JSON-encoded into `data-turbo-confirm` because
28
+ # Turbo rewrites a link with `data-turbo-method` into a synthetic form and
29
+ # copies only a fixed set of attributes across -- sibling
30
+ # `data-turbo-confirm-*` attributes on a link would be dropped.
31
+ #
32
+ # link_to "Delete", post_path(post), data: modal_confirm(
33
+ # "This can't be undone.", title: "Delete post?", accept: "Delete",
34
+ # variant: :danger, turbo_method: :delete)
35
+ #
36
+ # `native: true` opts this one confirmation back out to `window.confirm`.
37
+ # On a link that is the only way to say it, since Turbo drops sibling
38
+ # `data-turbo-confirm-native` along with everything else.
39
+ def modal_confirm(body = nil, title: nil, accept: nil, cancel: nil, variant: nil, native: nil, **data)
40
+ payload = {title: title, body: body, accept: accept, cancel: cancel,
41
+ variant: variant, native: native&.to_s}.compact
42
+ raise ArgumentError, "modal_confirm requires a body or at least one option" if payload.empty?
43
+
44
+ {turbo_confirm: payload.to_json}.merge(data)
45
+ end
15
46
  end
16
47
  end