Skip to content

feat(slide_over): bottom-sheet drawer mode for origin="bottom" - #637

Merged
nhobes merged 4 commits into
mainfrom
feat/slide-over-bottom-sheet
Aug 24, 2026
Merged

nhobes merged 4 commits into
mainfrom
feat/slide-over-bottom-sheet

Conversation

@mplatts

@mplatts mplatts commented Aug 12, 2026

Copy link
Copy Markdown
Member

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.

  • Rounded top corners on the large-panel radius rule (min(calc(var(--pc-radius) * 1.2), 20px)), top corners only since the bottom two are off-screen
  • Grab-handle pill, aria-hidden because it is decorative
  • 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; only a downward release below the lowest point dismisses
  • Opt-in scale_background for the vaul-style page shrink

Five new attrs, all 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.

Accessibility is unchanged. The drag is a pointer-only enhancement layered over the existing dialog: role="dialog", aria-modal, focus_first on 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-motion settles 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.exs is additions only — git diff shows 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(...)} became class={[get_classes(...), @drawer? && "pc-slideover__box--drawer"]} — the modifier is false (dropped) for every non-bottom origin
  • the playground page root gained data-pc-drawer-wrapper (for the scale_background demo)
  • the playground's showcase list gained the three new example ids

assets/default.css is 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 and Phoenix.LiveView.JS genuinely 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:

  • The physics is three exported pure functions — drawerResistance, drawerVelocity, drawerSettle — unit-tested with no DOM.
  • The Elixir side renders configuration only, as data-* attributes.
  • The hook attaches only when a bottom sheet is actually draggable, snapped or scaling its background. A plain drag_to_dismiss={false} bottom sheet stays CSS + LiveView.JS only, and side origins never get it. There is a test for each of those.
  • No new dependencies, npm or hex. The hook is hand-rolled in assets/js/petal_components.js like every other one.

