From 9a67e42176e00ed85f8bec868152325c7936df90 Mon Sep 17 00:00:00 2001 From: Zach Gollwitzer Date: Tue, 29 Apr 2025 15:14:29 -0400 Subject: [PATCH] Dialog replacements --- app/assets/tailwind/maybe-design-system.css | 6 + app/components/dialog_component.html.erb | 38 +++++++ app/components/dialog_component.rb | 93 ++++++++++++++++ .../dialog_controller.js} | 7 +- app/components/disclosure_component.html.erb | 2 +- app/helpers/application_helper.rb | 26 ----- app/helpers/styled_form_builder.rb | 2 +- .../controllers/bulk_select_controller.js | 7 +- .../controllers/deletion_controller.js | 32 +++--- app/views/accounts/new/_container.html.erb | 8 +- .../accounts/new/_method_selector.html.erb | 4 +- app/views/categories/_form.html.erb | 103 +++++++++--------- app/views/categories/edit.html.erb | 7 +- app/views/categories/new.html.erb | 7 +- app/views/category/deletions/new.html.erb | 33 ++++-- app/views/imports/new.html.erb | 2 +- app/views/layouts/lookbooks.html.erb | 2 +- ...alog.html.erb => _confirm_dialog.html.erb} | 8 +- app/views/layouts/shared/_htmldoc.html.erb | 2 +- app/views/rules/confirm.html.erb | 2 +- app/views/shared/_drawer.html.erb | 17 --- app/views/shared/_modal.html.erb | 19 ---- app/views/shared/_modal_form.html.erb | 18 --- app/views/shared/_subscribe_modal.html.erb | 32 +++--- .../transactions/bulk_updates/new.html.erb | 70 ++++-------- .../previews/dialog_component_preview.rb | 46 ++++++++ .../previews/disclosure_component_preview.rb | 4 +- 27 files changed, 344 insertions(+), 253 deletions(-) create mode 100644 app/components/dialog_component.html.erb create mode 100644 app/components/dialog_component.rb rename app/{javascript/controllers/modal_controller.js => components/dialog_controller.js} (75%) rename app/views/layouts/shared/{_custom_confirm_dialog.html.erb => _confirm_dialog.html.erb} (70%) delete mode 100644 app/views/shared/_drawer.html.erb delete mode 100644 app/views/shared/_modal.html.erb delete mode 100644 app/views/shared/_modal_form.html.erb create mode 100644 test/components/previews/dialog_component_preview.rb diff --git a/app/assets/tailwind/maybe-design-system.css b/app/assets/tailwind/maybe-design-system.css index c1ea15dd..009d1da3 100644 --- a/app/assets/tailwind/maybe-design-system.css +++ b/app/assets/tailwind/maybe-design-system.css @@ -259,6 +259,12 @@ @apply text-gray-200; } + /* We control the sizing through DialogComponent, so reset this value */ + dialog:modal { + max-width: 100dvw; + max-height: 100dvh; + } + details>summary::-webkit-details-marker { @apply hidden; } diff --git a/app/components/dialog_component.html.erb b/app/components/dialog_component.html.erb new file mode 100644 index 00000000..42d96492 --- /dev/null +++ b/app/components/dialog_component.html.erb @@ -0,0 +1,38 @@ + + <%= tag.dialog class: "w-full h-full bg-transparent backdrop:bg-overlay #{drawer? ? "lg:p-3" : "lg:p-1"}", **merged_opts do %> + <%= tag.div class: dialog_outer_classes do %> + <%= tag.div class: dialog_inner_classes do %> +
+ <% if header? %> + <%= header %> + <% end %> + + <% if body? %> +
+ <%= body %> + + <% if sections.any? %> +
+ <% sections.each do |section| %> + <%= section %> + <% end %> +
+ <% end %> +
+ <% end %> + + <%# Optional, for customizing dialogs %> + <%= content %> +
+ + <% if actions? %> +
+ <% actions.each do |action| %> + <%= action %> + <% end %> +
+ <% end %> + <% end %> + <% end %> + <% end %> +
\ No newline at end of file diff --git a/app/components/dialog_component.rb b/app/components/dialog_component.rb new file mode 100644 index 00000000..55c38b44 --- /dev/null +++ b/app/components/dialog_component.rb @@ -0,0 +1,93 @@ +class DialogComponent < ViewComponent::Base + renders_one :header, ->(title: nil, subtitle: nil, hide_close_icon: 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 + title = content_tag(:h2, title, class: class_names("font-medium text-primary", drawer? ? "text-lg" : "")) if title + close_icon = render ButtonComponent.new(variant: "icon", icon: "x", tabindex: "-1", data: { action: "dialog#close" }) unless hide_close_icon + 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: "modal#close" }) + else + button_opts + end + + render ButtonComponent.new(**merged_opts) + end + + renders_many :sections, ->(title:, **disclosure_opts, &block) do + render DisclosureComponent.new(title: title, align: :right, **disclosure_opts) do + block.call + end + end + + attr_reader :variant, :open_on_load, :reload_on_close, :opts + + VARIANTS = %w[modal drawer].freeze + + def initialize(variant: "modal", open_on_load: false, reload_on_close: false, frame: nil, **opts) + @variant = variant.to_sym + @open_on_load = open_on_load + @reload_on_close = reload_on_close + @frame = frame + @opts = opts + end + + def frame + @frame || variant + end + + def dialog_outer_classes + variant_classes = if 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 drawer? + "lg:w-[480px] h-full" + else + "max-w-lg max-h-full" + end + + class_names( + "flex flex-col bg-container lg:rounded-xl lg:shadow-border-xs w-full overflow-hidden", + variant_classes + ) + end + + def merged_opts + merged_opts = opts.dup + data = merged_opts.delete(:data) || {} + + data[:controller] = ["dialog", data[:controller]].compact.join(" ") + data[:dialog_open_on_load_value] = open_on_load + data[:dialog_reload_on_close_value] = reload_on_close + merged_opts[:data] = data + + merged_opts + end + + def drawer? + variant == :drawer + end +end \ No newline at end of file diff --git a/app/javascript/controllers/modal_controller.js b/app/components/dialog_controller.js similarity index 75% rename from app/javascript/controllers/modal_controller.js rename to app/components/dialog_controller.js index 242c0247..61491352 100644 --- a/app/javascript/controllers/modal_controller.js +++ b/app/components/dialog_controller.js @@ -1,14 +1,17 @@ import { Controller } from "@hotwired/stimulus"; -// Connects to data-controller="modal" +// Connects to data-controller="dialog" export default class extends Controller { static values = { + openOnLoad: { type: Boolean, default: true }, reloadOnClose: { type: Boolean, default: false }, }; connect() { if (this.element.open) return; - this.element.showModal(); + if (this.openOnLoadValue) { + this.element.showModal(); + } } // Hide the dialog when the user clicks outside of it diff --git a/app/components/disclosure_component.html.erb b/app/components/disclosure_component.html.erb index 967b013e..554342d1 100644 --- a/app/components/disclosure_component.html.erb +++ b/app/components/disclosure_component.html.erb @@ -22,4 +22,4 @@
<%= content %>
- \ No newline at end of file + diff --git a/app/helpers/application_helper.rb b/app/helpers/application_helper.rb index 0685db66..e3b1786e 100644 --- a/app/helpers/application_helper.rb +++ b/app/helpers/application_helper.rb @@ -44,32 +44,6 @@ module ApplicationHelper turbo_stream_from Current.family if Current.family end - ## - # Helper to open a centered and overlayed modal with custom contents - # - # @example Basic usage - # <%= modal classes: "custom-class" do %> - #
Content here
- # <% end %> - # - def modal(reload_on_close: false, overflow_visible: false, &block) - content = capture &block - render partial: "shared/modal", locals: { content:, reload_on_close:, overflow_visible: } - end - - ## - # Helper to open a drawer on the right side of the screen with custom contents - # - # @example Basic usage - # <%= drawer do %> - #
Content here
- # <% end %> - # - def drawer(reload_on_close: false, &block) - content = capture &block - render partial: "shared/drawer", locals: { content:, reload_on_close: } - end - def page_active?(path) current_page?(path) || (request.path.start_with?(path) && path != "/") end diff --git a/app/helpers/styled_form_builder.rb b/app/helpers/styled_form_builder.rb index 993ce3e4..1ddd445a 100644 --- a/app/helpers/styled_form_builder.rb +++ b/app/helpers/styled_form_builder.rb @@ -81,7 +81,7 @@ class StyledFormBuilder < ActionView::Helpers::FormBuilder @template.render( ButtonComponent.new( text: value, - data: { turbo_submits_with: "Submitting..." }, + data: (options[:data] || {}).merge({ turbo_submits_with: "Submitting..." }), full_width: true ) ) diff --git a/app/javascript/controllers/bulk_select_controller.js b/app/javascript/controllers/bulk_select_controller.js index a1e40157..0851da7a 100644 --- a/app/javascript/controllers/bulk_select_controller.js +++ b/app/javascript/controllers/bulk_select_controller.js @@ -7,7 +7,7 @@ export default class extends Controller { "group", "selectionBar", "selectionBarText", - "bulkEditDrawerTitle", + "bulkEditDrawerHeader", ]; static values = { singularLabel: String, @@ -25,8 +25,9 @@ export default class extends Controller { document.removeEventListener("turbo:load", this._updateView); } - bulkEditDrawerTitleTargetConnected(element) { - element.innerText = `Edit ${ + bulkEditDrawerHeaderTargetConnected(element) { + const headingTextEl = element.querySelector("h2"); + headingTextEl.innerText = `Edit ${ this.selectedIdsValue.length } ${this._pluralizedResourceName()}`; } diff --git a/app/javascript/controllers/deletion_controller.js b/app/javascript/controllers/deletion_controller.js index cd49065d..ec4bc9f2 100644 --- a/app/javascript/controllers/deletion_controller.js +++ b/app/javascript/controllers/deletion_controller.js @@ -1,30 +1,28 @@ import { Controller } from "@hotwired/stimulus"; export default class extends Controller { - static targets = ["replacementField", "submitButton"]; - static classes = ["dangerousAction", "safeAction"]; + static targets = [ + "replacementField", + "destructiveSubmitButton", + "safeSubmitButton", + ]; + static values = { submitTextWhenReplacing: String, submitTextWhenNotReplacing: String, }; - updateSubmitButton() { + chooseSubmitButton() { if (this.replacementFieldTarget.value) { - this.submitButtonTarget.value = this.submitTextWhenReplacingValue; - this.#markSafe(); + this.destructiveSubmitButtonTarget.hidden = true; + this.safeSubmitButtonTarget.textContent = + this.submitTextWhenReplacingValue; + this.safeSubmitButtonTarget.hidden = false; } else { - this.submitButtonTarget.value = this.submitTextWhenNotReplacingValue; - this.#markDangerous(); + this.destructiveSubmitButtonTarget.textContent = + this.submitTextWhenNotReplacingValue; + this.destructiveSubmitButtonTarget.hidden = false; + this.safeSubmitButtonTarget.hidden = true; } } - - #markSafe() { - this.submitButtonTarget.classList.remove(...this.dangerousActionClasses); - this.submitButtonTarget.classList.add(...this.safeActionClasses); - } - - #markDangerous() { - this.submitButtonTarget.classList.remove(...this.safeActionClasses); - this.submitButtonTarget.classList.add(...this.dangerousActionClasses); - } } diff --git a/app/views/accounts/new/_container.html.erb b/app/views/accounts/new/_container.html.erb index 3404b82d..99cfe389 100644 --- a/app/views/accounts/new/_container.html.erb +++ b/app/views/accounts/new/_container.html.erb @@ -1,7 +1,7 @@ <%# locals: (title:, back_path: nil) %> -<%= modal do %> -
+<%= render DialogComponent.new(open_on_load: true) do |dialog| %> +
<% if back_path %> @@ -16,7 +16,7 @@ <%= title %>
- <%= icon("x", as_button: true, size: "lg", data: { action: "modal#close" }) %> + <%= icon("x", as_button: true, size: "lg", data: { action: "dialog#close" }) %>
@@ -45,7 +45,7 @@
- + ESC
diff --git a/app/views/accounts/new/_method_selector.html.erb b/app/views/accounts/new/_method_selector.html.erb index 18a03f8d..25dc9f62 100644 --- a/app/views/accounts/new/_method_selector.html.erb +++ b/app/views/accounts/new/_method_selector.html.erb @@ -11,7 +11,7 @@ <% if us_link_token %> <%# Default US-only Link %> - + + + + +
+

