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..cca686625 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() { + // The may already be gone; the scroll lock must be lifted either way. + if (this.hasDialogTarget) { + 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"; + this.hideAfterExitAnimation(this.dialogTarget); + } + + afterExit() { + this.dialogTarget.close(); + } + + // Escape (and requestClose) must play the exit animation instead of closing at once. + handleCancel = (event) => { + // Already on its way out: let a second Escape close natively where the browser allows it. + if (this.dialogTarget.dataset.state === "closed") return; + + event.preventDefault(); + this.dismiss(); + }; + + handleClose = () => { document.body.classList.remove("overflow-hidden"); - // remove the element - this.element.remove(); + // A close this controller did not start (a second Escape mid-exit) must not leave a pending exit behind. + this.settleExit(this.dialogTarget); + }; + + // Overlay exit — unlike the other overlays this waits on the Animation objects: the ::backdrop animates too, + // and its events land on the under the same keyframe names as the panel's. + hideAfterExitAnimation(animated) { + const run = (this.exitRun = {}); + // subtree: true is what lists the ::backdrop's animation; descendants are filtered back out. + const exitAnimations = animated + .getAnimations({ subtree: true }) + .filter((animation) => animation instanceof CSSAnimation && animation.effect?.target === animated); + + // No exit animation, or no box to run it in: nothing would ever finish. + if (exitAnimations.length === 0) { + this.settleExit(animated); + return; + } + + // A cancelled exit (reopened mid-exit) counts as finished too. + Promise.allSettled(exitAnimations.map((animation) => animation.finished)).then(() => { + // A later dismiss or close owns the dialog now; this run is stale. + if (this.exitRun !== run) return; + + this.settleExit(animated); + }); + } + + settleExit(animated) { + this.exitRun = null; + // 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