Deviations from the issue's sketch

  1. 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 ::before overlay 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.

  2. initial_snap validation. The issue says it "must be a member of snap_points". Rather than raise, a value outside the list falls back to the lowest point — an initial_snap you 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.

  3. snap_points normalisation. They are sorted ascending on render regardless of the order passed, so [0.9, 0.4] and [0.4, 0.9] behave identically.

  4. scale_background needs 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 inline display on the content element. The hook watches that with a MutationObserver and toggles .pc-drawer-scaled on [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.

  5. Overlay does not fade with drag position — matching the issue's "keep it simple" instruction.

Tests

Suite Before After
mix test 913, 0 failures, 1 skipped 930, 0 failures, 1 skipped
npm test 157 191
  • 17 new ExUnit tests covering handle resolution per origin, the aria-hidden handle, drawer-only box classes, hook attachment and non-attachment, the emitted data-* config, snap sorting and initial_snap fallback, close_slide_over_target in the drag command, and that dialog semantics are untouched in drawer mode.
  • 34 new vitest specs in 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_background toggling, and reduced-motion settling.

mix format --check-formatted, mix compile --force --warnings-as-errors clean. Credo: one entry fewer than main, zero new — SlideOver picked up a moduledoc, which closed the existing Modules should have a @moduledoc tag finding.

Verified by hand in the playground

  • Escape closes the drawer.
  • Focus management: pressing Enter immediately after opening activates the close button, i.e. focus_first still lands inside the panel and the handle does not steal focus.
  • The hook runs: the snapped queue drawer opens at translate3d(0px, 422px, 0px) on a 90dvh sheet in an 844px viewport, which is exactly the initial_snap={0.4} peek.
  • Side sheets visibly unchanged.

What I could not verify: an end-to-end pointer drag in the browser. The automation tool's coordinate resolution mishandles position: fixed elements and its eval was 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 through data-pc-drawer-hide with a stubbed liveSocket), 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.

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

codecov Bot commented Aug 12, 2026 •

Copy link
Copy Markdown

Codecov Report

❌ Patch coverage is 98.41270% with 1 line in your changes missing coverage. Please review.
✅ Project coverage is 92.49%. Comparing base (871b2cf) to head (115f781).
⚠️ Report is 6 commits behind head on main.

Files with missing lines Patch % Lines
lib/petal_components/slide_over.ex 96.77% 1 Missing ⚠️
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.
📢 Have feedback on the report? Share it here.

🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.
  • 📦 JS Bundle Analysis: Save yourself from yourself by tracking and limiting bundle sizes in JS merges.

mplatts added a commit that referenced this pull request Aug 12, 2026
@mplatts

mplatts commented Aug 12, 2026

Copy link
Copy Markdown
Member Author

Screenshots

Bottom sheet open — desktop and mobile (390×844)

Sheet open
Sheet on mobile
Snapped sheet on mobile

Side sheet unchanged — the backward-compatibility proof

Side sheet unchanged

Page

Slide over, light
Slide over, dark

Verified independently

Check Result
mix test 930 tests, 0 failures, 1 skipped (+17)
npm test 191 passing (+34)
mix format / compile -Werror clean
mix credo one entry fewer, zero new (SlideOver gained a moduledoc)
new dependencies none
new nav slug none — correct, this extends the existing slide-over page

Backward compatibility — checked directly

git diff on test/petal/slide_over_test.exs shows zero deletions. Every pre-existing test passes unmodified, which is the proof that matters for a component already in real apps. No existing attr default changed, and the drawer class modifier evaluates to false and drops out entirely for left/right/top origins.

Note the new attrs use attr(...) with parens, against the house bare style — that's deliberate and right here: slide_over.ex already had 10 parenthesised attrs, so matching the file beats matching the global convention and leaving one module internally split.

The gap worth knowing before merge: an end-to-end pointer drag was never exercised. agent-browser's coordinate resolution mishandles position: fixed elements and eval is blocked in this sandbox, so every scripted drag landed on the page header instead. The physics is unit-tested as three pure exported functions (34 specs, including dismiss routing with a stubbed liveSocket), and the hook demonstrably runs — the snapped drawer opens at exactly translate3d(0px, 422px, 0px) on a 90dvh sheet in an 844px viewport, which is the initial_snap={0.4} peek. But drag-to-dismiss deserves a real phone pass before this leaves draft.

Images live on the pr-assets branch, which exists only to host PR screenshots - it never merges to main and ships in no Hex release.

nhobes added 2 commits August 13, 2026 11:51
… 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.
@nhobes

nhobes commented Aug 15, 2026

Copy link
Copy Markdown
Contributor

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 transform, but the sheet also renders a server-side style="height: Ndvh". Every patch syncs the style attribute back to the server's version, and the only inline style LiveView carries across is its own sticky display — not transform. So ANY re-render of an open drawer silently dropped the offset from the DOM: the sheet painted at 0 while the hook still believed offset=450, and the first pointermove re-applied 450px in one frame. updated() only re-seated when the snap config changed, and a handle toggle doesn't change it. (Same mechanism made a dial flip while closed open the drawer at full height instead of its initial snap.)

Fix, hook-only: updated() now restores the offset (re-asserting transition: none mid-drag) on any patch it doesn't re-seat on; onPointerDown baselines the drag on the offset the sheet is rendered at (parsed from the inline transform) rather than what the hook last wrote — when the two disagree, the DOM is the truth. Any consumer re-render mid-open is now safe; no Elixir/CSS changes.

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: mix test 931/0, vitest 208 (4 new specs, all 4 fail with the hook change stashed), format clean.

…m-sheet

# Conflicts:
#	CHANGELOG.md
#	assets/default.css
#	assets/js/petal_components.js
#	dev.exs
@nhobes
nhobes marked this pull request as ready for review August 24, 2026 03:30
@nhobes
nhobes merged commit 2d37a8e into main Aug 24, 2026
3 checks passed
@greptile-apps

greptile-apps Bot commented Aug 24, 2026

Copy link
Copy Markdown

Greptile Summary

The PR turns bottom-origin slide-overs into draggable mobile drawers with snap points, safe-area styling, and optional background scaling.

  • Adds the PetalDrawer LiveView hook and drawer physics.
  • Extends slide_over/1 with handle, dismissal, snap-point, initial-snap, and background-scaling options.
  • Adds drawer-specific CSS, showcase examples, documentation, and unit/component coverage.

Confidence Score: 3/5

The 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

Important Files Changed

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
Loading

Reviews (1): Last reviewed commit: "Merge remote-tracking branch 'origin/mai..." | Re-trigger Greptile

Comment on lines +396 to +406
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

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P1 Invalid snap-point geometry

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:

Comment on lines +6376 to +6378
const raw = this.startOffset + (e.clientY - this.startY);
this.offset = drawerResistance(raw, Math.min(...this.snapOffsets()));
this.apply(this.offset);

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

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

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.

Enhancement: SlideOver — bottom-sheet drawer mode (origin="bottom" upgrade)

2 participants