diff --git a/docs/app/views/docs/alert_dialog.rb b/docs/app/views/docs/alert_dialog.rb index 43f176549..162d6c073 100644 --- a/docs/app/views/docs/alert_dialog.rb +++ b/docs/app/views/docs/alert_dialog.rb @@ -11,7 +11,7 @@ def view_template <<~RUBY AlertDialog do AlertDialogTrigger do - Button { "Show dialog" } + Button(variant: :outline) { "Show dialog" } end AlertDialogContent do AlertDialogHeader do diff --git a/gem/lib/ruby_ui/alert_dialog/alert_dialog_action.rb b/gem/lib/ruby_ui/alert_dialog/alert_dialog_action.rb index 75a34d2ef..17293ecd8 100644 --- a/gem/lib/ruby_ui/alert_dialog/alert_dialog_action.rb +++ b/gem/lib/ruby_ui/alert_dialog/alert_dialog_action.rb @@ -10,7 +10,10 @@ def view_template(&) def default_attrs { - variant: :primary + variant: :primary, + data: { + action: "click->ruby-ui--alert-dialog#dismiss" + } } end end diff --git a/gem/lib/ruby_ui/alert_dialog/alert_dialog_cancel.rb b/gem/lib/ruby_ui/alert_dialog/alert_dialog_cancel.rb index 986dc8d8e..5af2d1aa8 100644 --- a/gem/lib/ruby_ui/alert_dialog/alert_dialog_cancel.rb +++ b/gem/lib/ruby_ui/alert_dialog/alert_dialog_cancel.rb @@ -11,6 +11,7 @@ def view_template(&) def default_attrs { variant: :outline, + autofocus: true, data: { action: "click->ruby-ui--alert-dialog#dismiss" }, diff --git a/gem/lib/ruby_ui/alert_dialog/alert_dialog_content.rb b/gem/lib/ruby_ui/alert_dialog/alert_dialog_content.rb index adc81fd4b..9da69fd25 100644 --- a/gem/lib/ruby_ui/alert_dialog/alert_dialog_content.rb +++ b/gem/lib/ruby_ui/alert_dialog/alert_dialog_content.rb @@ -2,42 +2,23 @@ module RubyUI class AlertDialogContent < Base - def view_template(&block) - template(**attrs) do - div(data: {controller: "ruby-ui--alert-dialog"}) do - background - container(&block) - end - end - end - - def background - div( - data_state: "open", - class: "fixed inset-0 z-50 bg-black/80 backdrop-blur-sm data-[state=open]:animate-in", - style: "pointer-events:auto", - data_aria_hidden: "true", - aria_hidden: "true" - ) - end - - def container(&) - div( - role: "alertdialog", - data_state: "open", - class: "flex flex-col fixed left-[50%] top-[50%] z-50 w-full max-w-lg max-h-screen overflow-y-auto translate-x-[-50%] translate-y-[-50%] gap-4 border bg-background p-6 shadow-lg duration-200 data-[state=open]:animate-in data-[state=open]:fade-in-0 data-[state=open]:zoom-in-95 sm:rounded-lg md:w-full", - style: "pointer-events:auto", - & - ) + def view_template(&) + dialog(**attrs, &) end private def default_attrs { + role: "alertdialog", data: { - ruby_ui__alert_dialog_target: "content" - } + ruby_ui__alert_dialog_target: "dialog" + }, + class: [ + "fixed open:flex flex-col left-[50%] top-[50%] z-50 w-full max-w-lg max-h-screen overflow-y-auto translate-x-[-50%] translate-y-[-50%] gap-4 border bg-background p-6 shadow-lg sm:rounded-lg md:w-full", + "duration-200 data-[state=open]:animate-in data-[state=open]:fade-in-0 data-[state=open]:zoom-in-95 data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=closed]:zoom-out-95 data-[state=closed]:fill-mode-forwards", + "backdrop:bg-background/80 backdrop:backdrop-blur-sm backdrop:duration-200 data-[state=open]:backdrop:animate-in data-[state=open]:backdrop:fade-in-0 data-[state=closed]:backdrop:animate-out data-[state=closed]:backdrop:fade-out-0 data-[state=closed]:backdrop:fill-mode-forwards" + ] } end end diff --git a/gem/lib/ruby_ui/alert_dialog/alert_dialog_controller.js b/gem/lib/ruby_ui/alert_dialog/alert_dialog_controller.js index 98952e955..54fc486f0 100644 --- a/gem/lib/ruby_ui/alert_dialog/alert_dialog_controller.js +++ b/gem/lib/ruby_ui/alert_dialog/alert_dialog_controller.js @@ -2,7 +2,7 @@ import { Controller } from "@hotwired/stimulus"; // Connects to data-controller="ruby-ui--alert-dialog" export default class extends Controller { - static targets = ["content"]; + static targets = ["dialog"]; static values = { open: { type: Boolean, @@ -11,21 +11,85 @@ export default class extends Controller { }; connect() { + this.dialogTarget.addEventListener("cancel", this.handleCancel); + this.dialogTarget.addEventListener("close", this.handleClose); if (this.openValue) { this.open(); } } + disconnect() { + this.dialogTarget.removeEventListener("cancel", this.handleCancel); + this.dialogTarget.removeEventListener("close", this.handleClose); + // Nothing is left to wait for the exit animation, so apply the pending close now. + this.settleExit(this.dialogTarget); + document.body.classList.remove("overflow-hidden"); + } + open() { - document.body.insertAdjacentHTML("beforeend", this.contentTarget.innerHTML); - // prevent scroll on body + this.dialogTarget.dataset.state = "open"; + if (!this.dialogTarget.open) this.dialogTarget.showModal(); document.body.classList.add("overflow-hidden"); } - dismiss(e) { - // allow scroll on body + dismiss() { + if (this.dialogTarget.dataset.state === "closed") return; + + this.dialogTarget.dataset.state = "closed"; + // The backdrop's animationend lands on this element under the same name, so both exits run for the same 200 ms. + this.hideAfterExitAnimation(this.dialogTarget); + } + + afterExit() { + this.dialogTarget.close(); + } + + // Escape (and requestClose) must play the exit animation instead of closing at once. + handleCancel = (event) => { + event.preventDefault(); + this.dismiss(); + }; + + // A close this controller did not start must not leave the exit listeners behind. + handleClose = () => { document.body.classList.remove("overflow-hidden"); - // remove the element - this.element.remove(); + this.settleExit(this.dialogTarget); + }; + + // Overlay exit — the same block in every overlay controller, so keep them in sync. + exitAnimationNames = new WeakMap(); + + hideAfterExitAnimation(animated) { + const exitAnimations = animated + .getAnimations() + .filter((animation) => animation instanceof CSSAnimation); + + // No exit animation, or no box to run it in: animationend would never fire. + if (exitAnimations.length === 0) { + this.settleExit(animated); + return; + } + + this.exitAnimationNames.set(animated, exitAnimations.map((animation) => animation.animationName)); + animated.addEventListener("animationend", this.handleExitAnimationEnd); + animated.addEventListener("animationcancel", this.handleExitAnimationEnd); + } + + handleExitAnimationEnd = (event) => { + // animationend bubbles — an animated child must not hide its container. + if (event.target !== event.currentTarget) return; + // Closing mid-open cancels the enter animation; only the exit run settles this. + if (!this.exitAnimationNames.get(event.currentTarget)?.includes(event.animationName)) return; + + this.settleExit(event.currentTarget); + }; + + settleExit(animated) { + animated.removeEventListener("animationend", this.handleExitAnimationEnd); + animated.removeEventListener("animationcancel", this.handleExitAnimationEnd); + // Reopened mid-exit: it is on its way back in, leave it visible. + if (animated.dataset.state !== "closed") return; + + this.afterExit(animated); } } diff --git a/gem/lib/ruby_ui/alert_dialog/alert_dialog_docs.rb b/gem/lib/ruby_ui/alert_dialog/alert_dialog_docs.rb index 43f176549..162d6c073 100644 --- a/gem/lib/ruby_ui/alert_dialog/alert_dialog_docs.rb +++ b/gem/lib/ruby_ui/alert_dialog/alert_dialog_docs.rb @@ -11,7 +11,7 @@ def view_template <<~RUBY AlertDialog do AlertDialogTrigger do - Button { "Show dialog" } + Button(variant: :outline) { "Show dialog" } end AlertDialogContent do AlertDialogHeader do diff --git a/gem/test/ruby_ui/alert_dialog_test.rb b/gem/test/ruby_ui/alert_dialog_test.rb index 9c27fb710..ae36f240d 100644 --- a/gem/test/ruby_ui/alert_dialog_test.rb +++ b/gem/test/ruby_ui/alert_dialog_test.rb @@ -24,4 +24,125 @@ def test_render_with_all_items assert_match(/Show dialog/, output) end + + # Regression test: content must be a native , not a