Icon

+
+ <% Category.icon_codes.each do |icon| %> + + <% end %> +
+
+ + -
- - <%= icon("pen", size: "sm") %> - - -
-
"> -
-

Color

-
- <% Category::COLORS.each do |color| %> - - <% end %> - -
- -
- -
-

Icon

-
- <% Category.icon_codes.each do |icon| %> - - <% end %> -
-
-
-
<% if category.errors.any? %> <%= render "shared/form_errors", model: category %> diff --git a/app/views/categories/edit.html.erb b/app/views/categories/edit.html.erb index 43daea2d..282c70d9 100644 --- a/app/views/categories/edit.html.erb +++ b/app/views/categories/edit.html.erb @@ -1,3 +1,6 @@ -<%= modal_form_wrapper title: t(".edit"), overflow_visible: true do %> - <%= render "form", category: @category, categories: @categories %> +<%= render DialogComponent.new(open_on_load: true) do |dialog| %> + <% dialog.with_header(title: t(".edit")) %> + <% dialog.with_body do %> + <%= render "form", category: @category, categories: @categories %> + <% end %> <% end %> diff --git a/app/views/categories/new.html.erb b/app/views/categories/new.html.erb index 6478d94d..93557920 100644 --- a/app/views/categories/new.html.erb +++ b/app/views/categories/new.html.erb @@ -1,3 +1,6 @@ -<%= modal_form_wrapper title: t(".new_category"), overflow_visible: true do %> - <%= render "form", category: @category, categories: @categories %> +<%= render DialogComponent.new(open_on_load: true) do |dialog| %> + <% dialog.with_header(title: t(".new_category")) %> + <% dialog.with_body do %> + <%= render "form", category: @category, categories: @categories %> + <% end %> <% end %> diff --git a/app/views/category/deletions/new.html.erb b/app/views/category/deletions/new.html.erb index 7c337ccf..5fff6a60 100644 --- a/app/views/category/deletions/new.html.erb +++ b/app/views/category/deletions/new.html.erb @@ -1,21 +1,32 @@ -<%= modal_form_wrapper title: t(".delete_category"), subtitle: t(".explanation", category_name: @category.name) do %> - <%= styled_form_with url: category_deletions_path(@category), - class: "space-y-4", +<%= render DialogComponent.new(open_on_load: true) do |dialog| %> + <% dialog.with_header(title: t(".delete_category"), subtitle: t(".explanation", category_name: @category.name)) %> + + <% dialog.with_body do %> + <%= styled_form_with url: category_deletions_path(@category), data: { turbo: false, controller: "deletion", - deletion_dangerous_action_class: "form-field__submit bg-container text-red-600 border hover:bg-red-50", - deletion_safe_action_class: "form-field__submit border border-transparent", deletion_submit_text_when_not_replacing_value: t(".delete_and_leave_uncategorized", category_name: @category.name), deletion_submit_text_when_replacing_value: t(".delete_and_recategorize", category_name: @category.name) } do |f| %> - <%= f.collection_select :replacement_category_id, + <%= f.collection_select :replacement_category_id, Current.family.categories.alphabetically.without(@category), :id, :name, - { prompt: t(".replacement_category_prompt"), label: t(".category") }, - { data: { deletion_target: "replacementField", action: "deletion#updateSubmitButton" } } %> + { prompt: t(".replacement_category_prompt"), label: t(".category"), container_class: "mb-4" }, + data: { deletion_target: "replacementField", action: "deletion#chooseSubmitButton" } %> - <%= f.submit t(".delete_and_leave_uncategorized", category_name: @category.name), - class: "form-field__submit bg-container text-red-600 border hover:bg-red-50", - data: { deletion_target: "submitButton" } %> + <%= render ButtonComponent.new( + variant: "destructive", + text: t(".delete_and_leave_uncategorized", category_name: @category.name), + full_width: true, + data: { deletion_target: "destructiveSubmitButton" } + ) %> + + <%= render ButtonComponent.new( + text: "Delete and reassign", + data: { deletion_target: "safeSubmitButton" }, + hidden: true, + full_width: true + ) %> + <% end %> <% end %> <% end %> diff --git a/app/views/imports/new.html.erb b/app/views/imports/new.html.erb index 8e7f6c61..20a48a0a 100644 --- a/app/views/imports/new.html.erb +++ b/app/views/imports/new.html.erb @@ -4,7 +4,7 @@

