Skip to content

feat(Drawer): add Drawer primitive#2591

Open
zernonia wants to merge 13 commits intounovue:v2from
zernonia:2515-feature-drawer
Open

feat(Drawer): add Drawer primitive#2591
zernonia wants to merge 13 commits intounovue:v2from
zernonia:2515-feature-drawer

Conversation

@zernonia
Copy link
Copy Markdown
Member

@zernonia zernonia commented Apr 9, 2026

Summary

Vue port of BaseUI's Drawer as a standalone primitive (closes #2515). Built on FocusScope, DismissableLayer, Presence, and TeleportPrimitive — not wrapping Dialog.

Parts: Root, Trigger, Close, Title, Description, Portal, Overlay (aliased as Backdrop), Content (aliased as Popup), Viewport, Handle, SwipeArea, Provider, Indent, IndentBackground.

Features

  • 4-sided swipe-to-dismiss (top / right / bottom / left) with pointer + touch handling, axis locking, directional damping, reversal cancel, and release-velocity tracking.
  • Swipe-to-open via DrawerSwipeArea for off-screen edge triggers.
  • Snap points as fractions 0-1, absolute pixels, 'Npx' strings, or 'Nrem' strings. Unknown units are dropped.
  • Snap release math ported line-for-line from BaseUI DrawerViewport.tsx (velocity-boosted target offset with SNAP_VELOCITY_MULTIPLIER=300, close-vs-closest-snap distance comparison, sequential-mode shouldAdvance + physical-crossing rules).
  • --drawer-swipe-strength CSS var written on release via computeSwipeReleaseScalar (remaining-distance / velocity scalar, clamped to [0.1, 1]) so consumer transitions can scale duration with flick speed.
  • Scroll-edge swipe allowance — scrollable drawer content is still dismissable once the scroll container reaches the relevant edge (ported from BaseUI canSwipeFromScrollEdgeOnPendingMove).
  • CSS.registerProperty for all high-frequency vars (--drawer-swipe-movement-x/y, --drawer-snap-point-offset, --drawer-swipe-progress, --drawer-swipe-strength).
  • Modal tiers: true (focus trap + scroll lock + block outside events), 'trap-focus' (focus trap only, outside events pass through), false (non-modal).
  • update:open change details — carries { reason: 'trigger-press' | 'close-press' | 'swipe' | 'escape-key' | 'outside-press' } so consumers can distinguish dismiss sources. update:openComplete fires after the transition.
  • initialFocus / finalFocus props on DrawerContent.
  • Nested drawers: Provider tracks running nestedOpenDrawerCount, parent popups subscribe to nestedSwipeProgressStore to animate in reverse while a child swipes away, and a keepHeightWhileNested guard prevents opening a child from mutating parent snap geometry.
  • Indent effect: DrawerIndent subscribes to visualStateStore and writes --drawer-swipe-progress imperatively, so consumer CSS can interpolate transforms in sync with the drag (see indent-provider story for the canonical --indent-transition recipe).
  • Backdrop data attributes: DrawerOverlayImpl carries data-swiping / data-swipe-direction for [data-swiping] [data-slot="drawer-backdrop"] CSS patterns.
  • Close-animation continuity: on swipe-dismiss, the drag transform is preserved through the close transition (no one-frame snap-back), and data-swipe-dismissed is set on the popup for consumer styling.

Tests

32 tests, all passing:

  • Drawer.test.ts (11) — render + click + keyboard + a11y integration, plus update:open reason assertions for trigger-press and close-press.
  • useDrawerSnapPoints.test.ts (11) — unit tests pinning the BaseUI release math: snap-point resolution, unknown-unit parseSnapPoint filter, close-vs-snap distance decision, velocity-boost dismiss, up-drawer sign convention, sequential-mode shouldAdvance + physical-crossing rules.
  • utils.test.ts (10) — getDisplacement sign conventions across all 4 directions and computeSwipeReleaseScalar (null-below-min-velocity, null-on-zero-remaining, [0.1, 1] range, fast/slow scalar endpoints, up-direction sign, snapPointOffset contribution).

Stories (Histoire)

Mirror the BaseUI demos: default bottom drawer, 4-side variants, snap points, non-modal, swipe-area (swipe-to-open), nested drawers, mobile nav, scrollable content, form, and the indent-effect recipe.

Test plan

  • Run pnpm --filter reka-ui test Drawer — 32 tests pass
  • Run pnpm --filter reka-ui story:dev — open the Drawer stories and walk through each variant
  • Verify swipe-to-dismiss on all 4 sides (desktop pointer + touch device)
  • Verify snap points: slow drag (snaps to nearest), fast flick (advances sequentially), drag past lowest snap (dismisses)
  • Verify nested drawer: open parent → open child → swipe child away → parent animates back in reverse
  • Verify indent effect: scale tracks drag in real time, not just on close
  • Verify scrollable content: scroll down, then try to dismiss from middle (blocked), then scroll to top, then dismiss (works)
  • Verify close animation continuity: swipe mid-way, release → drawer continues from dragged position (no snap-back flicker)

🤖 Generated with Claude Code

Summary by CodeRabbit

  • New Features

    • Complete Drawer component system: modal/non‑modal drawers, trigger/title/description/close/handle/viewport primitives, swipe-to-open/close with configurable snap points, nested-drawer coordination, indent/backdrop effects, swipe areas, provider/store, and interactive story demos.
  • Documentation

    • Added full design spec and multi‑phase implementation plans covering behavior, accessibility, gestures, snap math, and integration.
  • Tests

    • New test suites for accessibility, snap-point math, swipe gesture/release behavior, and open/close event semantics.

zernonia and others added 5 commits April 9, 2026 14:37
- Design spec capturing the Drawer primitive surface, gesture model,
  snap-point semantics, nested drawer indent effect, and modal tiers.
- Initial implementation plan (phased rollout of Root, Content,
  gestures, snap points, Provider/Indent, stories, tests).
- BaseUI parity plan covering the follow-up fix pass: C1-C5 critical
  items, I1-I10 important items, and minor fixes.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
…dent effect

Vue port of BaseUI's Drawer component system. Standalone primitive
built on FocusScope, DismissableLayer, Presence, and Teleport.

Parts: Root, Trigger, Close, Title, Description, Portal, Overlay
(aliased as Backdrop), Content (aliased as Popup), Viewport, Handle,
SwipeArea, Provider, Indent, IndentBackground.

Features:
- 4-sided swipe-to-dismiss (top/right/bottom/left) with pointer +
  touch event handling, axis locking, directional damping, reversal
  cancel, and release-velocity tracking.
- Swipe-to-open via DrawerSwipeArea for off-screen edge triggers.
- Snap points as fractions (0-1), absolute pixels, 'Npx' strings, or
  'Nrem' strings. Unknown units are dropped. Active snap point
  offsets are written to --drawer-snap-point-offset.
- Release math ported line-for-line from BaseUI DrawerViewport
  (velocity-boosted target offset with SNAP_VELOCITY_MULTIPLIER=300,
  close-vs-closest-snap distance comparison, and sequential-mode
  shouldAdvance + physical-crossing rules).
- --drawer-swipe-strength CSS var on release via
  computeSwipeReleaseScalar (remaining-distance / velocity scalar,
  clamped to [0.1, 1]).
- Scroll-edge swipe allowance so scrollable drawer content is still
  dismissable once the scroll container reaches the relevant edge.
- CSS.registerProperty for all high-frequency vars (swipeMovementX/Y,
  snapPointOffset, swipeProgress, swipeStrength).
- Modal tiers: true (focus trap + scroll lock + block outside events),
  'trap-focus' (focus trap only, outside events pass through), false
  (non-modal).
- update:open carries ChangeEventDetails.reason ('trigger-press',
  'close-press', 'swipe', 'escape-key', 'outside-press') and
  update:openComplete fires on transition end.
- initialFocus / finalFocus props on DrawerContent.
- Nested drawers: Provider tracks running nestedOpenDrawerCount,
  parent popups subscribe to nestedSwipeProgressStore to animate in
  reverse while child swipes away, and keepHeightWhileNested guards
  ResizeObserver so opening a child doesn't mutate parent snap
  geometry.
- Indent effect: DrawerIndent subscribes to visualStateStore and
  writes --drawer-swipe-progress imperatively for consumer CSS to
  interpolate transforms in sync with the drag.
- Backdrop (DrawerOverlayImpl) carries data-swiping /
  data-swipe-direction so [data-swiping] selectors work without
  reaching through to the popup.
- Close-animation continuity: on swipe-dismiss the drag transform is
  preserved through the close transition (no 1-frame snap-back), and
  data-swipe-dismissed is set on the popup for consumer styling.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Stories cover the full surface: default bottom drawer, 4-side
variants, snap points, non-modal, swipe-area (swipe-to-open),
nested drawers, mobile nav, scrollable content, form, and the
indent-effect recipe (which interpolates the indent transform via
--drawer-swipe-progress and zeros transition-duration during drag
using the canonical BaseUI --indent-transition 1-or-0 multiplier).

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
- Drawer.test.ts: render + click + keyboard + a11y integration tests,
  including update:open ChangeEventDetails.reason assertions for
  trigger-press and close-press.
- useDrawerSnapPoints.test.ts: 11 unit tests pinning down the ported
  BaseUI release math — snap-point resolution, unknown-unit parseSnapPoint
  filter, close-vs-snap distance decision, velocity-boost dismiss,
  up-drawer sign convention, and sequential-mode shouldAdvance +
  physical-crossing rules.
