diff --git a/docs/app/views/docs/sheet.rb b/docs/app/views/docs/sheet.rb index 8865c0a2d..cb9fc9029 100644 --- a/docs/app/views/docs/sheet.rb +++ b/docs/app/views/docs/sheet.rb @@ -5,7 +5,7 @@ def view_template component = "Sheet" div(class: "max-w-2xl mx-auto w-full py-10 space-y-10") do - render Docs::Header.new(title: "Sheet", description: "Extends the Sheet component to display content that complements the main content of the screen.") + render Docs::Header.new(title: "Sheet", description: "Extends the Dialog component to display content that complements the main content of the screen.") Heading(level: 2) { "Usage" } @@ -15,7 +15,7 @@ def view_template SheetTrigger do Button(variant: :outline) { "Open Sheet" } end - SheetContent(class: 'sm:max-w-sm') do + SheetContent do SheetHeader do SheetTitle { "Edit profile" } SheetDescription { "Make changes to your profile here. Click save when you're done." } @@ -28,7 +28,9 @@ def view_template Input(placeholder: "joel@drapper.me") end SheetFooter do - Button(variant: :outline, data: { action: 'click->ruby-ui--sheet-content#close' }) { "Cancel" } + SheetClose do + Button(variant: :outline) { "Cancel" } + end Button(type: "submit") { "Save" } end end @@ -38,27 +40,25 @@ def view_template render Docs::VisualCodeExample.new(title: "Side", description: "Use the side property to indicate the edge of the screen where the component will appear.", context: self) do <<~RUBY - div(class: 'grid grid-cols-2 gap-4') do - # -- TOP -- - Sheet do - SheetTrigger do - Button(variant: :outline, class: 'w-full justify-center') { :top } - end - SheetContent(side: :top, class: ("sm:max-w-sm" if [:left, :right].include?(:top))) do - SheetHeader do - SheetTitle { "Edit profile" } - SheetDescription { "Make changes to your profile here. Click save when you're done." } + div(class: 'flex flex-wrap gap-2') do + [:top, :right, :bottom, :left].each do |side| + Sheet do + SheetTrigger do + Button(variant: :outline, class: 'capitalize') { side.to_s } end - Form do + SheetContent(side: side) do + SheetHeader do + SheetTitle { "Edit profile" } + SheetDescription { "Make changes to your profile here. Click save when you're done." } + end SheetMiddle do label { "Name" } Input(placeholder: "Joel Drapper") { "Joel Drapper" } - - label { "Email" } - Input(placeholder: "joel@drapper.me") end SheetFooter do - Button(variant: :outline, data: { action: 'click->ruby-ui--sheet-content#close' }) { "Cancel" } + SheetClose do + Button(variant: :outline) { "Cancel" } + end Button(type: "submit") { "Save" } end end @@ -68,6 +68,27 @@ def view_template RUBY end + render Docs::VisualCodeExample.new(title: "No close button", description: "Pass show_close_button: false to hide the close button in the corner. The sheet still closes on Escape or a click outside.", context: self) do + <<~RUBY + Sheet do + SheetTrigger do + Button(variant: :outline) { "Open Sheet" } + end + SheetContent(show_close_button: false) do + SheetHeader do + SheetTitle { "No close button" } + SheetDescription { "This sheet has no close button in the corner. Press Escape or click outside to close it." } + end + SheetFooter do + SheetClose do + Button(variant: :outline) { "Close" } + end + end + end + end + RUBY + end + render Components::ComponentSetup::Tabs.new(component_name: component) render Docs::ComponentsTable.new(component_files(component)) diff --git a/gem/lib/ruby_ui/sheet/sheet_close.rb b/gem/lib/ruby_ui/sheet/sheet_close.rb new file mode 100644 index 000000000..dba1e8d3a --- /dev/null +++ b/gem/lib/ruby_ui/sheet/sheet_close.rb @@ -0,0 +1,17 @@ +# frozen_string_literal: true + +module RubyUI + class SheetClose < Base + def view_template(&) + div(**attrs, &) + end + + private + + def default_attrs + { + data: {action: "click->ruby-ui--sheet-content#close"} + } + end + end +end diff --git a/gem/lib/ruby_ui/sheet/sheet_content.rb b/gem/lib/ruby_ui/sheet/sheet_content.rb index 596d29386..ab5661503 100644 --- a/gem/lib/ruby_ui/sheet/sheet_content.rb +++ b/gem/lib/ruby_ui/sheet/sheet_content.rb @@ -2,28 +2,25 @@ module RubyUI class SheetContent < Base + # Per side: release the opposite edge (a modal is pinned to all four by inset: 0), + # then the default size and the direction to slide in from. SIDE_CLASS = { - top: "inset-x-0 top-0 border-b data-[state=closed]:slide-out-to-top data-[state=open]:slide-in-from-top", - right: "inset-y-0 right-0 h-full border-l data-[state=closed]:slide-out-to-right data-[state=open]:slide-in-from-right", - bottom: "inset-x-0 bottom-0 border-t data-[state=closed]:slide-out-to-bottom data-[state=open]:slide-in-from-bottom", - left: "inset-y-0 left-0 h-full border-r data-[state=closed]:slide-out-to-left data-[state=open]:slide-in-from-left" + top: "inset-x-0 top-0 bottom-auto w-full h-auto border-b data-[state=closed]:slide-out-to-top data-[state=open]:slide-in-from-top", + right: "inset-y-0 right-0 left-auto h-full w-3/4 sm:max-w-sm border-l data-[state=closed]:slide-out-to-right data-[state=open]:slide-in-from-right", + bottom: "inset-x-0 bottom-0 top-auto w-full h-auto border-t data-[state=closed]:slide-out-to-bottom data-[state=open]:slide-in-from-bottom", + left: "inset-y-0 left-0 right-auto h-full w-3/4 sm:max-w-sm border-r data-[state=closed]:slide-out-to-left data-[state=open]:slide-in-from-left" } - def initialize(side: :right, **attrs) + def initialize(side: :right, show_close_button: true, **attrs) @side = side - @side_classes = SIDE_CLASS[side] + @show_close_button = show_close_button super(**attrs) end def view_template(&block) - template(data: {ruby_ui__sheet_target: "content"}) do - div(data: {controller: "ruby-ui--sheet-content"}) do - backdrop - div(**attrs) do - block&.call - close_button - end - end + dialog(**attrs) do + block&.call + close_button if @show_close_button end end @@ -31,11 +28,20 @@ def view_template(&block) def default_attrs { - data_state: "open", # For animate in - data_ruby_ui__sheet_content_target: "panel", + data: { + controller: "ruby-ui--sheet-content", + ruby_ui__sheet_target: "dialog", + action: "click->ruby-ui--sheet-content#backdropClick", + side: @side + }, class: [ - "fixed pointer-events-auto z-50 gap-4 bg-background p-6 shadow-lg transition ease-in-out data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fill-mode-forwards data-[state=closed]:duration-300 data-[state=open]:duration-500 overflow-scroll", - @side_classes + # UA reset; not-open:hidden keeps a caller's bare `flex` from overriding the display: none of a closed dialog. + "m-0 max-w-full max-h-full not-open:hidden", + "fixed pointer-events-auto z-50 gap-4 bg-background text-foreground p-6 shadow-lg transition ease-in-out overflow-scroll", + "data-[state=open]:animate-in data-[state=open]:duration-500 data-[state=closed]:animate-out data-[state=closed]:duration-300 data-[state=closed]:fill-mode-forwards", + # The ::backdrop's animationend lands on the dialog itself, so its exit must last as long as the panel's. + "backdrop:bg-background/80 backdrop:backdrop-blur-sm 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:duration-300 data-[state=closed]:backdrop:fill-mode-forwards", + SIDE_CLASS[@side] ] } end @@ -65,15 +71,5 @@ def close_button span(class: "sr-only") { "Close" } end end - - def backdrop - div( - data_state: "open", - data_action: "click->ruby-ui--sheet-content#close", - data_ruby_ui__sheet_content_target: "backdrop", - class: - "fixed pointer-events-auto inset-0 z-50 bg-background/80 backdrop-blur-sm data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:fill-mode-forwards" - ) - end end end diff --git a/gem/lib/ruby_ui/sheet/sheet_content_controller.js b/gem/lib/ruby_ui/sheet/sheet_content_controller.js index caef6910b..81ab3917b 100644 --- a/gem/lib/ruby_ui/sheet/sheet_content_controller.js +++ b/gem/lib/ruby_ui/sheet/sheet_content_controller.js @@ -1,24 +1,56 @@ import { Controller } from "@hotwired/stimulus"; +// Connects to data-controller="ruby-ui--sheet-content" on the ; ruby-ui--sheet opens it. export default class extends Controller { - static targets = ["backdrop", "panel"]; + connect() { + this.element.addEventListener("cancel", this.handleCancel); + this.element.addEventListener("close", this.handleClose); + } disconnect() { - // Nothing is left to wait for the exit animation, so apply the pending removal now. - if (this.hasPanelTarget) this.settleExit(this.panelTarget); + this.element.removeEventListener("cancel", this.handleCancel); + this.element.removeEventListener("close", this.handleClose); + // Nothing is left to wait for the exit animation, so apply the pending close now. + this.settleExit(this.element); } close() { - this.backdropTarget.dataset.state = "closed"; - this.panelTarget.dataset.state = "closed"; - // The panel carries the longer exit, so the backdrop has finished by the time it settles. - this.hideAfterExitAnimation(this.panelTarget); + if (this.element.dataset.state === "closed") return; + + this.element.dataset.state = "closed"; + // The ::backdrop's animationend lands on the dialog too; panel and backdrop share one exit duration so either settles it. + this.hideAfterExitAnimation(this.element); } afterExit() { - this.element.remove(); + this.element.close(); + } + + // A click on the ::backdrop targets the dialog, but so does one on the panel's own padding: hit-test the box. + backdropClick(e) { + if (e.target === this.element && !this.coversPoint(e.clientX, e.clientY)) this.close(); + } + + coversPoint(x, y) { + const { top, right, bottom, left } = this.element.getBoundingClientRect(); + return left <= x && x <= right && top <= y && y <= bottom; } + // Escape (and requestClose()) fire cancel; route it through the exit animation. + handleCancel = (e) => { + // A cancelled file picker inside the sheet bubbles its own cancel event. + if (e.target !== this.element) return; + + e.preventDefault(); + this.close(); + }; + + handleClose = () => { + document.body.classList.remove("overflow-hidden"); + // A close this controller did not start (a second Escape mid-exit) must not leave the exit listeners behind. + this.settleExit(this.element); + }; + // Overlay exit — the same block in every overlay controller, so keep them in sync. exitAnimationNames = new WeakMap(); diff --git a/gem/lib/ruby_ui/sheet/sheet_controller.js b/gem/lib/ruby_ui/sheet/sheet_controller.js index 22ddf68d8..5a2ab6e6f 100644 --- a/gem/lib/ruby_ui/sheet/sheet_controller.js +++ b/gem/lib/ruby_ui/sheet/sheet_controller.js @@ -1,15 +1,23 @@ -import { Controller } from "@hotwired/stimulus" +import { Controller } from "@hotwired/stimulus"; +// Connects to data-controller="ruby-ui--sheet"; opens the that ruby-ui--sheet-content closes. export default class extends Controller { - static targets = ["content"] - - static values = { open: false } + static targets = ["dialog"]; + static values = { open: false }; connect() { - if (this.openValue) this.open() + if (this.openValue) this.open(); + } + + disconnect() { + document.body.classList.remove("overflow-hidden"); } - open() { - document.body.insertAdjacentHTML("beforeend", this.contentTarget.innerHTML) + open(e) { + e?.preventDefault(); + this.dialogTarget.dataset.state = "open"; + // Reopened mid-exit the dialog is still open; showModal() on an open dialog throws in older browsers. + if (!this.dialogTarget.open) this.dialogTarget.showModal(); + document.body.classList.add("overflow-hidden"); } } diff --git a/gem/lib/ruby_ui/sheet/sheet_docs.rb b/gem/lib/ruby_ui/sheet/sheet_docs.rb index 8865c0a2d..cb9fc9029 100644 --- a/gem/lib/ruby_ui/sheet/sheet_docs.rb +++ b/gem/lib/ruby_ui/sheet/sheet_docs.rb @@ -5,7 +5,7 @@ def view_template component = "Sheet" div(class: "max-w-2xl mx-auto w-full py-10 space-y-10") do - render Docs::Header.new(title: "Sheet", description: "Extends the Sheet component to display content that complements the main content of the screen.") + render Docs::Header.new(title: "Sheet", description: "Extends the Dialog component to display content that complements the main content of the screen.") Heading(level: 2) { "Usage" } @@ -15,7 +15,7 @@ def view_template SheetTrigger do Button(variant: :outline) { "Open Sheet" } end - SheetContent(class: 'sm:max-w-sm') do + SheetContent do SheetHeader do SheetTitle { "Edit profile" } SheetDescription { "Make changes to your profile here. Click save when you're done." } @@ -28,7 +28,9 @@ def view_template Input(placeholder: "joel@drapper.me") end SheetFooter do - Button(variant: :outline, data: { action: 'click->ruby-ui--sheet-content#close' }) { "Cancel" } + SheetClose do + Button(variant: :outline) { "Cancel" } + end Button(type: "submit") { "Save" } end end @@ -38,27 +40,25 @@ def view_template render Docs::VisualCodeExample.new(title: "Side", description: "Use the side property to indicate the edge of the screen where the component will appear.", context: self) do <<~RUBY - div(class: 'grid grid-cols-2 gap-4') do - # -- TOP -- - Sheet do - SheetTrigger do - Button(variant: :outline, class: 'w-full justify-center') { :top } - end - SheetContent(side: :top, class: ("sm:max-w-sm" if [:left, :right].include?(:top))) do - SheetHeader do - SheetTitle { "Edit profile" } - SheetDescription { "Make changes to your profile here. Click save when you're done." } + div(class: 'flex flex-wrap gap-2') do + [:top, :right, :bottom, :left].each do |side| + Sheet do + SheetTrigger do + Button(variant: :outline, class: 'capitalize') { side.to_s } end - Form do + SheetContent(side: side) do + SheetHeader do + SheetTitle { "Edit profile" } + SheetDescription { "Make changes to your profile here. Click save when you're done." } + end SheetMiddle do label { "Name" } Input(placeholder: "Joel Drapper") { "Joel Drapper" } - - label { "Email" } - Input(placeholder: "joel@drapper.me") end SheetFooter do - Button(variant: :outline, data: { action: 'click->ruby-ui--sheet-content#close' }) { "Cancel" } + SheetClose do + Button(variant: :outline) { "Cancel" } + end Button(type: "submit") { "Save" } end end @@ -68,6 +68,27 @@ def view_template RUBY end + render Docs::VisualCodeExample.new(title: "No close button", description: "Pass show_close_button: false to hide the close button in the corner. The sheet still closes on Escape or a click outside.", context: self) do + <<~RUBY + Sheet do + SheetTrigger do + Button(variant: :outline) { "Open Sheet" } + end + SheetContent(show_close_button: false) do + SheetHeader do + SheetTitle { "No close button" } + SheetDescription { "This sheet has no close button in the corner. Press Escape or click outside to close it." } + end + SheetFooter do + SheetClose do + Button(variant: :outline) { "Close" } + end + end + end + end + RUBY + end + render Components::ComponentSetup::Tabs.new(component_name: component) render Docs::ComponentsTable.new(component_files(component)) diff --git a/gem/lib/ruby_ui/sidebar/mobile_sidebar.rb b/gem/lib/ruby_ui/sidebar/mobile_sidebar.rb index 3ecc69764..80cb6818f 100644 --- a/gem/lib/ruby_ui/sidebar/mobile_sidebar.rb +++ b/gem/lib/ruby_ui/sidebar/mobile_sidebar.rb @@ -13,7 +13,8 @@ def view_template(&) Sheet(**attrs) do SheetContent( side: @side, - class: "w-[var(--sidebar-width)] bg-sidebar p-0 text-sidebar-foreground [&>button]:hidden", + show_close_button: false, + class: "w-[var(--sidebar-width)] bg-sidebar p-0 text-sidebar-foreground", style: { "--sidebar-width": SIDEBAR_WIDTH_MOBILE }, diff --git a/gem/test/ruby_ui/sheet_test.rb b/gem/test/ruby_ui/sheet_test.rb index 57e206b59..749ae53fa 100644 --- a/gem/test/ruby_ui/sheet_test.rb +++ b/gem/test/ruby_ui/sheet_test.rb @@ -20,7 +20,7 @@ def test_render_with_all_items RubyUI.Input(placeholder: "joel@drapper.me") RubyUI.SheetFooter do - RubyUI.Button(variant: :outline, data: {action: "click->ruby-ui--sheet-content#close"}) { "Cancel" } + RubyUI.SheetClose { RubyUI.Button(variant: :outline) { "Cancel" } } RubyUI.Button(type: "submit") { "Save" } end end @@ -43,12 +43,165 @@ def test_render_open_when_open_is_true assert_match(/data-ruby-ui--sheet-open-value="true"/, output) end - # Removal lands a frame after the animation ends; without a forwards fill mode that frame flashes. - def test_content_and_backdrop_hold_the_last_frame_of_the_exit_animation - output = phlex do - RubyUI.SheetContent { "sheet body" } + def test_trigger_has_open_action + assert_match(/data-action="click->ruby-ui--sheet#open"/, render_sheet) + end + + # Regression test: content must be a native , not a