From b5e0b26497e3829f766b6f1bf126ba6ec2dfc080 Mon Sep 17 00:00:00 2001 From: Tomek Date: Sat, 29 Aug 2026 08:13:31 +0200 Subject: [PATCH 1/5] [Feature] Drawer: bottom sheet with swipe handle, snap points and swipe-to-dismiss Add shadcn's Drawer: a bottom sheet on a native and a CSS scroll-snap scroller, with no JavaScript dependencies. Components: Drawer, DrawerTrigger, DrawerContent, DrawerHeader, DrawerTitle, DrawerDescription, DrawerMiddle, DrawerFooter, DrawerClose and DrawerSwipeHandle. DrawerContent takes snap_points (% of the viewport), initial, modal, dismissible and handle. The panel sits at the bottom of a full-screen snap scroller behind a full-screen spacer, so revealing it means scrolling up and scrollTop 0 is the dismissed position; each snap point is a zero-height sentinel. The controller animates scrollTop for open and close, drives the handle with pointer events, picks the rest point by distance or flick velocity, fades the scrim with the drag below the lowest snap point and lifts the scroller above the on-screen keyboard using the visual viewport. The visible band is written to --drawer-band by the controller, so the gem ships no CSS of its own; a scroll-timeline variant would need @property and @keyframes in the installed stylesheet. Docs page, Stimulus manifest, site files and the MCP registry included. Co-Authored-By: Claude Fable 5 --- docs/app/components/shared/components_list.rb | 1 + docs/app/controllers/docs_controller.rb | 4 + docs/app/javascript/controllers/index.js | 6 + .../ruby_ui/drawer_content_controller.js | 1 + .../controllers/ruby_ui/drawer_controller.js | 1 + docs/app/lib/site_files.rb | 1 + docs/app/views/docs/drawer.rb | 137 ++++++++ docs/config/routes.rb | 1 + docs/public/llms-full.txt | 5 + docs/public/llms.txt | 1 + docs/public/sitemap.xml | 5 + gem/lib/ruby_ui/drawer/drawer.rb | 25 ++ gem/lib/ruby_ui/drawer/drawer_close.rb | 17 + gem/lib/ruby_ui/drawer/drawer_content.rb | 89 +++++ .../drawer/drawer_content_controller.js | 329 ++++++++++++++++++ gem/lib/ruby_ui/drawer/drawer_controller.js | 20 ++ gem/lib/ruby_ui/drawer/drawer_description.rb | 18 + gem/lib/ruby_ui/drawer/drawer_docs.rb | 137 ++++++++ gem/lib/ruby_ui/drawer/drawer_footer.rb | 17 + gem/lib/ruby_ui/drawer/drawer_header.rb | 17 + gem/lib/ruby_ui/drawer/drawer_middle.rb | 18 + gem/lib/ruby_ui/drawer/drawer_swipe_handle.rb | 27 ++ gem/lib/ruby_ui/drawer/drawer_title.rb | 18 + gem/lib/ruby_ui/drawer/drawer_trigger.rb | 17 + gem/test/ruby_ui/drawer_test.rb | 122 +++++++ mcp/data/registry.json | 88 +++++ 26 files changed, 1122 insertions(+) create mode 120000 docs/app/javascript/controllers/ruby_ui/drawer_content_controller.js create mode 120000 docs/app/javascript/controllers/ruby_ui/drawer_controller.js create mode 100644 docs/app/views/docs/drawer.rb create mode 100644 gem/lib/ruby_ui/drawer/drawer.rb create mode 100644 gem/lib/ruby_ui/drawer/drawer_close.rb create mode 100644 gem/lib/ruby_ui/drawer/drawer_content.rb create mode 100644 gem/lib/ruby_ui/drawer/drawer_content_controller.js create mode 100644 gem/lib/ruby_ui/drawer/drawer_controller.js create mode 100644 gem/lib/ruby_ui/drawer/drawer_description.rb create mode 100644 gem/lib/ruby_ui/drawer/drawer_docs.rb create mode 100644 gem/lib/ruby_ui/drawer/drawer_footer.rb create mode 100644 gem/lib/ruby_ui/drawer/drawer_header.rb create mode 100644 gem/lib/ruby_ui/drawer/drawer_middle.rb create mode 100644 gem/lib/ruby_ui/drawer/drawer_swipe_handle.rb create mode 100644 gem/lib/ruby_ui/drawer/drawer_title.rb create mode 100644 gem/lib/ruby_ui/drawer/drawer_trigger.rb create mode 100644 gem/test/ruby_ui/drawer_test.rb diff --git a/docs/app/components/shared/components_list.rb b/docs/app/components/shared/components_list.rb index a87ac08d5..d71431070 100644 --- a/docs/app/components/shared/components_list.rb +++ b/docs/app/components/shared/components_list.rb @@ -29,6 +29,7 @@ def components {name: "Data Table", path: docs_data_table_path}, {name: "Date Picker", path: docs_date_picker_path}, {name: "Dialog / Modal", path: docs_dialog_path}, + {name: "Drawer", path: docs_drawer_path}, {name: "Dropdown Menu", path: docs_dropdown_menu_path}, {name: "Empty", path: docs_empty_path}, {name: "Form", path: docs_form_path}, diff --git a/docs/app/controllers/docs_controller.rb b/docs/app/controllers/docs_controller.rb index da3b0464e..ae397d526 100644 --- a/docs/app/controllers/docs_controller.rb +++ b/docs/app/controllers/docs_controller.rb @@ -142,6 +142,10 @@ def dialog render Views::Docs::Dialog.new end + def drawer + render Views::Docs::Drawer.new + end + def dropdown_menu render Views::Docs::DropdownMenu.new end diff --git a/docs/app/javascript/controllers/index.js b/docs/app/javascript/controllers/index.js index fb037ba7a..160627c83 100644 --- a/docs/app/javascript/controllers/index.js +++ b/docs/app/javascript/controllers/index.js @@ -64,6 +64,12 @@ application.register("ruby-ui--data-table-search", RubyUi__DataTableSearchContro import RubyUi__DialogController from "./ruby_ui/dialog_controller" application.register("ruby-ui--dialog", RubyUi__DialogController) +import RubyUi__DrawerContentController from "./ruby_ui/drawer_content_controller" +application.register("ruby-ui--drawer-content", RubyUi__DrawerContentController) + +import RubyUi__DrawerController from "./ruby_ui/drawer_controller" +application.register("ruby-ui--drawer", RubyUi__DrawerController) + import RubyUi__DropdownMenuController from "./ruby_ui/dropdown_menu_controller" application.register("ruby-ui--dropdown-menu", RubyUi__DropdownMenuController) diff --git a/docs/app/javascript/controllers/ruby_ui/drawer_content_controller.js b/docs/app/javascript/controllers/ruby_ui/drawer_content_controller.js new file mode 120000 index 000000000..b4f07f842 --- /dev/null +++ b/docs/app/javascript/controllers/ruby_ui/drawer_content_controller.js @@ -0,0 +1 @@ +../../../../../gem/lib/ruby_ui/drawer/drawer_content_controller.js \ No newline at end of file diff --git a/docs/app/javascript/controllers/ruby_ui/drawer_controller.js b/docs/app/javascript/controllers/ruby_ui/drawer_controller.js new file mode 120000 index 000000000..56803c736 --- /dev/null +++ b/docs/app/javascript/controllers/ruby_ui/drawer_controller.js @@ -0,0 +1 @@ +../../../../../gem/lib/ruby_ui/drawer/drawer_controller.js \ No newline at end of file diff --git a/docs/app/lib/site_files.rb b/docs/app/lib/site_files.rb index 94ef4ef81..c439f0f0d 100644 --- a/docs/app/lib/site_files.rb +++ b/docs/app/lib/site_files.rb @@ -102,6 +102,7 @@ class SiteFiles {title: "Data Table", path: "/docs/data_table", description: "Data table primitives for search, sorting, pagination, visibility, and bulk actions."}, {title: "Date Picker", path: "/docs/date_picker", description: "Date picker component with input."}, {title: "Dialog", path: "/docs/dialog", description: "Modal window that renders background content inert."}, + {title: "Drawer", path: "/docs/drawer", description: "Bottom sheet with a swipe handle, drag, snap points and swipe-to-dismiss."}, {title: "Dropdown Menu", path: "/docs/dropdown_menu", description: "Button-triggered menu for actions or functions."}, {title: "Empty", path: "/docs/empty", description: "Empty state for when there is no data or content."}, {title: "Form", path: "/docs/form", description: "Form fields with built-in client-side validations."}, diff --git a/docs/app/views/docs/drawer.rb b/docs/app/views/docs/drawer.rb new file mode 100644 index 000000000..cde29e33f --- /dev/null +++ b/docs/app/views/docs/drawer.rb @@ -0,0 +1,137 @@ +# frozen_string_literal: true + +class Views::Docs::Drawer < Views::Base + def view_template + component = "Drawer" + + div(class: "max-w-2xl mx-auto w-full py-10 space-y-10") do + render Docs::Header.new(title: "Drawer", description: "A bottom sheet you drag between snap points and swipe down to close. It is built on the native element and CSS scroll-snap and needs no JavaScript packages.") + + Heading(level: 2) { "Usage" } + + render Docs::VisualCodeExample.new(title: "Example", description: "The drawer opens at 60% of the viewport. Drag the handle up and it rests at 92%. Drag it down past the lowest snap point, or flick it down, and it closes.", context: self) do + <<~RUBY + Drawer do + DrawerTrigger do + Button(variant: :outline) { "Open Drawer" } + end + DrawerContent do + DrawerHeader do + DrawerTitle { "Edit profile" } + DrawerDescription { "Make changes to your profile here. Click save when you're done." } + end + DrawerMiddle do + label { "Name" } + Input(placeholder: "Joel Drapper") { "Joel Drapper" } + label { "Email" } + Input(placeholder: "joel@drapper.me") + end + DrawerFooter do + Button(type: "submit", class: "w-full") { "Save" } + DrawerClose do + Button(variant: :outline, class: "w-full") { "Cancel" } + end + end + end + end + RUBY + end + + render Docs::VisualCodeExample.new(title: "Snap points", description: "snap_points lists the heights the panel can rest at, as a percentage of the viewport. initial is the index of the one it opens at. A flick moves to the next snap point in its direction.", context: self) do + <<~RUBY + Drawer do + DrawerTrigger do + Button(variant: :outline) { "Open at 35%" } + end + DrawerContent(snap_points: [35, 70, 100]) do + DrawerHeader do + DrawerTitle { "Snap points" } + DrawerDescription { "Rests at 35%, 70% or 100% of the viewport." } + end + DrawerMiddle do + div(class: "space-y-3") do + 12.times { div(class: "h-12 rounded-md bg-muted") } + end + end + end + end + RUBY + end + + render Docs::VisualCodeExample.new(title: "Non-modal", description: "With modal: false there is no scrim and the page behind stays usable, the same as shadcn's modal={false}.", context: self) do + <<~RUBY + Drawer do + DrawerTrigger do + Button(variant: :outline) { "Open non-modal" } + end + DrawerContent(modal: false, snap_points: [40]) do + DrawerHeader do + DrawerTitle { "Now playing" } + DrawerDescription { "No scrim, so the page behind stays interactive." } + end + DrawerFooter do + DrawerClose do + Button(variant: :outline, class: "w-full") { "Close" } + end + end + end + end + RUBY + end + + render Docs::VisualCodeExample.new(title: "Non-dismissible", description: "With dismissible: false, clicking the scrim, pressing Escape and swiping the drawer down do nothing. Only DrawerClose closes it.", context: self) do + <<~RUBY + Drawer do + DrawerTrigger do + Button(variant: :outline) { "Open non-dismissible" } + end + DrawerContent(dismissible: false, snap_points: [45]) do + DrawerHeader do + DrawerTitle { "Delete account?" } + DrawerDescription { "This cannot be undone. Choose one of the options below." } + end + DrawerFooter do + DrawerClose do + Button(variant: :destructive, class: "w-full") { "Delete" } + end + DrawerClose do + Button(variant: :outline, class: "w-full") { "Keep my account" } + end + end + end + end + RUBY + end + + render Docs::VisualCodeExample.new(title: "Custom swipe handle", description: "handle: false leaves the handle out. Render DrawerSwipeHandle yourself to restyle it or put it somewhere else.", context: self) do + <<~RUBY + Drawer do + DrawerTrigger do + Button(variant: :outline) { "Open with a custom handle" } + end + DrawerContent(handle: false, snap_points: [50]) do + DrawerSwipeHandle(class: "w-20 bg-primary") + DrawerHeader do + DrawerTitle { "Custom handle" } + DrawerDescription { "Wider, and in the primary color." } + end + end + end + RUBY + end + + Heading(level: 2) { "Drawer or Sheet?" } + p(class: "text-muted-foreground text-sm leading-relaxed") do + plain "Sheet slides a panel in from any edge and leaves it there. Drawer is a bottom sheet: you drag it between snap points by the handle and swipe it down to close it. " + plain "The modal variant is a native " + code(class: "rounded bg-muted px-1.5 py-0.5 text-xs") { "" } + plain " opened with showModal(), so the browser provides the focus trap, aria-modal and the inert page. " + plain "Drawer only comes up from the bottom edge. There is no equivalent of shadcn's swipeDirection for the other sides." + end + + render Components::ComponentSetup::Tabs.new(component_name: component) + + render Docs::ComponentsTable.new(component_files(component)) + end + end +end diff --git a/docs/config/routes.rb b/docs/config/routes.rb index 2eab1c1e7..f4bd4922e 100644 --- a/docs/config/routes.rb +++ b/docs/config/routes.rb @@ -46,6 +46,7 @@ get "context_menu", to: "docs#context_menu", as: :docs_context_menu get "date_picker", to: "docs#date_picker", as: :docs_date_picker get "dialog", to: "docs#dialog", as: :docs_dialog + get "drawer", to: "docs#drawer", as: :docs_drawer get "dropdown_menu", to: "docs#dropdown_menu", as: :docs_dropdown_menu get "empty", to: "docs#empty", as: :docs_empty get "form", to: "docs#form", as: :docs_form diff --git a/docs/public/llms-full.txt b/docs/public/llms-full.txt index 5d98c5ae1..54cabb3aa 100644 --- a/docs/public/llms-full.txt +++ b/docs/public/llms-full.txt @@ -198,6 +198,11 @@ This file expands the curated /llms.txt map into a compact reference that can be - URL: https://rubyui.com/docs/dialog - Summary: Modal window that renders background content inert. +### Drawer + +- URL: https://rubyui.com/docs/drawer +- Summary: Bottom sheet with a swipe handle, drag, snap points and swipe-to-dismiss. + ### Dropdown Menu - URL: https://rubyui.com/docs/dropdown_menu diff --git a/docs/public/llms.txt b/docs/public/llms.txt index c98b90362..192a5e7e1 100644 --- a/docs/public/llms.txt +++ b/docs/public/llms.txt @@ -44,6 +44,7 @@ Use the core docs first for installation, theming, dark mode, and customization - [Data Table](https://rubyui.com/docs/data_table): Data table primitives for search, sorting, pagination, visibility, and bulk actions. - [Date Picker](https://rubyui.com/docs/date_picker): Date picker component with input. - [Dialog](https://rubyui.com/docs/dialog): Modal window that renders background content inert. +- [Drawer](https://rubyui.com/docs/drawer): Bottom sheet with a swipe handle, drag, snap points and swipe-to-dismiss. - [Dropdown Menu](https://rubyui.com/docs/dropdown_menu): Button-triggered menu for actions or functions. - [Empty](https://rubyui.com/docs/empty): Empty state for when there is no data or content. - [Form](https://rubyui.com/docs/form): Form fields with built-in client-side validations. diff --git a/docs/public/sitemap.xml b/docs/public/sitemap.xml index abb37124c..a629b6499 100644 --- a/docs/public/sitemap.xml +++ b/docs/public/sitemap.xml @@ -175,6 +175,11 @@ monthly 0.7 + + https://rubyui.com/docs/drawer + monthly + 0.7 + https://rubyui.com/docs/dropdown_menu monthly diff --git a/gem/lib/ruby_ui/drawer/drawer.rb b/gem/lib/ruby_ui/drawer/drawer.rb new file mode 100644 index 000000000..4863a8b9f --- /dev/null +++ b/gem/lib/ruby_ui/drawer/drawer.rb @@ -0,0 +1,25 @@ +# frozen_string_literal: true + +module RubyUI + class Drawer < Base + def initialize(open: false, **attrs) + @open = open + super(**attrs) + end + + def view_template(&) + div(**attrs, &) + end + + private + + def default_attrs + { + data: { + controller: "ruby-ui--drawer", + ruby_ui__drawer_open_value: @open.to_s + } + } + end + end +end diff --git a/gem/lib/ruby_ui/drawer/drawer_close.rb b/gem/lib/ruby_ui/drawer/drawer_close.rb new file mode 100644 index 000000000..93d35e128 --- /dev/null +++ b/gem/lib/ruby_ui/drawer/drawer_close.rb @@ -0,0 +1,17 @@ +# frozen_string_literal: true + +module RubyUI + class DrawerClose < Base + def view_template(&) + div(**attrs, &) + end + + private + + def default_attrs + { + data: {action: "click->ruby-ui--drawer-content#close"} + } + end + end +end diff --git a/gem/lib/ruby_ui/drawer/drawer_content.rb b/gem/lib/ruby_ui/drawer/drawer_content.rb new file mode 100644 index 000000000..d4ef6fb3b --- /dev/null +++ b/gem/lib/ruby_ui/drawer/drawer_content.rb @@ -0,0 +1,89 @@ +# frozen_string_literal: true + +module RubyUI + class DrawerContent < Base + # snap_points: % of the viewport the panel covers at each rest position; initial: index it opens at. + # dismissible: false ignores the scrim click, Escape and a swipe below the lowest snap point. + def initialize(snap_points: [60, 92], initial: 0, modal: true, dismissible: true, handle: true, **attrs) + @snap_points = snap_points + @initial = initial + @modal = modal + @dismissible = dismissible + @handle = handle + super(**attrs) + end + + def view_template(&block) + template(data: {ruby_ui__drawer_target: "content"}) do + dialog(**dialog_attrs) do + backdrop if @modal + scroller do + @snap_points.each { |pct| snap_marker(pct) } + # Full-screen spacer: makes the scroll range, and scrollTop 0 the dismissed rest position. + div(class: "h-full snap-start") + div(**attrs) do + RubyUI.DrawerSwipeHandle() if @handle + block&.call + end + end + end + end + end + + private + + # The panel is full-height; only the band above the fold (--drawer-band, tracked by the controller) is laid out. + def default_attrs + { + class: "relative flex h-full flex-col overflow-clip rounded-t-lg bg-background text-foreground shadow-[0_-8px_30px_rgb(0_0_0/0.12)] snap-start pointer-events-auto after:flex-none after:h-[calc(100%_-_var(--drawer-band))] after:content-['']", + data: {ruby_ui__drawer_content_target: "panel"} + } + end + + # Full-screen transparent with pointer events off (the non-modal page stays reachable); backdrop and panel opt back in. + def dialog_attrs + { + class: "fixed inset-0 z-50 m-0 h-full w-full max-h-none max-w-none border-0 bg-transparent p-0 pointer-events-none backdrop:bg-transparent", + style: "--drawer-band: #{open_pct}%", + data: { + controller: "ruby-ui--drawer-content", + turbo_temporary: true, + ruby_ui__drawer_content_snap_points_value: @snap_points.to_json, + ruby_ui__drawer_content_initial_value: @initial, + ruby_ui__drawer_content_modal_value: @modal.to_s, + ruby_ui__drawer_content_dismissible_value: @dismissible.to_s + } + } + end + + def open_pct = @snap_points[@initial] || @snap_points.first + + def backdrop + div( + class: "fixed inset-0 z-50 pointer-events-auto bg-background/80 backdrop-blur-sm transition-none duration-200 data-[state=open]:animate-in data-[state=open]:fade-in-0 data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=closed]:fill-mode-forwards", + data: { + state: "open", + action: "click->ruby-ui--drawer-content#dismiss", + ruby_ui__drawer_content_target: "backdrop" + } + ) + end + + # Vertical snap scroller: scrolling up reveals the panel and the browser snaps to the markers. + def scroller(&) + div( + class: "fixed inset-0 z-50 overflow-y-scroll overscroll-y-none snap-y snap-mandatory pointer-events-none [scrollbar-width:none] [&::-webkit-scrollbar]:hidden", + data: {ruby_ui__drawer_content_target: "scroller"}, + & + ) + end + + # Zero-height sentinel whose ::before sits exactly pct% down the scroller. + def snap_marker(pct) + div( + class: "relative -mb-px h-px before:absolute before:inset-x-0 before:top-px before:h-px before:snap-start before:content-['']", + style: "top: calc(#{pct}% - 1px)" + ) + end + end +end diff --git a/gem/lib/ruby_ui/drawer/drawer_content_controller.js b/gem/lib/ruby_ui/drawer/drawer_content_controller.js new file mode 100644 index 000000000..39097e3c5 --- /dev/null +++ b/gem/lib/ruby_ui/drawer/drawer_content_controller.js @@ -0,0 +1,329 @@ +import { Controller } from "@hotwired/stimulus" + +// One pace however far the panel travels, like native sheets; only a flick settles faster, down to MIN_SETTLE_MS. +const OPEN_MS = 250 +const SETTLE_MS = 200 +const MIN_SETTLE_MS = 100 +// A release faster than FLICK px/ms moves on to the next snap point in its direction, projected FLICK_MS ahead. +const FLICK = 0.4 +const FLICK_MS = 200 +// Scroll positions this close to a rest point count as being there. +const REST_SLACK = 4 + +// Bottom sheet on a native + CSS scroll-snap: a full-screen spacer above the panel makes scrollTop 0 the dismissed rest position. +export default class extends Controller { + static targets = ["scroller", "panel", "backdrop", "title", "description"] + static values = { + snapPoints: Array, + initial: { type: Number, default: 0 }, + modal: { type: Boolean, default: true }, + dismissible: { type: Boolean, default: true } + } + + ready = false + dragging = false + closing = false + closed = false + keyboardInset = 0 + keyboardShift = false + restPctBeforeKeyboard = null + samples = [] + + // showModal() brings top layer, focus trap and an inert page; show() keeps the page interactive, so Escape needs our own listener. + connect() { + this.labelDialog() + + if (this.modalValue) { + document.body.classList.add("overflow-hidden") + this.element.showModal() + } else { + this.element.show() + document.addEventListener("keydown", this.onKeydown) + } + this.element.addEventListener("cancel", this.onCancel) + this.element.addEventListener("close", this.onClose) + this.scrollerTarget.addEventListener("scroll", this.onScroll, { passive: true }) + this.scrollerTarget.addEventListener("scrollend", this.onSettle) + this.trackKeyboard() + + // After show: native autofocus may have scrolled the panel into view. + this.scrollerTarget.scrollTop = 0 + this.animateOpen() + } + + disconnect() { + this.cleanup() + } + + // Name the dialog by its own title and description, as Radix does. + labelDialog() { + if (this.hasTitleTarget) this.element.setAttribute("aria-labelledby", this.idFor(this.titleTarget)) + if (this.hasDescriptionTarget) this.element.setAttribute("aria-describedby", this.idFor(this.descriptionTarget)) + } + + idFor(element) { + element.id ||= `ruby-ui-drawer-${Math.random().toString(36).slice(2, 8)}` + return element.id + } + + get restPcts() { + return this.snapPointsValue.length ? this.snapPointsValue : [100] + } + + get openPct() { + return this.restPcts[this.initialValue] ?? this.restPcts[0] + } + + get openOffset() { + return this.offsetFor(this.openPct) + } + + get snapOffsets() { + return this.restPcts.map((pct) => this.offsetFor(pct)) + } + + get lowestOffset() { + return Math.min(...this.snapOffsets) + } + + get highestOffset() { + return Math.max(...this.snapOffsets) + } + + // Where the panel may rest: the snap points, plus 0 — dismissed — when dismissible. + get restPoints() { + return this.dismissibleValue ? [0, ...this.snapOffsets] : this.snapOffsets + } + + offsetFor(pct) { + return Math.round((this.scrollerTarget.clientHeight * pct) / 100) + } + + nearestRestPct() { + return this.nearest(this.restPcts, this.scrollerTarget.scrollTop, (pct) => this.offsetFor(pct)) + } + + nearest(values, target = this.scrollerTarget.scrollTop, offsetOf = (value) => value) { + return values.reduce((best, value) => (Math.abs(offsetOf(value) - target) < Math.abs(offsetOf(best) - target) ? value : best)) + } + + onScroll = () => { + this.trackBand() + this.trackScrim() + this.scheduleSettle() + } + + // Below the lowest snap point the scrim follows the panel, so swiping out fades it like a native sheet. + trackScrim() { + if (!this.hasBackdropTarget || !this.ready || this.closing) return + this.backdropTarget.style.opacity = Math.min(1, this.scrollerTarget.scrollTop / this.lowestOffset) + } + + // Only the band above the fold is laid out, floored at the open offset so the layout stays rigid while sliding open or out. + trackBand() { + const band = Math.max(this.scrollerTarget.scrollTop, this.openOffset) + this.element.style.setProperty("--drawer-band", `${Math.round(band)}px`) + } + + // Settles without scrollend (older Safari); where it exists onSettle simply runs twice and the guards make that a no-op. + scheduleSettle() { + clearTimeout(this.settleTimer) + this.settleTimer = setTimeout(this.onSettle, 120) + } + + onSettle = () => { + if (this.closing || this.dragging || this.keyboardShift) return + const atBottom = this.scrollerTarget.scrollTop <= REST_SLACK + if (!this.ready) { + this.ready = !atBottom + return + } + if (!atBottom) return + + if (this.dismissibleValue) this.close() + else this.animateScroll(this.lowestOffset, SETTLE_MS) + } + + // iOS has no keyboard-inset env(): derive the keyboard height from the visual viewport and lift the scroller. + trackKeyboard() { + if (!window.visualViewport) return + visualViewport.addEventListener("resize", this.onViewport) + visualViewport.addEventListener("scroll", this.onViewport) + } + + onViewport = () => { + const viewport = window.visualViewport + this.followKeyboard(Math.max(0, window.innerHeight - viewport.height - viewport.offsetTop)) + } + + // The pre-keyboard snap survives as a percentage — px offsets die with any resize. + followKeyboard(inset) { + inset = Math.round(inset) + if (inset === this.keyboardInset) return + const toggled = (inset > 0) !== (this.keyboardInset > 0) + if (toggled && inset > 0 && !this.dragging) this.restPctBeforeKeyboard = this.nearestRestPct() + this.keyboardInset = inset + this.scrollerTarget.style.bottom = `${inset}px` + this.trackBand() + if (!toggled || this.dragging || this.closing) return + + if (inset > 0) { + this.shiftTo(this.highestOffset) + } else if (this.restPctBeforeKeyboard != null) { + this.shiftTo(this.offsetFor(this.restPctBeforeKeyboard)) + this.restPctBeforeKeyboard = null + } + } + + // Chrome may clamp scrollTop to 0 while the scroller resizes; settling pauses so that is not read as a dismissal. + shiftTo(offset) { + this.keyboardShift = true + this.animateScroll(offset, SETTLE_MS, () => { this.keyboardShift = false }) + } + + onKeydown = (event) => { + if (event.key !== "Escape") return + event.preventDefault() + this.dismiss() + } + + // Escape on a modal dialog: cancel the instant native close, run ours instead. + onCancel = (event) => { + event.preventDefault() + this.dismiss() + } + + // Anything else that closes the dialog (e.g. a method="dialog" form) still tears the drawer down. + onClose = () => { + this.teardown() + } + + // Not scrollTo(): mandatory snap fights it mid-flight on a freshly inserted scroller and the open jumps. + animateOpen() { + this.animateScroll(this.openOffset, OPEN_MS) + } + + animateScroll(to, duration, onDone) { + const scroller = this.scrollerTarget + cancelAnimationFrame(this.frame) + scroller.style.scrollSnapType = "none" + const from = scroller.scrollTop + + let startTime = null + const step = (now) => { + if (startTime === null) startTime = now + const t = Math.min(1, (now - startTime) / duration) + scroller.scrollTop = from + (to - from) * (1 - (1 - t) ** 3) + + if (t < 1) { + this.frame = requestAnimationFrame(step) + } else { + scroller.style.scrollSnapType = "" + onDone?.() + } + } + this.frame = requestAnimationFrame(step) + } + + // Pointer-driven: WebKit will not scroll the pointer-events:none snap scroller by touch, so the handle drives it. + startDrag(event) { + if (this.closing) return + event.preventDefault() + // A grab during the open animation would otherwise fight it for scrollTop. + cancelAnimationFrame(this.frame) + this.dragging = true + this.restPctBeforeKeyboard = null + this.keyboardShift = false + this.dragOrigin = event.clientY + this.dragFrom = this.scrollerTarget.scrollTop + this.samples = [{ t: event.timeStamp, y: event.clientY }] + this.scrollerTarget.style.scrollSnapType = "none" + event.currentTarget.setPointerCapture(event.pointerId) + } + + drag(event) { + if (!this.dragging) return + this.samples.push({ t: event.timeStamp, y: event.clientY }) + if (this.samples.length > 8) this.samples.shift() + this.scrollerTarget.scrollTop = Math.max(0, this.dragFrom - (event.clientY - this.dragOrigin)) + } + + endDrag(event) { + if (!this.dragging) return + this.dragging = false + // pointercancel has already released it, and releasing twice throws. + if (event.currentTarget.hasPointerCapture(event.pointerId)) { + event.currentTarget.releasePointerCapture(event.pointerId) + } + + const velocity = this.releaseVelocity(event) + const rest = this.restTarget(velocity) + const duration = this.releaseDuration(Math.abs(rest - this.scrollerTarget.scrollTop), velocity) + if (rest === 0) this.slideOut(duration) + else this.animateScroll(rest, duration) + } + + // px/ms in scroll direction (positive opens) over the last 100ms of movement; a pause before release is not a flick. + releaseVelocity(event) { + const last = this.samples.at(-1) + if (event.timeStamp - last.t > 100) return 0 + const first = this.samples.find((sample) => last.t - sample.t <= 100) + const elapsed = last.t - first.t + return elapsed > 0 ? (first.y - last.y) / elapsed : 0 + } + + // A slow release settles on the nearest rest point; a flick moves on to the next one in its direction. + restTarget(velocity) { + const top = this.scrollerTarget.scrollTop + const ahead = this.restPoints.filter((point) => (velocity > 0 ? point > top + REST_SLACK : point < top - REST_SLACK)) + if (Math.abs(velocity) < FLICK || ahead.length === 0) return this.nearest(this.restPoints) + return this.nearest(ahead, top + velocity * FLICK_MS) + } + + releaseDuration(distance, velocity) { + if (Math.abs(velocity) < FLICK) return SETTLE_MS + return Math.min(SETTLE_MS, Math.max(MIN_SETTLE_MS, distance / Math.abs(velocity))) + } + + // The scrim, Escape and a swipe out ask; DrawerClose tells. + dismiss() { + if (this.dismissibleValue) this.close() + } + + close() { + this.slideOut(SETTLE_MS) + } + + slideOut(duration) { + if (this.closing) return + this.closing = true + + if (this.hasBackdropTarget) { + this.backdropTarget.style.setProperty("--tw-animation-duration", `${duration}ms`) + this.backdropTarget.dataset.state = "closed" + } + this.animateScroll(0, duration, () => this.teardown()) + } + + // dialog.close() (not just remove) so the browser restores focus to the trigger. + teardown() { + if (this.closed) return + this.closed = true + this.cleanup() + if (this.element.open) this.element.close() + this.element.remove() + } + + cleanup() { + if (this.modalValue) document.body.classList.remove("overflow-hidden") + cancelAnimationFrame(this.frame) + clearTimeout(this.settleTimer) + document.removeEventListener("keydown", this.onKeydown) + this.element.removeEventListener("cancel", this.onCancel) + this.element.removeEventListener("close", this.onClose) + this.scrollerTarget.removeEventListener("scroll", this.onScroll) + this.scrollerTarget.removeEventListener("scrollend", this.onSettle) + window.visualViewport?.removeEventListener("resize", this.onViewport) + window.visualViewport?.removeEventListener("scroll", this.onViewport) + } +} diff --git a/gem/lib/ruby_ui/drawer/drawer_controller.js b/gem/lib/ruby_ui/drawer/drawer_controller.js new file mode 100644 index 000000000..4d4b78f20 --- /dev/null +++ b/gem/lib/ruby_ui/drawer/drawer_controller.js @@ -0,0 +1,20 @@ +import { Controller } from "@hotwired/stimulus" + +export default class extends Controller { + static targets = ["content"] + static values = { open: { type: Boolean, default: false } } + + connect() { + if (this.openValue) this.open() + } + + // The content waits in a