<%= t(".title") %>

- <%= icon("x", as_button: true, data: { action: "mousedown->modal#close" }, tabindex: "-1") %> + <%= icon("x", as_button: true, data: { action: "mousedown->dialog#close" }, tabindex: "-1") %>

<%= t(".description") %>

diff --git a/app/views/layouts/lookbooks.html.erb b/app/views/layouts/lookbooks.html.erb index 3ff85f64..de1b9dc9 100644 --- a/app/views/layouts/lookbooks.html.erb +++ b/app/views/layouts/lookbooks.html.erb @@ -8,7 +8,7 @@ <%= javascript_include_tag "application", "data-turbo-track": "reload", defer: true %> <%= javascript_importmap_tags %> - + <%= yield %> diff --git a/app/views/layouts/shared/_custom_confirm_dialog.html.erb b/app/views/layouts/shared/_confirm_dialog.html.erb similarity index 70% rename from app/views/layouts/shared/_custom_confirm_dialog.html.erb rename to app/views/layouts/shared/_confirm_dialog.html.erb index 987ea50d..a74b6130 100644 --- a/app/views/layouts/shared/_custom_confirm_dialog.html.erb +++ b/app/views/layouts/shared/_confirm_dialog.html.erb @@ -1,5 +1,7 @@ - -
+<%# This dialog is used as an override to the browser's confirm API when submitting forms with data-turbo-confirm %> +<%# See confirm_dialog_controller.js and _htmldoc.html.erb %> +<%= render DialogComponent.new(id: "confirm-dialog", data: { controller: "confirm-dialog" }) do %> +

