Add swipe-to-dismiss gesture support for Drawer, Sheet, Sidebar#124
Draft
developit wants to merge 21 commits into
Draft
Add swipe-to-dismiss gesture support for Drawer, Sheet, Sidebar#124developit wants to merge 21 commits into
developit wants to merge 21 commits into
Conversation
Make the overlays gesture-driven on touch with almost everything in CSS. On `(hover: none) and (pointer: coarse)` each `<dialog>` becomes a native scroll-snap container: the content panel rests against one edge next to an empty "rail" you can fling it away into, so the browser owns the drag, momentum and snap-back. Desktop is untouched (the existing transform transition) — the mode is selected purely in CSS via a `--swipe` flag, so there's no branching in component code. The entire JS control plane is one shared `installSwipe()` helper (~15 lines): it glides the panel in on open (`beforetoggle`) and closes the dialog once a dismiss gesture settles off-screen (`scrollend`). Net JS cost for the feature is ~0.4KB gzipped across all three components, versus the multi-KB gesture engines JS-driven drawer libraries ship. - Drawer/Sheet/Sidebar render a `rail` + `panel` wrap; on desktop the panel is `display: contents` and the rail collapses, so layout is identical to before. - Add a shared `_swipe.css` partial parameterising axis/edge per component. - Convert Sidebar from `createSimpleComponent` to a plain component so it can carry the scaffolding; drop dead commented code along the way. - Rail taps dismiss via the existing dialog backdrop handler. - Tests: SSR smoke coverage for the new structure + a mocked unit test pinning the open/dismiss control plane. Docs + changeset included. https://claude.ai/code/session_01HVhWBjQBAoNHwgfubbsADb
✅ Deploy Preview for kinu-sh ready!
To edit notification comments on pull requests, go to your Netlify project configuration. |
Contributor
|
Size Change: +3.44 kB (+9.12%) 🔍 Total Size: 41.1 kB 📦 View Changed
ℹ️ View Unchanged
|
Completely rework the swipe implementation to use zero wrapper elements. On touch devices the <dialog> itself becomes a CSS scroll-snap container: a ::before pseudo acts as the transparent dismiss rail and background-attachment:local paints the panel surface behind the content. No rail divs, no panel wrappers — "zero wrapper divs" stays true. This approach covers: - Drawer (k="drawer-content") - Popover with mobile="drawer" - DropdownMenu with mobile="drawer" - ContextMenu with mobile="drawer" Sheet and Sidebar are reverted to their original transform-based transitions (horizontal scroll-snap can't be done markup-free, and the sidebar had an overlay-blocking bug when closed). The JS control plane is now ~10 lines: scrollTo on open (beforetoggle), close on dismiss (scrollend at scrollTop 0). Desktop is unchanged — the mode is selected purely in CSS via --swipe: 1. https://claude.ai/code/session_01HVhWBjQBAoNHwgfubbsADb
The swipe scroller styles (display:flex, position:fixed, inset:0) were applied unconditionally, and author display:flex beats the UA dialog:not([open]) display:none by cascade origin. That turned every closed drawer and mobile="drawer" overlay into a full-viewport transparent overlay that blocked scrolling and interaction. Gate all the layout on [open]; keep only the --swipe flag ungated so the beforetoggle handler (which fires before [open] is set) can still detect swipe mode. Verified on touch: closed overlays are display:none, the page stays scrollable/interactive, and open + swipe-to-dismiss still work. https://claude.ai/code/session_01HVhWBjQBAoNHwgfubbsADb
Two fixes for the swipe scroller: 1. Content was crushed to min-content height (~40px slivers). The dialog is a column flex container whose 100dvh rail consumes the whole fixed height, so default flex-shrink:1 children were squeezed to nothing instead of overflowing into scroll range. flex:none on children restores natural heights — drawers now open to their full panel, and the gesture gets a real scroll range (the "dead" swipe feel and missing Android overscroll bounce were side effects of the ~40px range; overscroll-behavior:contain was already computed correctly). 2. Replace the per-child margin hack with horizontal padding on the dialog itself, restoring the original padding contract so consumer overrides like the demo's `padding: 0` full-bleed drawers compose correctly. Horizontal padding doesn't disturb the rail or the painted surface (the background positioning area spans the full width). Vertical breathing room comes from first/last child margins, which stay inside the ::after snap anchor. The grab handle is dropped — it hijacked the consumer's ::first-child::before. Also scope the demo's kh-app-maximized overrides (overflow:hidden, 100dvh-20px height) to non-touch so they don't disable the swipe scroller. https://claude.ai/code/session_01HVhWBjQBAoNHwgfubbsADb
…mation
Three fixes for the swipe drawer polish:
1. Rounded corners + drag handle: The ::before rail is extended 1.5rem past
100dvh (with negative margin to overlap the content top), its bottom
painted with the panel bg color and border-radius. A radial-gradient on
the same pseudo draws a centered handle bar. No consumer pseudos are
touched — everything is on the rail itself.
2. Exit flash: Removing [open] stripped the swipe layout, briefly revealing
the base transform:translateY(100%) before display:none kicked in. Fix:
set transition:none on the non-[open] swipe selector too (safe — it
doesn't affect display or layout, just prevents the exit slide).
3. Double animation on open: The base CSS transform transition
(translateY(100%) → 0) and the JS scrollTo({smooth}) were both firing.
With transition:none on both the base and [open] swipe selectors, the
scroll animation is the only entrance — no more 600ms+ double easing.
Also removed scroll-behavior:smooth from the CSS (the JS scrollTo call
passes behavior:'smooth' explicitly; CSS scroll-behavior was redundant
and could slow snap-settling).
https://claude.ai/code/session_01HVhWBjQBAoNHwgfubbsADb
Two fixes for the latest round of feedback:
1. Entrance animation restored — and faster. scrollTo({behavior:'smooth'}) on
a freshly-displayed scroller was unreliable (and tied the entrance to the
browser's slow built-in scroll easing). Now installSwipe jumps the scroller
to its open position instantly inside the pre-paint rAF, and a one-shot
k-swipe-in animation (translate: 0 100% → none, 300ms var(--k-ease))
slides the whole dialog up. This is @starting-style semantics via
@Keyframes — @starting-style itself can't nest inside @media per our CSS
tooling. Exit stays instant (transition:none), no flash.
2. The rounded cap + handle were painted on the ::before rail, which lives
inside the dialog's content box — so with any horizontal padding the cap
rendered 2rem narrower than the full-bleed surface (the broken "top bar"
on the docs dropdown). All painting now moves to background layers on the
dialog itself: two quarter-disc radial-gradients for the corners, a strip
between them, the handle pill, and the main surface — all
background-attachment:local, positioned in the padding box, so they span
the full width and stay aligned regardless of consumer padding.
https://claude.ai/code/session_01HVhWBjQBAoNHwgfubbsADb
The panel vanished instantly on dismiss but the 300ms backdrop fade-out from the base CSS lingered, making the close feel slow. Set transition:none on the non-[open] ::backdrop in swipe mode — the fade-in still runs (the [open]::backdrop rule from the base styles carries the transition property). https://claude.ai/code/session_01HVhWBjQBAoNHwgfubbsADb
The ::backdrop opacity is now driven by the swipe position itself via a named scroll-timeline on the dialog and a scroll-driven animation on ::backdrop — dragging the panel dims/undims the page in lockstep with the finger, and a dismissed panel leaves no lingering dim (fully fixes the slow-feeling close). A second, time-based instance of the same keyframes covers the 300ms entrance slide: the scroller is already parked at its open position then, so the scroll-driven instance alone would snap to fully dimmed. It's listed last so it wins while it runs, then hands off seamlessly (both end at opacity 1). Gated behind @supports (animation-timeline: scroll()); other browsers keep the existing transition fade-in / instant-out behavior. https://claude.ai/code/session_01HVhWBjQBAoNHwgfubbsADb
Move all swipe layout properties except display:flex to the non-[open] base
selector. A display:none element generates no box, so position/inset/translate
are inert when closed — but they're present when the [open] attribute is
removed, giving the exit transition a stable starting layout to animate from.
The base now carries translate:0 100% (the closed position) and a transition
on translate + display (allow-discrete) + overlay (allow-discrete). The [open]
rule sets translate:none (open position) and display:flex. On ESC or back:
1. [open] removed → translate transitions from none to 0 100% (slides down)
2. display transitions discretely — stays flex for 300ms, then none
3. The backdrop fades out via its own opacity transition (same 300ms)
Swipe dismiss is unaffected: the scroll-driven animation has already brought
the backdrop to 0 and the panel is behind the rail when close() fires, so the
translate transition is imperceptible.
The covering-page bug cannot recur because only display:flex is gated on
[open], and that was the only property whose author value beat the UA
dialog:not([open]) { display: none }.
https://claude.ai/code/session_01HVhWBjQBAoNHwgfubbsADb
The swipe already scrolls the panel off-screen, then close() fires — but the CSS translate transition (meant for ESC/back) would play a second slide-down on top of the already-gone panel. Fix: set translate to the closed position inline before calling close(), so the transition has nowhere to go. Cleared on the next open. https://claude.ai/code/session_01HVhWBjQBAoNHwgfubbsADb
22ec54a to
c022275
Compare
The remaining exit flash on Drawer: the rail/flex structure was [open]-gated, so during any exit transition the rail pseudo vanished and the content jumped to the top of the transparent full-screen box — that collapsed layout is what slid down. Fix: ungate everything except display:flex (boxes don't exist at display:none, so closed state is unaffected). Now ESC/back slides the intact panel down with its scroll position preserved, and the swipe exit shows only the transparent rail. Swipe teardown is made instant with one inline line: transitionDuration='0s' before close(), cleared on reopen. The prettier idea — a scroll-driven animation stepping a --k-exit custom property that feeds transition-duration, continued across the close via animation list position matching — was implemented and frame-tested, but doesn't work: transition parameters come from the after-change style, which (in Chrome) does not carry CSS-animation-derived custom property values. The inline declaration does. Also removes the previous style.translate hack, which frame sampling proved ineffective (both before/after-change values land in the same style update, so the transition fired regardless). https://claude.ai/code/session_01HVhWBjQBAoNHwgfubbsADb
…ex crush
Backdrop click: a swipe overlay spans the whole viewport, so the existing
outside-the-rect backdrop check never fired, and taps on the dimmed rail hit
the dialog itself. The click handler now treats any direct dialog hit above
the panel surface (clientY < clientHeight - scrollTop) as a backdrop click —
and the close is animated for free via the exit transition.
Flex crush: the previous commit dropped [open] from the swipe children
selectors, lowering their specificity below consumer rules like the demo's
.kh-composer-list { flex: 1 } — which the rail then crushed to 8px (the
"60px tall model picker"). flex:none is a geometric invariant inside the rail
scroller (the rail consumes the entire fixed height, so flex-sizing against
the container is meaningless), so assert it with !important, matching the
reduced-motion invariants in base.css. Children still size intrinsically via
height/max-height.
https://claude.ai/code/session_01HVhWBjQBAoNHwgfubbsADb
Extend the swipe-to-dismiss system to the horizontal axis. Sheet (right
panel, swipe left to dismiss) and mobile Sidebar (left panel, swipe right
to dismiss) now use the same scroll-snap pattern as Drawer, but with a CSS
grid layout: two columns (rail + panel) where content children are placed in
the panel column and stack vertically via auto-rows. No wrapper divs.
The --swipe custom property now encodes axis and direction:
y — Drawer: vertical, dismiss at scrollTop 0
x — Sheet: horizontal, dismiss at scrollLeft 0
-x — Sidebar: horizontal, dismiss at scrollLeft max
The JS control plane (installSwipe) is axis-aware: swipeToggle jumps to the
open scroll position per axis, swipeSettle checks the dismiss condition per
axis, and the backdrop-click handler computes rail-vs-panel hit per axis.
Scroll-driven backdrop dimming extends to horizontal via scroll-timeline on
the inline axis. Entrance animations slide from the appropriate edge. Exit
transitions (ESC/back) slide back out with the same 300ms timing.
Key fix: scrollTo({behavior:'instant'}) didn't survive mandatory snap in the
horizontal case (the rail snap pulled it back). Direct property assignment
(el.scrollLeft = max) works because it bypasses the smooth-scroll machinery
that re-evaluates snap points.
Back-gesture conflict mitigation: overscroll-behavior:contain on both axes
prevents the browser's edge-swipe-back from firing during panel swipes.
14 unit tests (12 swipe covering all three axes + 2 SSR).
https://claude.ai/code/session_01HVhWBjQBAoNHwgfubbsADb
Sheet: - Buttons use justify-self:start so they size naturally (matching desktop) - ::after snap target with scroll-snap-align:end snaps at scrollLeft=max (the previous :last-child snap didn't reach max when the child was small) Sidebar: - Backdrop animation direction: use k-swipe-fade with reverse on the scroll-driven layer so opacity=1 at scrollLeft=0 (open) and opacity=0 at scrollLeft=max (closed). Previous k-sidebar-fade + reverse was double- inverted. - Vertical scroll: split the axes — dialog scrolls only horizontally (overflow-x:auto, overflow-y:hidden), content nav scrolls vertically (overflow-y:auto). Prevents the 2D-scroller touch-direction-locking issue. - grid-template-rows:auto 1fr constrains the grid to viewport height, header auto-sizes, nav fills the rest and scrolls. - ::before rail uses grid-row:1/-1 (spans the 2 explicit rows cleanly instead of span 999 creating hundreds of empty implicit rows). https://claude.ai/code/session_01HVhWBjQBAoNHwgfubbsADb
Sidebar close flicker:
- The swipe-mode `display:grid` lived only on [open]; the closed value fell
back to the base `display:flex` (the persistent desktop sidebar). A discrete
grid→flex display flip at the exit midpoint reflowed the panel to full width
for one frame. Add `[k="sidebar"]:not([open]) { display: none }` in the swipe
block so the exit animates grid→none via allow-discrete, holding the panel's
grid layout for the whole slide-out. Specificity (0,2,0) beats the demo's
`.docs-sidebar` class.
Scroll chaining (match iOS/Android nested-scroll sheet physics):
- ScrollArea now defaults to `overscroll-behavior: contain` (a self-contained
region shouldn't leak scroll to the page).
- Inside a swipe overlay on touch (Drawer/Sheet/Sidebar and mobile="drawer"
Popover/Dropdown/ContextMenu), ScrollArea switches to `overscroll-behavior:
auto` so a fling past the list edge chains out to the overlay's scroll
container and dismisses it. The overlay itself keeps `contain`, so it's the
page-boundary stop — chaining goes inner → overlay, never to the page.
- Sidebar's inner scroll region and the demo's `.docs-sidebar-content` switched
from contain to auto for the same reason.
https://claude.ai/code/session_01HVhWBjQBAoNHwgfubbsADb
The Sheet's touch swipe mode uses a CSS grid to place the rail and panel without a wrapper element (a right-anchored swipe panel can't be built in normal flow — scrollable overflow is clamped at the inline-start edge, so the rail can't live to the left as flow content). Grid blockifies its items, which would stretch inline-level children that are natural-width in block flow. Extend the justify-self pin to anchors (alongside button / [k="button"]) and add a comment explaining the grid is an internal mechanism and which block-flow behaviors these rules restore (and the one residual: vertical margins don't collapse between grid items). https://claude.ai/code/session_01HVhWBjQBAoNHwgfubbsADb
Previously the swipe (touch) mode used a grid while desktop used block (Sheet) or flex (Sidebar), so user-supplied children laid out under a different algorithm across breakpoints. Unify on grid everywhere — same stretching, sizing and quirk-neutralization at all widths; only the grid parameters adapt responsively (the touch mode adds the rail column). Sheet: - Base is now a single-column grid (`display:grid` on [open]); horizontal padding moved to the children so the box model matches the swipe mode. - The child-placement + inline-element justify-self pins moved to the base so they apply at all breakpoints; the swipe block only adds the rail column and moves content to column 2. Sidebar: - Base switches from flex column to a stacking grid. - Demo `.docs-sidebar` switches to `grid-template-rows: auto 1fr` (it set its own `display:flex` that would otherwise win on desktop) — the always-present 4rem header row + filling, scrollable content row, matching the swipe mode. Drawer is intentionally left on flex: its stylesheet is shared with the CSS-only mobile="drawer" Popover/Dropdown/ContextMenu, which have no wrapper and no JS to restructure around. Verified desktop + mobile for both: desktop sheet single-column grid (blocks fill, buttons natural), desktop sidebar grid header+scroll, mobile swipe open/snap/dismiss intact, and the sidebar close stays grid every frame (no full-width flex flicker). https://claude.ai/code/session_01HVhWBjQBAoNHwgfubbsADb
…tent The no-wrapper grid forced layout responsibilities onto user content elements, which collided with content styling: buttons needed justify-self pins, and the horizontal inset broke for shrink-to-fit children (padding) or got overridden by element resets (margin on a heading → flush-left Close button / heading). SheetContent now renders a [k="sheet-panel"] wrapper holding the children, so: - User content lays out in plain block flow — margins collapse, inline elements are natural width, nothing to neutralize. The 1rem inset is the wrapper's padding, isolated from content styling (fixes the flush-left button/heading). - The dialog stays the swipe scroll container; on touch it's a flex row with a full-viewport ::before rail in front of the panel wrapper (a real element with its own background and scroll-snap-align), which drops the grid and the background-attachment:local gradient hack entirely. Net CSS is slightly smaller. - The dialog scrolls only horizontally (the swipe); the panel scrolls its own content vertically, keeping the gesture axes independent. Swipe JS, backdrop hit-testing and the --swipe:x contract are unchanged (the dialog is still the scroller). Verified desktop block flow (heading + button both inset) and mobile swipe open/dismiss. Sidebar stays on grid by design. https://claude.ai/code/session_01HVhWBjQBAoNHwgfubbsADb
Giving the panel wrapper overflow-y:auto made it a scroll container — and CSS coerces the other axis off `visible`, so it silently became a horizontal scroll container too, capturing the swipe instead of letting it chain to the dialog. Only the rail/backdrop region (still the dialog) stayed swipeable. Restore the model the grid Sheet used: the dialog is the single 2-D scroller (X scroll-snaps for the swipe, Y scrolls content freely) and the panel is a plain flex item with overflow:visible — not a scroller. A swipe starting on the panel now scrolls the dialog, exactly as it did when the children were direct grid items. Verified the panel computes overflow:visible (not a scroller), the dialog is the X scroller, content stays inset in block flow, and dismiss still fires. https://claude.ai/code/session_01HVhWBjQBAoNHwgfubbsADb
Adding contain to ScrollArea's base rule trapped the scroll wheel on desktop: when the user scrolled to the edge of a scroll area, the page behind it stopped scrolling. ScrollArea is just a styled scroll container — it shouldn't contain by default. The swipe-overlay chaining (which set auto inside Drawer/Sheet/Sidebar on touch) is also removed since the base no longer needs an override. https://claude.ai/code/session_01HVhWBjQBAoNHwgfubbsADb
…er mode The desktop popover's closed state is a fade+scale (opacity: 0). In drawer mode the enter/exit is a pure slide, and the drawer-mode transition lists don't include opacity — so on close, the panel inherited the desktop base's opacity: 0 with no transition and snapped invisible on the first frame. The 300ms slide-down still ran, but on a fully transparent element. Pin opacity: 1 in the mobile=drawer base block so the closed state stays opaque; the slide (transform on mobile, translate + display/overlay allow-discrete in swipe mode) is the whole exit. Desktop fade is unaffected (the pin lives inside the max-width/mobile="drawer" block). Verified frame-by-frame in headless Chromium (touch emulation): backdrop tap now slides the panel out over 300ms with opacity held at 1, display flipping to none only when the transition completes. https://claude.ai/code/session_01HVhWBjQBAoNHwgfubbsADb
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Adds native swipe-to-dismiss functionality to Drawer, Sheet, and Sidebar components on touch devices, while preserving the existing transform-based animations on desktop.
Summary
On touch devices (
hover: noneandpointer: coarse), the overlay dialogs now become CSS scroll-snap containers where users can swipe the panel away into an empty "rail" to dismiss. The gesture tracking, momentum, and snap-back are all handled by the browser's native scroll behavior. A newinstallSwipe()helper provides minimal JavaScript control, only nudging the scroller at open/close boundaries.Key Changes
New
installSwipe()handler (src/lib/commands.ts): Registersbeforetoggleandscrollendlisteners that:Shared swipe CSS primitive (
src/components/_swipe.css): Provides the scroll-snap container scaffolding for Drawer and Sheet, configurable via CSS variables (--swipe-axis,--swipe-snap,--swipe-align)Component updates: Drawer, Sheet, and Sidebar now wrap their content in
*-paneland*-raildivs. On desktop these are inert (display: noneordisplay: contents); on touch they become the scroll-snap structureRail tap-to-close: Added click handler in
dialogsDropdownsClickHandlerto close dialogs when tapping the rail areaTests and docs: Added comprehensive test suite for the swipe control plane and updated component documentation
Implementation Details
--swipe: 1, enabling zero JavaScript branching in componentsenteringWeakSet prevents the entrance scroll from being misinterpreted as a dismiss gesturehttps://claude.ai/code/session_01HVhWBjQBAoNHwgfubbsADb