feat(slide_over): bottom-sheet drawer mode for origin="bottom" - #637
Conversation
origin="bottom" already existed but slid a plain full-width panel up with the same chrome as a side sheet. It is now a proper mobile drawer. - rounded top corners on the large-panel radius rule (token x 1.2, capped at 20px), top corners only since the bottom two sit off-screen - grab-handle pill, aria-hidden because it is decorative - the drag is the interaction, not the element - env(safe-area-inset-bottom) padding on the body and footer - max-height 92dvh with the body scrolling inside it - drag-to-dismiss: distance threshold (25% of sheet height) OR release velocity, with rubber-band resistance above the top snap - optional snap_points as viewport-height fractions, opening at initial_snap; a flick skips to the next point in its direction and only a downward release below the lowest point dismisses - opt-in scale_background for the vaul-style page shrink Five new attrs on the existing slide_over/1: handle, drag_to_dismiss, snap_points, initial_snap, scale_background. handle defaults to nil, which resolves to on for bottom sheets and off elsewhere, so a bare <.slide_over origin="bottom"> gets the pill with no configuration. Dismissal always routes through hide_slide_over/3 via a data-pc-drawer-hide attribute the hook execJS's, so the server sees one "close_slide_over" event with the same close_slide_over_target whether the user drags, taps away, presses escape or clicks the close button. The PetalDrawer hook is the one thing CSS and LiveView.JS genuinely cannot do - following a finger and deciding from distance and velocity whether the sheet springs back or closes. Its physics is three exported pure functions (drawerResistance, drawerVelocity, drawerSettle) unit-tested without a DOM. The hook attaches only to bottom sheets that are actually draggable, snapped or scaling their background; a plain bottom sheet stays CSS and LiveView.JS only. Accessibility is unchanged: the drag is a pointer-only enhancement over the existing dialog. role="dialog", aria-modal, focus_first on open, escape and click-away all behave exactly as before. Snap changes are not announced, and prefers-reduced-motion settles instantly instead of springing. Backward compatibility: no existing attr default changed, no existing test modified (the diff on test/petal/slide_over_test.exs is additions only), and left/right/top sheets emit byte-identical markup - no handle, no hook, no data attributes. Elixir 913 -> 930 tests, 0 failures. Vitest 157 -> 191. Credo: one entry fewer than main (SlideOver gained a moduledoc), zero new. Closes #614 Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Codecov Report❌ Patch coverage is
Additional details and impacted files@@ Coverage Diff @@
## main #637 +/- ##
==========================================
+ Coverage 92.40% 92.49% +0.09%
==========================================
Files 119 119
Lines 5066 5128 +62
==========================================
+ Hits 4681 4743 +62
Misses 385 385 ☔ View full report in Codecov by Harness. 🚀 New features to boost your workflow:
|
ScreenshotsBottom sheet open — desktop and mobile (390×844) Side sheet unchanged — the backward-compatibility proof Page Verified independently
Backward compatibility — checked directly
Note the new attrs use The gap worth knowing before merge: an end-to-end pointer drag was never exercised. Images live on the |
… touch claim
Audit round. (1) The hook's `updated()` compared
`el.dataset.snapPoints + el.dataset.initialSnap` against `this.configKey`,
but readConfig() derives configKey from that same, already-patched dataset -
so the two strings were equal by construction and `reset()` never ran. The
playground's own snaps dial showed it: flip snaps from off to [0.4, 0.9],
reopen, and the drawer opened full-height instead of at the 0.4 peek.
`previous` is now the configKey from before the re-read. Specs pin both
directions: a config change re-seats, a re-render mid-drag still does not.
(2) A browser-cancelled gesture was settled with full release physics.
pointercancel shared onPointerUp, so a touch the browser reclaimed as a
scroll arrived carrying whatever velocity the last samples held - and the
drawer closed on an input the user never completed. pointercancel now has
its own handler that always springs back to the nearest snap with zero
velocity and dismissible off.
(3) preventDefault inside a pointermove handler does nothing to touch
scrolling; only a non-passive touchmove can decline the gesture. Added one,
on the sheet rather than the window (a touch sequence keeps targeting the
element it began on, so the sheet sees every move of its own gesture while
only this sheet pays the scroll-blocking cost). It weighs direction before
claiming: pulling down, or any move once the sheet is off its top rest, is a
drag; a swipe up from a body at scroll-top is the user reading the content
and is left to the scroller. Deliberately NOT `touch-action: none` on the
box - on the sheet that value is also consulted for the body's own scroll
chain in Chrome, which would cost the drawer its inner scrolling. The body
gains `overscroll-behavior: contain` so a scroll cannot chain to the page
and trigger pull-to-refresh mid-drag.
(4) `isOpen()` read inline display only and returned false for "". JS.show
skips writing an inline display when the element is already visible, which
is exactly what happens to a slide_over rendered without `hide` - so
scale_background never scaled the wrapper until the first close/open cycle.
It now falls back to measuring, the way LiveView's own isVisible does.
(5) The MutationObserver watches [style] and a drag rewrites transform every
pointermove, so syncBackground() was toggling the wrapper's class once per
frame. The last state is cached and unchanged states early-return. The
observer is also created unconditionally now, and readConfig() swaps the
wrapper (clearing the class off the old one), so turning scale_background on
or off in a later render works without a remount - it previously did
nothing, since the observer only existed if the wrapper was present at mount.
(6) Snap offsets were computed from window.innerHeight while the sheet's
height is dvh-based, so the peek drifted from the CSS height whenever a
mobile URL bar collapsed. They now derive from the sheet's own measured
height, with the viewport as the fallback for the not-yet-displayed case.
(7) `cursor: grab` sat on every handle, including the hookless
drag_to_dismiss={false} sheet the tests pin - advertising a drag that does
nothing. Scoped, with touch-action, to the data attributes that are rendered
exactly when the hook is attached.
(8) `Enum.max(snaps) * 100` emitted `height: 90.0dvh`. Whole numbers now
print whole, fractions keep their decimals rounded off float noise, and the
ExUnit assertion is tightened from `=~ "dvh"` to the exact value.
931 Elixir tests, 204 JS specs, credo unchanged from main.
…pen re-render
Open a snapped drawer with handle={false}, flip the handle on, then grab it:
the sheet snapped straight down to the 0.4 point instead of following the
finger.
The drag offset lives in the sheet's inline transform, and inline styles are
not the hook's to keep. A patch syncs the style attribute back to what the
server rendered - `height: Ndvh` and nothing else - and the only inline styles
LiveView carries across are its own sticky ones, which is display and not
transform. So every re-render of an open drawer silently dropped the offset
out of the DOM. `updated()` only re-seated on a snap-config change, and
toggling a handle does not change the config key, so the hook kept an offset
of 450px for a sheet the browser was now painting at 0. The first pointermove
re-applied that offset and the sheet leapt down 450px in one frame, then
settled on the snap it appeared to have jumped to.
Confirmed in the playground before the fix: hook.offset 450 with an empty
inline transform, a 10px pull moving the sheet 460px.
Two changes, both in the hook:
- `updated()` re-applies the offset (and the drag's `transition: none`) after
any patch it does not re-seat on, so the sheet stays where it was through a
re-render, whether it is resting, mid-settle or under a finger.
- `onPointerDown` baselines the drag on the offset the sheet is *rendered* at
rather than on what the hook last wrote. The two only disagree when
something moved the sheet behind the hook's back, and in that case the DOM
is the truth - a drag has to start where the finger is.
Same bug, same fix, for the closed case: a dial flip between two opens dropped
the transform too, so the drawer opened full-height instead of at its initial
snap.
Four specs, each failing before this: an offset restored across a re-render, a
drag baselined on the DOM after a patch the hook never saw, the handle-appears
repro end to end, and a patch landing mid-gesture.
|
Eye-test bug, root-caused and fixed in the new commit — maintainer repro: handle off → open → handle on → drag handle → drawer snaps down. Root cause is a LiveView mechanic, not drawer logic: the drag offset lives in the sheet's inline Fix, hook-only: CDP-verified (420×900): the exact repro tracks 1:1 with the finger from the correct position; both snaps up/down, drag-to-dismiss, a patch landing mid-gesture, unsnapped mode, and scale_background all still behave. Gates: |
…m-sheet # Conflicts: # CHANGELOG.md # assets/default.css # assets/js/petal_components.js # dev.exs
Greptile SummaryThe PR turns bottom-origin slide-overs into draggable mobile drawers with snap points, safe-area styling, and optional background scaling.
Confidence Score: 3/5The PR should not merge until snap-point inputs are constrained and upward content scrolling no longer moves the drawer concurrently. Invalid public snap configurations can generate unusable drawer geometry, and the touch gesture handlers currently apply sheet transforms during native upward content scrolling. Files Needing Attention: lib/petal_components/slide_over.ex, assets/js/petal_components.js
|
| Filename | Overview |
|---|---|
| lib/petal_components/slide_over.ex | Adds the drawer API and emitted hook configuration, but snap-point normalization does not enforce the numeric domain required by the browser calculations. |
| assets/js/petal_components.js | Implements drag, snap, dismissal, lifecycle restoration, and background scaling; upward content scrolling can concurrently transform the sheet. |
| assets/default.css | Adds bottom-drawer geometry, scrolling, safe-area, handle, and background-scale styles aligned with the new markup. |
| test/js/drawer.test.js | Provides broad unit coverage of physics and hook behavior but does not combine real touch scrolling with pointer-driven transforms. |
| test/petal/slide_over_test.exs | Covers drawer-only markup, configuration, hook attachment, ordering, and existing overlay semantics, but omits invalid snap-point domains. |
Sequence Diagram
sequenceDiagram
participant User
participant Drawer as PetalDrawer hook
participant DOM as Slide-over DOM
participant LV as LiveView
User->>Drawer: pointerdown / pointermove
Drawer->>DOM: apply drag transform
alt release settles
Drawer->>DOM: animate to snap point
else release dismisses
Drawer->>LV: execJS(data-pc-drawer-hide)
LV->>DOM: run shared hide_slide_over path
end
Reviews (1): Last reviewed commit: "Merge remote-tracking branch 'origin/mai..." | Re-trigger Greptile
| defp normalize_snap_points(points) when is_list(points) and points != [] do | ||
| points | ||
| |> Enum.filter(&is_number/1) | ||
| |> Enum.sort() | ||
| |> case do | ||
| [] -> nil | ||
| sorted -> sorted | ||
| end | ||
| end | ||
|
|
||
| defp normalize_snap_points(_points), do: nil |
There was a problem hiding this comment.
When snap_points contains zero, a negative number, or a value greater than one, normalization accepts it and the hook uses the maximum as both the CSS height and an offset divisor, causing zero-height, inverted, oversized, or invalid drawer geometry.
Knowledge Base Used:
| const raw = this.startOffset + (e.clientY - this.startY); | ||
| this.offset = drawerResistance(raw, Math.min(...this.snapOffsets())); | ||
| this.apply(this.offset); |
There was a problem hiding this comment.
Content scroll also moves drawer
When a user swipes upward from the content scroller at scrollTop === 0, onPointerMove applies a negative drawer transform while onTouchMove allows native scrolling, causing the sheet and its contents to move concurrently before the sheet springs back on pointer cancellation.
Knowledge Base Used:






Closes #614
Summary
origin="bottom"already existed, but it slid a plain full-width panel up with the same chrome as a side sheet. This turns it into a proper mobile drawer.min(calc(var(--pc-radius) * 1.2), 20px)), top corners only since the bottom two are off-screenaria-hiddenbecause it is decorativeenv(safe-area-inset-bottom)padding on the body and footermax-height: 92dvhwith the body scrolling inside itsnap_pointsas viewport-height fractions, opening atinitial_snap. A flick skips to the next point in its direction; only a downward release below the lowest point dismissesscale_backgroundfor the vaul-style page shrinkFive new attrs, all on the existing
slide_over/1:handle,drag_to_dismiss,snap_points,initial_snap,scale_background.handledefaults tonil, which resolves to on for bottom sheets and off elsewhere, so a bare<.slide_over origin="bottom">gets the pill with no configuration.Dismissal always routes through
hide_slide_over/3via adata-pc-drawer-hideattribute the hookexecJS's, so the server sees one"close_slide_over"event with the sameclose_slide_over_targetwhether the user drags, taps away, presses escape or clicks the close button.Accessibility is unchanged. The drag is a pointer-only enhancement layered over the existing dialog:
role="dialog",aria-modal,focus_firston open, escape and click-away all behave exactly as before. No new keyboard bindings, no ARIA on the drag mechanics, snap changes not announced,prefers-reduced-motionsettles instantly instead of springing.Backward compatibility
No existing attr default changed and no existing test was modified. The diff on
test/petal/slide_over_test.exsis additions only —git diffshows zero deleted lines in that file, which is the compatibility proof. All 913 pre-existing tests pass untouched.Left, right and top sheets emit byte-identical markup: no handle, no hook, no data attributes, no new classes. A test asserts this explicitly for all three origins even when every drawer attr is passed. Verified visually too — the side sheet renders exactly as it did on
main.The only changed lines outside additions are three, all additive in effect:
class={get_classes(...)}becameclass={[get_classes(...), @drawer? && "pc-slideover__box--drawer"]}— the modifier isfalse(dropped) for every non-bottom origindata-pc-drawer-wrapper(for thescale_backgrounddemo)assets/default.cssis appended to in a fresh@layer components { }block; nothing existing was reflowed.Did you ship a hook, and why
Yes —
PetalDrawer. Pointer-driven drag physics is the one thing CSS andPhoenix.LiveView.JSgenuinely cannot do: following a finger 1:1 and then deciding, from distance and release velocity, whether the sheet springs back or closes. The specific failure without it is that there is no way to express "translate follows pointerY, and on pointerup dismiss if travelled > 25% of height OR |velocity| > 0.5px/ms" declaratively.Kept minimal:
drawerResistance,drawerVelocity,drawerSettle— unit-tested with no DOM.data-*attributes.drag_to_dismiss={false}bottom sheet stays CSS +LiveView.JSonly, and side origins never get it. There is a test for each of those.assets/js/petal_components.jslike every other one.Deviations from the issue's sketch
Handle hit target. The issue asks for a min 44px tall touch area on the pill. A 44px block above the header pushed the title down noticeably, and faking it with a
::beforeoverlay would have covered the close button. Instead the handle is ~28px visually and the hook captures pointerdown across the whole sheet head, not just the pill — so the real drag target is the full sheet width by everything above the scrolled content, far larger than 44px. Happy to change this if you'd rather have the literal 44px pill.initial_snapvalidation. The issue says it "must be a member ofsnap_points". Rather than raise, a value outside the list falls back to the lowest point — aninitial_snapyou can never drag back to would be a worse failure mode than a silent correction. There is a test for it. Say the word if you'd prefer it to raise.snap_pointsnormalisation. They are sorted ascending on render regardless of the order passed, so[0.9, 0.4]and[0.4, 0.9]behave identically.scale_backgroundneeds the hook. It is not pointer-driven, but it has to follow the panel opening and closing, and the open/close is driven by JS commands that write inlinedisplayon the content element. The hook watches that with aMutationObserverand toggles.pc-drawer-scaledon[data-pc-drawer-wrapper]. I could not find a CSS-only way to reach a sibling page wrapper from the sheet's open state; if there's a house pattern for this I've missed, I'll switch to it.Overlay does not fade with drag position — matching the issue's "keep it simple" instruction.
Tests
mix testnpm testaria-hiddenhandle, drawer-only box classes, hook attachment and non-attachment, the emitteddata-*config, snap sorting andinitial_snapfallback,close_slide_over_targetin the drag command, and that dialog semantics are untouched in drawer mode.test/js/drawer.test.js: the threshold and velocity maths, nearest-snap resolution with and without velocity bias, rubber-band resistance, plus hook-level specs for initial snap offset, spring-back, dismiss routing through the shared hide command, ignoring taps on controls, the scroll-top capture rule,scale_backgroundtoggling, and reduced-motion settling.mix format --check-formatted,mix compile --force --warnings-as-errorsclean. Credo: one entry fewer thanmain, zero new —SlideOverpicked up a moduledoc, which closed the existingModules should have a @moduledoc tagfinding.Verified by hand in the playground
focus_firststill lands inside the panel and the handle does not steal focus.translate3d(0px, 422px, 0px)on a90dvhsheet in an 844px viewport, which is exactly theinitial_snap={0.4}peek.What I could not verify: an end-to-end pointer drag in the browser. The automation tool's coordinate resolution mishandles
position: fixedelements and itsevalwas unavailable, so every scripted drag landed on the page header instead of the sheet. The drag path is covered at the unit level (including dismiss routing throughdata-pc-drawer-hidewith a stubbedliveSocket), but it has not been exercised on a real touch device or with real pointer events — worth a manual pass on a phone before merge.Screenshots
Light and dark playground pages, the drawer open at desktop and phone width, and the snapped queue drawer peeking at 0.4 — attached separately.