Are you sure?

@@ -23,4 +25,4 @@ <% end %>
-
+<% end %> diff --git a/app/views/layouts/shared/_htmldoc.html.erb b/app/views/layouts/shared/_htmldoc.html.erb index 1acb2064..11016d3f 100644 --- a/app/views/layouts/shared/_htmldoc.html.erb +++ b/app/views/layouts/shared/_htmldoc.html.erb @@ -31,7 +31,7 @@ <%= turbo_frame_tag "drawer" %> <%# Custom overrides for browser's confirm API %> - <%= render "layouts/shared/custom_confirm_dialog" %> + <%= render "layouts/shared/confirm_dialog" %> <%= render "impersonation_sessions/super_admin_bar" if Current.true_user&.super_admin? && show_super_admin_bar? %> <%= render "impersonation_sessions/approval_bar" if Current.true_user&.impersonated_support_sessions&.initiated&.any? %> diff --git a/app/views/rules/confirm.html.erb b/app/views/rules/confirm.html.erb index c6887326..444b2628 100644 --- a/app/views/rules/confirm.html.erb +++ b/app/views/rules/confirm.html.erb @@ -3,7 +3,7 @@

Confirm changes

- <%= icon("x", as_button: true, data: { action: "mousedown->modal#close" }) %> + <%= icon("x", as_button: true, data: { action: "mousedown->dialog#close" }) %>

