Files
sure/app/components/DS/dialog.rb
Neko 1f036fc526 feat: add preference to disable modal close on outside click (#2226)
* Add a user preference under Settings → Appearance that prevents modals
  from closing when clicking outside them. Useful to avoid accidentally
  losing unsaved form data.

  - Add `disable_modal_click_outside?` helper to User model (JSONB prefs)
  - DS::Dialog reads the user preference as default when not explicitly set
    (existing callers passing disable_click_outside: true/false are unaffected)
  - Wire up save in AppearancesController
  - Add toggle in the Modals section of the Appearance settings page
  - Add i18n strings

* fix(i18n): move modal keys to appearances.show namespace in 7 locales

   The modal translation keys (modals_title, modals_subtitle,
   disable_modal_click_outside_title, disable_modal_click_outside_description)
   were under settings.preferences.show but the view calls t(".modals_title")
   from settings/appearances/show.html.erb. Moved them to
   settings.appearances.show in de, es, nb, nl, ro, tr, and zh-TW.

* refactor(ds): decouple Dialog from Current.user via defaults_provider

---------

Co-authored-by: neko <neko@nixos>
2026-07-01 01:50:20 +02:00

158 lines
4.8 KiB
Ruby

class DS::Dialog < DesignSystemComponent
renders_one :header, ->(title: nil, subtitle: nil, custom_header: false, **opts, &block) do
content_tag(:header, class: "px-4 flex flex-col gap-2", **opts) do
title_div = content_tag(:div, class: "flex items-center justify-between gap-2") do
# `id: title_id` lets the host `<dialog>` reference the title via
# `aria-labelledby` so AT users hear the title when focus lands
# in the dialog. `content_tag("h#{heading_level}", ...)` builds an
# h2/h3/etc based on the caller's `heading_level:`.
title = content_tag("h#{heading_level}", title, id: title_id, class: class_names("font-medium text-primary", drawer? ? "text-lg" : "")) if title
close_icon = close_button unless custom_header
safe_join([ title, close_icon ].compact)
end
subtitle = content_tag(:p, subtitle, class: "text-sm text-secondary") if subtitle
block_content = capture(&block) if block
safe_join([ title_div, subtitle, block_content ].compact)
end
end
renders_one :body
renders_many :actions, ->(cancel_action: false, **button_opts) do
merged_opts = if cancel_action
button_opts.merge(type: "button", data: { action: "DS--dialog#close" })
else
button_opts
end
render DS::Button.new(**merged_opts)
end
renders_many :sections, ->(title:, **disclosure_opts, &block) do
render DS::Disclosure.new(title: title, align: :right, **disclosure_opts) do
block.call
end
end
attr_reader :variant, :auto_open, :reload_on_close, :width, :disable_frame, :content_class, :disable_click_outside, :opts, :responsive, :scrollable, :heading_level, :title_id
VARIANTS = %w[modal drawer].freeze
WIDTHS = {
sm: "lg:max-w-[300px]",
md: "lg:max-w-[550px]",
lg: "lg:max-w-[700px]",
full: "lg:max-w-full"
}.freeze
VALID_HEADING_LEVELS = (1..6).freeze
class_attribute :defaults_provider, default: nil
def initialize(variant: "modal", auto_open: true, reload_on_close: false, width: "md", frame: nil, disable_frame: false, content_class: nil, disable_click_outside: nil, responsive: false, scrollable: true, heading_level: 2, **opts)
unless heading_level.is_a?(Integer) && VALID_HEADING_LEVELS.cover?(heading_level)
raise ArgumentError, "heading_level must be an Integer between 1 and 6, got: #{heading_level.inspect}"
end
defaults = self.class.defaults_provider&.call || {}
@variant = variant.to_sym
@auto_open = auto_open
@reload_on_close = reload_on_close
@width = width.to_sym
@frame = frame
@disable_frame = disable_frame
@content_class = content_class
@disable_click_outside = disable_click_outside.nil? ? defaults.fetch(:disable_click_outside, false) : disable_click_outside
@responsive = responsive
@scrollable = scrollable
@heading_level = heading_level
@title_id = "dialog-title-#{SecureRandom.hex(4)}"
@opts = opts
end
def frame
@frame || variant
end
# Caller must "opt-out" of using the default turbo-frame based on the variant
def wrapper_element(&block)
if disable_frame
content_tag(:div, &block)
else
content_tag("turbo-frame", id: frame, &block)
end
end
def dialog_outer_classes
variant_classes = if responsive?
"items-center justify-center lg:items-end lg:justify-end"
elsif drawer?
"items-end justify-end"
else
"items-center justify-center"
end
class_names(
"flex h-full w-full",
variant_classes
)
end
def dialog_inner_classes
variant_classes = if responsive?
"max-h-full lg:h-full lg:w-[550px]"
elsif drawer?
"lg:w-[550px] h-full"
else
class_names(
"max-h-full",
WIDTHS[width]
)
end
class_names(
"flex flex-col bg-container rounded-xl shadow-border-xs mx-3 lg:mx-0 w-full",
variant_classes,
content_class
)
end
def merged_opts
merged_opts = opts.dup
data = merged_opts.delete(:data) || {}
data[:controller] = [ "DS--dialog", "hotkey", data[:controller] ].compact.join(" ")
data[:DS__dialog_auto_open_value] = auto_open
data[:DS__dialog_reload_on_close_value] = reload_on_close
data[:DS__dialog_disable_click_outside_value] = disable_click_outside
data[:action] = [ "click->DS--dialog#clickOutside", data[:action] ].compact.join(" ")
data[:hotkey] = "esc:DS--dialog#close"
merged_opts[:data] = data
merged_opts
end
def drawer?
variant == :drawer
end
def responsive?
@responsive
end
def close_button
classes = responsive? ? "ml-auto hidden lg:flex" : "ml-auto"
render DS::Button.new(
variant: "icon",
size: :sm,
class: classes,
icon: "x",
title: I18n.t("ds.dialog.close"),
aria_label: I18n.t("ds.dialog.close"),
data: { action: "DS--dialog#close" }
)
end
end