[Feature] Drawer: bottom sheet with swipe handle, snap points and swipe-to-dismiss - #519
[Feature] Drawer: bottom sheet with swipe handle, snap points and swipe-to-dismiss#519tvq wants to merge 6 commits into
Conversation
…pe-to-dismiss Add shadcn's Drawer: a bottom sheet on a native <dialog> 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 <noreply@anthropic.com>
There was a problem hiding this comment.
1 issue found across 26 files
Prompt for AI agents (unresolved issues)
Check if these issues are valid — if so, understand the root cause of each and fix them. If appropriate, use sub-agents to investigate and fix each issue separately.
<file name="gem/test/ruby_ui/drawer_test.rb">
<violation number="1" location="gem/test/ruby_ui/drawer_test.rb:50">
P2: Every test here is a markup-rendering assertion; none exercise the component's core behavior. The controller logic this PR adds (drag between snap points, flick/swipe-to-dismiss, scrim fade below the lowest snap point, Escape/scrim dismissal, and dismissible:false/initial handling) is verified only by asserting that the corresponding data-* value strings and action names are present, not by any behavioral test. Add tests driven by the viewer or a JSDOM/Selenium harness that move the drag position and assert the scrim opacity, snap rest point, and dismissal transitions.</violation>
</file>
Tip: cubic used a learning from your PR history. Let your coding agent read cubic learnings directly with the cubic MCP.
Re-trigger cubic
- open_pct mirrors the controller: a negative or out-of-range initial
opens at the first snap point, no snap points opens full height
- Escape on a non-modal, non-dismissible drawer is left to the page
- the trigger prevents the wrapped element's default action
- disconnect() tears an open drawer down instead of only detaching
listeners, so a stopped Stimulus app leaves no dead modal behind
- the body scroll lock is released only by the drawer that took it
- cleanup() tolerates a missing scroller target
- the "Drawer or Sheet?" paragraph reaches the MCP registry: the docs
parser now accepts p(class: ...) { "..." }
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Match three more pieces of shadcn's documented Drawer surface. 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 (for example data-expanded:rounded-none). The controller fires opened after the open animation, snap when the panel comes to rest at a different snap point, and closed before the dialog leaves the DOM, standing in for onOpenChangeComplete and onSnapPointChange. initial_focus: false renders the panel as the dialog's autofocus target, so a drawer full of fields does not raise the on-screen keyboard the moment it opens. The keyboard restore now remembers the snap index rather than its percentage, which is what it wanted in the first place: indexes survive the resize that moves the offsets. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Adopt shadcn's snap point units: up to 1 is a fraction of the viewport, above 1 is pixels, and a string is any CSS length, so snap_points: ["24rem", 1] does what it does there. Percentages were our own convention, and the same literal meant different things in each: their 1 is the full viewport, ours was one percent of it. The controller now measures the snap markers instead of computing offsets from percentages, which is what makes the other units work. Reading offsetTop also drops the per-frame clientHeight read: the offsets are cached and only re-measured when a resize or the on-screen keyboard moves them. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Pair the listeners in addEventListeners/removeEventListeners, the shape popover_controller already uses, so every subscription can be checked against its removal in one place. connect() is left with the four steps it actually performs, and trackKeyboard folds into the pair it belonged to. nearestSnapIndex reuses nearest instead of repeating the same reduce, the comment about the keyboard restore describes the index it keeps rather than the percentage it used to, and the number helper is named for what it produces. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
There was a problem hiding this comment.
All reported issues were addressed across 6 files (changes from recent commits).
Tip: Review your code locally with the cubic CLI to iterate faster.
Re-trigger cubic
…size The content controller dispatches opened, snap and closed on the <dialog>, which lives under <body> once open, so a data-action on DrawerContent (the panel, a descendant) or on any controller in the page never received them. The ruby-ui--drawer wrapper now relays them as ruby-ui--drawer:* on its own element, where the page's controllers can bind, and the docs example listens on Drawer. A window resize moves the snap markers (they are a percentage of the viewport); the panel now goes back onto the snap index it rested at instead of relying on the engine's re-snap. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
There was a problem hiding this comment.
1 issue found across 5 files (changes from recent commits).
Prompt for AI agents (unresolved issues)
Check if these issues are valid — if so, understand the root cause of each and fix them. If appropriate, use sub-agents to investigate and fix each issue separately.
<file name="mcp/data/registry.json">
<violation number="1" location="mcp/data/registry.json:1401">
P2: On Android, opening the soft keyboard can fire a window `resize` (the layout viewport shrinks) that reaches `onResize()` even while `followKeyboard()` has the panel lifted above the keyboard. `onResize()` then re-anchors `scrollTop` to `snapOffsets[this.snapIndex]`, and `snapIndex` is still the pre-keyboard index because `shiftTo()` never updates it and `keyboardShift` only guards the 200ms animation. The result is the panel jumps back down behind the keyboard. Guard `onResize()` from re-anchoring while `this.keyboardInset > 0`, or update `snapIndex`/preserve the keyboard-lifted position in `followKeyboard()`.</violation>
</file>
Tip: Review your code locally with the cubic CLI to iterate faster.
Re-trigger cubic
| }, | ||
| { | ||
| "path": "drawer_content_controller.js", | ||
| "content": "import { Controller } from \"@hotwired/stimulus\"\n\n// One pace however far the panel travels, like native sheets; only a flick settles faster, down to MIN_SETTLE_MS.\nconst OPEN_MS = 250\nconst SETTLE_MS = 200\nconst MIN_SETTLE_MS = 100\n// A release faster than FLICK px/ms moves on to the next snap point in its direction, projected FLICK_MS ahead.\nconst FLICK = 0.4\nconst FLICK_MS = 200\n// Scroll positions this close to a rest point count as being there.\nconst REST_SLACK = 4\n\n// Bottom sheet on a native <dialog> + CSS scroll-snap: a full-screen spacer above the panel makes scrollTop 0 the dismissed rest position.\nexport default class extends Controller {\n static targets = [\"scroller\", \"panel\", \"backdrop\", \"title\", \"description\", \"snap\"]\n static values = {\n initial: { type: Number, default: 0 },\n modal: { type: Boolean, default: true },\n dismissible: { type: Boolean, default: true }\n }\n\n ready = false\n dragging = false\n closing = false\n closed = false\n lockedBody = false\n keyboardInset = 0\n keyboardShift = false\n snapIndexBeforeKeyboard = null\n samples = []\n snaps = null\n\n connect() {\n this.labelDialog()\n this.show()\n this.addEventListeners()\n\n // After show: native autofocus may have scrolled the panel into view.\n this.scrollerTarget.scrollTop = 0\n this.snapIndex = this.initialValue\n this.markState()\n this.animateOpen()\n }\n\n // showModal() brings top layer, focus trap and an inert page; show() keeps the page interactive.\n show() {\n if (!this.modalValue) {\n this.element.show()\n return\n }\n\n // Only the lock we took is ours to release; a Dialog underneath keeps its own.\n this.lockedBody = !document.body.classList.contains(\"overflow-hidden\")\n if (this.lockedBody) document.body.classList.add(\"overflow-hidden\")\n this.element.showModal()\n }\n\n addEventListeners() {\n // A non-modal dialog has no close watcher, so Escape is ours to handle.\n if (!this.modalValue) document.addEventListener(\"keydown\", this.onKeydown)\n this.element.addEventListener(\"cancel\", this.onCancel)\n this.element.addEventListener(\"close\", this.onClose)\n this.scrollerTarget.addEventListener(\"scroll\", this.onScroll, { passive: true })\n this.scrollerTarget.addEventListener(\"scrollend\", this.onSettle)\n window.addEventListener(\"resize\", this.onResize)\n // iOS has no keyboard-inset env(): the visual viewport is where the keyboard height comes from.\n window.visualViewport?.addEventListener(\"resize\", this.onViewport)\n window.visualViewport?.addEventListener(\"scroll\", this.onViewport)\n }\n\n removeEventListeners() {\n document.removeEventListener(\"keydown\", this.onKeydown)\n this.element.removeEventListener(\"cancel\", this.onCancel)\n this.element.removeEventListener(\"close\", this.onClose)\n if (this.hasScrollerTarget) {\n this.scrollerTarget.removeEventListener(\"scroll\", this.onScroll)\n this.scrollerTarget.removeEventListener(\"scrollend\", this.onSettle)\n }\n window.removeEventListener(\"resize\", this.onResize)\n window.visualViewport?.removeEventListener(\"resize\", this.onViewport)\n window.visualViewport?.removeEventListener(\"scroll\", this.onViewport)\n }\n\n // Losing the controller while open (Stimulus stopped, element swapped out) must not leave a dead modal in the top layer.\n disconnect() {\n this.teardown()\n }\n\n // Name the dialog by its own title and description, as Radix does.\n labelDialog() {\n if (this.hasTitleTarget) this.element.setAttribute(\"aria-labelledby\", this.idFor(this.titleTarget))\n if (this.hasDescriptionTarget) this.element.setAttribute(\"aria-describedby\", this.idFor(this.descriptionTarget))\n }\n\n idFor(element) {\n element.id ||= `ruby-ui-drawer-${Math.random().toString(36).slice(2, 8)}`\n return element.id\n }\n\n get openOffset() {\n return this.snapOffsets[Math.max(0, this.initialValue)] ?? this.snapOffsets[0]\n }\n\n // Measured, not computed from the values, so a snap point can be given in any CSS unit. Only a resize moves them.\n get snapOffsets() {\n return (this.snaps ??= this.measureSnaps())\n }\n\n // Without snap points the panel has one rest position: the full height of the scroller.\n measureSnaps() {\n const offsets = this.snapTargets.map((marker) => marker.offsetTop)\n return offsets.length ? offsets : [this.scrollerTarget.clientHeight]\n }\n\n get lowestOffset() {\n return Math.min(...this.snapOffsets)\n }\n\n get highestOffset() {\n return Math.max(...this.snapOffsets)\n }\n\n // Where the panel may rest: the snap points, plus 0 — dismissed — when dismissible.\n get restPoints() {\n return this.dismissibleValue ? [0, ...this.snapOffsets] : this.snapOffsets\n }\n\n // Indexes survive a resize, unlike the pixel offsets they are measured from.\n nearestSnapIndex() {\n return this.snapOffsets.indexOf(this.nearest(this.snapOffsets))\n }\n\n nearest(values, target = this.scrollerTarget.scrollTop) {\n return values.reduce((best, value) => (Math.abs(value - target) < Math.abs(best - target) ? value : best))\n }\n\n onScroll = () => {\n this.trackBand()\n this.trackScrim()\n this.markState()\n this.scheduleSettle()\n }\n\n // Style hooks, as in shadcn: data-expanded at the largest snap point, data-swiping while a drag is in flight.\n markState() {\n this.panelTarget.toggleAttribute(\"data-expanded\", this.scrollerTarget.scrollTop >= this.highestOffset - REST_SLACK)\n this.panelTarget.toggleAttribute(\"data-swiping\", this.dragging)\n }\n\n // Below the lowest snap point the scrim follows the panel, so swiping out fades it like a native sheet.\n trackScrim() {\n if (!this.hasBackdropTarget || !this.ready || this.closing) return\n this.backdropTarget.style.opacity = Math.min(1, this.scrollerTarget.scrollTop / this.lowestOffset)\n }\n\n // Only the band above the fold is laid out, floored at the open offset so the layout stays rigid while sliding open or out.\n trackBand() {\n const band = Math.max(this.scrollerTarget.scrollTop, this.openOffset)\n this.element.style.setProperty(\"--drawer-band\", `${Math.round(band)}px`)\n }\n\n // Settles without scrollend (older Safari); where it exists onSettle simply runs twice and the guards make that a no-op.\n scheduleSettle() {\n clearTimeout(this.settleTimer)\n this.settleTimer = setTimeout(this.onSettle, 120)\n }\n\n onSettle = () => {\n if (this.closing || this.dragging || this.keyboardShift) return\n const atBottom = this.scrollerTarget.scrollTop <= REST_SLACK\n if (!this.ready) {\n this.ready = !atBottom\n return\n }\n if (!atBottom) {\n this.reportSnap()\n return\n }\n\n if (this.dismissibleValue) this.close()\n else this.animateScroll(this.lowestOffset, SETTLE_MS)\n }\n\n // The snap points move with the viewport (measured, not stored), so the panel goes back onto the one it rested at.\n onResize = () => {\n this.snaps = null\n if (this.ready && !this.dragging && !this.closing && !this.keyboardShift) {\n this.scrollerTarget.scrollTop = this.snapOffsets[this.snapIndex] ?? this.openOffset\n }\n this.trackBand()\n this.markState()\n }\n\n // The snap point the panel came to rest at, reported once per change.\n reportSnap() {\n const index = this.nearestSnapIndex()\n if (index === this.snapIndex) return\n this.snapIndex = index\n this.dispatch(\"snap\", { detail: { index, offset: this.snapOffsets[index] } })\n }\n\n // The keyboard height, from the only place iOS reports it.\n onViewport = () => {\n const viewport = window.visualViewport\n this.followKeyboard(Math.max(0, window.innerHeight - viewport.height - viewport.offsetTop))\n }\n\n // The pre-keyboard snap is kept as an index: the offsets it was measured from move with the resize.\n followKeyboard(inset) {\n inset = Math.round(inset)\n if (inset === this.keyboardInset) return\n const toggled = (inset > 0) !== (this.keyboardInset > 0)\n if (toggled && inset > 0 && !this.dragging) this.snapIndexBeforeKeyboard = this.nearestSnapIndex()\n this.keyboardInset = inset\n this.scrollerTarget.style.bottom = `${inset}px`\n this.snaps = null\n this.trackBand()\n if (!toggled || this.dragging || this.closing) return\n\n if (inset > 0) {\n this.shiftTo(this.highestOffset)\n } else if (this.snapIndexBeforeKeyboard != null) {\n this.shiftTo(this.snapOffsets[this.snapIndexBeforeKeyboard])\n this.snapIndexBeforeKeyboard = null\n }\n }\n\n // Chrome may clamp scrollTop to 0 while the scroller resizes; settling pauses so that is not read as a dismissal.\n shiftTo(offset) {\n this.keyboardShift = true\n this.animateScroll(offset, SETTLE_MS, () => { this.keyboardShift = false })\n }\n\n onKeydown = (event) => {\n if (event.key !== \"Escape\" || !this.dismissibleValue) return\n event.preventDefault()\n this.close()\n }\n\n // Escape on a modal dialog: cancel the instant native close, run ours instead.\n onCancel = (event) => {\n event.preventDefault()\n this.dismiss()\n }\n\n // Anything else that closes the dialog (e.g. a method=\"dialog\" form) still tears the drawer down.\n onClose = () => {\n this.teardown()\n }\n\n // Not scrollTo(): mandatory snap fights it mid-flight on a freshly inserted scroller and the open jumps.\n animateOpen() {\n this.animateScroll(this.openOffset, OPEN_MS, () => this.dispatch(\"opened\"))\n }\n\n animateScroll(to, duration, onDone) {\n const scroller = this.scrollerTarget\n cancelAnimationFrame(this.frame)\n scroller.style.scrollSnapType = \"none\"\n const from = scroller.scrollTop\n\n let startTime = null\n const step = (now) => {\n if (startTime === null) startTime = now\n const t = Math.min(1, (now - startTime) / duration)\n scroller.scrollTop = from + (to - from) * (1 - (1 - t) ** 3)\n\n if (t < 1) {\n this.frame = requestAnimationFrame(step)\n } else {\n scroller.style.scrollSnapType = \"\"\n onDone?.()\n }\n }\n this.frame = requestAnimationFrame(step)\n }\n\n // Pointer-driven: WebKit will not scroll the pointer-events:none snap scroller by touch, so the handle drives it.\n startDrag(event) {\n if (this.closing) return\n event.preventDefault()\n // A grab during the open animation would otherwise fight it for scrollTop.\n cancelAnimationFrame(this.frame)\n this.dragging = true\n this.snapIndexBeforeKeyboard = null\n this.keyboardShift = false\n this.markState()\n this.dragOrigin = event.clientY\n this.dragFrom = this.scrollerTarget.scrollTop\n this.samples = [{ t: event.timeStamp, y: event.clientY }]\n this.scrollerTarget.style.scrollSnapType = \"none\"\n event.currentTarget.setPointerCapture(event.pointerId)\n }\n\n drag(event) {\n if (!this.dragging) return\n this.samples.push({ t: event.timeStamp, y: event.clientY })\n if (this.samples.length > 8) this.samples.shift()\n this.scrollerTarget.scrollTop = Math.max(0, this.dragFrom - (event.clientY - this.dragOrigin))\n }\n\n endDrag(event) {\n if (!this.dragging) return\n this.dragging = false\n this.markState()\n // pointercancel has already released it, and releasing twice throws.\n if (event.currentTarget.hasPointerCapture(event.pointerId)) {\n event.currentTarget.releasePointerCapture(event.pointerId)\n }\n\n const velocity = this.releaseVelocity(event)\n const rest = this.restTarget(velocity)\n const duration = this.releaseDuration(Math.abs(rest - this.scrollerTarget.scrollTop), velocity)\n if (rest === 0) this.slideOut(duration)\n else this.animateScroll(rest, duration)\n }\n\n // px/ms in scroll direction (positive opens) over the last 100ms of movement; a pause before release is not a flick.\n releaseVelocity(event) {\n const last = this.samples.at(-1)\n if (event.timeStamp - last.t > 100) return 0\n const first = this.samples.find((sample) => last.t - sample.t <= 100)\n const elapsed = last.t - first.t\n return elapsed > 0 ? (first.y - last.y) / elapsed : 0\n }\n\n // A slow release settles on the nearest rest point; a flick moves on to the next one in its direction.\n restTarget(velocity) {\n const top = this.scrollerTarget.scrollTop\n const ahead = this.restPoints.filter((point) => (velocity > 0 ? point > top + REST_SLACK : point < top - REST_SLACK))\n if (Math.abs(velocity) < FLICK || ahead.length === 0) return this.nearest(this.restPoints)\n return this.nearest(ahead, top + velocity * FLICK_MS)\n }\n\n releaseDuration(distance, velocity) {\n if (Math.abs(velocity) < FLICK) return SETTLE_MS\n return Math.min(SETTLE_MS, Math.max(MIN_SETTLE_MS, distance / Math.abs(velocity)))\n }\n\n // The scrim, Escape and a swipe out ask; DrawerClose tells.\n dismiss() {\n if (this.dismissibleValue) this.close()\n }\n\n close() {\n this.slideOut(SETTLE_MS)\n }\n\n slideOut(duration) {\n if (this.closing) return\n this.closing = true\n\n if (this.hasBackdropTarget) {\n this.backdropTarget.style.setProperty(\"--tw-animation-duration\", `${duration}ms`)\n this.backdropTarget.dataset.state = \"closed\"\n }\n this.animateScroll(0, duration, () => this.teardown())\n }\n\n // dialog.close() (not just remove) so the browser restores focus to the trigger.\n teardown() {\n if (this.closed) return\n this.closed = true\n this.cleanup()\n if (this.element.open) this.element.close()\n // Before the removal, so the event still reaches listeners up the tree.\n this.dispatch(\"closed\")\n this.element.remove()\n }\n\n cleanup() {\n if (this.lockedBody) document.body.classList.remove(\"overflow-hidden\")\n cancelAnimationFrame(this.frame)\n clearTimeout(this.settleTimer)\n this.removeEventListeners()\n }\n}\n" |
There was a problem hiding this comment.
P2: On Android, opening the soft keyboard can fire a window resize (the layout viewport shrinks) that reaches onResize() even while followKeyboard() has the panel lifted above the keyboard. onResize() then re-anchors scrollTop to snapOffsets[this.snapIndex], and snapIndex is still the pre-keyboard index because shiftTo() never updates it and keyboardShift only guards the 200ms animation. The result is the panel jumps back down behind the keyboard. Guard onResize() from re-anchoring while this.keyboardInset > 0, or update snapIndex/preserve the keyboard-lifted position in followKeyboard().
Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At mcp/data/registry.json, line 1401:
<comment>On Android, opening the soft keyboard can fire a window `resize` (the layout viewport shrinks) that reaches `onResize()` even while `followKeyboard()` has the panel lifted above the keyboard. `onResize()` then re-anchors `scrollTop` to `snapOffsets[this.snapIndex]`, and `snapIndex` is still the pre-keyboard index because `shiftTo()` never updates it and `keyboardShift` only guards the 200ms animation. The result is the panel jumps back down behind the keyboard. Guard `onResize()` from re-anchoring while `this.keyboardInset > 0`, or update `snapIndex`/preserve the keyboard-lifted position in `followKeyboard()`.</comment>
<file context>
@@ -1398,11 +1398,11 @@
{
"path": "drawer_content_controller.js",
- "content": "import { Controller } from \"@hotwired/stimulus\"\n\n// One pace however far the panel travels, like native sheets; only a flick settles faster, down to MIN_SETTLE_MS.\nconst OPEN_MS = 250\nconst SETTLE_MS = 200\nconst MIN_SETTLE_MS = 100\n// A release faster than FLICK px/ms moves on to the next snap point in its direction, projected FLICK_MS ahead.\nconst FLICK = 0.4\nconst FLICK_MS = 200\n// Scroll positions this close to a rest point count as being there.\nconst REST_SLACK = 4\n\n// Bottom sheet on a native <dialog> + CSS scroll-snap: a full-screen spacer above the panel makes scrollTop 0 the dismissed rest position.\nexport default class extends Controller {\n static targets = [\"scroller\", \"panel\", \"backdrop\", \"title\", \"description\", \"snap\"]\n static values = {\n initial: { type: Number, default: 0 },\n modal: { type: Boolean, default: true },\n dismissible: { type: Boolean, default: true }\n }\n\n ready = false\n dragging = false\n closing = false\n closed = false\n lockedBody = false\n keyboardInset = 0\n keyboardShift = false\n snapIndexBeforeKeyboard = null\n samples = []\n snaps = null\n\n connect() {\n this.labelDialog()\n this.show()\n this.addEventListeners()\n\n // After show: native autofocus may have scrolled the panel into view.\n this.scrollerTarget.scrollTop = 0\n this.snapIndex = this.initialValue\n this.markState()\n this.animateOpen()\n }\n\n // showModal() brings top layer, focus trap and an inert page; show() keeps the page interactive.\n show() {\n if (!this.modalValue) {\n this.element.show()\n return\n }\n\n // Only the lock we took is ours to release; a Dialog underneath keeps its own.\n this.lockedBody = !document.body.classList.contains(\"overflow-hidden\")\n if (this.lockedBody) document.body.classList.add(\"overflow-hidden\")\n this.element.showModal()\n }\n\n addEventListeners() {\n // A non-modal dialog has no close watcher, so Escape is ours to handle.\n if (!this.modalValue) document.addEventListener(\"keydown\", this.onKeydown)\n this.element.addEventListener(\"cancel\", this.onCancel)\n this.element.addEventListener(\"close\", this.onClose)\n this.scrollerTarget.addEventListener(\"scroll\", this.onScroll, { passive: true })\n this.scrollerTarget.addEventListener(\"scrollend\", this.onSettle)\n window.addEventListener(\"resize\", this.onResize)\n // iOS has no keyboard-inset env(): the visual viewport is where the keyboard height comes from.\n window.visualViewport?.addEventListener(\"resize\", this.onViewport)\n window.visualViewport?.addEventListener(\"scroll\", this.onViewport)\n }\n\n removeEventListeners() {\n document.removeEventListener(\"keydown\", this.onKeydown)\n this.element.removeEventListener(\"cancel\", this.onCancel)\n this.element.removeEventListener(\"close\", this.onClose)\n if (this.hasScrollerTarget) {\n this.scrollerTarget.removeEventListener(\"scroll\", this.onScroll)\n this.scrollerTarget.removeEventListener(\"scrollend\", this.onSettle)\n }\n window.removeEventListener(\"resize\", this.onResize)\n window.visualViewport?.removeEventListener(\"resize\", this.onViewport)\n window.visualViewport?.removeEventListener(\"scroll\", this.onViewport)\n }\n\n // Losing the controller while open (Stimulus stopped, element swapped out) must not leave a dead modal in the top layer.\n disconnect() {\n this.teardown()\n }\n\n // Name the dialog by its own title and description, as Radix does.\n labelDialog() {\n if (this.hasTitleTarget) this.element.setAttribute(\"aria-labelledby\", this.idFor(this.titleTarget))\n if (this.hasDescriptionTarget) this.element.setAttribute(\"aria-describedby\", this.idFor(this.descriptionTarget))\n }\n\n idFor(element) {\n element.id ||= `ruby-ui-drawer-${Math.random().toString(36).slice(2, 8)}`\n return element.id\n }\n\n get openOffset() {\n return this.snapOffsets[Math.max(0, this.initialValue)] ?? this.snapOffsets[0]\n }\n\n // Measured, not computed from the values, so a snap point can be given in any CSS unit. Only a resize moves them.\n get snapOffsets() {\n return (this.snaps ??= this.measureSnaps())\n }\n\n // Without snap points the panel has one rest position: the full height of the scroller.\n measureSnaps() {\n const offsets = this.snapTargets.map((marker) => marker.offsetTop)\n return offsets.length ? offsets : [this.scrollerTarget.clientHeight]\n }\n\n get lowestOffset() {\n return Math.min(...this.snapOffsets)\n }\n\n get highestOffset() {\n return Math.max(...this.snapOffsets)\n }\n\n // Where the panel may rest: the snap points, plus 0 — dismissed — when dismissible.\n get restPoints() {\n return this.dismissibleValue ? [0, ...this.snapOffsets] : this.snapOffsets\n }\n\n // Indexes survive a resize, unlike the pixel offsets they are measured from.\n nearestSnapIndex() {\n return this.snapOffsets.indexOf(this.nearest(this.snapOffsets))\n }\n\n nearest(values, target = this.scrollerTarget.scrollTop) {\n return values.reduce((best, value) => (Math.abs(value - target) < Math.abs(best - target) ? value : best))\n }\n\n onScroll = () => {\n this.trackBand()\n this.trackScrim()\n this.markState()\n this.scheduleSettle()\n }\n\n // Style hooks, as in shadcn: data-expanded at the largest snap point, data-swiping while a drag is in flight.\n markState() {\n this.panelTarget.toggleAttribute(\"data-expanded\", this.scrollerTarget.scrollTop >= this.highestOffset - REST_SLACK)\n this.panelTarget.toggleAttribute(\"data-swiping\", this.dragging)\n }\n\n // Below the lowest snap point the scrim follows the panel, so swiping out fades it like a native sheet.\n trackScrim() {\n if (!this.hasBackdropTarget || !this.ready || this.closing) return\n this.backdropTarget.style.opacity = Math.min(1, this.scrollerTarget.scrollTop / this.lowestOffset)\n }\n\n // Only the band above the fold is laid out, floored at the open offset so the layout stays rigid while sliding open or out.\n trackBand() {\n const band = Math.max(this.scrollerTarget.scrollTop, this.openOffset)\n this.element.style.setProperty(\"--drawer-band\", `${Math.round(band)}px`)\n }\n\n // Settles without scrollend (older Safari); where it exists onSettle simply runs twice and the guards make that a no-op.\n scheduleSettle() {\n clearTimeout(this.settleTimer)\n this.settleTimer = setTimeout(this.onSettle, 120)\n }\n\n onSettle = () => {\n if (this.closing || this.dragging || this.keyboardShift) return\n const atBottom = this.scrollerTarget.scrollTop <= REST_SLACK\n if (!this.ready) {\n this.ready = !atBottom\n return\n }\n if (!atBottom) {\n this.reportSnap()\n return\n }\n\n if (this.dismissibleValue) this.close()\n else this.animateScroll(this.lowestOffset, SETTLE_MS)\n }\n\n onResize = () => {\n this.snaps = null\n this.trackBand()\n this.markState()\n }\n\n // The snap point the panel came to rest at, reported once per change.\n reportSnap() {\n const index = this.nearestSnapIndex()\n if (index === this.snapIndex) return\n this.snapIndex = index\n this.dispatch(\"snap\", { detail: { index, offset: this.snapOffsets[index] } })\n }\n\n // The keyboard height, from the only place iOS reports it.\n onViewport = () => {\n const viewport = window.visualViewport\n this.followKeyboard(Math.max(0, window.innerHeight - viewport.height - viewport.offsetTop))\n }\n\n // The pre-keyboard snap is kept as an index: the offsets it was measured from move with the resize.\n followKeyboard(inset) {\n inset = Math.round(inset)\n if (inset === this.keyboardInset) return\n const toggled = (inset > 0) !== (this.keyboardInset > 0)\n if (toggled && inset > 0 && !this.dragging) this.snapIndexBeforeKeyboard = this.nearestSnapIndex()\n this.keyboardInset = inset\n this.scrollerTarget.style.bottom = `${inset}px`\n this.snaps = null\n this.trackBand()\n if (!toggled || this.dragging || this.closing) return\n\n if (inset > 0) {\n this.shiftTo(this.highestOffset)\n } else if (this.snapIndexBeforeKeyboard != null) {\n this.shiftTo(this.snapOffsets[this.snapIndexBeforeKeyboard])\n this.snapIndexBeforeKeyboard = null\n }\n }\n\n // Chrome may clamp scrollTop to 0 while the scroller resizes; settling pauses so that is not read as a dismissal.\n shiftTo(offset) {\n this.keyboardShift = true\n this.animateScroll(offset, SETTLE_MS, () => { this.keyboardShift = false })\n }\n\n onKeydown = (event) => {\n if (event.key !== \"Escape\" || !this.dismissibleValue) return\n event.preventDefault()\n this.close()\n }\n\n // Escape on a modal dialog: cancel the instant native close, run ours instead.\n onCancel = (event) => {\n event.preventDefault()\n this.dismiss()\n }\n\n // Anything else that closes the dialog (e.g. a method=\"dialog\" form) still tears the drawer down.\n onClose = () => {\n this.teardown()\n }\n\n // Not scrollTo(): mandatory snap fights it mid-flight on a freshly inserted scroller and the open jumps.\n animateOpen() {\n this.animateScroll(this.openOffset, OPEN_MS, () => this.dispatch(\"opened\"))\n }\n\n animateScroll(to, duration, onDone) {\n const scroller = this.scrollerTarget\n cancelAnimationFrame(this.frame)\n scroller.style.scrollSnapType = \"none\"\n const from = scroller.scrollTop\n\n let startTime = null\n const step = (now) => {\n if (startTime === null) startTime = now\n const t = Math.min(1, (now - startTime) / duration)\n scroller.scrollTop = from + (to - from) * (1 - (1 - t) ** 3)\n\n if (t < 1) {\n this.frame = requestAnimationFrame(step)\n } else {\n scroller.style.scrollSnapType = \"\"\n onDone?.()\n }\n }\n this.frame = requestAnimationFrame(step)\n }\n\n // Pointer-driven: WebKit will not scroll the pointer-events:none snap scroller by touch, so the handle drives it.\n startDrag(event) {\n if (this.closing) return\n event.preventDefault()\n // A grab during the open animation would otherwise fight it for scrollTop.\n cancelAnimationFrame(this.frame)\n this.dragging = true\n this.snapIndexBeforeKeyboard = null\n this.keyboardShift = false\n this.markState()\n this.dragOrigin = event.clientY\n this.dragFrom = this.scrollerTarget.scrollTop\n this.samples = [{ t: event.timeStamp, y: event.clientY }]\n this.scrollerTarget.style.scrollSnapType = \"none\"\n event.currentTarget.setPointerCapture(event.pointerId)\n }\n\n drag(event) {\n if (!this.dragging) return\n this.samples.push({ t: event.timeStamp, y: event.clientY })\n if (this.samples.length > 8) this.samples.shift()\n this.scrollerTarget.scrollTop = Math.max(0, this.dragFrom - (event.clientY - this.dragOrigin))\n }\n\n endDrag(event) {\n if (!this.dragging) return\n this.dragging = false\n this.markState()\n // pointercancel has already released it, and releasing twice throws.\n if (event.currentTarget.hasPointerCapture(event.pointerId)) {\n event.currentTarget.releasePointerCapture(event.pointerId)\n }\n\n const velocity = this.releaseVelocity(event)\n const rest = this.restTarget(velocity)\n const duration = this.releaseDuration(Math.abs(rest - this.scrollerTarget.scrollTop), velocity)\n if (rest === 0) this.slideOut(duration)\n else this.animateScroll(rest, duration)\n }\n\n // px/ms in scroll direction (positive opens) over the last 100ms of movement; a pause before release is not a flick.\n releaseVelocity(event) {\n const last = this.samples.at(-1)\n if (event.timeStamp - last.t > 100) return 0\n const first = this.samples.find((sample) => last.t - sample.t <= 100)\n const elapsed = last.t - first.t\n return elapsed > 0 ? (first.y - last.y) / elapsed : 0\n }\n\n // A slow release settles on the nearest rest point; a flick moves on to the next one in its direction.\n restTarget(velocity) {\n const top = this.scrollerTarget.scrollTop\n const ahead = this.restPoints.filter((point) => (velocity > 0 ? point > top + REST_SLACK : point < top - REST_SLACK))\n if (Math.abs(velocity) < FLICK || ahead.length === 0) return this.nearest(this.restPoints)\n return this.nearest(ahead, top + velocity * FLICK_MS)\n }\n\n releaseDuration(distance, velocity) {\n if (Math.abs(velocity) < FLICK) return SETTLE_MS\n return Math.min(SETTLE_MS, Math.max(MIN_SETTLE_MS, distance / Math.abs(velocity)))\n }\n\n // The scrim, Escape and a swipe out ask; DrawerClose tells.\n dismiss() {\n if (this.dismissibleValue) this.close()\n }\n\n close() {\n this.slideOut(SETTLE_MS)\n }\n\n slideOut(duration) {\n if (this.closing) return\n this.closing = true\n\n if (this.hasBackdropTarget) {\n this.backdropTarget.style.setProperty(\"--tw-animation-duration\", `${duration}ms`)\n this.backdropTarget.dataset.state = \"closed\"\n }\n this.animateScroll(0, duration, () => this.teardown())\n }\n\n // dialog.close() (not just remove) so the browser restores focus to the trigger.\n teardown() {\n if (this.closed) return\n this.closed = true\n this.cleanup()\n if (this.element.open) this.element.close()\n // Before the removal, so the event still reaches listeners up the tree.\n this.dispatch(\"closed\")\n this.element.remove()\n }\n\n cleanup() {\n if (this.lockedBody) document.body.classList.remove(\"overflow-hidden\")\n cancelAnimationFrame(this.frame)\n clearTimeout(this.settleTimer)\n this.removeEventListeners()\n }\n}\n"
+ "content": "import { Controller } from \"@hotwired/stimulus\"\n\n// One pace however far the panel travels, like native sheets; only a flick settles faster, down to MIN_SETTLE_MS.\nconst OPEN_MS = 250\nconst SETTLE_MS = 200\nconst MIN_SETTLE_MS = 100\n// A release faster than FLICK px/ms moves on to the next snap point in its direction, projected FLICK_MS ahead.\nconst FLICK = 0.4\nconst FLICK_MS = 200\n// Scroll positions this close to a rest point count as being there.\nconst REST_SLACK = 4\n\n// Bottom sheet on a native <dialog> + CSS scroll-snap: a full-screen spacer above the panel makes scrollTop 0 the dismissed rest position.\nexport default class extends Controller {\n static targets = [\"scroller\", \"panel\", \"backdrop\", \"title\", \"description\", \"snap\"]\n static values = {\n initial: { type: Number, default: 0 },\n modal: { type: Boolean, default: true },\n dismissible: { type: Boolean, default: true }\n }\n\n ready = false\n dragging = false\n closing = false\n closed = false\n lockedBody = false\n keyboardInset = 0\n keyboardShift = false\n snapIndexBeforeKeyboard = null\n samples = []\n snaps = null\n\n connect() {\n this.labelDialog()\n this.show()\n this.addEventListeners()\n\n // After show: native autofocus may have scrolled the panel into view.\n this.scrollerTarget.scrollTop = 0\n this.snapIndex = this.initialValue\n this.markState()\n this.animateOpen()\n }\n\n // showModal() brings top layer, focus trap and an inert page; show() keeps the page interactive.\n show() {\n if (!this.modalValue) {\n this.element.show()\n return\n }\n\n // Only the lock we took is ours to release; a Dialog underneath keeps its own.\n this.lockedBody = !document.body.classList.contains(\"overflow-hidden\")\n if (this.lockedBody) document.body.classList.add(\"overflow-hidden\")\n this.element.showModal()\n }\n\n addEventListeners() {\n // A non-modal dialog has no close watcher, so Escape is ours to handle.\n if (!this.modalValue) document.addEventListener(\"keydown\", this.onKeydown)\n this.element.addEventListener(\"cancel\", this.onCancel)\n this.element.addEventListener(\"close\", this.onClose)\n this.scrollerTarget.addEventListener(\"scroll\", this.onScroll, { passive: true })\n this.scrollerTarget.addEventListener(\"scrollend\", this.onSettle)\n window.addEventListener(\"resize\", this.onResize)\n // iOS has no keyboard-inset env(): the visual viewport is where the keyboard height comes from.\n window.visualViewport?.addEventListener(\"resize\", this.onViewport)\n window.visualViewport?.addEventListener(\"scroll\", this.onViewport)\n }\n\n removeEventListeners() {\n document.removeEventListener(\"keydown\", this.onKeydown)\n this.element.removeEventListener(\"cancel\", this.onCancel)\n this.element.removeEventListener(\"close\", this.onClose)\n if (this.hasScrollerTarget) {\n this.scrollerTarget.removeEventListener(\"scroll\", this.onScroll)\n this.scrollerTarget.removeEventListener(\"scrollend\", this.onSettle)\n }\n window.removeEventListener(\"resize\", this.onResize)\n window.visualViewport?.removeEventListener(\"resize\", this.onViewport)\n window.visualViewport?.removeEventListener(\"scroll\", this.onViewport)\n }\n\n // Losing the controller while open (Stimulus stopped, element swapped out) must not leave a dead modal in the top layer.\n disconnect() {\n this.teardown()\n }\n\n // Name the dialog by its own title and description, as Radix does.\n labelDialog() {\n if (this.hasTitleTarget) this.element.setAttribute(\"aria-labelledby\", this.idFor(this.titleTarget))\n if (this.hasDescriptionTarget) this.element.setAttribute(\"aria-describedby\", this.idFor(this.descriptionTarget))\n }\n\n idFor(element) {\n element.id ||= `ruby-ui-drawer-${Math.random().toString(36).slice(2, 8)}`\n return element.id\n }\n\n get openOffset() {\n return this.snapOffsets[Math.max(0, this.initialValue)] ?? this.snapOffsets[0]\n }\n\n // Measured, not computed from the values, so a snap point can be given in any CSS unit. Only a resize moves them.\n get snapOffsets() {\n return (this.snaps ??= this.measureSnaps())\n }\n\n // Without snap points the panel has one rest position: the full height of the scroller.\n measureSnaps() {\n const offsets = this.snapTargets.map((marker) => marker.offsetTop)\n return offsets.length ? offsets : [this.scrollerTarget.clientHeight]\n }\n\n get lowestOffset() {\n return Math.min(...this.snapOffsets)\n }\n\n get highestOffset() {\n return Math.max(...this.snapOffsets)\n }\n\n // Where the panel may rest: the snap points, plus 0 — dismissed — when dismissible.\n get restPoints() {\n return this.dismissibleValue ? [0, ...this.snapOffsets] : this.snapOffsets\n }\n\n // Indexes survive a resize, unlike the pixel offsets they are measured from.\n nearestSnapIndex() {\n return this.snapOffsets.indexOf(this.nearest(this.snapOffsets))\n }\n\n nearest(values, target = this.scrollerTarget.scrollTop) {\n return values.reduce((best, value) => (Math.abs(value - target) < Math.abs(best - target) ? value : best))\n }\n\n onScroll = () => {\n this.trackBand()\n this.trackScrim()\n this.markState()\n this.scheduleSettle()\n }\n\n // Style hooks, as in shadcn: data-expanded at the largest snap point, data-swiping while a drag is in flight.\n markState() {\n this.panelTarget.toggleAttribute(\"data-expanded\", this.scrollerTarget.scrollTop >= this.highestOffset - REST_SLACK)\n this.panelTarget.toggleAttribute(\"data-swiping\", this.dragging)\n }\n\n // Below the lowest snap point the scrim follows the panel, so swiping out fades it like a native sheet.\n trackScrim() {\n if (!this.hasBackdropTarget || !this.ready || this.closing) return\n this.backdropTarget.style.opacity = Math.min(1, this.scrollerTarget.scrollTop / this.lowestOffset)\n }\n\n // Only the band above the fold is laid out, floored at the open offset so the layout stays rigid while sliding open or out.\n trackBand() {\n const band = Math.max(this.scrollerTarget.scrollTop, this.openOffset)\n this.element.style.setProperty(\"--drawer-band\", `${Math.round(band)}px`)\n }\n\n // Settles without scrollend (older Safari); where it exists onSettle simply runs twice and the guards make that a no-op.\n scheduleSettle() {\n clearTimeout(this.settleTimer)\n this.settleTimer = setTimeout(this.onSettle, 120)\n }\n\n onSettle = () => {\n if (this.closing || this.dragging || this.keyboardShift) return\n const atBottom = this.scrollerTarget.scrollTop <= REST_SLACK\n if (!this.ready) {\n this.ready = !atBottom\n return\n }\n if (!atBottom) {\n this.reportSnap()\n return\n }\n\n if (this.dismissibleValue) this.close()\n else this.animateScroll(this.lowestOffset, SETTLE_MS)\n }\n\n // The snap points move with the viewport (measured, not stored), so the panel goes back onto the one it rested at.\n onResize = () => {\n this.snaps = null\n if (this.ready && !this.dragging && !this.closing && !this.keyboardShift) {\n this.scrollerTarget.scrollTop = this.snapOffsets[this.snapIndex] ?? this.openOffset\n }\n this.trackBand()\n this.markState()\n }\n\n // The snap point the panel came to rest at, reported once per change.\n reportSnap() {\n const index = this.nearestSnapIndex()\n if (index === this.snapIndex) return\n this.snapIndex = index\n this.dispatch(\"snap\", { detail: { index, offset: this.snapOffsets[index] } })\n }\n\n // The keyboard height, from the only place iOS reports it.\n onViewport = () => {\n const viewport = window.visualViewport\n this.followKeyboard(Math.max(0, window.innerHeight - viewport.height - viewport.offsetTop))\n }\n\n // The pre-keyboard snap is kept as an index: the offsets it was measured from move with the resize.\n followKeyboard(inset) {\n inset = Math.round(inset)\n if (inset === this.keyboardInset) return\n const toggled = (inset > 0) !== (this.keyboardInset > 0)\n if (toggled && inset > 0 && !this.dragging) this.snapIndexBeforeKeyboard = this.nearestSnapIndex()\n this.keyboardInset = inset\n this.scrollerTarget.style.bottom = `${inset}px`\n this.snaps = null\n this.trackBand()\n if (!toggled || this.dragging || this.closing) return\n\n if (inset > 0) {\n this.shiftTo(this.highestOffset)\n } else if (this.snapIndexBeforeKeyboard != null) {\n this.shiftTo(this.snapOffsets[this.snapIndexBeforeKeyboard])\n this.snapIndexBeforeKeyboard = null\n }\n }\n\n // Chrome may clamp scrollTop to 0 while the scroller resizes; settling pauses so that is not read as a dismissal.\n shiftTo(offset) {\n this.keyboardShift = true\n this.animateScroll(offset, SETTLE_MS, () => { this.keyboardShift = false })\n }\n\n onKeydown = (event) => {\n if (event.key !== \"Escape\" || !this.dismissibleValue) return\n event.preventDefault()\n this.close()\n }\n\n // Escape on a modal dialog: cancel the instant native close, run ours instead.\n onCancel = (event) => {\n event.preventDefault()\n this.dismiss()\n }\n\n // Anything else that closes the dialog (e.g. a method=\"dialog\" form) still tears the drawer down.\n onClose = () => {\n this.teardown()\n }\n\n // Not scrollTo(): mandatory snap fights it mid-flight on a freshly inserted scroller and the open jumps.\n animateOpen() {\n this.animateScroll(this.openOffset, OPEN_MS, () => this.dispatch(\"opened\"))\n }\n\n animateScroll(to, duration, onDone) {\n const scroller = this.scrollerTarget\n cancelAnimationFrame(this.frame)\n scroller.style.scrollSnapType = \"none\"\n const from = scroller.scrollTop\n\n let startTime = null\n const step = (now) => {\n if (startTime === null) startTime = now\n const t = Math.min(1, (now - startTime) / duration)\n scroller.scrollTop = from + (to - from) * (1 - (1 - t) ** 3)\n\n if (t < 1) {\n this.frame = requestAnimationFrame(step)\n } else {\n scroller.style.scrollSnapType = \"\"\n onDone?.()\n }\n }\n this.frame = requestAnimationFrame(step)\n }\n\n // Pointer-driven: WebKit will not scroll the pointer-events:none snap scroller by touch, so the handle drives it.\n startDrag(event) {\n if (this.closing) return\n event.preventDefault()\n // A grab during the open animation would otherwise fight it for scrollTop.\n cancelAnimationFrame(this.frame)\n this.dragging = true\n this.snapIndexBeforeKeyboard = null\n this.keyboardShift = false\n this.markState()\n this.dragOrigin = event.clientY\n this.dragFrom = this.scrollerTarget.scrollTop\n this.samples = [{ t: event.timeStamp, y: event.clientY }]\n this.scrollerTarget.style.scrollSnapType = \"none\"\n event.currentTarget.setPointerCapture(event.pointerId)\n }\n\n drag(event) {\n if (!this.dragging) return\n this.samples.push({ t: event.timeStamp, y: event.clientY })\n if (this.samples.length > 8) this.samples.shift()\n this.scrollerTarget.scrollTop = Math.max(0, this.dragFrom - (event.clientY - this.dragOrigin))\n }\n\n endDrag(event) {\n if (!this.dragging) return\n this.dragging = false\n this.markState()\n // pointercancel has already released it, and releasing twice throws.\n if (event.currentTarget.hasPointerCapture(event.pointerId)) {\n event.currentTarget.releasePointerCapture(event.pointerId)\n }\n\n const velocity = this.releaseVelocity(event)\n const rest = this.restTarget(velocity)\n const duration = this.releaseDuration(Math.abs(rest - this.scrollerTarget.scrollTop), velocity)\n if (rest === 0) this.slideOut(duration)\n else this.animateScroll(rest, duration)\n }\n\n // px/ms in scroll direction (positive opens) over the last 100ms of movement; a pause before release is not a flick.\n releaseVelocity(event) {\n const last = this.samples.at(-1)\n if (event.timeStamp - last.t > 100) return 0\n const first = this.samples.find((sample) => last.t - sample.t <= 100)\n const elapsed = last.t - first.t\n return elapsed > 0 ? (first.y - last.y) / elapsed : 0\n }\n\n // A slow release settles on the nearest rest point; a flick moves on to the next one in its direction.\n restTarget(velocity) {\n const top = this.scrollerTarget.scrollTop\n const ahead = this.restPoints.filter((point) => (velocity > 0 ? point > top + REST_SLACK : point < top - REST_SLACK))\n if (Math.abs(velocity) < FLICK || ahead.length === 0) return this.nearest(this.restPoints)\n return this.nearest(ahead, top + velocity * FLICK_MS)\n }\n\n releaseDuration(distance, velocity) {\n if (Math.abs(velocity) < FLICK) return SETTLE_MS\n return Math.min(SETTLE_MS, Math.max(MIN_SETTLE_MS, distance / Math.abs(velocity)))\n }\n\n // The scrim, Escape and a swipe out ask; DrawerClose tells.\n dismiss() {\n if (this.dismissibleValue) this.close()\n }\n\n close() {\n this.slideOut(SETTLE_MS)\n }\n\n slideOut(duration) {\n if (this.closing) return\n this.closing = true\n\n if (this.hasBackdropTarget) {\n this.backdropTarget.style.setProperty(\"--tw-animation-duration\", `${duration}ms`)\n this.backdropTarget.dataset.state = \"closed\"\n }\n this.animateScroll(0, duration, () => this.teardown())\n }\n\n // dialog.close() (not just remove) so the browser restores focus to the trigger.\n teardown() {\n if (this.closed) return\n this.closed = true\n this.cleanup()\n if (this.element.open) this.element.close()\n // Before the removal, so the event still reaches listeners up the tree.\n this.dispatch(\"closed\")\n this.element.remove()\n }\n\n cleanup() {\n if (this.lockedBody) document.body.classList.remove(\"overflow-hidden\")\n cancelAnimationFrame(this.frame)\n clearTimeout(this.settleTimer)\n this.removeEventListeners()\n }\n}\n"
},
{
</file context>
| "content": "import { Controller } from \"@hotwired/stimulus\"\n\n// One pace however far the panel travels, like native sheets; only a flick settles faster, down to MIN_SETTLE_MS.\nconst OPEN_MS = 250\nconst SETTLE_MS = 200\nconst MIN_SETTLE_MS = 100\n// A release faster than FLICK px/ms moves on to the next snap point in its direction, projected FLICK_MS ahead.\nconst FLICK = 0.4\nconst FLICK_MS = 200\n// Scroll positions this close to a rest point count as being there.\nconst REST_SLACK = 4\n\n// Bottom sheet on a native <dialog> + CSS scroll-snap: a full-screen spacer above the panel makes scrollTop 0 the dismissed rest position.\nexport default class extends Controller {\n static targets = [\"scroller\", \"panel\", \"backdrop\", \"title\", \"description\", \"snap\"]\n static values = {\n initial: { type: Number, default: 0 },\n modal: { type: Boolean, default: true },\n dismissible: { type: Boolean, default: true }\n }\n\n ready = false\n dragging = false\n closing = false\n closed = false\n lockedBody = false\n keyboardInset = 0\n keyboardShift = false\n snapIndexBeforeKeyboard = null\n samples = []\n snaps = null\n\n connect() {\n this.labelDialog()\n this.show()\n this.addEventListeners()\n\n // After show: native autofocus may have scrolled the panel into view.\n this.scrollerTarget.scrollTop = 0\n this.snapIndex = this.initialValue\n this.markState()\n this.animateOpen()\n }\n\n // showModal() brings top layer, focus trap and an inert page; show() keeps the page interactive.\n show() {\n if (!this.modalValue) {\n this.element.show()\n return\n }\n\n // Only the lock we took is ours to release; a Dialog underneath keeps its own.\n this.lockedBody = !document.body.classList.contains(\"overflow-hidden\")\n if (this.lockedBody) document.body.classList.add(\"overflow-hidden\")\n this.element.showModal()\n }\n\n addEventListeners() {\n // A non-modal dialog has no close watcher, so Escape is ours to handle.\n if (!this.modalValue) document.addEventListener(\"keydown\", this.onKeydown)\n this.element.addEventListener(\"cancel\", this.onCancel)\n this.element.addEventListener(\"close\", this.onClose)\n this.scrollerTarget.addEventListener(\"scroll\", this.onScroll, { passive: true })\n this.scrollerTarget.addEventListener(\"scrollend\", this.onSettle)\n window.addEventListener(\"resize\", this.onResize)\n // iOS has no keyboard-inset env(): the visual viewport is where the keyboard height comes from.\n window.visualViewport?.addEventListener(\"resize\", this.onViewport)\n window.visualViewport?.addEventListener(\"scroll\", this.onViewport)\n }\n\n removeEventListeners() {\n document.removeEventListener(\"keydown\", this.onKeydown)\n this.element.removeEventListener(\"cancel\", this.onCancel)\n this.element.removeEventListener(\"close\", this.onClose)\n if (this.hasScrollerTarget) {\n this.scrollerTarget.removeEventListener(\"scroll\", this.onScroll)\n this.scrollerTarget.removeEventListener(\"scrollend\", this.onSettle)\n }\n window.removeEventListener(\"resize\", this.onResize)\n window.visualViewport?.removeEventListener(\"resize\", this.onViewport)\n window.visualViewport?.removeEventListener(\"scroll\", this.onViewport)\n }\n\n // Losing the controller while open (Stimulus stopped, element swapped out) must not leave a dead modal in the top layer.\n disconnect() {\n this.teardown()\n }\n\n // Name the dialog by its own title and description, as Radix does.\n labelDialog() {\n if (this.hasTitleTarget) this.element.setAttribute(\"aria-labelledby\", this.idFor(this.titleTarget))\n if (this.hasDescriptionTarget) this.element.setAttribute(\"aria-describedby\", this.idFor(this.descriptionTarget))\n }\n\n idFor(element) {\n element.id ||= `ruby-ui-drawer-${Math.random().toString(36).slice(2, 8)}`\n return element.id\n }\n\n get openOffset() {\n return this.snapOffsets[Math.max(0, this.initialValue)] ?? this.snapOffsets[0]\n }\n\n // Measured, not computed from the values, so a snap point can be given in any CSS unit. Only a resize moves them.\n get snapOffsets() {\n return (this.snaps ??= this.measureSnaps())\n }\n\n // Without snap points the panel has one rest position: the full height of the scroller.\n measureSnaps() {\n const offsets = this.snapTargets.map((marker) => marker.offsetTop)\n return offsets.length ? offsets : [this.scrollerTarget.clientHeight]\n }\n\n get lowestOffset() {\n return Math.min(...this.snapOffsets)\n }\n\n get highestOffset() {\n return Math.max(...this.snapOffsets)\n }\n\n // Where the panel may rest: the snap points, plus 0 — dismissed — when dismissible.\n get restPoints() {\n return this.dismissibleValue ? [0, ...this.snapOffsets] : this.snapOffsets\n }\n\n // Indexes survive a resize, unlike the pixel offsets they are measured from.\n nearestSnapIndex() {\n return this.snapOffsets.indexOf(this.nearest(this.snapOffsets))\n }\n\n nearest(values, target = this.scrollerTarget.scrollTop) {\n return values.reduce((best, value) => (Math.abs(value - target) < Math.abs(best - target) ? value : best))\n }\n\n onScroll = () => {\n this.trackBand()\n this.trackScrim()\n this.markState()\n this.scheduleSettle()\n }\n\n // Style hooks, as in shadcn: data-expanded at the largest snap point, data-swiping while a drag is in flight.\n markState() {\n this.panelTarget.toggleAttribute(\"data-expanded\", this.scrollerTarget.scrollTop >= this.highestOffset - REST_SLACK)\n this.panelTarget.toggleAttribute(\"data-swiping\", this.dragging)\n }\n\n // Below the lowest snap point the scrim follows the panel, so swiping out fades it like a native sheet.\n trackScrim() {\n if (!this.hasBackdropTarget || !this.ready || this.closing) return\n this.backdropTarget.style.opacity = Math.min(1, this.scrollerTarget.scrollTop / this.lowestOffset)\n }\n\n // Only the band above the fold is laid out, floored at the open offset so the layout stays rigid while sliding open or out.\n trackBand() {\n const band = Math.max(this.scrollerTarget.scrollTop, this.openOffset)\n this.element.style.setProperty(\"--drawer-band\", `${Math.round(band)}px`)\n }\n\n // Settles without scrollend (older Safari); where it exists onSettle simply runs twice and the guards make that a no-op.\n scheduleSettle() {\n clearTimeout(this.settleTimer)\n this.settleTimer = setTimeout(this.onSettle, 120)\n }\n\n onSettle = () => {\n if (this.closing || this.dragging || this.keyboardShift) return\n const atBottom = this.scrollerTarget.scrollTop <= REST_SLACK\n if (!this.ready) {\n this.ready = !atBottom\n return\n }\n if (!atBottom) {\n this.reportSnap()\n return\n }\n\n if (this.dismissibleValue) this.close()\n else this.animateScroll(this.lowestOffset, SETTLE_MS)\n }\n\n // The snap points move with the viewport (measured, not stored), so the panel goes back onto the one it rested at.\n onResize = () => {\n this.snaps = null\n if (this.ready && !this.dragging && !this.closing && !this.keyboardShift) {\n this.scrollerTarget.scrollTop = this.snapOffsets[this.snapIndex] ?? this.openOffset\n }\n this.trackBand()\n this.markState()\n }\n\n // The snap point the panel came to rest at, reported once per change.\n reportSnap() {\n const index = this.nearestSnapIndex()\n if (index === this.snapIndex) return\n this.snapIndex = index\n this.dispatch(\"snap\", { detail: { index, offset: this.snapOffsets[index] } })\n }\n\n // The keyboard height, from the only place iOS reports it.\n onViewport = () => {\n const viewport = window.visualViewport\n this.followKeyboard(Math.max(0, window.innerHeight - viewport.height - viewport.offsetTop))\n }\n\n // The pre-keyboard snap is kept as an index: the offsets it was measured from move with the resize.\n followKeyboard(inset) {\n inset = Math.round(inset)\n if (inset === this.keyboardInset) return\n const toggled = (inset > 0) !== (this.keyboardInset > 0)\n if (toggled && inset > 0 && !this.dragging) this.snapIndexBeforeKeyboard = this.nearestSnapIndex()\n this.keyboardInset = inset\n this.scrollerTarget.style.bottom = `${inset}px`\n this.snaps = null\n this.trackBand()\n if (!toggled || this.dragging || this.closing) return\n\n if (inset > 0) {\n this.shiftTo(this.highestOffset)\n } else if (this.snapIndexBeforeKeyboard != null) {\n this.shiftTo(this.snapOffsets[this.snapIndexBeforeKeyboard])\n this.snapIndexBeforeKeyboard = null\n }\n }\n\n // Chrome may clamp scrollTop to 0 while the scroller resizes; settling pauses so that is not read as a dismissal.\n shiftTo(offset) {\n this.keyboardShift = true\n this.animateScroll(offset, SETTLE_MS, () => { this.keyboardShift = false })\n }\n\n onKeydown = (event) => {\n if (event.key !== \"Escape\" || !this.dismissibleValue) return\n event.preventDefault()\n this.close()\n }\n\n // Escape on a modal dialog: cancel the instant native close, run ours instead.\n onCancel = (event) => {\n event.preventDefault()\n this.dismiss()\n }\n\n // Anything else that closes the dialog (e.g. a method=\"dialog\" form) still tears the drawer down.\n onClose = () => {\n this.teardown()\n }\n\n // Not scrollTo(): mandatory snap fights it mid-flight on a freshly inserted scroller and the open jumps.\n animateOpen() {\n this.animateScroll(this.openOffset, OPEN_MS, () => this.dispatch(\"opened\"))\n }\n\n animateScroll(to, duration, onDone) {\n const scroller = this.scrollerTarget\n cancelAnimationFrame(this.frame)\n scroller.style.scrollSnapType = \"none\"\n const from = scroller.scrollTop\n\n let startTime = null\n const step = (now) => {\n if (startTime === null) startTime = now\n const t = Math.min(1, (now - startTime) / duration)\n scroller.scrollTop = from + (to - from) * (1 - (1 - t) ** 3)\n\n if (t < 1) {\n this.frame = requestAnimationFrame(step)\n } else {\n scroller.style.scrollSnapType = \"\"\n onDone?.()\n }\n }\n this.frame = requestAnimationFrame(step)\n }\n\n // Pointer-driven: WebKit will not scroll the pointer-events:none snap scroller by touch, so the handle drives it.\n startDrag(event) {\n if (this.closing) return\n event.preventDefault()\n // A grab during the open animation would otherwise fight it for scrollTop.\n cancelAnimationFrame(this.frame)\n this.dragging = true\n this.snapIndexBeforeKeyboard = null\n this.keyboardShift = false\n this.markState()\n this.dragOrigin = event.clientY\n this.dragFrom = this.scrollerTarget.scrollTop\n this.samples = [{ t: event.timeStamp, y: event.clientY }]\n this.scrollerTarget.style.scrollSnapType = \"none\"\n event.currentTarget.setPointerCapture(event.pointerId)\n }\n\n drag(event) {\n if (!this.dragging) return\n this.samples.push({ t: event.timeStamp, y: event.clientY })\n if (this.samples.length > 8) this.samples.shift()\n this.scrollerTarget.scrollTop = Math.max(0, this.dragFrom - (event.clientY - this.dragOrigin))\n }\n\n endDrag(event) {\n if (!this.dragging) return\n this.dragging = false\n this.markState()\n // pointercancel has already released it, and releasing twice throws.\n if (event.currentTarget.hasPointerCapture(event.pointerId)) {\n event.currentTarget.releasePointerCapture(event.pointerId)\n }\n\n const velocity = this.releaseVelocity(event)\n const rest = this.restTarget(velocity)\n const duration = this.releaseDuration(Math.abs(rest - this.scrollerTarget.scrollTop), velocity)\n if (rest === 0) this.slideOut(duration)\n else this.animateScroll(rest, duration)\n }\n\n // px/ms in scroll direction (positive opens) over the last 100ms of movement; a pause before release is not a flick.\n releaseVelocity(event) {\n const last = this.samples.at(-1)\n if (event.timeStamp - last.t > 100) return 0\n const first = this.samples.find((sample) => last.t - sample.t <= 100)\n const elapsed = last.t - first.t\n return elapsed > 0 ? (first.y - last.y) / elapsed : 0\n }\n\n // A slow release settles on the nearest rest point; a flick moves on to the next one in its direction.\n restTarget(velocity) {\n const top = this.scrollerTarget.scrollTop\n const ahead = this.restPoints.filter((point) => (velocity > 0 ? point > top + REST_SLACK : point < top - REST_SLACK))\n if (Math.abs(velocity) < FLICK || ahead.length === 0) return this.nearest(this.restPoints)\n return this.nearest(ahead, top + velocity * FLICK_MS)\n }\n\n releaseDuration(distance, velocity) {\n if (Math.abs(velocity) < FLICK) return SETTLE_MS\n return Math.min(SETTLE_MS, Math.max(MIN_SETTLE_MS, distance / Math.abs(velocity)))\n }\n\n // The scrim, Escape and a swipe out ask; DrawerClose tells.\n dismiss() {\n if (this.dismissibleValue) this.close()\n }\n\n close() {\n this.slideOut(SETTLE_MS)\n }\n\n slideOut(duration) {\n if (this.closing) return\n this.closing = true\n\n if (this.hasBackdropTarget) {\n this.backdropTarget.style.setProperty(\"--tw-animation-duration\", `${duration}ms`)\n this.backdropTarget.dataset.state = \"closed\"\n }\n this.animateScroll(0, duration, () => this.teardown())\n }\n\n // dialog.close() (not just remove) so the browser restores focus to the trigger.\n teardown() {\n if (this.closed) return\n this.closed = true\n this.cleanup()\n if (this.element.open) this.element.close()\n // Before the removal, so the event still reaches listeners up the tree.\n this.dispatch(\"closed\")\n this.element.remove()\n }\n\n cleanup() {\n if (this.lockedBody) document.body.classList.remove(\"overflow-hidden\")\n cancelAnimationFrame(this.frame)\n clearTimeout(this.settleTimer)\n this.removeEventListeners()\n }\n}\n" | |
| if (this.ready && !this.dragging && !this.closing && !this.keyboardShift && this.keyboardInset === 0) { | |
| this.scrollerTarget.scrollTop = this.snapOffsets[this.snapIndex] ?? this.openOffset | |
| } |
Related issue
Related to #333. The comparison there lists Drawer as a partial match: Sheet covers an overlay panel, but not shadcn's Drawer. shadcn has since moved Drawer from Vaul to Base UI; the gap is the same.
Description
Adds
Drawer, shadcn's bottom sheet, with the API mirrored 1:1:Drawer,DrawerTrigger,DrawerContent,DrawerHeader,DrawerTitle,DrawerDescription,DrawerMiddle,DrawerFooter,DrawerCloseandDrawerSwipeHandle.DrawerContenttakessnap_points:,initial:,modal:,dismissible:,handle:andinitial_focus:.Sheet slides a panel in from an edge and leaves it there. Drawer adds what Sheet does not have: a swipe handle, dragging between snap points, flick gestures and swipe-to-dismiss.
How it works: the content is a native
<dialog>(showModal()for the modal variant,show()formodal: false, so the focus trap,aria-modaland the inert page come from the browser) holding a full-screen CSS scroll-snap scroller. A full-screen spacer sits above the panel, so revealing it means scrolling up andscrollTop 0is the dismissed position. The Stimulus controller animatesscrollTopon open and close, drives the handle with pointer events, picks the rest point by distance or by flick velocity, fades the scrim with the drag below the lowest snap point and keeps the sheet above the on-screen keyboard on iOS. No npm dependencies.Snap points follow shadcn's units: up to
1is a fraction of the viewport, above1is pixels, and a string is any CSS length, sosnap_points: ["24rem", 1]does there what it does here. The controller measures the snap markers rather than computing offsets, which is what lets a snap point be given in any unit.The panel carries the state attributes shadcn documents for styling:
data-snap-pointswhen the drawer has snap points,data-expandedonce it reaches the largest one anddata-swipingwhile a drag is in flight, sodata-expanded:rounded-noneand friends work. The controller firesopened,snapandclosedevents, standing in foronOpenChangeCompleteandonSnapPointChange.initial_focus: falsemakes the panel the dialog's autofocus target, so a drawer full of fields does not raise the keyboard the moment it opens.Styling is Tailwind utilities only. The visible band of the panel is tracked by the controller (
--drawer-band), so the gem ships no CSS of its own. A CSS-first variant (scroll-timeline+@property, band computed on the compositor) would need about 40 lines in the installedtailwind.css; happy to add it if shipping component CSS is acceptable.Scope: bottom edge only (no
swipeDirection), no nested drawers.One deliberate deviation: shadcn puts
snapPoints,modalandshowSwipeHandleon the rootDrawer, where Base UI keeps its state machine. Phlex has no equivalent of React context, and RubyUI already keeps this kind of option on the content component (SheetContent(side:),DialogContent(size:)), so they live onDrawerContenthere.Testing instructions
cd gem && bundle exec rake(tests + StandardRB).cd docs && bin/dev, open/docs/drawer.DrawerCloseworks. "Custom swipe handle": the drawer opens 24rem tall, from a snap point given in rem.