diff --git a/app/views/shared/_drawer.html.erb b/app/views/shared/_drawer.html.erb deleted file mode 100644 index 21171ed0..00000000 --- a/app/views/shared/_drawer.html.erb +++ /dev/null @@ -1,17 +0,0 @@ -<%# locals: (content:, reload_on_close: false) %> - -<%= turbo_frame_tag "drawer" do %> -

-
-
- <%= icon("x", as_button: true, data: { action: "mousedown->modal#close" }) %> -
-
- <%= content %> -
-
-
-<% end %> diff --git a/app/views/shared/_modal.html.erb b/app/views/shared/_modal.html.erb deleted file mode 100644 index 3fdcb70a..00000000 --- a/app/views/shared/_modal.html.erb +++ /dev/null @@ -1,19 +0,0 @@ -<%# locals: (content:, reload_on_close:, overflow_visible: false) -%> - -<%= turbo_frame_tag "modal" do %> - <%= tag.dialog( - class: class_names( - "focus:outline-none md:m-auto bg-container rounded-none md:rounded-2xl max-w-screen max-h-screen md:max-w-max w-full h-full md:h-fit md:w-auto shadow-border-xs", - overflow_visible ? "overflow-visible" : "overflow-auto" - ), - data: { - controller: "modal", - action: "mousedown->modal#clickOutside", - modal_reload_on_close_value: reload_on_close - } - ) do %> -
- <%= content %> -
- <% end %> -<% end %> diff --git a/app/views/shared/_modal_form.html.erb b/app/views/shared/_modal_form.html.erb deleted file mode 100644 index d12c6799..00000000 --- a/app/views/shared/_modal_form.html.erb +++ /dev/null @@ -1,18 +0,0 @@ -<%# locals: (title:, content:, subtitle: nil, overflow_visible: false) %> - -<%= modal overflow_visible: overflow_visible do %> -
-
-
-

<%= title %>

- <%= icon("x", as_button: true, data: { action: "mousedown->modal#close" }) %> -
- - <% if subtitle.present? %> - <%= tag.p subtitle, class: "text-secondary font-light" %> - <% end %> -
- - <%= content %> -
-<% end %> diff --git a/app/views/shared/_subscribe_modal.html.erb b/app/views/shared/_subscribe_modal.html.erb index 5f8d2a7c..b2d88e79 100644 --- a/app/views/shared/_subscribe_modal.html.erb +++ b/app/views/shared/_subscribe_modal.html.erb @@ -1,30 +1,26 @@ -