Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
61 changes: 41 additions & 20 deletions docs/app/views/docs/sheet.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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" }

Expand All @@ -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." }
Expand All @@ -28,8 +28,10 @@ def view_template
Input(placeholder: "joel@drapper.me")
end
SheetFooter do
Button(variant: :outline, data: { action: 'click->ruby-ui--sheet-content#close' }) { "Cancel" }
Button(type: "submit") { "Save" }
SheetClose do
Button(variant: :outline) { "Cancel" }
end
Button { "Save" }
end
end
end
Expand All @@ -38,28 +40,26 @@ 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
Comment thread
tvq marked this conversation as resolved.
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" }
Button(type: "submit") { "Save" }
SheetClose do
Button(variant: :outline) { "Cancel" }
end
Button { "Save" }
end
end
end
Expand All @@ -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))
Expand Down
17 changes: 17 additions & 0 deletions gem/lib/ruby_ui/sheet/sheet_close.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
# frozen_string_literal: true

module RubyUI
class SheetClose < Base
def view_template(&)
div(**attrs, &)
Comment thread
tvq marked this conversation as resolved.
end

private

def default_attrs
{
data: {action: "click->ruby-ui--sheet-content#close"}
}
end
end
end
52 changes: 24 additions & 28 deletions gem/lib/ruby_ui/sheet/sheet_content.rb
Original file line number Diff line number Diff line change
Expand Up @@ -2,40 +2,46 @@

module RubyUI
class SheetContent < Base
# Per side: release the opposite edge (a modal <dialog> 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

private

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 <dialog> 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",
# ::backdrop does not inherit the panel's --tw-duration; give it the same exit so both fade in step.
"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
Expand Down Expand Up @@ -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
91 changes: 64 additions & 27 deletions gem/lib/ruby_ui/sheet/sheet_content_controller.js
Original file line number Diff line number Diff line change
@@ -1,55 +1,92 @@
import { Controller } from "@hotwired/stimulus";

// Connects to data-controller="ruby-ui--sheet-content" on the <dialog>; 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);
Comment thread
tvq marked this conversation as resolved.
// Removed while open, the dialog fires no close event; the lock must not outlive it.
this.releaseScrollLock();

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2: When Stimulus disconnects this controller while the dialog is still open, the helper treats that dialog as another lock owner and leaves overflow-hidden on the body. Close the still-open dialog during teardown, or exclude the disconnected element from the modal check while retaining checks for other dialogs.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At gem/lib/ruby_ui/sheet/sheet_content_controller.js, line 16:

<comment>When Stimulus disconnects this controller while the dialog is still open, the helper treats that dialog as another lock owner and leaves `overflow-hidden` on the body. Close the still-open dialog during teardown, or exclude the disconnected element from the modal check while retaining checks for other dialogs.</comment>

<file context>
@@ -12,6 +12,8 @@ export default class extends Controller {
     // Nothing is left to wait for the exit animation, so apply the pending close now.
     this.settleExit(this.element);
+    // Removed while open, the dialog fires no close event; the lock must not outlive it.
+    this.releaseScrollLock();
   }
 
</file context>

}

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";
this.hideAfterExitAnimation(this.element);
Comment thread
tvq marked this conversation as resolved.
}

afterExit() {
this.element.remove();
this.element.close();
}

// Overlay exit — the same block in every overlay controller, so keep them in sync.
exitAnimationNames = new WeakMap();
// 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;
// Already on its way out: let a second Escape close natively where the browser allows it.
if (this.element.dataset.state === "closed") return;

e.preventDefault();
this.close();
};

handleClose = () => {
this.releaseScrollLock();
// A close this controller did not start (a second Escape mid-exit) must not leave a pending exit behind.
this.settleExit(this.element);
};

// A nested Sheet or a Dialog may still be open underneath; the page stays locked for it.
releaseScrollLock() {
if (document.querySelector("dialog:modal")) return;

document.body.classList.remove("overflow-hidden");
}

// Overlay exit — unlike the other overlays this waits on the Animation objects: the ::backdrop animates too,
// and its events land on the <dialog> 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()
.filter((animation) => animation instanceof CSSAnimation);
.getAnimations({ subtree: true })
.filter((animation) => animation instanceof CSSAnimation && animation.effect?.target === animated);

// No exit animation, or no box to run it in: animationend would never fire.
// No exit animation, or no box to run it in: nothing would ever finish.
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);
}
// A cancelled exit (reopened mid-exit) counts as finished too.
Promise.allSettled(exitAnimations.map((animation) => animation.finished)).then(() => {
// A later close owns the dialog now; this run is stale.
if (this.exitRun !== run) return;

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);
};
this.settleExit(animated);
});
}

settleExit(animated) {
animated.removeEventListener("animationend", this.handleExitAnimationEnd);
animated.removeEventListener("animationcancel", this.handleExitAnimationEnd);
this.exitRun = null;
// Reopened mid-exit: it is on its way back in, leave it visible.
if (animated.dataset.state !== "closed") return;

Expand Down
18 changes: 11 additions & 7 deletions gem/lib/ruby_ui/sheet/sheet_controller.js
Original file line number Diff line number Diff line change
@@ -1,15 +1,19 @@
import { Controller } from "@hotwired/stimulus"
import { Controller } from "@hotwired/stimulus";

// Connects to data-controller="ruby-ui--sheet"; opens the <dialog> 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();
}

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");
}
}
Loading