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..436a1fe34 --- /dev/null +++ b/docs/app/views/docs/drawer.rb @@ -0,0 +1,145 @@ +# 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, in shadcn's units: up to 1 is a fraction of the viewport, above 1 is pixels, and a string is any CSS length. initial is the index of the one it opens at, and 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: [0.35, 0.7, 1]) 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: [0.4]) 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: [0.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: ["24rem"]) do + DrawerSwipeHandle(class: "w-20 bg-primary") + DrawerHeader do + DrawerTitle { "Custom handle" } + DrawerDescription { "Wider, in the primary color, and the drawer opens 24rem tall." } + end + end + end + RUBY + end + + Heading(level: 2) { "Styling and events" } + p(class: "text-muted-foreground text-sm leading-relaxed") { "The panel carries data-snap-points when the drawer has snap points, data-expanded once it reaches the largest one and data-swiping while a drag is in flight, so each state can be styled with a data-* variant. The controller fires opened, snap and closed events, and the snap event names the index it came to rest at." } + + div(class: "rounded-md border bg-muted/30 p-4 mt-2") do + Codeblock(<<~RUBY, syntax: :ruby) + DrawerContent( + class: "data-expanded:rounded-none", + data: {action: "ruby-ui--drawer-content:snap->player#trackSnap"} + ) do + # ... + end + RUBY + end + + Heading(level: 2) { "Drawer or Sheet?" } + p(class: "text-muted-foreground text-sm leading-relaxed") { "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. The modal variant is a native opened with showModal(), so the browser provides the focus trap, aria-modal and the inert page. Drawer only comes up from the bottom edge. There is no equivalent of shadcn's swipeDirection for the other sides." } + + 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..975f7710f --- /dev/null +++ b/gem/lib/ruby_ui/drawer/drawer_content.rb @@ -0,0 +1,116 @@ +# frozen_string_literal: true + +module RubyUI + class DrawerContent < Base + # snap_points: how far up the panel comes at each rest position, in shadcn's units; initial: index it opens at. + # dismissible: false ignores the scrim click, Escape and a swipe below the lowest snap point. + # initial_focus: false focuses the panel instead of the first field, so a form does not raise the keyboard on open. + def initialize(snap_points: [0.6, 0.92], initial: 0, modal: true, dismissible: true, handle: true, initial_focus: true, **attrs) + @snap_points = snap_points + @initial = initial + @modal = modal + @dismissible = dismissible + @handle = handle + @initial_focus = initial_focus + 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 { |point| snap_marker(point) } + # 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. + # The dialog's focusing steps prefer an autofocus element, so a focusable panel keeps focus off the fields. + def default_attrs + { + class: "group/drawer 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-['']", + tabindex: ("-1" unless @initial_focus), + autofocus: (true unless @initial_focus), + data: { + ruby_ui__drawer_content_target: "panel", + # Style hooks, as in shadcn: the controller adds data-expanded and data-swiping. + snap_points: (true if @snap_points.any?) + } + } + 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_length}", + data: { + controller: "ruby-ui--drawer-content", + turbo_temporary: true, + 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 + + # Out of range opens at the first snap point, as the controller does; without any, the panel is full height. + def open_length + point = @snap_points[@initial.clamp(0..)] || @snap_points.first + point ? css_length(point) : "100%" + end + + # shadcn's units: up to 1 is a fraction of the viewport, above 1 is pixels, and a string passes through ("24rem"). + def css_length(point) + return point if point.is_a?(String) + + (point > 1) ? "#{css_number(point)}px" : "#{css_number(point * 100)}%" + end + + # 0.6 * 100 is 60.00000000000001 in binary floating point, and CSS should read 60%. + # Kernel#format would be the obvious tool, but phlex-rails gives every component its own zero-argument #format. + def css_number(number) + rounded = number.round(6) + ((rounded % 1) == 0) ? rounded.to_i : rounded + end + + 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 + + # Sentinel out of the flow: its ::before is the snap target, and its offsetTop is the scroll position the controller animates to. + def snap_marker(point) + div( + class: "relative -mb-px h-px before:absolute before:inset-x-0 before:top-0 before:h-px before:snap-start before:content-['']", + style: "top: #{css_length(point)}", + data: {ruby_ui__drawer_content_target: "snap"} + ) + 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..e6146677c --- /dev/null +++ b/gem/lib/ruby_ui/drawer/drawer_content_controller.js @@ -0,0 +1,373 @@ +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", "snap"] + static values = { + initial: { type: Number, default: 0 }, + modal: { type: Boolean, default: true }, + dismissible: { type: Boolean, default: true } + } + + ready = false + dragging = false + closing = false + closed = false + lockedBody = false + keyboardInset = 0 + keyboardShift = false + snapIndexBeforeKeyboard = null + samples = [] + snaps = null + + connect() { + this.labelDialog() + this.show() + this.addEventListeners() + + // After show: native autofocus may have scrolled the panel into view. + this.scrollerTarget.scrollTop = 0 + this.snapIndex = this.initialValue + this.markState() + this.animateOpen() + } + + // showModal() brings top layer, focus trap and an inert page; show() keeps the page interactive. + show() { + if (!this.modalValue) { + this.element.show() + return + } + + // Only the lock we took is ours to release; a Dialog underneath keeps its own. + this.lockedBody = !document.body.classList.contains("overflow-hidden") + if (this.lockedBody) document.body.classList.add("overflow-hidden") + this.element.showModal() + } + + addEventListeners() { + // A non-modal dialog has no close watcher, so Escape is ours to handle. + if (!this.modalValue) 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) + window.addEventListener("resize", this.onResize) + // iOS has no keyboard-inset env(): the visual viewport is where the keyboard height comes from. + window.visualViewport?.addEventListener("resize", this.onViewport) + window.visualViewport?.addEventListener("scroll", this.onViewport) + } + + removeEventListeners() { + document.removeEventListener("keydown", this.onKeydown) + this.element.removeEventListener("cancel", this.onCancel) + this.element.removeEventListener("close", this.onClose) + if (this.hasScrollerTarget) { + this.scrollerTarget.removeEventListener("scroll", this.onScroll) + this.scrollerTarget.removeEventListener("scrollend", this.onSettle) + } + window.removeEventListener("resize", this.onResize) + window.visualViewport?.removeEventListener("resize", this.onViewport) + window.visualViewport?.removeEventListener("scroll", this.onViewport) + } + + // Losing the controller while open (Stimulus stopped, element swapped out) must not leave a dead modal in the top layer. + disconnect() { + this.teardown() + } + + // 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 openOffset() { + return this.snapOffsets[Math.max(0, this.initialValue)] ?? this.snapOffsets[0] + } + + // Measured, not computed from the values, so a snap point can be given in any CSS unit. Only a resize moves them. + get snapOffsets() { + return (this.snaps ??= this.measureSnaps()) + } + + // Without snap points the panel has one rest position: the full height of the scroller. + measureSnaps() { + const offsets = this.snapTargets.map((marker) => marker.offsetTop) + return offsets.length ? offsets : [this.scrollerTarget.clientHeight] + } + + 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 + } + + // Indexes survive a resize, unlike the pixel offsets they are measured from. + nearestSnapIndex() { + return this.snapOffsets.indexOf(this.nearest(this.snapOffsets)) + } + + nearest(values, target = this.scrollerTarget.scrollTop) { + return values.reduce((best, value) => (Math.abs(value - target) < Math.abs(best - target) ? value : best)) + } + + onScroll = () => { + this.trackBand() + this.trackScrim() + this.markState() + this.scheduleSettle() + } + + // Style hooks, as in shadcn: data-expanded at the largest snap point, data-swiping while a drag is in flight. + markState() { + this.panelTarget.toggleAttribute("data-expanded", this.scrollerTarget.scrollTop >= this.highestOffset - REST_SLACK) + this.panelTarget.toggleAttribute("data-swiping", this.dragging) + } + + // 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) { + this.reportSnap() + return + } + + if (this.dismissibleValue) this.close() + else this.animateScroll(this.lowestOffset, SETTLE_MS) + } + + onResize = () => { + this.snaps = null + this.trackBand() + this.markState() + } + + // The snap point the panel came to rest at, reported once per change. + reportSnap() { + const index = this.nearestSnapIndex() + if (index === this.snapIndex) return + this.snapIndex = index + this.dispatch("snap", { detail: { index, offset: this.snapOffsets[index] } }) + } + + // The keyboard height, from the only place iOS reports it. + onViewport = () => { + const viewport = window.visualViewport + this.followKeyboard(Math.max(0, window.innerHeight - viewport.height - viewport.offsetTop)) + } + + // The pre-keyboard snap is kept as an index: the offsets it was measured from move with the 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.snapIndexBeforeKeyboard = this.nearestSnapIndex() + this.keyboardInset = inset + this.scrollerTarget.style.bottom = `${inset}px` + this.snaps = null + this.trackBand() + if (!toggled || this.dragging || this.closing) return + + if (inset > 0) { + this.shiftTo(this.highestOffset) + } else if (this.snapIndexBeforeKeyboard != null) { + this.shiftTo(this.snapOffsets[this.snapIndexBeforeKeyboard]) + this.snapIndexBeforeKeyboard = 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" || !this.dismissibleValue) return + event.preventDefault() + this.close() + } + + // 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, () => this.dispatch("opened")) + } + + 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.snapIndexBeforeKeyboard = null + this.keyboardShift = false + this.markState() + 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 + this.markState() + // 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() + // Before the removal, so the event still reaches listeners up the tree. + this.dispatch("closed") + this.element.remove() + } + + cleanup() { + if (this.lockedBody) document.body.classList.remove("overflow-hidden") + cancelAnimationFrame(this.frame) + clearTimeout(this.settleTimer) + this.removeEventListeners() + } +} 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..ebcf09b0e --- /dev/null +++ b/gem/lib/ruby_ui/drawer/drawer_controller.js @@ -0,0 +1,21 @@ +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