[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.
3 issues 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/lib/ruby_ui/drawer/drawer_controller.js">
<violation number="1" location="gem/lib/ruby_ui/drawer/drawer_controller.js:18">
P2: This controller has no disconnect(), so when its element is removed from the DOM while the drawer is open (Turbo navigation/re-render that doesn't strip the body dialog), the dialog appended to document.body is never closed or removed and this.dialog holds a stale detached reference. DrawerContent.disconnect() only detaches listeners and clears overflow-hidden; it does not close the dialog, and close() is only reached through a user/scrim close. Add a disconnect() that closes and removes the open dialog (guarded so it does not fight an in-flight teardown).</violation>
</file>
<file name="gem/lib/ruby_ui/drawer/drawer_swipe_handle.rb">
<violation number="1" location="gem/lib/ruby_ui/drawer/drawer_swipe_handle.rb:21">
P2: The swipe handle's `::after` grab strip (`after:-bottom-6`, full viewport width, `z-10`) overlays the top of DrawerHeader, intercepting pointer events over it. Dragging in that band works as intended, but any interactive element placed near the top of the header (or the title/description band) becomes unclickable. Keep the grab area, but avoid silently eating clicks over the header, e.g. make the strip `pointer-events-none` and rely on the 6px bar + explicit handle hit area for drag, or document the overlay as intentional.</violation>
</file>
<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
|
|
||
| this.dialog = this.contentTarget.content.firstElementChild.cloneNode(true) | ||
| this.dialog.addEventListener("close", () => { this.dialog = null }, { once: true }) | ||
| document.body.append(this.dialog) |
There was a problem hiding this comment.
P2: This controller has no disconnect(), so when its element is removed from the DOM while the drawer is open (Turbo navigation/re-render that doesn't strip the body dialog), the dialog appended to document.body is never closed or removed and this.dialog holds a stale detached reference. DrawerContent.disconnect() only detaches listeners and clears overflow-hidden; it does not close the dialog, and close() is only reached through a user/scrim close. Add a disconnect() that closes and removes the open dialog (guarded so it does not fight an in-flight teardown).
Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At gem/lib/ruby_ui/drawer/drawer_controller.js, line 18:
<comment>This controller has no disconnect(), so when its element is removed from the DOM while the drawer is open (Turbo navigation/re-render that doesn't strip the body dialog), the dialog appended to document.body is never closed or removed and this.dialog holds a stale detached reference. DrawerContent.disconnect() only detaches listeners and clears overflow-hidden; it does not close the dialog, and close() is only reached through a user/scrim close. Add a disconnect() that closes and removes the open dialog (guarded so it does not fight an in-flight teardown).</comment>
<file context>
@@ -0,0 +1,20 @@
+
+ this.dialog = this.contentTarget.content.firstElementChild.cloneNode(true)
+ this.dialog.addEventListener("close", () => { this.dialog = null }, { once: true })
+ document.body.append(this.dialog)
+ }
+}
</file context>
There was a problem hiding this comment.
The dialog is self-contained: it stays usable and closable (scrim, Escape, DrawerClose) after its trigger leaves the DOM, which is the contract Sheet has as well, and the reference is dropped on the dialog's close event, so nothing stale is retained. Closing the drawer whenever its trigger is re-rendered (a Turbo Stream replacing the list it lives in, say) would be surprising, so the wrapper stays without a disconnect(). On the content side disconnect() now tears the drawer down (33ae617).
| # The bar is 6px tall; ::after stretches the grab strip across the panel and down over the header. | ||
| def default_attrs | ||
| { | ||
| class: "relative z-10 mx-auto mt-3 h-1.5 w-12 shrink-0 cursor-grab touch-none rounded-full bg-muted-foreground/40 active:cursor-grabbing after:absolute after:-inset-x-[50vw] after:-top-3 after:-bottom-6 after:content-['']", |
There was a problem hiding this comment.
P2: The swipe handle's ::after grab strip (after:-bottom-6, full viewport width, z-10) overlays the top of DrawerHeader, intercepting pointer events over it. Dragging in that band works as intended, but any interactive element placed near the top of the header (or the title/description band) becomes unclickable. Keep the grab area, but avoid silently eating clicks over the header, e.g. make the strip pointer-events-none and rely on the 6px bar + explicit handle hit area for drag, or document the overlay as intentional.
Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At gem/lib/ruby_ui/drawer/drawer_swipe_handle.rb, line 21:
<comment>The swipe handle's `::after` grab strip (`after:-bottom-6`, full viewport width, `z-10`) overlays the top of DrawerHeader, intercepting pointer events over it. Dragging in that band works as intended, but any interactive element placed near the top of the header (or the title/description band) becomes unclickable. Keep the grab area, but avoid silently eating clicks over the header, e.g. make the strip `pointer-events-none` and rely on the 6px bar + explicit handle hit area for drag, or document the overlay as intentional.</comment>
<file context>
@@ -0,0 +1,27 @@
+ # The bar is 6px tall; ::after stretches the grab strip across the panel and down over the header.
+ def default_attrs
+ {
+ class: "relative z-10 mx-auto mt-3 h-1.5 w-12 shrink-0 cursor-grab touch-none rounded-full bg-muted-foreground/40 active:cursor-grabbing after:absolute after:-inset-x-[50vw] after:-top-3 after:-bottom-6 after:content-['']",
+ aria: {hidden: "true"},
+ data: {action: DRAG_ACTIONS}
</file context>
| class: "relative z-10 mx-auto mt-3 h-1.5 w-12 shrink-0 cursor-grab touch-none rounded-full bg-muted-foreground/40 active:cursor-grabbing after:absolute after:-inset-x-[50vw] after:-top-3 after:-bottom-6 after:content-['']", | |
| class: "relative z-10 mx-auto mt-3 h-1.5 w-12 shrink-0 cursor-grab touch-none rounded-full bg-muted-foreground/40 active:cursor-grabbing after:absolute after:-inset-x-[50vw] after:-top-3 after:-bottom-6 after:pointer-events-none after:content-['']", |
There was a problem hiding this comment.
Intentional, and noted in the component comment: the strip is the drag surface, and on touch a swipe is what dismisses the sheet, so the grab area has to reach into the header band (shadcn's Drawer drags from anywhere on the content). The header carries the title and description; controls belong below it or in the footer. pointer-events-none on the strip would shrink the hit area to the 6px bar.
- 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.
1 issue found across 6 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="gem/lib/ruby_ui/drawer/drawer_docs.rb">
<violation number="1" location="gem/lib/ruby_ui/drawer/drawer_docs.rb:130">
P2: The snap/opened/closed events are dispatched by the Stimulus controller on its element, the <dialog> (drawer_content.rb sets `data-controller` on the dialog; the controller calls `this.dispatch("snap")` with the default target `this.element`). The example puts the `data-action` on `DrawerContent`'s attributes, which render onto the panel <div> — a descendant of the <dialog>. The event bubbles up from the dialog, so a listener on the panel is never reached and `player#trackSnap` never fires. The action must sit on the dialog or on an ancestor of it in the propagation path.</violation>
</file>
Tip: Review your code locally with the cubic CLI to iterate faster.
Re-trigger cubic
| Codeblock(<<~RUBY, syntax: :ruby) | ||
| DrawerContent( | ||
| class: "data-expanded:rounded-none", | ||
| data: {action: "ruby-ui--drawer-content:snap->player#trackSnap"} |
There was a problem hiding this comment.
P2: The snap/opened/closed events are dispatched by the Stimulus controller on its element, the (drawer_content.rb sets data-controller on the dialog; the controller calls this.dispatch("snap") with the default target this.element). The example puts the data-action on DrawerContent's attributes, which render onto the panel
player#trackSnap never fires. The action must sit on the dialog or on an ancestor of it in the propagation path.
Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At gem/lib/ruby_ui/drawer/drawer_docs.rb, line 130:
<comment>The snap/opened/closed events are dispatched by the Stimulus controller on its element, the <dialog> (drawer_content.rb sets `data-controller` on the dialog; the controller calls `this.dispatch("snap")` with the default target `this.element`). The example puts the `data-action` on `DrawerContent`'s attributes, which render onto the panel <div> — a descendant of the <dialog>. The event bubbles up from the dialog, so a listener on the panel is never reached and `player#trackSnap` never fires. The action must sit on the dialog or on an ancestor of it in the propagation path.</comment>
<file context>
@@ -109,17 +109,31 @@ def view_template
+ Codeblock(<<~RUBY, syntax: :ruby)
+ DrawerContent(
+ class: "data-expanded:rounded-none",
+ data: {action: "ruby-ui--drawer-content:snap->player#trackSnap"}
+ ) do
+ # ...
</file context>
There was a problem hiding this comment.
Valid, and the problem is wider than the example: once open, the dialog is appended to <body>, so a data-action inside it cannot reach a controller on the page at all, and the panel is a descendant of the dispatching <dialog>, so a listener there never fires. The ruby-ui--drawer wrapper now relays the content's opened, snap and closed events as ruby-ui--drawer:* on its own element, which sits in the page where the user's controllers live, and the docs example listens on Drawer. Verified in headless Chrome with a player controller wrapping Drawer: opened, snap (detail {index: 1, offset: 736}) and closed all reach it, across a close and reopen, while a listener on the panel never fires. 48cd152
…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.