- utils.test.ts: getDisplacement sign conventions across all 4
  directions and computeSwipeReleaseScalar (null-below-min-velocity,
  null-on-zero-remaining, [0.1, 1] range, fast/slow scalar endpoints,
  up-direction sign handling, snapPointOffset contribution).

32 tests total, all passing.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
@zernonia zernonia linked an issue Apr 9, 2026 that may be closed by this pull request
2 tasks
@coderabbitai
Copy link
Copy Markdown
Contributor

coderabbitai bot commented Apr 9, 2026

Note

Reviews paused

It looks like this branch is under active development. To avoid overwhelming you with review comments due to an influx of new commits, CodeRabbit has automatically paused this review. You can configure this behavior by changing the reviews.auto_review.auto_pause_after_reviewed_commits setting.

Use the following commands to manage reviews:

  • @coderabbitai resume to resume automatic reviews.
  • @coderabbitai review to trigger a single review.

Use the checkboxes below for quick actions:

  • ▶️ Resume reviews
  • 🔍 Trigger review
📝 Walkthrough

Walkthrough

Adds a complete Drawer feature: ~15 Vue components, composables for swipe gestures and snap-point math, provider/root context for nesting and CSS-variable coordination, utilities, tests, Storybook demos, docs/specs/plans, and package exports. All additions; no removals.

Changes

Cohort / File(s) Summary
Docs & Plans
docs/superpowers/specs/2026-04-04-drawer-design.md, docs/superpowers/plans/2026-04-05-drawer.md, docs/superpowers/plans/2026-04-09-drawer-baseui-parity-fixes.md
Design spec and multi-phase implementation plans covering architecture, accessibility, gesture/snap behavior, BaseUI parity steps, and test/verification plans.
Public API / Barrel
packages/core/src/Drawer/index.ts, packages/core/src/index.ts
New Drawer barrel export and package-level re-export; adds alias exports (DrawerPopup/DrawerBackdrop) and many Drawer types.
Root & Provider
packages/core/src/Drawer/DrawerRoot.vue, packages/core/src/Drawer/DrawerProvider.vue
Root context with v-model/open semantics, nested-drawer tracking, provider visualStateStore, registration lifecycle and context APIs.
Content & Overlay
packages/core/src/Drawer/DrawerContent.vue, packages/core/src/Drawer/DrawerContentImpl.vue, packages/core/src/Drawer/DrawerOverlay.vue, packages/core/src/Drawer/DrawerOverlayImpl.vue
Modal vs non-modal gating, FocusScope/DismissableLayer wiring, presence/transition/open-complete handling, CSS var management, body-scroll-lock and swipe coordination.
Controls & Accessibility Primitives
packages/core/src/Drawer/DrawerTrigger.vue, packages/core/src/Drawer/DrawerClose.vue, packages/core/src/Drawer/DrawerTitle.vue, packages/core/src/Drawer/DrawerDescription.vue, packages/core/src/Drawer/DrawerHandle.vue
Primitive wrappers binding ARIA, managing refs, and invoking rootContext callbacks (trigger/close semantics and aria relationships).
Portal / Viewport / Indent
packages/core/src/Drawer/DrawerPortal.vue, packages/core/src/Drawer/DrawerViewport.vue, packages/core/src/Drawer/DrawerIndent.vue, packages/core/src/Drawer/DrawerIndentBackground.vue
Teleport wrapper, viewport element, and indent/background components subscribing to provider visualStateStore and mirroring CSS vars.
Swipe Gesture System
packages/core/src/Drawer/DrawerSwipeArea.vue, packages/core/src/Drawer/composables/useSwipeDismiss.ts
Pointer/touch gesture handling with axis lock, scroll-edge detection, velocity sampling, CSS var updates, and callbacks for swiping/dismiss — complex, event-heavy logic.
Snap-Point Math & Tests
packages/core/src/Drawer/composables/useDrawerSnapPoints.ts, packages/core/src/Drawer/composables/useDrawerSnapPoints.test.ts
Snap resolution, unit parsing, sequential vs nearest snap selection, velocity boosting and dismissal math with unit-tested edge cases.
Utilities & CSS Props
packages/core/src/Drawer/utils.ts, packages/core/src/Drawer/utils.test.ts
Swipe types, displacement & release-scalar math, transform parsing, nested swipe progress store, and guarded CSS.registerProperty calls.
Stories / Demos
packages/core/src/Drawer/story/Drawer.story.vue, packages/core/src/Drawer/story/_Drawer.vue
Storybook demos showing orientations, snap points, nesting, indent effects, swipe-to-open, and mobile nav examples with styling and animations.
Tests: Integration / A11y
packages/core/src/Drawer/Drawer.test.ts
Vitest + vitest-axe accessibility and open/close event semantics, including update:open details and console warning for missing title.

Sequence Diagram(s)

sequenceDiagram
    participant User
    participant Trigger as DrawerTrigger
    participant Root as DrawerRoot
    participant Provider as DrawerProvider
    participant Overlay as DrawerOverlay
    participant Content as DrawerContent

    User->>Trigger: click
    Trigger->>Root: onOpenChange(true, 'trigger-press')
    Root->>Provider: register openById
    Root->>Overlay: mount (if modal)
    Root->>Content: mount (open=true)
    Content->>Content: measure height & write CSS vars
    Overlay->>Overlay: useBodyScrollLock activate
    Provider->>DrawerIndent: visualStateStore updates
Loading
sequenceDiagram
    participant User
    participant SwipeArea as DrawerSwipeArea
    participant Swipe as useSwipeDismiss
    participant CSS as CSS_Vars
    participant Snap as useDrawerSnapPoints
    participant Root as DrawerRoot
    participant Content as DrawerContent

    User->>SwipeArea: pointer/touch start
    SwipeArea->>Swipe: init tracking
    User->>SwipeArea: drag
    Swipe->>CSS: update --drawer-swipe-movement-*
    Swipe->>Swipe: sample velocity & emit onProgress
    User->>SwipeArea: release
    Swipe->>Snap: snapToNearest(delta, velocity, direction)
    Snap->>Root: decide snap OR dismiss
    alt Dismiss
        Root->>Root: onOpenChange(false, 'swipe')
        Root->>Content: close flow
    else Snap
        Root->>Content: set activeSnapPoint, update --drawer-snap-point-offset
    end
Loading

Estimated code review effort

🎯 4 (Complex) | ⏱️ ~75 minutes

Suggested labels

v2

Poem

🐇 I dug a tunnel, peeped the UX hues,
A panel slides out with gentle, swipy news,
Snap-points hum, CSS vars pulse bright,
Nested drawers nestle snug and just-right,
Hooray — a rabbit's patch of UI delight!

🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 27.59% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (4 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Linked Issues check ✅ Passed All objectives from issue #2515 are met: Drawer primitive implemented with BaseUI behavior, gesture handling, snap points, modal options, focus controls, nested drawer support, CSS vars, and comprehensive tests/stories.
Out of Scope Changes check ✅ Passed All changes are directly within scope of issue #2515 (Drawer implementation). Only one additional export in index.ts to expose the Drawer module.
Title check ✅ Passed The PR title 'feat(Drawer): add Drawer primitive' directly and clearly summarizes the main change—adding a new Drawer primitive component system to the codebase.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands and usage tips.

@pkg-pr-new
Copy link
Copy Markdown

pkg-pr-new bot commented Apr 9, 2026

Open in StackBlitz

npm i https://pkg.pr.new/reka-ui@2591

commit: 3b1eb97

Copy link
Copy Markdown
Contributor

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

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

Actionable comments posted: 12

🧹 Nitpick comments (1)
packages/core/src/Drawer/Drawer.test.ts (1)

71-74: Mock cleanup order should be reversed.

mockRestore() restores the original console.warn, so calling mockClear() afterward on consoleWarnMockFunction is redundant (it clears the mock's call history, but the spy is already detached). If you intend to clear call history between tests while keeping the spy active, swap the order or remove mockClear() entirely since beforeEach re-creates the spy anyway.

🔧 Suggested fix
   afterEach(() => {
-    consoleWarnMock.mockRestore()
-    consoleWarnMockFunction.mockClear()
+    consoleWarnMock.mockRestore()
   })
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@packages/core/src/Drawer/Drawer.test.ts` around lines 71 - 74, The afterEach
cleanup currently calls consoleWarnMock.mockRestore() before
consoleWarnMockFunction.mockClear(), which is redundant and can error because
mockRestore removes the spy; in the afterEach for Drawer tests (referencing
consoleWarnMock and consoleWarnMockFunction), either swap the calls so
mockClear() runs before mockRestore(), or remove mockClear() entirely since
beforeEach re-creates the spy; update the afterEach accordingly to clear the
mock history before restoring or just restore only.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In `@docs/superpowers/specs/2026-04-04-drawer-design.md`:
- Around line 19-42: The fenced code blocks showing the Drawer file tree
(starting with "packages/core/src/Drawer/") and the "return-shape" example need
explicit language tags to satisfy markdownlint MD040; change the file-tree fence
to use "text" (```text) and the return-shape example fence to use "ts" (```ts)
so the tree and TypeScript return-shape are properly annotated (also apply the
same edits to the other occurrence noted around lines 212-220).

In `@packages/core/src/Drawer/composables/useSwipeDismiss.ts`:
- Around line 265-278: The code is promoting a pending swipe even when dir is
undefined; only promote when an allowed direction is identified. In the
pendingSwipe block (variables: pendingSwipe, pendingSwipeStartPos, dir,
getDisplacement, MIN_DRAG_THRESHOLD), first ensure dir is defined (i.e., bail
out if dir is undefined) before computing displacement and clearing
pendingSwipe; only then set intendedDirection, swipeDirection.value, call
setSwiping(true) and onSwipeStart?.(); remove the usage of the fallback (dir ??
toValue(directions)[0]) so promotion only happens for an explicitly determined
allowed direction.
- Around line 484-487: The first touch/move event that flips the gesture into
swiping isn't being prevented because the code checks isSwiping.value before
calling processMove(), which is what sets isSwiping.value = true; modify the
move handler in useSwipeDismiss.ts so you capture the previous swiping state
(e.g., const wasSwiping = isSwiping.value), call processMove(el, pos,
e.timeStamp), then if (wasSwiping || isSwiping.value) call e.preventDefault();
this ensures the same event that transitions into swiping is prevented and
avoids the mobile scroll hitch.

In `@packages/core/src/Drawer/DrawerContent.vue`:
- Around line 75-84: In the interact-outside handler inside DrawerContent.vue
you're reassigning the ref variables instead of mutating their .value; change
the assignments to set hasInteractedOutside.value = true and
hasPointerDownOutside.value = true, and when checking the trigger element use
rootContext.triggerElement.value?.contains(target) as already done; update any
other direct ref reassignments in this handler to mutate .value so the
template/reactivity works correctly.
- Around line 67-74: The close-auto-focus handler is using the ref objects
directly instead of their values, so replace checks and resets to use
hasInteractedOutside.value and hasPointerDownOutside.value; specifically, in the
`@close-auto-focus` callback (the function that currently checks if
(!hasInteractedOutside) and later sets hasInteractedOutside = false /
hasPointerDownOutside = false), change the condition to if
(!hasInteractedOutside.value) before focusing rootContext.triggerElement.value,
and reset both refs by assigning hasInteractedOutside.value = false and
hasPointerDownOutside.value = false.

In `@packages/core/src/Drawer/DrawerIndent.vue`:
- Around line 55-56: The template sets :data-active and :data-inactive using
providerContext?.active.value which, when providerContext is missing, makes
!providerContext?.active.value truthy and always applies inactive styles; update
the bindings for data-active and data-inactive to first guard that
providerContext exists and has an active property (e.g. check providerContext !=
null and providerContext.active != null) before reading
providerContext.active.value so that neither attribute is set when there is no
provider; locate the bindings in DrawerIndent.vue (the :data-active and
:data-inactive attributes) and change their expressions to only evaluate
active.value when providerContext is present.

In `@packages/core/src/Drawer/DrawerOverlayImpl.vue`:
- Line 17: The call useBodyScrollLock(true) unconditionally locks scrolling even
when the drawer is mounted but closed (e.g. with forceMount); replace the
literal true with the drawer's open state so the lock is active only while
open—i.e., pass the reactive isOpen/ref (or a computed that returns
isOpen.value) to useBodyScrollLock in DrawerOverlayImpl.vue instead of true
(ensure you reference the same open state used by the overlay component).

In `@packages/core/src/Drawer/DrawerRoot.vue`:
- Around line 226-230: The current watch on open uses queueMicrotask to emit
'update:openComplete' too early; remove that microtask-based emit and instead
emit 'update:openComplete' from the content transition lifecycle (e.g., the
component's transition end hooks or a content element transitionend handler).
Concretely: stop emitting inside the watch(open, ...) block and add an
onAfterEnter/onAfterLeave or a transitionend handler (e.g., a method like
onContentTransitionEnd) that calls emit('update:openComplete', /* current open
state */) so the event fires only after the enter/exit CSS transition completes.

In `@packages/core/src/Drawer/DrawerSwipeArea.vue`:
- Around line 43-57: The directions array is currently fixed to the initial
value because it uses [openDirection.value]; update the useSwipeDismiss call so
the directions argument is a reactive computed that returns an array based on
openDirection (e.g., computed(() => [openDirection.value])), keeping the change
local to the directions property in the useSwipeDismiss(...) invocation; the
composable (useSwipeDismiss) already accepts MaybeRef<SwipeDirection[]>, so no
signature changes are needed and rootContext callbacks
(onDismiss/onSwipingChange) can remain untouched.

In `@packages/core/src/Drawer/DrawerTrigger.vue`:
- Around line 8-19: The onMounted-only assignment to rootContext.triggerElement
(done in the DrawerTrigger.vue using onMounted, rootContext, currentElement, and
useForwardExpose) will become stale when currentElement changes; replace it with
a reactive watch on currentElement that updates rootContext.triggerElement.value
whenever currentElement.value changes (and also set it initially), and ensure
you stop the watcher on unmount if needed so the triggerElement stays
synchronized for focus restoration in DrawerContent.

In `@packages/core/src/Drawer/DrawerViewport.vue`:
- Around line 20-31: DrawerViewport currently passes only :as, :as-child and ref
to Primitive, dropping other PrimitiveProps (class, style, id, data-attrs,
listeners); update the template for DrawerViewport to forward all remaining
props to Primitive by binding the component props object (the props returned
from defineProps / the local props variable) with v-bind (e.g., v-bind="props")
while keeping explicit :as, :as-child and :ref="forwardRef" and the data-state
attr; ensure you reference DrawerViewport, Primitive, forwardRef and asChild so
the change forwards attributes/listeners consistently with sibling components
that use v-bind="props".

In `@packages/core/src/Drawer/story/Drawer.story.vue`:
- Line 460: The min-height rule in Drawer.story.vue uses the non-existent CSS
variable --drawer-snap-height so the drawer stays at auto; update that rule to
use the actual variable --drawer-height (or delete the min-height declaration if
undesired) so the story reflects the Drawer primitive's written height; locate
the CSS block containing "min-height: var(--drawer-snap-height, auto)" and
replace the variable name with "--drawer-height" (or remove the line).

---

Nitpick comments:
In `@packages/core/src/Drawer/Drawer.test.ts`:
- Around line 71-74: The afterEach cleanup currently calls
consoleWarnMock.mockRestore() before consoleWarnMockFunction.mockClear(), which
is redundant and can error because mockRestore removes the spy; in the afterEach
for Drawer tests (referencing consoleWarnMock and consoleWarnMockFunction),
either swap the calls so mockClear() runs before mockRestore(), or remove
mockClear() entirely since beforeEach re-creates the spy; update the afterEach
accordingly to clear the mock history before restoring or just restore only.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro

Run ID: d35700be-da56-4634-af98-3f1dd5054d6c

📥 Commits

Reviewing files that changed from the base of the PR and between 68a490b and 3606026.

📒 Files selected for processing (29)
  • docs/superpowers/plans/2026-04-05-drawer.md
  • docs/superpowers/plans/2026-04-09-drawer-baseui-parity-fixes.md
  • docs/superpowers/specs/2026-04-04-drawer-design.md
  • packages/core/src/Drawer/Drawer.test.ts
  • packages/core/src/Drawer/DrawerClose.vue
  • packages/core/src/Drawer/DrawerContent.vue
  • packages/core/src/Drawer/DrawerContentImpl.vue
  • packages/core/src/Drawer/DrawerDescription.vue
  • packages/core/src/Drawer/DrawerHandle.vue
  • packages/core/src/Drawer/DrawerIndent.vue
  • packages/core/src/Drawer/DrawerIndentBackground.vue
  • packages/core/src/Drawer/DrawerOverlay.vue
  • packages/core/src/Drawer/DrawerOverlayImpl.vue
  • packages/core/src/Drawer/DrawerPortal.vue
  • packages/core/src/Drawer/DrawerProvider.vue
  • packages/core/src/Drawer/DrawerRoot.vue
  • packages/core/src/Drawer/DrawerSwipeArea.vue
  • packages/core/src/Drawer/DrawerTitle.vue
  • packages/core/src/Drawer/DrawerTrigger.vue
  • packages/core/src/Drawer/DrawerViewport.vue
  • packages/core/src/Drawer/composables/useDrawerSnapPoints.test.ts
  • packages/core/src/Drawer/composables/useDrawerSnapPoints.ts
  • packages/core/src/Drawer/composables/useSwipeDismiss.ts
  • packages/core/src/Drawer/index.ts
  • packages/core/src/Drawer/story/Drawer.story.vue
  • packages/core/src/Drawer/story/_Drawer.vue
  • packages/core/src/Drawer/utils.test.ts
  • packages/core/src/Drawer/utils.ts
  • packages/core/src/index.ts

Critical:
- DrawerContent.vue: non-modal close-auto-focus and interact-outside
  handlers were reading/writing bare ref variables in template inline
  handlers. Move them to named functions in <script setup> so the
  hasInteractedOutside / hasPointerDownOutside refs are accessed via
  .value explicitly. Fixes broken focus restoration in non-modal mode.
- useSwipeDismiss.ts: preventDefault was guarded on isSwiping.value
  BEFORE processMove, so the very move that crosses the swipe threshold
  (and sets isSwiping=true) was not prevented, causing a one-frame
  scroll hitch on mobile. Capture wasSwiping before processMove and
  prevent on either side of the transition.

Major:
- useSwipeDismiss.ts: the pendingSwipe block was promoting to an active
  swipe even when dir === undefined (user dragging against the dismiss
  direction), which could steal scroll/drag on one-direction drawers.
  Bail out early when no allowed direction is identified.
- DrawerOverlayImpl.vue: useBodyScrollLock was locking unconditionally,
  which could keep page scroll blocked while forceMount kept the overlay
  mounted with the drawer closed. Use the returned `locked` ref and sync
  it with rootContext.open.value via a watcher.
- DrawerRoot.vue: update:openComplete was firing via queueMicrotask,
  which completes almost immediately after the open state changes --
  well before any CSS transition finishes. Move the emit to
  DrawerContentImpl which attaches a one-shot transitionend/animationend
  listener on the popup element and calls rootContext.notifyOpenComplete
  only when the transition actually finishes. Guards against child
  element transitions via event.target === el.
- DrawerSwipeArea.vue: directions array captured the initial value
  only. Wrap in computed so prop/state changes propagate to
  useSwipeDismiss.
- DrawerViewport.vue: only :as and :as-child were forwarded to
  Primitive, dropping class/style/id/listeners/data-attrs. Use
  v-bind="props" to match sibling components.

Minor:
- DrawerIndent.vue: the data-inactive attribute was always set when
  providerContext was missing. Only set when active is explicitly
  false.
- DrawerTrigger.vue: triggerElement was assigned in onMounted only.
  Replace with watch on currentElement so v-if / conditional
  rendering scenarios keep the trigger reference in sync. Clear on
  unmount.
- docs/superpowers/specs/2026-04-04-drawer-design.md: add language
  tags to fenced blocks (text) to satisfy markdownlint MD040.

Not applied:
- story --drawer-snap-height -> --drawer-height: CodeRabbit claimed
  the snap-height var is not produced by the primitive, but it is
  written by DrawerContentImpl:77 when a snap point is active (and
  cleared on 81). Changing the story would break snap-point height
  behavior.

Tests: 32/32 still passing.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
@zernonia
Copy link
Copy Markdown
Member Author

zernonia commented Apr 9, 2026

Addressed CodeRabbit review in 63ea0fb.

Fixed (11/12):

Critical

  • DrawerContent.vue — non-modal close-auto-focus / interact-outside inline handlers moved into <script setup> as named functions with explicit .value access. Fixes broken focus restoration and the state refs that were being reassigned instead of mutated.
  • useSwipeDismiss.ts:487preventDefault now captures wasSwiping BEFORE processMove, so the move event that crosses the drag threshold (and sets isSwiping=true) is prevented on the same frame. Eliminates the first-frame scroll hitch on mobile.

Major

  • useSwipeDismiss.ts:278pendingSwipe block now bails out early when dir is undefined, so dragging against the dismiss direction on one-direction drawers no longer steals the gesture from native scroll.
  • DrawerOverlayImpl.vue — body scroll lock is now tied to rootContext.open.value via the returned locked ref + a watcher. forceMount no longer keeps page scroll blocked.
  • DrawerRoot.vue — removed the queueMicrotask-based update:openComplete emit. Moved to DrawerContentImpl which attaches a one-shot transitionend/animationend listener on the popup element (guarded with event.target === el) and calls rootContext.notifyOpenComplete only when the transition actually finishes. Listener is cancelled on rapid open→close and cleaned up on unmount.
  • DrawerSwipeArea.vuedirections is now computed(() => [openDirection.value]) so prop/state changes propagate.
  • DrawerViewport.vuev-bind="props" now forwards all PrimitiveProps to Primitive, matching sibling components.

Minor

  • DrawerIndent.vuedata-inactive now set only when providerContext?.active.value === false, not when provider is missing.
  • DrawerTrigger.vuetriggerElement now synced via watch(currentElement, …, { immediate: true }) + onUnmounted cleanup, so v-if / conditional rendering keeps the trigger reference fresh for finalFocus.
  • docs spectext language tags added to the two unlabelled fenced blocks for MD040.

Pushed back on (1/12):

  • story/Drawer.story.vue:460 — --drawer-snap-height--drawer-height. The suggestion is incorrect: the snap-height var IS produced by DrawerContentImpl:77 when a snap point is active, and cleared on line 81. Changing to --drawer-height would break the snap-point story's height behavior (the drawer is supposed to expand/collapse to the active snap, not always be the full popup height).

Verification: 32/32 tests passing, Drawer typecheck clean.

🤖 Generated with Claude Code

Copy link
Copy Markdown
Contributor

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

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

Actionable comments posted: 1

🧹 Nitpick comments (4)
packages/core/src/Drawer/composables/useSwipeDismiss.ts (3)

206-214: Unused return value from getElementTransform.

The return value from getElementTransform(el) is discarded. If the intent is to prime a cache or trigger a side effect, consider adding a comment. Otherwise, if the transform data is needed later, store it.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@packages/core/src/Drawer/composables/useSwipeDismiss.ts` around lines 206 -
214, The call to getElementTransform(el) inside startSwipe discards its return
value; either store that transform for later use (e.g., assign to a new variable
like initialTransform or previousTransform at the top-level or in the startSwipe
scope) and use it where transform/state adjustments occur, or if the function is
only invoked for a side-effect, add a concise comment explaining that
side-effect to avoid linter warnings; update startSwipe to assign the result
when the returned transform is needed by other logic (references: function
startSwipe, getElementTransform, variables
dragStartPos/pendingSwipeStartPos/elementSize).

436-443: Single-axis scrollable ancestor detection may miss horizontal scrollables in bi-directional drawers.

When the drawer allows both vertical and horizontal swipe directions, this logic always prioritizes 'vertical' for scrollable ancestor detection. If a horizontal scrollable exists but not a vertical one, it won't be found, potentially causing unexpected scroll conflicts.

💡 Suggested improvement
     if (!options.ignoreScrollableAncestors) {
-      const axis = hasVertical.value ? 'vertical' : 'horizontal'
-      const scrollable = findScrollableAncestor(target, axis)
+      // Check both axes when drawer supports both directions
+      let scrollable: HTMLElement | null = null
+      if (hasVertical.value)
+        scrollable = findScrollableAncestor(target, 'vertical')
+      if (!scrollable && hasHorizontal.value)
+        scrollable = findScrollableAncestor(target, 'horizontal')
       if (scrollable) {
         swipeFromScrollable = true
         scrollableAncestor = scrollable
       }
     }
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@packages/core/src/Drawer/composables/useSwipeDismiss.ts` around lines 436 -
443, The current logic inside useSwipeDismiss uses hasVertical.value to choose
axis and calls findScrollableAncestor(target, axis), which biases to 'vertical'
and can miss horizontal scrollables when bi-directional swipes are allowed;
update the detection to check both axes when the drawer supports both
directions: first check vertical (hasVertical) then fallback to horizontal (or
vice versa based on hasHorizontal if present), or explicitly call
findScrollableAncestor for both 'vertical' and 'horizontal' when not ignoring
scrollable ancestors; set swipeFromScrollable and scrollableAncestor based on
the first non-null result so horizontal scrollables are detected in
bi-directional drawers (refer to options.ignoreScrollableAncestors, hasVertical,
findScrollableAncestor, swipeFromScrollable, scrollableAncestor).

394-397: Empty catch block silently swallows errors.

Silently ignoring setPointerCapture failures can mask issues during development. Consider logging in dev mode or adding a comment explaining why it's safe to ignore.

💡 Suggested improvement
     try {
       el.setPointerCapture(e.pointerId)
     }
-    catch {}
+    catch {
+      // Pointer capture may fail if the element is removed or not connected.
+      // Safe to ignore since we track the pointer via activePointerId.
+    }
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@packages/core/src/Drawer/composables/useSwipeDismiss.ts` around lines 394 -
397, The empty catch in useSwipeDismiss around el.setPointerCapture(e.pointerId)
silently swallows errors; update the catch to either log the caught error in
non-production/dev builds (e.g., console.warn or processLogger.warn) or add a
clear comment explaining why ignoring the error is safe (pointer capture
failures are expected on some browsers/devices). Target the try/catch
surrounding setPointerCapture inside the useSwipeDismiss composable and ensure
the handler surfaces the error only in dev (NODE_ENV check) or documents
rationale if intentionally suppressed.
packages/core/src/Drawer/DrawerContentImpl.vue (1)

197-220: Use proper event types instead of any.

The event handlers onPointerDownOutside, onFocusOutside, and onInteractOutside use event: any. Using the proper types from DismissableLayer improves type safety and IDE support.

💡 Suggested fix
+import type { FocusOutsideEvent, PointerDownOutsideEvent } from '@/DismissableLayer'
+
 // ...
 
-function onPointerDownOutside(event: any) {
+function onPointerDownOutside(event: PointerDownOutsideEvent) {
   if (isSwiping.value) {
     event.preventDefault()
     return
   }
   pendingDismissReason = 'outside-press'
   emits('pointerDownOutside', event)
 }

-function onFocusOutside(event: any) {
+function onFocusOutside(event: FocusOutsideEvent) {
   if (isSwiping.value) {
     event.preventDefault()
     return
   }
   emits('focusOutside', event)
 }

-function onInteractOutside(event: any) {
+function onInteractOutside(event: PointerDownOutsideEvent | FocusOutsideEvent) {
   if (isSwiping.value) {
     event.preventDefault()
     return
   }
   emits('interactOutside', event)
 }
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@packages/core/src/Drawer/DrawerContentImpl.vue` around lines 197 - 220, The
three handlers use event: any; change them to use the proper DismissableLayer
event types by importing the appropriate types from the DismissableLayer module
and updating the signatures: onPointerDownOutside(event:
DismissableLayerPointerDownOutsideEvent), onFocusOutside(event:
DismissableLayerFocusOutsideEvent), onInteractOutside(event:
DismissableLayerInteractOutsideEvent); ensure the imports are added at the top
of the file and update any related tooling/type references so the handlers keep
the same logic but now have strong typing.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In `@packages/core/src/Drawer/DrawerContentImpl.vue`:
- Around line 235-256: The watcher for rootContext.open in DrawerContentImpl
currently waits only for transitionend/animationend which can never fire if
there are no CSS transitions; update the handler to detect and fallback: inside
the watch (using currentElement.value and
clearOpenCompleteListener/openCompleteCleanup), read getComputedStyle(el) for
transition-duration and animation-duration (and their delays) and if all
durations are zero call rootContext.notifyOpenComplete(isOpen) immediately; if
durations exist, register the existing transitionend/animationend handlers but
also start a safety timeout calculated from the computed durations plus a small
buffer that will call rootContext.notifyOpenComplete(isOpen) if events never
arrive; ensure the timeout is cleared in
clearOpenCompleteListener/openCompleteCleanup along with removing event
listeners to avoid leaks or double-calls.

---

Nitpick comments:
In `@packages/core/src/Drawer/composables/useSwipeDismiss.ts`:
- Around line 206-214: The call to getElementTransform(el) inside startSwipe
discards its return value; either store that transform for later use (e.g.,
assign to a new variable like initialTransform or previousTransform at the
top-level or in the startSwipe scope) and use it where transform/state
adjustments occur, or if the function is only invoked for a side-effect, add a
concise comment explaining that side-effect to avoid linter warnings; update
startSwipe to assign the result when the returned transform is needed by other
logic (references: function startSwipe, getElementTransform, variables
dragStartPos/pendingSwipeStartPos/elementSize).
- Around line 436-443: The current logic inside useSwipeDismiss uses
hasVertical.value to choose axis and calls findScrollableAncestor(target, axis),
which biases to 'vertical' and can miss horizontal scrollables when
bi-directional swipes are allowed; update the detection to check both axes when
the drawer supports both directions: first check vertical (hasVertical) then
fallback to horizontal (or vice versa based on hasHorizontal if present), or
explicitly call findScrollableAncestor for both 'vertical' and 'horizontal' when
not ignoring scrollable ancestors; set swipeFromScrollable and
scrollableAncestor based on the first non-null result so horizontal scrollables
are detected in bi-directional drawers (refer to
options.ignoreScrollableAncestors, hasVertical, findScrollableAncestor,
swipeFromScrollable, scrollableAncestor).
- Around line 394-397: The empty catch in useSwipeDismiss around
el.setPointerCapture(e.pointerId) silently swallows errors; update the catch to
either log the caught error in non-production/dev builds (e.g., console.warn or
processLogger.warn) or add a clear comment explaining why ignoring the error is
safe (pointer capture failures are expected on some browsers/devices). Target
the try/catch surrounding setPointerCapture inside the useSwipeDismiss
composable and ensure the handler surfaces the error only in dev (NODE_ENV
check) or documents rationale if intentionally suppressed.

In `@packages/core/src/Drawer/DrawerContentImpl.vue`:
- Around line 197-220: The three handlers use event: any; change them to use the
proper DismissableLayer event types by importing the appropriate types from the
DismissableLayer module and updating the signatures: onPointerDownOutside(event:
DismissableLayerPointerDownOutsideEvent), onFocusOutside(event:
DismissableLayerFocusOutsideEvent), onInteractOutside(event:
DismissableLayerInteractOutsideEvent); ensure the imports are added at the top
of the file and update any related tooling/type references so the handlers keep
the same logic but now have strong typing.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro

Run ID: 5a8fd102-267b-4eb1-ab27-750516f3cbb2

📥 Commits

Reviewing files that changed from the base of the PR and between 3606026 and 63ea0fb.

📒 Files selected for processing (10)
  • docs/superpowers/specs/2026-04-04-drawer-design.md
  • packages/core/src/Drawer/DrawerContent.vue
  • packages/core/src/Drawer/DrawerContentImpl.vue
  • packages/core/src/Drawer/DrawerIndent.vue
  • packages/core/src/Drawer/DrawerOverlayImpl.vue
  • packages/core/src/Drawer/DrawerRoot.vue
  • packages/core/src/Drawer/DrawerSwipeArea.vue
  • packages/core/src/Drawer/DrawerTrigger.vue
  • packages/core/src/Drawer/DrawerViewport.vue
  • packages/core/src/Drawer/composables/useSwipeDismiss.ts
✅ Files skipped from review due to trivial changes (1)
  • docs/superpowers/specs/2026-04-04-drawer-design.md
🚧 Files skipped from review as they are similar to previous changes (5)
  • packages/core/src/Drawer/DrawerTrigger.vue
  • packages/core/src/Drawer/DrawerIndent.vue
  • packages/core/src/Drawer/DrawerContent.vue
  • packages/core/src/Drawer/DrawerOverlayImpl.vue
  • packages/core/src/Drawer/DrawerRoot.vue

The Snap Points story used `min-height: var(--drawer-snap-height)` on
the popup, which the library wrote based on `popupHeight - offset`.
Since popupHeight was measured from the popup's rendered height, and
that height depended on min-height, snap resolution couldn't exceed
the popup's natural content height: both 0.5 and 1.0 snap points got
clamped to popupHeight (~270px of natural content) and deduplicated
into a single offset=0 snap. Clicking "Snap to 100%" changed the
active snap state but nothing moved visually.

BaseUI doesn't use a snap-height CSS var at all. In their snap-points
demo the popup has `max-height: 100dvh` and flex-column layout with a
scrollable inner region, and the snap point is applied purely as a
transform via `--drawer-snap-point-offset`. popupHeight measures as
the full viewport height, so 0.5/1.0 resolve to distinct offsets and
the transform slides the popup visually.

Changes:
- Remove `--drawer-snap-height` writes from DrawerContentImpl. Snap
  points are now pure transform-driven (consumer sets popup height
  independently, library only writes --drawer-snap-point-offset).
- Drop min-height/transition from .drawer-content-bottom in the story
  and remove its snap-point-offset contribution (bottom variants that
  don't use snap points don't need it).
- Add a dedicated .drawer-content-snap class that mirrors BaseUI's
  snap recipe: `height: 100dvh; max-height: 100dvh; flex-column` with
  a `.drawer-snap-scroll` child that takes `flex: 1 1 auto; min-height: 0;
  overflow-y: auto`. Transform is driven by --drawer-snap-point-offset
  + --drawer-swipe-movement-y.
- Rewire the Snap Points variant to use the new class.

Verified in histoire: Snap to 0.5 → drawer fills bottom 50% of viewport,
Snap to 1.0 → drawer fills the whole viewport, transitions are smooth.

Note on "Invalid vnode type when creating vnode: undefined" warning:
could not reproduce locally in tests (JSDOM) or in histoire dev
(Position-Top variant open/close, variant switching, sandbox direct
load). If the warning is still showing after this commit, please share
the exact repro path and I'll dig deeper.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Copy link
Copy Markdown
Contributor

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

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

Actionable comments posted: 2

♻️ Duplicate comments (1)
packages/core/src/Drawer/DrawerContentImpl.vue (1)

235-256: ⚠️ Potential issue | 🟡 Minor

Add fallback for notifyOpenComplete when drawer has no CSS transition/animation.

If the drawer element lacks CSS transitions or animations, neither transitionend nor animationend will fire, causing notifyOpenComplete to never execute. Consumers relying on this callback would hang indefinitely.

Consider detecting zero-duration transitions via getComputedStyle and firing synchronously, or adding a safety timeout based on the computed duration:

Example fallback approach
 watch(() => rootContext.open.value, (isOpen) => {
   clearOpenCompleteListener()
   const el = currentElement.value
   if (!el) {
     rootContext.notifyOpenComplete(isOpen)
     return
   }
+  const cs = getComputedStyle(el)
+  const hasTransition = cs.transitionDuration.split(',').some(d => parseFloat(d) > 0)
+  const hasAnimation = cs.animationDuration.split(',').some(d => parseFloat(d) > 0)
+  if (!hasTransition && !hasAnimation) {
+    rootContext.notifyOpenComplete(isOpen)
+    return
+  }
   const handler = (event: Event) => {
     if (event.target !== el)
       return
     clearOpenCompleteListener()
     rootContext.notifyOpenComplete(isOpen)
   }
   // ... rest unchanged
 })
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@packages/core/src/Drawer/DrawerContentImpl.vue` around lines 235 - 256, The
watcher for rootContext.open currently only listens for
transitionend/animationend and misses cases where the drawer has no CSS
transition/animation; update the handler in the watch callback (where
currentElement.value, el, handler, openCompleteCleanup and
rootContext.notifyOpenComplete are used) to compute computedStyle =
getComputedStyle(el), parse transition-duration/transition-delay and
animation-duration/animation-delay into milliseconds and derive a maxDuration;
if maxDuration is 0 call rootContext.notifyOpenComplete(isOpen) synchronously;
otherwise set a safety timeout equal to maxDuration plus a small buffer to call
notifyOpenComplete if no events fire; ensure the timeout is cleared in
openCompleteCleanup alongside removing the event listeners so there are no leaks
or duplicate notifications.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In `@packages/core/src/Drawer/story/Drawer.story.vue`:
- Around line 400-402: The close control rendered by the DrawerClose component
uses only the "×" glyph which is ambiguous for assistive tech; update the
DrawerClose element (the DrawerClose component instance) to include an explicit
accessible name via an aria-label (for example aria-label="Close" or
aria-label="Close drawer") so the icon-only button is announced correctly to
screen readers; ensure the aria-label is added to the DrawerClose JSX/ template
attributes and not to a nested element.
- Around line 317-340: When the outer drawer closes the nested state innerOpen
isn't reset so the inner drawer can reopen unexpectedly; add logic to set
innerOpen = false whenever the outer drawer's open state changes to false
(either by watching the outer drawer's v-model variable — e.g., outerOpen — or
by handling the outer DrawerRoot's openChange/close event) so the inner
DrawerRoot/innerOpen pair is always reset when the outer drawer is dismissed.

---

Duplicate comments:
In `@packages/core/src/Drawer/DrawerContentImpl.vue`:
- Around line 235-256: The watcher for rootContext.open currently only listens
for transitionend/animationend and misses cases where the drawer has no CSS
transition/animation; update the handler in the watch callback (where
currentElement.value, el, handler, openCompleteCleanup and
rootContext.notifyOpenComplete are used) to compute computedStyle =
getComputedStyle(el), parse transition-duration/transition-delay and
animation-duration/animation-delay into milliseconds and derive a maxDuration;
if maxDuration is 0 call rootContext.notifyOpenComplete(isOpen) synchronously;
otherwise set a safety timeout equal to maxDuration plus a small buffer to call
notifyOpenComplete if no events fire; ensure the timeout is cleared in
openCompleteCleanup alongside removing the event listeners so there are no leaks
or duplicate notifications.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro

Run ID: d1b732b3-f189-4da3-9bf6-c981cdf8c813

📥 Commits

Reviewing files that changed from the base of the PR and between 63ea0fb and 745b3a0.

📒 Files selected for processing (2)
  • packages/core/src/Drawer/DrawerContentImpl.vue
  • packages/core/src/Drawer/story/Drawer.story.vue

zernonia and others added 2 commits April 9, 2026 18:08
Problem: when the drawer opens at snap=0.5, the CSS keyframe animation
`drawer-slide-bottom-in` was animating the `transform` property from
translateY(100%) to translateY(0). Because `transform` is the same CSS
property used to apply the snap-point offset, the keyframe clobbered
the inline transform for the full 450ms of the enter animation. The
drawer slid in all the way to translateY(0) (snap=1.0), then the
animation ended and the inline transform reasserted, snapping the
drawer down to translateY(400) (snap=0.5). Visible as a brief "slide
past 50% then drop back" flicker.

Fix: switch the snap-points drawer to a dedicated `drawer-snap-slide-in`
/ `drawer-snap-slide-out` pair keyed on the independent `translate` CSS
property instead of `transform`. `translate` and `transform` are
composable: the keyframe animates `translate: 0 100dvh -> 0 0` while
the inline `transform: translateY(calc(snap-offset + swipe-y))` stays
applied throughout. Enter/exit now slide from below the viewport up to
the active snap point in a single continuous motion.

Only the snap-points drawer needs this; the regular bottom/top/left/
right variants keep the original `transform`-keyframe animations
because they have no snap-point offset to preserve.

Verified in histoire: open at 0.5 slides in to 50%, Snap 100% expands
smoothly via inline transform transition, Snap 50% returns smoothly,
Close slides off-screen. 32/32 tests still passing.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Three interacting bugs in the snap-points path:

1. **Overshoot past viewport top during drag.** The snap-points drawer
   accepts `directions = ['down', 'up']` so dragging up is an allowed
   direction with no damping. Dragging aggressively from snap=0.5 could
   push `--drawer-swipe-movement-y` to -500px or further, making the
   computed transform negative (`translateY(400 + -500) = -100px`) and
   the drawer would fly past the top edge of the viewport.

   Fix: clamp the story CSS transform with `max(0px, ...)` so the
   drawer can't translate above the viewport edge regardless of raw
   delta. BaseUI's snap-points demo can get away without this because
   their useSwipeDismiss damps non-allowed directions, but we allow
   both directions for snap-point navigation.

2. **Leftover `--drawer-swipe-movement-y` after snap release.** When
   bug 1's overshoot drag crossed the swipe threshold in the 'up'
   direction, `finishSwipe` evaluated `shouldDismiss = true` (the
   displacement along intendedDirection='up' exceeded threshold) and
   took the "keep transform for close animation" branch added by the
   earlier flicker fix. CSS vars were preserved, onDismiss fired but
   did nothing (hasSnapPoints guard), and the stale -500px movement
   value composed with the new snap=1.0 offset=0 → drawer stuck at
   translateY(-500).

   Fix: in `DrawerContentImpl.onRelease`, explicitly clear
   `--drawer-swipe-movement-{x,y}` after calling `snapToNearest` when
   snap points are active. The flicker-fix's transform preservation
   is only correct when the drawer is actually closing.

3. **Reopen shows wrong snap position.** `popupHeight` is stored on
   the root context and persists across close/reopen cycles. On the
   first open it's 0 → `useDrawerSnapPoints` computes resolvedSnapPoints
   with zero-offset entries → `activeSnapPointOffset.value` is 0/null
   → lazy `watch(activeSnapPointOffset, ...)` fires on the subsequent
   ResizeObserver-driven change to popupHeight=800 → CSS var set.
   But on reopen, popupHeight is already 800 from the previous mount
   → `activeSnapPointOffset.value` is correct immediately → nothing
   changes → the lazy watcher never fires → the fresh element renders
   with snapOffset=0 (appearing as snap=1.0 regardless of the actual
   activeSnapPoint).

   Fix: factor the CSS-var write into a named `writeSnapPointOffset()`
   function and call it both from the watcher (for live updates) and
   from `onMounted` (for the initial value on each mount). Tried
   `{ immediate: true, flush: 'post' }` first but it didn't reliably
   pick up the element; an explicit mount-time write is simpler and
   correct.

Verified all three in live histoire with aggressive drag sequences:
- Drag up past top → drawer pinned at y=0, not overshooting
- Release → snapOffset=0, swipeY=0, active=1 (clean state)
- Snap 50% → close → reopen → y=400, snapOffset=400px, active=0.5

32/32 tests still passing.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Copy link
Copy Markdown
Contributor

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

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

Actionable comments posted: 3

🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In `@packages/core/src/Drawer/DrawerContentImpl.vue`:
- Around line 57-61: The snap-point dismissal path in DrawerContentImpl.vue's
onSnapPointChange currently calls rootContext.onOpenChange(false) and drops the
close reason; update that branch so when point === null you call
rootContext.onOpenChange(false, { reason: 'swipe' }) (or otherwise pass a
details object with reason: 'swipe') instead of the single-arg call, leaving the
setActiveSnapPoint path unchanged so DrawerRoot.vue receives details.reason for
swipe dismissals.
- Around line 204-223: The handlers onEscapeKeyDown and onPointerDownOutside
currently set pendingDismissReason before emitting cancelable events, which can
leak a stale reason if the event is prevented; change both functions so they set
pendingDismissReason, call emits('escapeKeyDown', event) /
emits('pointerDownOutside', event), then check event.defaultPrevented and if
true clear pendingDismissReason (set to undefined) and return without
proceeding; ensure onDismiss still uses pendingDismissReason when performing the
actual close so the reason is only preserved when the event wasn't canceled.
- Around line 254-275: The watcher for rootContext.open currently only attaches
transition/animationend listeners on subsequent open changes, missing the case
where the content mounts while open; update the mount logic (e.g., in onMounted
or immediately after currentElement is set) to perform the same arming: if
currentElement.value exists and rootContext.open.value is true, register the
same transitionend/animationend handler that calls
rootContext.notifyOpenComplete(isOpen) and set openCompleteCleanup accordingly
(mirroring the handler and cleanup created in the watch block), and fall back to
calling rootContext.notifyOpenComplete(true) synchronously if no element is
present; ensure you reuse the same handler/clearOpenCompleteListener behavior to
avoid duplicate listeners.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro

Run ID: 00db3fe9-a56d-4a50-813a-d1334ad5fe99

📥 Commits

Reviewing files that changed from the base of the PR and between 330753a and f09c013.

📒 Files selected for processing (2)
  • packages/core/src/Drawer/DrawerContentImpl.vue
  • packages/core/src/Drawer/story/Drawer.story.vue
✅ Files skipped from review due to trivial changes (1)
  • packages/core/src/Drawer/story/Drawer.story.vue

Comment on lines +57 to +61
onSnapPointChange: (point) => {
if (point === null)
rootContext.onOpenChange(false)
else
rootContext.setActiveSnapPoint(point)
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟠 Major

Propagate the swipe close reason from snap-point dismissal.

When snapToNearest() resolves to null, this path closes with onOpenChange(false) and drops the reason entirely. DrawerRoot.vue only includes details.reason when a reason is passed, so snap-point swipe dismissals currently emit update:open without the advertised 'swipe' reason.

Proposed fix
   onSnapPointChange: (point) => {
     if (point === null)
-      rootContext.onOpenChange(false)
+      rootContext.onOpenChange(false, 'swipe')
     else
       rootContext.setActiveSnapPoint(point)
   },
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
onSnapPointChange: (point) => {
if (point === null)
rootContext.onOpenChange(false)
else
rootContext.setActiveSnapPoint(point)
onSnapPointChange: (point) => {
if (point === null)
rootContext.onOpenChange(false, 'swipe')
else
rootContext.setActiveSnapPoint(point)
},
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@packages/core/src/Drawer/DrawerContentImpl.vue` around lines 57 - 61, The
snap-point dismissal path in DrawerContentImpl.vue's onSnapPointChange currently
calls rootContext.onOpenChange(false) and drops the close reason; update that
branch so when point === null you call rootContext.onOpenChange(false, { reason:
'swipe' }) (or otherwise pass a details object with reason: 'swipe') instead of
the single-arg call, leaving the setActiveSnapPoint path unchanged so
DrawerRoot.vue receives details.reason for swipe dismissals.

Comment on lines +204 to +223
function onDismiss() {
if (isSwiping.value)
return
rootContext.onOpenChange(false, pendingDismissReason ?? 'outside-press')
pendingDismissReason = undefined
}

function onEscapeKeyDown(event: KeyboardEvent) {
pendingDismissReason = 'escape-key'
emits('escapeKeyDown', event)
}

function onPointerDownOutside(event: any) {
if (isSwiping.value) {
event.preventDefault()
return
}
pendingDismissReason = 'outside-press'
emits('pointerDownOutside', event)
}
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟡 Minor

Clear pendingDismissReason when the dismiss gets canceled.

onEscapeKeyDown() and onPointerDownOutside() cache the reason before emitting a cancelable event. If user code calls preventDefault(), dismiss never follows, so the stale reason can leak into the next close attempt.

Proposed fix
 function onEscapeKeyDown(event: KeyboardEvent) {
   pendingDismissReason = 'escape-key'
   emits('escapeKeyDown', event)
+  if (event.defaultPrevented)
+    pendingDismissReason = undefined
 }

 function onPointerDownOutside(event: any) {
   if (isSwiping.value) {
     event.preventDefault()
     return
   }
   pendingDismissReason = 'outside-press'
   emits('pointerDownOutside', event)
+  if (event.defaultPrevented)
+    pendingDismissReason = undefined
 }
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@packages/core/src/Drawer/DrawerContentImpl.vue` around lines 204 - 223, The
handlers onEscapeKeyDown and onPointerDownOutside currently set
pendingDismissReason before emitting cancelable events, which can leak a stale
reason if the event is prevented; change both functions so they set
pendingDismissReason, call emits('escapeKeyDown', event) /
emits('pointerDownOutside', event), then check event.defaultPrevented and if
true clear pendingDismissReason (set to undefined) and return without
proceeding; ensure onDismiss still uses pendingDismissReason when performing the
actual close so the reason is only preserved when the event wasn't canceled.

Comment on lines +254 to +275
watch(() => rootContext.open.value, (isOpen) => {
clearOpenCompleteListener()
const el = currentElement.value
if (!el) {
// If there's no element (e.g. closed without ever mounting), fire
// synchronously so consumers still get the contract.
rootContext.notifyOpenComplete(isOpen)
return
}
const handler = (event: Event) => {
if (event.target !== el)
return
clearOpenCompleteListener()
rootContext.notifyOpenComplete(isOpen)
}
el.addEventListener('transitionend', handler)
el.addEventListener('animationend', handler)
openCompleteCleanup = () => {
el.removeEventListener('transitionend', handler)
el.removeEventListener('animationend', handler)
}
})
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟠 Major

Arm openComplete for the initial mount-open transition too.

This watcher only runs on later open changes. In the normal Presence flow the content is mounted after open is already true, so no listener is registered for the enter transition and update:openComplete(true) never fires on open.

Proposed fix
 let openCompleteCleanup: (() => void) | undefined

+function armOpenCompleteListener(isOpen: boolean) {
+  clearOpenCompleteListener()
+  const el = currentElement.value
+  if (!el) {
+    rootContext.notifyOpenComplete(isOpen)
+    return
+  }
+  const handler = (event: Event) => {
+    if (event.target !== el)
+      return
+    clearOpenCompleteListener()
+    rootContext.notifyOpenComplete(isOpen)
+  }
+  el.addEventListener('transitionend', handler)
+  el.addEventListener('animationend', handler)
+  openCompleteCleanup = () => {
+    el.removeEventListener('transitionend', handler)
+    el.removeEventListener('animationend', handler)
+  }
+}
+
-watch(() => rootContext.open.value, (isOpen) => {
-  clearOpenCompleteListener()
-  const el = currentElement.value
-  if (!el) {
-    rootContext.notifyOpenComplete(isOpen)
-    return
-  }
-  const handler = (event: Event) => {
-    if (event.target !== el)
-      return
-    clearOpenCompleteListener()
-    rootContext.notifyOpenComplete(isOpen)
-  }
-  el.addEventListener('transitionend', handler)
-  el.addEventListener('animationend', handler)
-  openCompleteCleanup = () => {
-    el.removeEventListener('transitionend', handler)
-    el.removeEventListener('animationend', handler)
-  }
-})
+watch(() => rootContext.open.value, armOpenCompleteListener)

 onMounted(() => {
+  if (rootContext.open.value)
+    armOpenCompleteListener(true)
+
   rootContext.contentElement.value = currentElement.value
   rootContext.notifyParentHasNestedDrawer?.(true)
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
watch(() => rootContext.open.value, (isOpen) => {
clearOpenCompleteListener()
const el = currentElement.value
if (!el) {
// If there's no element (e.g. closed without ever mounting), fire
// synchronously so consumers still get the contract.
rootContext.notifyOpenComplete(isOpen)
return
}
const handler = (event: Event) => {
if (event.target !== el)
return
clearOpenCompleteListener()
rootContext.notifyOpenComplete(isOpen)
}
el.addEventListener('transitionend', handler)
el.addEventListener('animationend', handler)
openCompleteCleanup = () => {
el.removeEventListener('transitionend', handler)
el.removeEventListener('animationend', handler)
}
})
let openCompleteCleanup: (() => void) | undefined
function armOpenCompleteListener(isOpen: boolean) {
clearOpenCompleteListener()
const el = currentElement.value
if (!el) {
// If there's no element (e.g. closed without ever mounting), fire
// synchronously so consumers still get the contract.
rootContext.notifyOpenComplete(isOpen)
return
}
const handler = (event: Event) => {
if (event.target !== el)
return
clearOpenCompleteListener()
rootContext.notifyOpenComplete(isOpen)
}
el.addEventListener('transitionend', handler)
el.addEventListener('animationend', handler)
openCompleteCleanup = () => {
el.removeEventListener('transitionend', handler)
el.removeEventListener('animationend', handler)
}
}
watch(() => rootContext.open.value, armOpenCompleteListener)
onMounted(() => {
if (rootContext.open.value)
armOpenCompleteListener(true)
rootContext.contentElement.value = currentElement.value
rootContext.notifyParentHasNestedDrawer?.(true)
})
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@packages/core/src/Drawer/DrawerContentImpl.vue` around lines 254 - 275, The
watcher for rootContext.open currently only attaches transition/animationend
listeners on subsequent open changes, missing the case where the content mounts
while open; update the mount logic (e.g., in onMounted or immediately after
currentElement is set) to perform the same arming: if currentElement.value
exists and rootContext.open.value is true, register the same
transitionend/animationend handler that calls
rootContext.notifyOpenComplete(isOpen) and set openCompleteCleanup accordingly
(mirroring the handler and cleanup created in the watch block), and fall back to
calling rootContext.notifyOpenComplete(true) synchronously if no element is
present; ensure you reuse the same handler/clearOpenCompleteListener behavior to
avoid duplicate listeners.

…ort bottom

When dragging up from snap=0.5 toward snap=1.0 and releasing, the
drawer would snap down by the full viewport height (100dvh) and then
slide back up to the new snap position over 450ms, instead of
continuing smoothly from where the user released.

Two interacting causes:

1. **Sequencing in DrawerContentImpl.onRelease.** We were clearing
   --drawer-swipe-movement-y before the activeSnapPoint watcher had
   a chance to fire, so the computed transform visibly jumped from
   the drag value (translateY(50)) back to the old snap position
   (translateY(400)) for one frame before the new snap offset
   arrived. Fixed by calling `writeSnapPointOffset()` synchronously
   after `snapToNearest` and before clearing the movement vars —
   both writes land in the same frame and CSS transitions from the
   current rendered transform to the new target.

2. **CSS animation restart in the snap story.** The bigger issue was
   the story's `.drawer-content-snap[data-swiping]` rule setting
   `animation: none`, which transitioned animation-name from
   `drawer-snap-slide-in` to `none` during the drag. When
   `data-swiping` was removed at release, animation-name went back
   to `drawer-snap-slide-in` — which the browser interprets as a
   NEW animation declaration and runs from keyframe 0
   (`translate: 0 100dvh`). The element's rendered position became
   drag-transform (190) + translate (800) = 990 for one frame, then
   the transform transitioned from 190→0 while the translate
   animated from 800→0 — visible as "drop to the bottom of the
   screen then slide all the way up" on every release.

   Fixed by NOT touching `animation` in the data-swiping override.
   The enter animation completes within 450ms of mount, so it's
   inert by the time the user can drag. We only need to disable the
   `transform` transition during active swiping so it doesn't fight
   the raw pointer-driven transform.

Verified with raf-level instrumentation in histoire at 400x800:
- End of drag: y=190, swY=-210, tDur=0s
- First frame after release: y=190, snap=0, swY=0, tDur=0.45s
- Subsequent frames: 181.9 → 173.1 → 164.1 → ... → 0

The transform transition now starts exactly at the user's finger
position and smoothly interpolates to the new snap target.

Regression tests: snap-to-50% via button, small downward drag (stay
at 50%), aggressive downward drag (dismiss), reopen — all correct.
32/32 tests still passing.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
zernonia added a commit that referenced this pull request Apr 9, 2026
Critical:
- DrawerContent.vue: non-modal close-auto-focus and interact-outside
  handlers were reading/writing bare ref variables in template inline
  handlers. Move them to named functions in <script setup> so the
  hasInteractedOutside / hasPointerDownOutside refs are accessed via
  .value explicitly. Fixes broken focus restoration in non-modal mode.
- useSwipeDismiss.ts: preventDefault was guarded on isSwiping.value
  BEFORE processMove, so the very move that crosses the swipe threshold
  (and sets isSwiping=true) was not prevented, causing a one-frame
  scroll hitch on mobile. Capture wasSwiping before processMove and
  prevent on either side of the transition.

Major:
- useSwipeDismiss.ts: the pendingSwipe block was promoting to an active
  swipe even when dir === undefined (user dragging against the dismiss
  direction), which could steal scroll/drag on one-direction drawers.
  Bail out early when no allowed direction is identified.
- DrawerOverlayImpl.vue: useBodyScrollLock was locking unconditionally,
  which could keep page scroll blocked while forceMount kept the overlay
  mounted with the drawer closed. Use the returned `locked` ref and sync
  it with rootContext.open.value via a watcher.
- DrawerRoot.vue: update:openComplete was firing via queueMicrotask,
  which completes almost immediately after the open state changes --
  well before any CSS transition finishes. Move the emit to
  DrawerContentImpl which attaches a one-shot transitionend/animationend
  listener on the popup element and calls rootContext.notifyOpenComplete
  only when the transition actually finishes. Guards against child
  element transitions via event.target === el.
- DrawerSwipeArea.vue: directions array captured the initial value
  only. Wrap in computed so prop/state changes propagate to
  useSwipeDismiss.
- DrawerViewport.vue: only :as and :as-child were forwarded to
  Primitive, dropping class/style/id/listeners/data-attrs. Use
  v-bind="props" to match sibling components.

Minor:
- DrawerIndent.vue: the data-inactive attribute was always set when
  providerContext was missing. Only set when active is explicitly
  false.
- DrawerTrigger.vue: triggerElement was assigned in onMounted only.
  Replace with watch on currentElement so v-if / conditional
  rendering scenarios keep the trigger reference in sync. Clear on
  unmount.
- docs/superpowers/specs/2026-04-04-drawer-design.md: add language
  tags to fenced blocks (text) to satisfy markdownlint MD040.

Not applied:
- story --drawer-snap-height -> --drawer-height: CodeRabbit claimed
  the snap-height var is not produced by the primitive, but it is
  written by DrawerContentImpl:77 when a snap point is active (and
  cleared on 81). Changing the story would break snap-point height
  behavior.

Tests: 32/32 still passing.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Covers every library-level bug fixed in this PR's follow-up rounds.
Browser-only rendering issues (animation keyframe restart, CSS
clamping via max(0px,...)) can't be reproduced in JSDOM, but the
library behaviors they depend on are all now pinned down by tests.

New files:

**useSwipeDismiss.test.ts** (3 tests) — dismiss vs cancel CSS var
clearing behavior. Pins down the close-animation flicker fix:
- dismiss path preserves --drawer-swipe-movement-{x,y} so the close
  transition runs smoothly from the dragged position
- cancel path (release below threshold) clears the vars so the drawer
  animates back to rest
- data-swipe-dismissed marker attribute is set only on dismiss
- onRelease fires with a measured velocity vector
JSDOM polyfills for PointerEvent and setPointerCapture are included
in-file so the test is self-contained.

**Drawer.snap.test.ts** (6 tests) — integration tests for the three
snap-point bugs, stubbing ResizeObserver and window.innerHeight so
popupHeight resolves deterministically to 800px:

1. Initial open at snap=0.5 writes --drawer-snap-point-offset=400px.
   Sanity check for the baseline snap-offset CSS var write.

2. Reopen after close writes the offset on mount (lazy-watcher bug).
   The REGRESSION here: on reopen, popupHeight is already cached on
   the root context, so activeSnapPointOffset is immediately correct
   and a lazy `watch(activeSnapPointOffset, ...)` would never fire.
   Drawer rendered as if snap=1.0 regardless of actual activeSnapPoint.
   Fix: explicit writeSnapPointOffset() call in onMounted.

3. Compound close→reopen cycle preserves the correct offset.

4. Initial open at defaultSnapPoint=1 produces offset 0px.

5. Snap-to-snap release clears --drawer-swipe-movement-y. Regression
   for the bug where the flicker fix's "preserve transform on dismiss"
   branch left the swipe movement var set after a snap transition,
   so the next frame's transform composed stale drag state into the
   new snap offset.

6. Snap-to-snap release updates --drawer-snap-point-offset and clears
   the movement var synchronously (sequencing regression for the
   smooth drag-to-next-snap animation).

**useDrawerSnapPoints.test.ts** — 3 new overshoot-clamping tests
covering the Base UI snap-release math's `Math.max(0, Math.min(ph, ...))`
dragTargetOffset clamp. Verifies that aggressive up-drags past the
fully-open position (huge negative drag deltas) resolve to the
nearest valid snap rather than returning a negative offset. Includes
the sign-flipped equivalent for `up` drawers.

Test count: 32 → 44.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
@zernonia zernonia changed the title feat(Drawer): add Drawer primitive with BaseUI parity feat(Drawer): add Drawer primitive Apr 9, 2026
zernonia and others added 2 commits April 10, 2026 00:42
Ports BaseUI's `uncontained` drawer demo — an iOS-style action sheet
where the popup itself is a `pointer-events: none` flex container that
carries the swipe transform but has no visible background, and the
visible "surfaces" inside (an action list card and a danger button
card) re-enable pointer events and render as independently-rounded
floating cards.

This was the one BaseUI demo with no equivalent in our story. The
other variants are either direct ports (Indent Effect) or
reka-flavored versions of BaseUI patterns that cover more API corners
(4 Position variants instead of 1; interactive snap-points with
toggle buttons).

Wire-compatible with BaseUI:
- Controlled `v-model:open` with explicit close on button click
- Visually-hidden Drawer.Title/Description so screen readers still
  announce the sheet purpose
- Visually-hidden Drawer.Close on the first list item so keyboard and
  AT users have a reachable close action without a visible Cancel
  button (the visible buttons just toggle the controlled state)
- Popup max-width 28rem, bottom-gap layout with 0.75rem between the
  action list card and the danger button card

Verified live in histoire at 400x800: two separate rounded cards
stacked with the danger "Block User" button in its own surface, swipe
transform on the popup only affects the outer flex container.
44/44 tests still passing.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
The Default story and the 4 Position variants all reproduced the same
animation-restart bug that the Snap Points variant had:

Cause 1: CSS \`animation: none\` on \`[data-swiping]\`

During drag, the \`[data-swiping]\` rule set \`animation: none\`,
transitioning animation-name from e.g. \`drawer-slide-bottom-in\` to
\`none\`. When data-swiping was removed on release, animation-name
went BACK to \`drawer-slide-bottom-in\` — which the browser interprets
as a brand-new animation declaration and restarts from keyframe 0.
The drawer visibly bounced to its off-screen start position
(translateY(100%), translateY(-100%), translateX(-100%),
translateX(100%)) and then slid all the way back after every drag
release.

Cause 2: Keyframes animated \`transform\`

The existing keyframes used \`transform: translateY(100%)\` etc, which
is the same CSS property used to apply the drag offset. Even without
cause 1, any keyframe running on top of the drag would clobber the
inline transform for its duration.

Fix (ported from the earlier Snap Points fix):

- Rewrite all 8 slide keyframes to animate the independent \`translate\`
  CSS property instead of \`transform\`, so they compose with the inline
  \`transform\` on the popup.
- Drop \`animation: none\` from the \`[data-swiping]\` overrides for all
  5 position/default variants. The enter animation is inert within
  450ms of mount, so there's no reason to suppress it during drag.
  We only need to zero transition-duration so the transform transition
  doesn't fight the raw pointer delta.
- Delete the duplicate \`<style>\` block from \`_Drawer.vue\` (it had its
  own copy of the buggy keyframe + the \`animation: none\` rule). The
  Default variant now inherits from \`Drawer.story.vue\`'s global styles
  like every other variant, so the fix lives in exactly one place.

Verified with raf-level instrumentation in histoire at 400x800 for
both the Default (bottom) and Position-Top variants:

  Default + small downward drag to +20px:
    dt=1023 end-of-drag: y=616, transform=translateY(20), tDur=0s
    dt=1197 first release frame: y=616, transform=translateY(20),
            tDur=0.45s  <-- starts exactly where drag ended
    dt=1205: translateY(19.15)
    dt=1213: translateY(18.23)
    ...smooth interpolation to translateY(0)

  Position-Top + small upward drag:
    y=0 -> drag -15px -> release -> settles at translateY(0) cleanly

44/44 tests still passing.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
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.

[Feature]: Drawer

1 participant