Skip to content

[Feature] Drawer: bottom sheet with swipe handle, snap points and swipe-to-dismiss - #519

Open
tvq wants to merge 6 commits into
ruby-ui:mainfrom
tvq:add_drawer
Open

[Feature] Drawer: bottom sheet with swipe handle, snap points and swipe-to-dismiss#519
tvq wants to merge 6 commits into
ruby-ui:mainfrom
tvq:add_drawer

Conversation

@tvq

@tvq tvq commented Aug 29, 2026

Copy link
Copy Markdown
Contributor

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, DrawerClose and DrawerSwipeHandle. DrawerContent takes snap_points:, initial:, modal:, dismissible:, handle: and initial_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() for modal: false, so the focus trap, aria-modal and 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 and scrollTop 0 is the dismissed position. The Stimulus controller animates scrollTop on 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 1 is a fraction of the viewport, above 1 is pixels, and a string is any CSS length, so snap_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-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 data-expanded:rounded-none and friends work. The controller fires opened, snap and closed events, standing in for onOpenChangeComplete and onSnapPointChange. initial_focus: false makes 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 installed tailwind.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, modal and showSwipeHandle on the root Drawer, 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 on DrawerContent here.

Before After
n/a (new component) recording below

Testing instructions

  1. cd gem && bundle exec rake (tests + StandardRB).
  2. cd docs && bin/dev, open /docs/drawer.
  3. Open the first example. Drag the handle up (rests at 92%), a little down (back to 60%), then past the middle or with a quick flick (closes; focus returns to the trigger). Click the scrim or press Escape: animated close.
  4. "Snap points": a strong flick up from 35% skips 70% and lands on 100%. "Non-modal": no scrim, the page stays usable. "Non-dismissible": scrim, Escape and swiping are ignored; DrawerClose works. "Custom swipe handle": the drawer opens 24rem tall, from a snap point given in rem.
  5. On a phone: focus an input inside the drawer. The sheet lifts above the keyboard and returns to its snap point when the keyboard closes.

…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>
@tvq
tvq requested a review from cirdes as a code owner August 29, 2026 06:13

@cubic-dev-ai cubic-dev-ai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

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

Comment thread gem/lib/ruby_ui/drawer/drawer_content.rb Outdated
Comment thread gem/lib/ruby_ui/drawer/drawer_content_controller.js Outdated
Comment thread gem/lib/ruby_ui/drawer/drawer_content_controller.js Outdated
Comment thread gem/lib/ruby_ui/drawer/drawer_content_controller.js

this.dialog = this.contentTarget.content.firstElementChild.cloneNode(true)
this.dialog.addEventListener("close", () => { this.dialog = null }, { once: true })
document.body.append(this.dialog)

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P2: 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>

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

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).

Comment thread mcp/data/registry.json
# 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-['']",

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P2: 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>
Suggested change
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-['']",

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

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.

Comment thread gem/lib/ruby_ui/drawer/drawer_controller.js Outdated
Comment thread mcp/data/registry.json Outdated
Comment thread mcp/data/registry.json Outdated
tvq and others added 4 commits August 29, 2026 11:32
- 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>

@cubic-dev-ai cubic-dev-ai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

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

Comment thread gem/lib/ruby_ui/drawer/drawer_docs.rb Outdated
Codeblock(<<~RUBY, syntax: :ruby)
DrawerContent(
class: "data-expanded:rounded-none",
data: {action: "ruby-ui--drawer-content:snap->player#trackSnap"}

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P2: 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

— a descendant of the . 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.

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>

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

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

Comment thread gem/lib/ruby_ui/drawer/drawer_content_controller.js
@cirdes
cirdes requested review from djalmaaraujo and removed request for cirdes August 31, 2026 11:54
…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>

@cubic-dev-ai cubic-dev-ai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

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

Comment thread mcp/data/registry.json
},
{
"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"

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P2: 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>
Suggested change
"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
}

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant