For agentic workers: REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (
- [ ]) syntax for tracking.
Goal: Port the BaseUI Drawer component to Reka UI as a standalone Vue 3 component system with swipe gestures, snap points, Provider/Indent effect, and nested drawer support.
Architecture: Standalone (not wrapping Dialog) — built from FocusScope, DismissableLayer, Presence, and TeleportPrimitive primitives. Gesture system uses a custom useSwipeDismiss composable (pointer + touch events, scroll conflict detection, velocity tracking) plus VueUse utilities. CSS variables are set imperatively for 60fps performance.
Tech Stack: Vue 3, TypeScript, @vueuse/core (useEventListener, useResizeObserver), vitest, @testing-library/vue, vitest-axe
Spec: docs/superpowers/specs/2026-04-04-drawer-design.md
| File | Responsibility |
|---|---|
packages/core/src/Drawer/utils.ts |
CSS var name constants, getDisplacement, getElementTransform, getOpenState |
packages/core/src/Drawer/DrawerRoot.vue |
Context provider — open/modal/swipeDirection/snapPoint state |
packages/core/src/Drawer/DrawerTrigger.vue |
Button that opens drawer |
packages/core/src/Drawer/DrawerClose.vue |
Button that closes drawer |
packages/core/src/Drawer/DrawerPortal.vue |
Teleports children to body |
packages/core/src/Drawer/DrawerOverlay.vue |
Backdrop with swipe-progress CSS var |
packages/core/src/Drawer/DrawerContent.vue |
Presence-wrapped content (forceMount prop) |
packages/core/src/Drawer/DrawerContentImpl.vue |
FocusScope + DismissableLayer + gesture binding + CSS var management |
packages/core/src/Drawer/DrawerTitle.vue |
h2 label |
packages/core/src/Drawer/DrawerDescription.vue |
p description |
packages/core/src/Drawer/DrawerHandle.vue |
Visible drag grip — binds pointer/touch events from context |
packages/core/src/Drawer/DrawerSwipeArea.vue |
Invisible zone for swipe-to-open gestures |
packages/core/src/Drawer/DrawerProvider.vue |
Global coordinator — tracks open drawers, owns visualStateStore |
packages/core/src/Drawer/DrawerIndent.vue |
App UI wrapper — subscribes to visualStateStore, sets CSS vars imperatively |
packages/core/src/Drawer/DrawerIndentBackground.vue |
Background layer — data-active/data-inactive |
packages/core/src/Drawer/composables/useSwipeDismiss.ts |
Core gesture composable — pointer events + touch events + scroll conflict + velocity |
packages/core/src/Drawer/composables/useDrawerSnapPoints.ts |
Resolves snap points (fraction/px/rem) → pixel heights/offsets |
packages/core/src/Drawer/index.ts |
All exports |
packages/core/src/Drawer/Drawer.test.ts |
Integration tests |
packages/core/src/Drawer/story/Drawer.story.vue |
Histoire story |
packages/core/src/Drawer/story/_Drawer.vue |
Story component |
packages/core/src/index.ts |
Add export * from './Drawer' |
Files:
-
Create:
packages/core/src/Drawer/utils.ts -
Step 1: Create
utils.ts
// packages/core/src/Drawer/utils.ts
export type SwipeDirection = 'up' | 'down' | 'left' | 'right'
export type DrawerSnapPoint = number | string
export const DRAWER_CSS_VARS = {
swipeMovementX: '--drawer-swipe-movement-x',
swipeMovementY: '--drawer-swipe-movement-y',
snapPointOffset: '--drawer-snap-point-offset',
height: '--drawer-height',
frontmostHeight: '--drawer-frontmost-height',
swipeProgress: '--drawer-swipe-progress',
swipeStrength: '--drawer-swipe-strength',
nestedDrawers: '--nested-drawers',
} as const
export interface NestedSwipeProgressStore {
getSnapshot: () => number
subscribe: (listener: () => void) => () => void
}
export function createNestedSwipeProgressStore(): NestedSwipeProgressStore & {
set: (progress: number) => void
} {
let progress = 0
const listeners = new Set<() => void>()
return {
getSnapshot: () => progress,
subscribe(listener) {
listeners.add(listener)
return () => listeners.delete(listener)
},
set(next: number) {
if (next !== progress) {
progress = next
listeners.forEach(l => l())
}
},
}
}
export function getDisplacement(
direction: SwipeDirection,
deltaX: number,
deltaY: number,
): number {
switch (direction) {
case 'up': return -deltaY
case 'down': return deltaY
case 'left': return -deltaX
case 'right': return deltaX
default: return 0
}
}
export function getElementTransform(element: HTMLElement): {
x: number
y: number
scale: number
} {
const style = window.getComputedStyle(element)
const transform = style.transform
let x = 0
let y = 0
let scale = 1
if (transform && transform !== 'none') {
const matrix = transform.match(/matrix(?:3d)?\(([^)]+)\)/)
if (matrix) {
const v = matrix[1].split(', ').map(Number)
if (v.length === 6) {
x = v[4]
y = v[5]
scale = Math.sqrt(v[0] * v[0] + v[1] * v[1])
}
else if (v.length === 16) {
x = v[12]
y = v[13]
scale = v[0]
}
}
}
return { x, y, scale }
}
export function registerDrawerCssProperties() {
if (typeof CSS === 'undefined' || !CSS.registerProperty)
return
const vars = Object.values(DRAWER_CSS_VARS)
for (const name of vars) {
try {
CSS.registerProperty({ name, syntax: '*', inherits: false, initialValue: '0' })
}
catch {}
}
}- Step 2: Commit
git add packages/core/src/Drawer/utils.ts
git commit -m "feat(Drawer): add utils, types, and CSS var constants"Files:
-
Create:
packages/core/src/Drawer/DrawerRoot.vue -
Step 1: Create
DrawerRoot.vue
<!-- packages/core/src/Drawer/DrawerRoot.vue -->
<script lang="ts">
import type { Ref } from 'vue'
import type {
DrawerSnapPoint,
NestedSwipeProgressStore,
SwipeDirection,
} from './utils'
import { createContext, useId } from '@/shared'
import { createNestedSwipeProgressStore } from './utils'
export interface DrawerRootProps {
/** v-model:open */
open?: boolean
defaultOpen?: boolean
modal?: boolean
/** Direction to swipe to dismiss. @default 'down' */
swipeDirection?: SwipeDirection
/** Preset snap positions (fractions 0-1, pixels >1, or '148px'/'30rem' strings) */
snapPoints?: DrawerSnapPoint[]
/** v-model:snapPoint */
snapPoint?: DrawerSnapPoint | null
defaultSnapPoint?: DrawerSnapPoint | null
/**
* When true, velocity determines which snap to target (skip to non-adjacent).
* When false, nearest snap by height is used.
* @default true
*/
snapToSequentialPoints?: boolean
}
export type DrawerRootEmits = {
'update:open': [value: boolean]
'update:snapPoint': [value: DrawerSnapPoint | null]
}
export interface DrawerRootContext {
open: Readonly<Ref<boolean>>
modal: Ref<boolean>
swipeDirection: Ref<SwipeDirection>
snapPoints: Ref<DrawerSnapPoint[] | undefined>
activeSnapPoint: Ref<DrawerSnapPoint | null | undefined>
snapToSequentialPoints: Ref<boolean>
popupHeight: Ref<number>
frontmostHeight: Ref<number>
hasNestedDrawer: Ref<boolean>
nestedSwiping: Ref<boolean>
nestedSwipeProgressStore: NestedSwipeProgressStore
onOpenChange: (value: boolean) => void
setActiveSnapPoint: (point: DrawerSnapPoint | null) => void
onPopupHeightChange: (height: number) => void
onNestedFrontmostHeightChange: (height: number) => void
onNestedDrawerPresenceChange: (present: boolean) => void
onNestedSwipingChange: (swiping: boolean) => void
onNestedSwipeProgressChange: (progress: number) => void
notifyParentFrontmostHeight?: (height: number) => void
notifyParentSwipingChange?: (swiping: boolean) => void
notifyParentSwipeProgressChange?: (progress: number) => void
notifyParentHasNestedDrawer?: (present: boolean) => void
triggerElement: Ref<HTMLElement | undefined>
contentElement: Ref<HTMLElement | undefined>
contentId: string
titleId: string
descriptionId: string
}
export const [injectDrawerRootContext, provideDrawerRootContext]
= createContext<DrawerRootContext>('DrawerRoot')
</script>
<script setup lang="ts">
import { useVModel } from '@vueuse/core'
import { ref, toRefs } from 'vue'
const props = withDefaults(defineProps<DrawerRootProps>(), {
open: undefined,
defaultOpen: false,
modal: true,
swipeDirection: 'down',
snapPoints: undefined,
snapPoint: undefined,
defaultSnapPoint: undefined,
snapToSequentialPoints: true,
})
const emit = defineEmits<DrawerRootEmits>()
defineSlots<{
default?: (props: { open: boolean, close: () => void }) => any
}>()
const open = useVModel(props, 'open', emit, {
defaultValue: props.defaultOpen,
passive: (props.open === undefined) as false,
}) as Ref<boolean>
const activeSnapPoint = useVModel(props, 'snapPoint', emit, {
defaultValue: props.defaultSnapPoint ?? null,
passive: (props.snapPoint === undefined) as false,
eventName: 'update:snapPoint',
}) as Ref<DrawerSnapPoint | null | undefined>
const { modal, swipeDirection, snapPoints, snapToSequentialPoints } = toRefs(props)
const triggerElement = ref<HTMLElement>()
const contentElement = ref<HTMLElement>()
const popupHeight = ref(0)
const frontmostHeight = ref(0)
const hasNestedDrawer = ref(false)
const nestedSwiping = ref(false)
const nestedSwipeProgressStore = createNestedSwipeProgressStore()
// Optional parent context for nested drawer support
const parentContext = injectDrawerRootContext(null)
provideDrawerRootContext({
open,
modal,
swipeDirection,
snapPoints,
activeSnapPoint,
snapToSequentialPoints,
popupHeight,
frontmostHeight,
hasNestedDrawer,
nestedSwiping,
nestedSwipeProgressStore,
onOpenChange(value) { open.value = value },
setActiveSnapPoint(point) { activeSnapPoint.value = point },
onPopupHeightChange(h) { popupHeight.value = h },
onNestedFrontmostHeightChange(h) { frontmostHeight.value = h },
onNestedDrawerPresenceChange(present) {
hasNestedDrawer.value = present
parentContext?.notifyParentHasNestedDrawer?.(present)
},
onNestedSwipingChange(swiping) {
nestedSwiping.value = swiping
parentContext?.notifyParentSwipingChange?.(swiping)
},
onNestedSwipeProgressChange(progress) {
nestedSwipeProgressStore.set(progress)
parentContext?.notifyParentSwipeProgressChange?.(progress)
},
notifyParentFrontmostHeight: parentContext?.onNestedFrontmostHeightChange,
notifyParentSwipingChange: parentContext?.onNestedSwipingChange,
notifyParentSwipeProgressChange: parentContext?.onNestedSwipeProgressChange,
notifyParentHasNestedDrawer: parentContext?.onNestedDrawerPresenceChange,
triggerElement,
contentElement,
contentId: useId(undefined, 'reka-drawer-content'),
titleId: useId(undefined, 'reka-drawer-title'),
descriptionId: useId(undefined, 'reka-drawer-description'),
})
</script>
<template>
<slot :open="open" :close="() => open = false" />
</template>- Step 2: Commit
git add packages/core/src/Drawer/DrawerRoot.vue
git commit -m "feat(Drawer): add DrawerRoot context and state management"Files:
-
Create:
packages/core/src/Drawer/DrawerTrigger.vue -
Create:
packages/core/src/Drawer/DrawerClose.vue -
Create:
packages/core/src/Drawer/DrawerTitle.vue -
Create:
packages/core/src/Drawer/DrawerDescription.vue -
Create:
packages/core/src/Drawer/DrawerPortal.vue -
Step 1: Create
DrawerTrigger.vue
<!-- packages/core/src/Drawer/DrawerTrigger.vue -->
<script lang="ts">
import type { PrimitiveProps } from '@/Primitive'
export interface DrawerTriggerProps extends PrimitiveProps {}
</script>
<script setup lang="ts">
import { onMounted } from 'vue'
import { Primitive } from '@/Primitive'
import { useForwardExpose } from '@/shared'
import { injectDrawerRootContext } from './DrawerRoot.vue'
const props = withDefaults(defineProps<DrawerTriggerProps>(), { as: 'button' })
const rootContext = injectDrawerRootContext()
const { forwardRef, currentElement } = useForwardExpose()
onMounted(() => {
rootContext.triggerElement.value = currentElement.value
})
</script>
<template>
<Primitive
v-bind="props"
:ref="forwardRef"
:type="as === 'button' ? 'button' : undefined"
aria-haspopup="dialog"
:aria-expanded="rootContext.open.value"
:aria-controls="rootContext.open.value ? rootContext.contentId : undefined"
:data-state="rootContext.open.value ? 'open' : 'closed'"
@click="rootContext.onOpenChange(true)"
>
<slot />
</Primitive>
</template>- Step 2: Create
DrawerClose.vue
<!-- packages/core/src/Drawer/DrawerClose.vue -->
<script lang="ts">
import type { PrimitiveProps } from '@/Primitive'
export interface DrawerCloseProps extends PrimitiveProps {}
</script>
<script setup lang="ts">
import { Primitive } from '@/Primitive'
import { useForwardExpose } from '@/shared'
import { injectDrawerRootContext } from './DrawerRoot.vue'
const props = withDefaults(defineProps<DrawerCloseProps>(), { as: 'button' })
useForwardExpose()
const rootContext = injectDrawerRootContext()
</script>
<template>
<Primitive
v-bind="props"
:type="as === 'button' ? 'button' : undefined"
@click="rootContext.onOpenChange(false)"
>
<slot />
</Primitive>
</template>- Step 3: Create
DrawerTitle.vue
<!-- packages/core/src/Drawer/DrawerTitle.vue -->
<script lang="ts">
import type { PrimitiveProps } from '@/Primitive'
export interface DrawerTitleProps extends PrimitiveProps {}
</script>
<script setup lang="ts">
import { Primitive } from '@/Primitive'
import { useForwardExpose } from '@/shared'
import { injectDrawerRootContext } from './DrawerRoot.vue'
const props = withDefaults(defineProps<DrawerTitleProps>(), { as: 'h2' })
const rootContext = injectDrawerRootContext()
useForwardExpose()
</script>
<template>
<Primitive v-bind="props" :id="rootContext.titleId">
<slot />
</Primitive>
</template>- Step 4: Create
DrawerDescription.vue
<!-- packages/core/src/Drawer/DrawerDescription.vue -->
<script lang="ts">
import type { PrimitiveProps } from '@/Primitive'
export interface DrawerDescriptionProps extends PrimitiveProps {}
</script>
<script setup lang="ts">
import { Primitive } from '@/Primitive'
import { useForwardExpose } from '@/shared'
import { injectDrawerRootContext } from './DrawerRoot.vue'
const props = withDefaults(defineProps<DrawerDescriptionProps>(), { as: 'p' })
useForwardExpose()
const rootContext = injectDrawerRootContext()
</script>
<template>
<Primitive v-bind="props" :id="rootContext.descriptionId">
<slot />
</Primitive>
</template>- Step 5: Create
DrawerPortal.vue
<!-- packages/core/src/Drawer/DrawerPortal.vue -->
<script lang="ts">
import type { TeleportProps } from '@/Teleport'
export interface DrawerPortalProps extends TeleportProps {}
</script>
<script setup lang="ts">
import { TeleportPrimitive } from '@/Teleport'
defineProps<DrawerPortalProps>()
</script>
<template>
<TeleportPrimitive v-bind="$props">
<slot />
</TeleportPrimitive>
</template>- Step 6: Commit
git add 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/DrawerPortal.vue
git commit -m "feat(Drawer): add Trigger, Close, Title, Description, Portal components"Files:
-
Create:
packages/core/src/Drawer/DrawerOverlay.vue -
Step 1: Create
DrawerOverlay.vue
<!-- packages/core/src/Drawer/DrawerOverlay.vue -->
<script lang="ts">
import type { PrimitiveProps } from '@/Primitive'
export interface DrawerOverlayProps extends PrimitiveProps {
/** Keep mounted for animation control. */
forceMount?: boolean
/** Render even when inside a nested drawer. @default false */
forceRender?: boolean
}
</script>
<script setup lang="ts">
import { Presence } from '@/Presence'
import { Primitive } from '@/Primitive'
import { useBodyScrollLock, useForwardExpose } from '@/shared'
import { injectDrawerRootContext } from './DrawerRoot.vue'
import { DRAWER_CSS_VARS } from './utils'
const props = withDefaults(defineProps<DrawerOverlayProps>(), {
forceMount: false,
forceRender: false,
})
const rootContext = injectDrawerRootContext()
const { forwardRef } = useForwardExpose()
// Only lock scroll when modal + not nested (parent already locked)
const isTopLevel = !injectDrawerRootContext(null) || props.forceRender
useBodyScrollLock(isTopLevel && rootContext.modal.value)
</script>
<template>
<Presence :present="forceMount || rootContext.open.value">
<Primitive
v-if="rootContext.modal.value"
v-bind="$attrs"
:ref="forwardRef"
:as="as"
:as-child="asChild"
:data-state="rootContext.open.value ? 'open' : 'closed'"
:style="{
pointerEvents: rootContext.open.value ? 'auto' : 'none',
userSelect: 'none',
[DRAWER_CSS_VARS.swipeProgress]: '0',
[DRAWER_CSS_VARS.swipeStrength]: '1',
}"
>
<slot />
</Primitive>
</Presence>
</template>- Step 2: Commit
git add packages/core/src/Drawer/DrawerOverlay.vue
git commit -m "feat(Drawer): add DrawerOverlay with swipe-progress CSS var"Files:
- Create:
packages/core/src/Drawer/composables/useSwipeDismiss.ts
This is the most critical piece. It handles pointer events (mouse/pen) and touch events (mobile) with scroll conflict detection and velocity tracking.
- Step 1: Create
useSwipeDismiss.ts
// packages/core/src/Drawer/composables/useSwipeDismiss.ts
import type { Ref } from 'vue'
import type { SwipeDirection } from '../utils'
import { useEventListener } from '@vueuse/core'
import { onUnmounted, ref, toValue, watch } from 'vue'
import { getDisplacement, getElementTransform } from '../utils'
export interface SwipeProgressDetails {
deltaX: number
deltaY: number
direction: SwipeDirection | undefined
}
export interface UseSwipeDismissOptions {
enabled: boolean | Ref<boolean>
elementRef: Ref<HTMLElement | null | undefined>
directions: SwipeDirection[]
movementCssVars: { x: string, y: string }
swipeThreshold?: number | ((opts: { element: HTMLElement, direction: SwipeDirection }) => number)
ignoreScrollableAncestors?: boolean
canStart?: () => boolean
onDismiss?: () => void
onProgress?: (progress: number, details?: SwipeProgressDetails) => void
onCancel?: () => void
onSwipeStart?: () => void
onRelease?: (velocity: { x: number, y: number }) => void
onSwipingChange?: (swiping: boolean) => void
}
const DEFAULT_SWIPE_THRESHOLD = 40
const REVERSE_CANCEL_THRESHOLD = 10
const MIN_DRAG_THRESHOLD = 1
const MIN_RELEASE_VELOCITY_DURATION_MS = 16
const MAX_RELEASE_VELOCITY_AGE_MS = 80
const DEFAULT_IGNORE_SELECTOR = 'button,a,input,select,textarea,label,[role="button"]'
function findScrollableAncestor(
el: Element | null,
axis: 'vertical' | 'horizontal',
): HTMLElement | null {
if (!el || el === document.body)
return null
const style = window.getComputedStyle(el as HTMLElement)
const overflow = axis === 'vertical'
? style.overflowY
: style.overflowX
if (
(overflow === 'auto' || overflow === 'scroll')
&& (axis === 'vertical'
? (el as HTMLElement).scrollHeight > (el as HTMLElement).clientHeight
: (el as HTMLElement).scrollWidth > (el as HTMLElement).clientWidth)
) {
return el as HTMLElement
}
return findScrollableAncestor(el.parentElement, axis)
}
export function useSwipeDismiss(options: UseSwipeDismissOptions) {
const {
elementRef,
directions,
movementCssVars,
swipeThreshold: swipeThresholdProp,
canStart,
onDismiss,
onProgress,
onCancel,
onSwipeStart,
onRelease,
onSwipingChange,
} = options
const hasVertical = directions.includes('up') || directions.includes('down')
const hasHorizontal = directions.includes('left') || directions.includes('right')
const isSwiping = ref(false)
const swipeDirection = ref<SwipeDirection | undefined>(undefined)
const dragOffset = ref({ x: 0, y: 0 })
// Internal state (not reactive — use refs for perf)
let dragStartPos = { x: 0, y: 0 }
let initialTransform = { x: 0, y: 0, scale: 1 }
let intendedDirection: SwipeDirection | undefined
let maxDisplacement = 0
let cancelledSwipe = false
let swipeCancelBaseline = { x: 0, y: 0 }
let isFirstMove = false
let pendingSwipe = false
let pendingSwipeStartPos: { x: number, y: number } | null = null
let swipeFromScrollable = false
let sawPrimaryButtonsOnMove = false
let elementSize = { width: 0, height: 0 }
let swipeProgress = 0
let lastDragSample: { x: number, y: number, time: number } | null = null
let lastVelocity = { x: 0, y: 0 }
let lockedAxis: 'horizontal' | 'vertical' | null = null
let activePointerId: number | null = null
let pointerStarted = false
function getThreshold(el: HTMLElement, dir: SwipeDirection): number {
if (typeof swipeThresholdProp === 'function')
return Math.max(0, swipeThresholdProp({ element: el, direction: dir }))
return typeof swipeThresholdProp === 'number' ? swipeThresholdProp : DEFAULT_SWIPE_THRESHOLD
}
function getPos(e: PointerEvent | TouchEvent): { x: number, y: number } | null {
if ('touches' in e) {
const t = e.touches[0]
return t ? { x: t.clientX, y: t.clientY } : null
}
return { x: (e as PointerEvent).clientX, y: (e as PointerEvent).clientY }
}
function setSwiping(next: boolean) {
if (isSwiping.value === next)
return
isSwiping.value = next
onSwipingChange?.(next)
}
function recordSample(offset: { x: number, y: number }, time: number) {
if (lastDragSample && time > lastDragSample.time) {
const dt = Math.max(time - lastDragSample.time, MIN_RELEASE_VELOCITY_DURATION_MS)
lastVelocity = {
x: (offset.x - lastDragSample.x) / dt,
y: (offset.y - lastDragSample.y) / dt,
}
}
lastDragSample = { x: offset.x, y: offset.y, time }
}
function setCssVars(el: HTMLElement, x: number, y: number) {
el.style.setProperty(movementCssVars.x, `${x}`)
el.style.setProperty(movementCssVars.y, `${y}`)
}
function clearCssVars(el: HTMLElement) {
el.style.setProperty(movementCssVars.x, '0')
el.style.setProperty(movementCssVars.y, '0')
}
function reset() {
setSwiping(false)
swipeDirection.value = undefined
dragOffset.value = { x: 0, y: 0 }
dragStartPos = { x: 0, y: 0 }
initialTransform = { x: 0, y: 0, scale: 1 }
intendedDirection = undefined
maxDisplacement = 0
cancelledSwipe = false
swipeCancelBaseline = { x: 0, y: 0 }
isFirstMove = false
pendingSwipe = false
pendingSwipeStartPos = null
swipeFromScrollable = false
sawPrimaryButtonsOnMove = false
elementSize = { width: 0, height: 0 }
swipeProgress = 0
lastDragSample = null
lastVelocity = { x: 0, y: 0 }
lockedAxis = null
activePointerId = null
pointerStarted = false
}
function startSwipe(el: HTMLElement, pos: { x: number, y: number }) {
initialTransform = getElementTransform(el)
dragStartPos = pos
pendingSwipeStartPos = pos
elementSize = { width: el.offsetWidth, height: el.offsetHeight }
isFirstMove = true
pendingSwipe = true
}
function processMove(el: HTMLElement, pos: { x: number, y: number }, time: number) {
const rawDx = pos.x - dragStartPos.x
const rawDy = pos.y - dragStartPos.y
// Determine direction lock on first move
if (isFirstMove) {
isFirstMove = false
const absX = Math.abs(rawDx)
const absY = Math.abs(rawDy)
if (hasVertical && hasHorizontal) {
lockedAxis = absX > absY ? 'horizontal' : 'vertical'
}
else if (hasVertical) {
lockedAxis = 'vertical'
}
else {
lockedAxis = 'horizontal'
}
}
const dx = lockedAxis === 'vertical' ? 0 : rawDx
const dy = lockedAxis === 'horizontal' ? 0 : rawDy
// Determine swipe direction from displacement
const dir: SwipeDirection | undefined = directions.find(d => getDisplacement(d, dx, dy) > 0)
if (pendingSwipe && pendingSwipeStartPos) {
const pending = getDisplacement(
dir ?? directions[0],
pos.x - pendingSwipeStartPos.x,
pos.y - pendingSwipeStartPos.y,
)
if (Math.abs(pending) < MIN_DRAG_THRESHOLD)
return
pendingSwipe = false
intendedDirection = dir
swipeDirection.value = dir
setSwiping(true)
onSwipeStart?.()
}
if (!isSwiping.value)
return
const displacement = getDisplacement(intendedDirection ?? directions[0], dx, dy)
// Detect reversal (cancel swipe)
if (!cancelledSwipe) {
maxDisplacement = Math.max(maxDisplacement, displacement)
if (
maxDisplacement > DEFAULT_SWIPE_THRESHOLD / 2
&& maxDisplacement - displacement > REVERSE_CANCEL_THRESHOLD
) {
cancelledSwipe = true
swipeCancelBaseline = pos
}
}
// Apply damping when moving against dismiss direction (overshoot)
const overshoot = Math.max(0, -displacement)
const dampedDisplacement = overshoot > 0
? -Math.sqrt(overshoot)
: displacement
const offsetX = lockedAxis === 'vertical'
? 0
: (intendedDirection === 'left' || intendedDirection === 'right')
? dampedDisplacement
: 0
const offsetY = lockedAxis === 'horizontal'
? 0
: (intendedDirection === 'up' || intendedDirection === 'down')
? dampedDisplacement
: 0
dragOffset.value = { x: offsetX, y: offsetY }
setCssVars(el, offsetX, offsetY)
recordSample({ x: offsetX, y: offsetY }, time)
// Progress: 0 = closed, 1 = fully dismissed
const el2 = elementRef.value
if (el2) {
const dim = intendedDirection === 'up' || intendedDirection === 'down'
? elementSize.height || el2.offsetHeight
: elementSize.width || el2.offsetWidth
const threshold = getThreshold(el2, intendedDirection ?? directions[0])
const p = Math.min(1, Math.max(0, displacement / (dim + threshold)))
if (p !== swipeProgress) {
swipeProgress = p
onProgress?.(p, { deltaX: dx, deltaY: dy, direction: intendedDirection })
}
}
}
function finishSwipe(el: HTMLElement) {
if (!isSwiping.value) {
reset()
return
}
const displacement = getDisplacement(
intendedDirection ?? directions[0],
dragOffset.value.x,
dragOffset.value.y,
)
const threshold = getThreshold(el, intendedDirection ?? directions[0])
// Check if stale velocity (older than MAX_RELEASE_VELOCITY_AGE_MS)
const now = performance.now()
const velAge = lastDragSample ? now - lastDragSample.time : Infinity
const velocity = velAge > MAX_RELEASE_VELOCITY_AGE_MS ? { x: 0, y: 0 } : lastVelocity
onRelease?.(velocity)
const velInDirection = getDisplacement(
intendedDirection ?? directions[0],
velocity.x,
velocity.y,
)
const shouldDismiss = !cancelledSwipe
&& (displacement >= threshold || velInDirection > 0.3)
clearCssVars(el)
if (shouldDismiss) {
onDismiss?.()
}
else {
onCancel?.()
}
reset()
}
// ─── Pointer Events (mouse + pen) ──────────────────────────────────────────
function onPointerDown(e: PointerEvent) {
if (!toValue(options.enabled))
return
if (e.pointerType === 'touch')
return // handled by touch events
if (e.button !== 0)
return // only primary button
if (canStart && !canStart())
return
const target = e.target as HTMLElement
if (target?.closest(DEFAULT_IGNORE_SELECTOR))
return
const el = elementRef.value
if (!el)
return
const pos = { x: e.clientX, y: e.clientY }
startSwipe(el, pos)
activePointerId = e.pointerId
pointerStarted = true
el.setPointerCapture(e.pointerId)
}
function onPointerMove(e: PointerEvent) {
if (!pointerStarted || e.pointerId !== activePointerId)
return
const el = elementRef.value
if (!el)
return
// Only track primary button held down
if ((e.buttons & 1) === 0) {
sawPrimaryButtonsOnMove = false
return
}
sawPrimaryButtonsOnMove = true
processMove(el, { x: e.clientX, y: e.clientY }, e.timeStamp)
}
function onPointerUp(e: PointerEvent) {
if (!pointerStarted || e.pointerId !== activePointerId)
return
const el = elementRef.value
if (!el)
return
finishSwipe(el)
}
// ─── Touch Events (mobile) ─────────────────────────────────────────────────
function onTouchStart(e: TouchEvent) {
if (!toValue(options.enabled))
return
if (canStart && !canStart())
return
const target = e.target as HTMLElement
if (target?.closest(DEFAULT_IGNORE_SELECTOR))
return
const el = elementRef.value
if (!el)
return
// Scroll conflict: yield to native scroll if ancestor is scrollable in our axis
if (!options.ignoreScrollableAncestors) {
const axis = hasVertical ? 'vertical' : 'horizontal'
const scrollable = findScrollableAncestor(target, axis)
if (scrollable) {
swipeFromScrollable = true
}
}
const t = e.touches[0]
if (!t)
return
startSwipe(el, { x: t.clientX, y: t.clientY })
}
function onTouchMove(e: TouchEvent) {
const el = elementRef.value
if (!el || (!pendingSwipe && !isSwiping.value))
return
const t = e.touches[0]
if (!t)
return
const pos = { x: t.clientX, y: t.clientY }
// If started from scrollable, check if this move would scroll — if so, abort
if (swipeFromScrollable && pendingSwipe) {
const dx = pos.x - dragStartPos.x
const dy = pos.y - dragStartPos.y
const scrollDir = hasVertical && Math.abs(dy) > Math.abs(dx) ? 'vertical' : 'horizontal'
if (
(scrollDir === 'vertical' && hasVertical)
|| (scrollDir === 'horizontal' && hasHorizontal)
) {
// Let browser handle scroll — abort our swipe
reset()
return
}
}
// Once we're swiping, prevent native scroll
if (isSwiping.value) {
e.preventDefault()
}
processMove(el, pos, e.timeStamp)
}
function onTouchEnd(_e: TouchEvent) {
const el = elementRef.value
if (!el)
return
finishSwipe(el)
}
// ─── Attach listeners ──────────────────────────────────────────────────────
watch(
() => elementRef.value,
(el) => {
if (!el)
return
useEventListener(el, 'pointerdown', onPointerDown)
useEventListener(el, 'pointermove', onPointerMove)
useEventListener(el, 'pointerup', onPointerUp)
useEventListener(el, 'pointercancel', onPointerUp)
useEventListener(el, 'touchstart', onTouchStart, { passive: true })
useEventListener(el, 'touchmove', onTouchMove, { passive: false })
useEventListener(el, 'touchend', onTouchEnd)
useEventListener(el, 'touchcancel', onTouchEnd)
},
{ immediate: true },
)
onUnmounted(reset)
return {
isSwiping,
swipeDirection,
dragOffset,
}
}- Step 2: Commit
git add packages/core/src/Drawer/composables/useSwipeDismiss.ts
git commit -m "feat(Drawer): add useSwipeDismiss gesture composable"Files:
-
Create:
packages/core/src/Drawer/composables/useDrawerSnapPoints.ts -
Step 1: Create
useDrawerSnapPoints.ts
// packages/core/src/Drawer/composables/useDrawerSnapPoints.ts
import type { Ref } from 'vue'
import type { DrawerSnapPoint } from '../utils'
import { useResizeObserver } from '@vueuse/core'
import { computed, ref, watch } from 'vue'
export interface ResolvedSnapPoint {
value: DrawerSnapPoint
height: number // resolved px height
offset: number // popupHeight - height (CSS translate amount)
}
function parseSnapPoint(value: DrawerSnapPoint, viewportHeight: number, rootFontSize: number): number {
if (typeof value === 'number') {
if (value >= 0 && value <= 1)
return Math.round(value * viewportHeight)
return Math.round(value) // pixel value > 1
}
// string: '148px' or '30rem'
if (value.endsWith('rem')) {
return Math.round(Number.parseFloat(value) * rootFontSize)
}
if (value.endsWith('px')) {
return Math.round(Number.parseFloat(value))
}
return 0
}
export function useDrawerSnapPoints(options: {
snapPoints: Ref<DrawerSnapPoint[] | undefined>
activeSnapPoint: Ref<DrawerSnapPoint | null | undefined>
popupHeight: Ref<number>
viewportRef: Ref<HTMLElement | null | undefined>
onSnapPointChange: (point: DrawerSnapPoint | null) => void
}) {
const { snapPoints, activeSnapPoint, popupHeight, viewportRef, onSnapPointChange } = options
const viewportHeight = ref(typeof window !== 'undefined' ? window.innerHeight : 0)
const rootFontSize = ref(typeof document !== 'undefined'
? Number.parseFloat(getComputedStyle(document.documentElement).fontSize) || 16
: 16,
)
useResizeObserver(viewportRef, ([entry]) => {
viewportHeight.value = entry.contentRect.height
})
const resolvedSnapPoints = computed<ResolvedSnapPoint[]>(() => {
const points = snapPoints.value
if (!points || points.length === 0)
return []
const vh = viewportHeight.value
const fs = rootFontSize.value
const ph = popupHeight.value
const resolved: ResolvedSnapPoint[] = []
for (const pt of points) {
const height = parseSnapPoint(pt, vh, fs)
// Deduplicate within 1px
if (resolved.some(r => Math.abs(r.height - height) <= 1))
continue
resolved.push({ value: pt, height, offset: ph - height })
}
return resolved.sort((a, b) => a.height - b.height)
})
const activeSnapPointOffset = computed<number | null>(() => {
if (!activeSnapPoint.value || resolvedSnapPoints.value.length === 0)
return null
const match = resolvedSnapPoints.value.find(
r => r.value === activeSnapPoint.value
|| Math.abs(r.height - parseSnapPoint(activeSnapPoint.value as DrawerSnapPoint, viewportHeight.value, rootFontSize.value)) <= 1,
)
return match?.offset ?? null
})
function snapToNearest(
currentOffset: number,
velocity: { x: number, y: number },
direction: 'up' | 'down' | 'left' | 'right',
sequential: boolean,
) {
const points = resolvedSnapPoints.value
if (points.length === 0)
return
const currentHeight = popupHeight.value - currentOffset
const velY = velocity.y // negative = moving up, positive = moving down
if (sequential) {
// Move one step in velocity direction
const sorted = [...points].sort((a, b) => a.height - b.height)
const currentIdx = sorted.findIndex(p => Math.abs(p.height - currentHeight) < 20)
if (velY < -0.1 || direction === 'up') {
// Moving up → higher snap
const next = currentIdx < sorted.length - 1 ? sorted[currentIdx + 1] : sorted.at(-1)
onSnapPointChange(next.value)
}
else if (velY > 0.1 || direction === 'down') {
// Moving down → lower snap or close
if (currentIdx <= 0) {
onSnapPointChange(null) // dismiss
}
else {
onSnapPointChange(sorted[currentIdx - 1].value)
}
}
}
else {
// Nearest by height
let nearest = points[0]
for (const p of points) {
if (Math.abs(p.height - currentHeight) < Math.abs(nearest.height - currentHeight))
nearest = p
}
onSnapPointChange(nearest.value)
}
}
return {
resolvedSnapPoints,
activeSnapPointOffset,
viewportHeight,
snapToNearest,
}
}- Step 2: Commit
git add packages/core/src/Drawer/composables/useDrawerSnapPoints.ts
git commit -m "feat(Drawer): add useDrawerSnapPoints composable"Files:
-
Create:
packages/core/src/Drawer/DrawerContentImpl.vue -
Step 1: Create
DrawerContentImpl.vue
<!-- packages/core/src/Drawer/DrawerContentImpl.vue -->
<script lang="ts">
import type { DismissableLayerEmits, DismissableLayerProps } from '@/DismissableLayer'
export type DrawerContentImplEmits = DismissableLayerEmits & {
openAutoFocus: [event: Event]
closeAutoFocus: [event: Event]
}
export interface DrawerContentImplProps extends DismissableLayerProps {
trapFocus?: boolean
}
</script>
<script setup lang="ts">
import { useResizeObserver } from '@vueuse/core'
import { onMounted, onUnmounted, watch } from 'vue'
import { DismissableLayer } from '@/DismissableLayer'
import { FocusScope } from '@/FocusScope'
import { getActiveElement, useForwardExpose, useId } from '@/shared'
import { useDrawerSnapPoints } from './composables/useDrawerSnapPoints.ts'
import { useSwipeDismiss } from './composables/useSwipeDismiss.ts'
import { injectDrawerRootContext } from './DrawerRoot.vue'
import { DRAWER_CSS_VARS, registerDrawerCssProperties } from './utils'
const props = defineProps<DrawerContentImplProps>()
const emits = defineEmits<DrawerContentImplEmits>()
registerDrawerCssProperties()
const rootContext = injectDrawerRootContext()
const { forwardRef, currentElement } = useForwardExpose()
rootContext.titleId ||= useId(undefined, 'reka-drawer-title')
rootContext.descriptionId ||= useId(undefined, 'reka-drawer-description')
onMounted(() => {
rootContext.contentElement = currentElement
if (getActiveElement() !== document.body)
rootContext.triggerElement.value = getActiveElement() as HTMLElement
// Register with parent nested drawer
rootContext.notifyParentHasNestedDrawer?.(true)
})
onUnmounted(() => {
rootContext.notifyParentHasNestedDrawer?.(false)
})
// ─── Snap points ─────────────────────────────────────────────────────────────
const { resolvedSnapPoints, activeSnapPointOffset, snapToNearest } = useDrawerSnapPoints({
snapPoints: rootContext.snapPoints,
activeSnapPoint: rootContext.activeSnapPoint,
popupHeight: rootContext.popupHeight,
viewportRef: currentElement,
onSnapPointChange: rootContext.setActiveSnapPoint,
})
// ─── Measure popup height ─────────────────────────────────────────────────────
useResizeObserver(currentElement, ([entry]) => {
const h = entry.contentRect.height
rootContext.onPopupHeightChange(h)
const el = currentElement.value
if (el) {
el.style.setProperty(DRAWER_CSS_VARS.height, `${h}px`)
}
})
// ─── Snap point offset CSS var ────────────────────────────────────────────────
watch(activeSnapPointOffset, (offset) => {
const el = currentElement.value
if (!el)
return
if (offset === null) {
el.style.removeProperty(DRAWER_CSS_VARS.snapPointOffset)
}
else {
el.style.setProperty(DRAWER_CSS_VARS.snapPointOffset, `${offset}px`)
}
})
// ─── Frontmost height CSS var ─────────────────────────────────────────────────
watch(rootContext.frontmostHeight, (h) => {
const el = currentElement.value
if (!el)
return
el.style.setProperty(DRAWER_CSS_VARS.frontmostHeight, `${h}px`)
})
// ─── Nested drawer count CSS var ──────────────────────────────────────────────
// Will be updated from parent context if nested
const nestedDepth = injectDrawerRootContext(null) ? 1 : 0
onMounted(() => {
const el = currentElement.value
if (el)
el.style.setProperty(DRAWER_CSS_VARS.nestedDrawers, `${nestedDepth}`)
})
// ─── Swipe dismiss ────────────────────────────────────────────────────────────
const { isSwiping, dragOffset } = useSwipeDismiss({
enabled: rootContext.open,
elementRef: currentElement,
directions: [rootContext.swipeDirection.value],
movementCssVars: {
x: DRAWER_CSS_VARS.swipeMovementX,
y: DRAWER_CSS_VARS.swipeMovementY,
},
onDismiss() {
if (resolvedSnapPoints.value.length > 0) {
snapToNearest(
dragOffset.value.y,
{ x: 0, y: 0.5 },
rootContext.swipeDirection.value,
rootContext.snapToSequentialPoints.value,
)
}
else {
rootContext.onOpenChange(false)
}
},
onRelease(velocity) {
if (resolvedSnapPoints.value.length > 0) {
snapToNearest(
dragOffset.value.y,
velocity,
rootContext.swipeDirection.value,
rootContext.snapToSequentialPoints.value,
)
}
},
onSwipingChange(swiping) {
rootContext.onNestedSwipingChange(swiping)
},
onProgress(progress) {
rootContext.onNestedSwipeProgressChange(progress)
},
})
// ─── Data attributes ──────────────────────────────────────────────────────────
function getDataAttributes() {
return {
'data-state': rootContext.open.value ? 'open' : 'closed',
'data-swiping': isSwiping.value ? '' : undefined,
'data-swipe-direction': isSwiping.value ? rootContext.swipeDirection.value : undefined,
'data-nested-drawer-open': rootContext.hasNestedDrawer.value ? '' : undefined,
}
}
// Dev warning for missing title
if (process.env.NODE_ENV !== 'production') {
onMounted(() => {
if (!document.getElementById(rootContext.titleId)) {
console.warn(
`Warning: \`DrawerContent\` requires a \`DrawerTitle\` for accessibility.\n`
+ `Wrap it with VisuallyHidden if you want to hide it visually.\n`
+ `See https://www.reka-ui.com/docs/components/drawer`,
)
}
})
}
</script>
<template>
<FocusScope
as-child
loop
:trapped="props.trapFocus"
@mount-auto-focus="emits('openAutoFocus', $event)"
@unmount-auto-focus="emits('closeAutoFocus', $event)"
>
<DismissableLayer
:id="rootContext.contentId"
:ref="forwardRef"
:as="as"
:as-child="asChild"
:disable-outside-pointer-events="disableOutsidePointerEvents"
role="dialog"
:aria-describedby="rootContext.descriptionId"
:aria-labelledby="rootContext.titleId"
v-bind="{ ...getDataAttributes(), ...$attrs }"
@dismiss="rootContext.onOpenChange(false)"
@escape-key-down="emits('escapeKeyDown', $event)"
@focus-outside="emits('focusOutside', $event)"
@interact-outside="emits('interactOutside', $event)"
@pointer-down-outside="emits('pointerDownOutside', $event)"
>
<slot />
</DismissableLayer>
</FocusScope>
</template>- Step 2: Commit
git add packages/core/src/Drawer/DrawerContentImpl.vue
git commit -m "feat(Drawer): add DrawerContentImpl with gestures and CSS vars"Files:
-
Create:
packages/core/src/Drawer/DrawerContent.vue -
Step 1: Create
DrawerContent.vue
<!-- packages/core/src/Drawer/DrawerContent.vue -->
<script lang="ts">
import type { DrawerContentImplEmits, DrawerContentImplProps } from './DrawerContentImpl.vue'
export type DrawerContentEmits = DrawerContentImplEmits
export interface DrawerContentProps extends Omit<DrawerContentImplProps, 'trapFocus'> {
forceMount?: boolean
}
</script>
<script setup lang="ts">
import { ref } from 'vue'
import { Presence } from '@/Presence'
import { useEmitAsProps, useForwardExpose, useHideOthers } from '@/shared'
import DrawerContentImpl from './DrawerContentImpl.vue'
import { injectDrawerRootContext } from './DrawerRoot.vue'
const props = defineProps<DrawerContentProps>()
const emits = defineEmits<DrawerContentEmits>()
const rootContext = injectDrawerRootContext()
const emitsAsProps = useEmitAsProps(emits)
const { forwardRef, currentElement } = useForwardExpose()
const hasInteractedOutside = ref(false)
const hasPointerDownOutside = ref(false)
// Hide other elements from screen readers when modal
useHideOthers(rootContext.modal.value ? currentElement : ref(undefined))
</script>
<template>
<Presence :present="forceMount || rootContext.open.value">
<!-- Modal: trap focus, block outside pointer events -->
<DrawerContentImpl
v-if="rootContext.modal.value"
:ref="forwardRef"
v-bind="{ ...props, ...emitsAsProps, ...$attrs }"
:trap-focus="rootContext.open.value"
:disable-outside-pointer-events="true"
@close-auto-focus="(e) => {
if (!e.defaultPrevented) {
e.preventDefault()
rootContext.triggerElement.value?.focus()
}
}"
@pointer-down-outside="(e) => {
const orig = e.detail.originalEvent
const isRightClick = orig.button === 2 || (orig.button === 0 && orig.ctrlKey)
if (isRightClick) e.preventDefault()
}"
@focus-outside="(e) => e.preventDefault()"
>
<slot />
</DrawerContentImpl>
<!-- Non-modal: no focus trap, interaction outside allowed -->
<DrawerContentImpl
v-else
:ref="forwardRef"
v-bind="{ ...props, ...emitsAsProps, ...$attrs }"
:trap-focus="false"
:disable-outside-pointer-events="false"
@close-auto-focus="(e) => {
if (!e.defaultPrevented) {
if (!hasInteractedOutside) rootContext.triggerElement.value?.focus()
e.preventDefault()
}
hasInteractedOutside = false
hasPointerDownOutside = false
}"
@interact-outside="(e) => {
if (!e.defaultPrevented) {
hasInteractedOutside = true
if (e.detail.originalEvent.type === 'pointerdown')
hasPointerDownOutside = true
}
const target = e.target as HTMLElement
if (rootContext.triggerElement.value?.contains(target)) e.preventDefault()
if (e.detail.originalEvent.type === 'focusin' && hasPointerDownOutside) e.preventDefault()
}"
>
<slot />
</DrawerContentImpl>
</Presence>
</template>- Step 2: Commit
git add packages/core/src/Drawer/DrawerContent.vue
git commit -m "feat(Drawer): add DrawerContent with Presence and modal/non-modal split"Files:
-
Create:
packages/core/src/Drawer/DrawerHandle.vue -
Step 1: Create
DrawerHandle.vue
<!-- packages/core/src/Drawer/DrawerHandle.vue -->
<script lang="ts">
import type { PrimitiveProps } from '@/Primitive'
export interface DrawerHandleProps extends PrimitiveProps {}
</script>
<script setup lang="ts">
import { Primitive } from '@/Primitive'
import { useForwardExpose } from '@/shared'
import { injectDrawerRootContext } from './DrawerRoot.vue'
const props = withDefaults(defineProps<DrawerHandleProps>(), { as: 'div' })
useForwardExpose()
const rootContext = injectDrawerRootContext()
</script>
<template>
<Primitive
v-bind="props"
aria-hidden="true"
:data-state="rootContext.open.value ? 'open' : 'closed'"
>
<slot />
</Primitive>
</template>- Step 2: Commit
git add packages/core/src/Drawer/DrawerHandle.vue
git commit -m "feat(Drawer): add DrawerHandle visible drag grip"Files:
- Create:
packages/core/src/Drawer/DrawerSwipeArea.vue
The DrawerSwipeArea is positioned outside the popup and listens for swipe gestures in the opposite direction of the dismiss direction to open the drawer.
- Step 1: Create
DrawerSwipeArea.vue
<!-- packages/core/src/Drawer/DrawerSwipeArea.vue -->
<script lang="ts">
import type { SwipeDirection } from './utils'
import type { PrimitiveProps } from '@/Primitive'
export interface DrawerSwipeAreaProps extends PrimitiveProps {
/** Override the open swipe direction (defaults to opposite of Root's swipeDirection). */
swipeDirection?: SwipeDirection
/** Disable swipe-to-open. */
disabled?: boolean
}
</script>
<script setup lang="ts">
import { computed } from 'vue'
import { Primitive } from '@/Primitive'
import { useForwardExpose } from '@/shared'
import { useSwipeDismiss } from './composables/useSwipeDismiss.ts'
import { injectDrawerRootContext } from './DrawerRoot.vue'
import { DRAWER_CSS_VARS } from './utils'
const props = withDefaults(defineProps<DrawerSwipeAreaProps>(), {
as: 'div',
disabled: false,
})
const { forwardRef, currentElement } = useForwardExpose()
const rootContext = injectDrawerRootContext()
// Open direction = opposite of dismiss direction
const openDirection = computed<SwipeDirection>(() => {
if (props.swipeDirection)
return props.swipeDirection
const dismiss = rootContext.swipeDirection.value
const opposite: Record<SwipeDirection, SwipeDirection> = {
up: 'down',
down: 'up',
left: 'right',
right: 'left',
}
return opposite[dismiss]
})
// Apply swipe movement CSS vars to the popup element during swipe-to-open
useSwipeDismiss({
enabled: computed(() => !props.disabled && !rootContext.open.value),
elementRef: currentElement,
directions: computed(() => [openDirection.value]) as any,
movementCssVars: {
x: DRAWER_CSS_VARS.swipeMovementX,
y: DRAWER_CSS_VARS.swipeMovementY,
},
onDismiss() {
// "Dismiss" here means the swipe threshold was met → open the drawer
rootContext.onOpenChange(true)
},
onSwipingChange(swiping) {
rootContext.onNestedSwipingChange(swiping)
},
})
</script>
<template>
<Primitive
v-bind="props"
:ref="forwardRef"
:data-state="rootContext.open.value ? 'open' : 'closed'"
:data-swipe-direction="openDirection"
>
<slot />
</Primitive>
</template>- Step 2: Commit
git add packages/core/src/Drawer/DrawerSwipeArea.vue
git commit -m "feat(Drawer): add DrawerSwipeArea for swipe-to-open gestures"Files:
-
Create:
packages/core/src/Drawer/DrawerProvider.vue -
Create:
packages/core/src/Drawer/DrawerIndent.vue -
Create:
packages/core/src/Drawer/DrawerIndentBackground.vue -
Step 1: Create
DrawerProvider.vue
<!-- packages/core/src/Drawer/DrawerProvider.vue -->
<script lang="ts">
import { createContext } from '@/shared'
export interface DrawerVisualState {
swipeProgress: number
frontmostHeight: number
}
export interface DrawerVisualStateStore {
getSnapshot: () => DrawerVisualState
set: (next: Partial<DrawerVisualState>) => void
subscribe: (listener: () => void) => () => void
}
export interface DrawerProviderContext {
active: import('vue').Ref<boolean>
setDrawerOpen: (id: string, open: boolean) => void
removeDrawer: (id: string) => void
visualStateStore: DrawerVisualStateStore
}
export const [injectDrawerProviderContext, provideDrawerProviderContext]
= createContext<DrawerProviderContext>('DrawerProvider', null)
</script>
<script setup lang="ts">
import { computed, ref } from 'vue'
function createVisualStateStore(): DrawerVisualStateStore {
let state: DrawerVisualState = { swipeProgress: 0, frontmostHeight: 0 }
const listeners = new Set<() => void>()
return {
getSnapshot: () => state,
set(next) {
const nextProgress = next.swipeProgress !== undefined
? (Number.isFinite(next.swipeProgress) ? next.swipeProgress : 0)
: state.swipeProgress
const nextHeight = next.frontmostHeight !== undefined
? (Number.isFinite(next.frontmostHeight) ? next.frontmostHeight : 0)
: state.frontmostHeight
if (nextProgress === state.swipeProgress && nextHeight === state.frontmostHeight)
return
state = { swipeProgress: nextProgress, frontmostHeight: nextHeight }
listeners.forEach(l => l())
},
subscribe(listener) {
listeners.add(listener)
return () => listeners.delete(listener)
},
}
}
const openById = ref(new Map<string, boolean>())
const visualStateStore = createVisualStateStore()
const active = computed(() => {
for (const open of openById.value.values()) {
if (open)
return true
}
return false
})
provideDrawerProviderContext({
active,
setDrawerOpen(id, open) {
const prev = openById.value.get(id)
if (prev === open)
return
const next = new Map(openById.value)
next.set(id, open)
openById.value = next
},
removeDrawer(id) {
if (!openById.value.has(id))
return
const next = new Map(openById.value)
next.delete(id)
openById.value = next
},
visualStateStore,
})
</script>
<template>
<slot />
</template>- Step 2: Create
DrawerIndentBackground.vue
<!-- packages/core/src/Drawer/DrawerIndentBackground.vue -->
<script lang="ts">
import type { PrimitiveProps } from '@/Primitive'
export interface DrawerIndentBackgroundProps extends PrimitiveProps {}
</script>
<script setup lang="ts">
import { Primitive } from '@/Primitive'
import { useForwardExpose } from '@/shared'
import { injectDrawerProviderContext } from './DrawerProvider.vue'
const props = withDefaults(defineProps<DrawerIndentBackgroundProps>(), { as: 'div' })
useForwardExpose()
const providerContext = injectDrawerProviderContext(null)
const active = providerContext?.active
</script>
<template>
<Primitive
v-bind="props"
:data-active="active?.value ? '' : undefined"
:data-inactive="!active?.value ? '' : undefined"
>
<slot />
</Primitive>
</template>- Step 3: Create
DrawerIndent.vue
<!-- packages/core/src/Drawer/DrawerIndent.vue -->
<script lang="ts">
import type { PrimitiveProps } from '@/Primitive'
export interface DrawerIndentProps extends PrimitiveProps {}
</script>
<script setup lang="ts">
import { onMounted, onUnmounted } from 'vue'
import { Primitive } from '@/Primitive'
import { useForwardExpose } from '@/shared'
import { injectDrawerProviderContext } from './DrawerProvider.vue'
import { DRAWER_CSS_VARS } from './utils'
const props = withDefaults(defineProps<DrawerIndentProps>(), { as: 'div' })
const { forwardRef, currentElement } = useForwardExpose()
const providerContext = injectDrawerProviderContext(null)
const active = providerContext?.active
let unsubscribe: (() => void) | undefined
onMounted(() => {
const store = providerContext?.visualStateStore
if (!store)
return
const el = currentElement.value
if (!el)
return
const sync = () => {
const { swipeProgress, frontmostHeight } = store.getSnapshot()
el.style.setProperty(
DRAWER_CSS_VARS.swipeProgress,
swipeProgress > 0 ? `${swipeProgress}` : '0',
)
if (frontmostHeight > 0) {
el.style.setProperty(DRAWER_CSS_VARS.height, `${frontmostHeight}px`)
}
else {
el.style.removeProperty(DRAWER_CSS_VARS.height)
}
}
sync()
unsubscribe = store.subscribe(sync)
})
onUnmounted(() => {
unsubscribe?.()
const el = currentElement.value
if (el) {
el.style.setProperty(DRAWER_CSS_VARS.swipeProgress, '0')
el.style.removeProperty(DRAWER_CSS_VARS.height)
}
})
</script>
<template>
<Primitive
v-bind="props"
:ref="forwardRef"
:data-active="active?.value ? '' : undefined"
:data-inactive="!active?.value ? '' : undefined"
:style="{ [DRAWER_CSS_VARS.swipeProgress]: '0' }"
>
<slot />
</Primitive>
</template>- Step 4: Commit
git add packages/core/src/Drawer/DrawerProvider.vue packages/core/src/Drawer/DrawerIndent.vue packages/core/src/Drawer/DrawerIndentBackground.vue
git commit -m "feat(Drawer): add DrawerProvider, DrawerIndent, DrawerIndentBackground"Files:
-
Create:
packages/core/src/Drawer/index.ts -
Modify:
packages/core/src/index.ts -
Step 1: Create
packages/core/src/Drawer/index.ts
export {
default as DrawerClose,
type DrawerCloseProps,
} from './DrawerClose.vue'
export {
default as DrawerContent,
type DrawerContentEmits,
type DrawerContentProps,
} from './DrawerContent.vue'
export {
default as DrawerDescription,
type DrawerDescriptionProps,
} from './DrawerDescription.vue'
export {
default as DrawerHandle,
type DrawerHandleProps,
} from './DrawerHandle.vue'
export {
default as DrawerIndent,
type DrawerIndentProps,
} from './DrawerIndent.vue'
export {
default as DrawerIndentBackground,
type DrawerIndentBackgroundProps,
} from './DrawerIndentBackground.vue'
export {
default as DrawerOverlay,
type DrawerOverlayProps,
} from './DrawerOverlay.vue'
export {
default as DrawerPortal,
type DrawerPortalProps,
} from './DrawerPortal.vue'
export {
default as DrawerProvider,
} from './DrawerProvider.vue'
export {
default as DrawerRoot,
type DrawerRootEmits,
type DrawerRootProps,
injectDrawerRootContext,
} from './DrawerRoot.vue'
export {
default as DrawerSwipeArea,
type DrawerSwipeAreaProps,
} from './DrawerSwipeArea.vue'
export {
default as DrawerTitle,
type DrawerTitleProps,
} from './DrawerTitle.vue'
export {
default as DrawerTrigger,
type DrawerTriggerProps,
} from './DrawerTrigger.vue'- Step 2: Add to
packages/core/src/index.ts
Open packages/core/src/index.ts and add this line in alphabetical order (after Dialog, before DropdownMenu):
export * from './Drawer'- Step 3: Commit
git add packages/core/src/Drawer/index.ts packages/core/src/index.ts
git commit -m "feat(Drawer): add index.ts exports and register in core"Files:
-
Create:
packages/core/src/Drawer/Drawer.test.ts -
Step 1: Write failing tests
// packages/core/src/Drawer/Drawer.test.ts
import { findByText, fireEvent, render } from '@testing-library/vue'
import { mount } from '@vue/test-utils'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { axe } from 'vitest-axe'
import { defineComponent, nextTick } from 'vue'
import {
DrawerClose,
DrawerContent,
DrawerDescription,
DrawerOverlay,
DrawerPortal,
DrawerRoot,
DrawerTitle,
DrawerTrigger,
} from '.'
const OPEN_TEXT = 'Open Drawer'
const CLOSE_TEXT = 'Close Drawer'
const TITLE_TEXT = 'Drawer Title'
const DrawerTest = defineComponent({
components: {
DrawerRoot,
DrawerTrigger,
DrawerPortal,
DrawerOverlay,
DrawerContent,
DrawerTitle,
DrawerDescription,
DrawerClose,
},
template: `
<DrawerRoot>
<DrawerTrigger>${OPEN_TEXT}</DrawerTrigger>
<DrawerPortal>
<DrawerOverlay />
<DrawerContent>
<DrawerTitle>${TITLE_TEXT}</DrawerTitle>
<DrawerDescription>Description</DrawerDescription>
<DrawerClose>${CLOSE_TEXT}</DrawerClose>
</DrawerContent>
</DrawerPortal>
</DrawerRoot>
`,
})
const NoTitleDrawerTest = defineComponent({
components: { DrawerRoot, DrawerTrigger, DrawerPortal, DrawerContent, DrawerClose },
template: `
<DrawerRoot>
<DrawerTrigger>${OPEN_TEXT}</DrawerTrigger>
<DrawerPortal>
<DrawerContent>
<DrawerClose>${CLOSE_TEXT}</DrawerClose>
</DrawerContent>
</DrawerPortal>
</DrawerRoot>
`,
})
describe('given a default Drawer', () => {
let consoleWarnMock: ReturnType<typeof vi.spyOn>
beforeEach(() => {
document.body.innerHTML = ''
consoleWarnMock = vi.spyOn(console, 'warn').mockImplementation(() => {})
})
afterEach(() => {
consoleWarnMock.mockRestore()
})
it('should pass axe accessibility tests when closed', async () => {
render(DrawerTest)
expect(await axe(document.body)).toHaveNoViolations()
})
it('should pass axe accessibility tests when open', async () => {
const { getByText } = render(DrawerTest)
await fireEvent.click(getByText(OPEN_TEXT))
await nextTick()
expect(await axe(document.body)).toHaveNoViolations()
})
describe('after clicking the trigger', () => {
it('should show drawer content', async () => {
const { getByText } = render(DrawerTest)
await fireEvent.click(getByText(OPEN_TEXT))
await nextTick()
expect(document.body).toContainElement(await findByText(document.body, TITLE_TEXT))
})
it('should close when close button is clicked', async () => {
const { getByText } = render(DrawerTest)
await fireEvent.click(getByText(OPEN_TEXT))
await nextTick()
const closeBtn = await findByText(document.body, CLOSE_TEXT)
await fireEvent.click(closeBtn)
await nextTick()
expect(document.body).not.toContainElement(closeBtn)
})
it('should close on Escape key', async () => {
const { getByText } = render(DrawerTest)
await fireEvent.click(getByText(OPEN_TEXT))
await nextTick()
await fireEvent.keyDown(document.activeElement!, { key: 'Escape' })
await nextTick()
expect(document.body).not.toContainHTML(TITLE_TEXT)
})
it('should have role="dialog" on content', async () => {
const { getByText } = render(DrawerTest)
await fireEvent.click(getByText(OPEN_TEXT))
await nextTick()
const content = document.querySelector('[role="dialog"]')
expect(content).not.toBeNull()
})
it('should have aria-labelledby pointing to title', async () => {
const { getByText } = render(DrawerTest)
await fireEvent.click(getByText(OPEN_TEXT))
await nextTick()
const content = document.querySelector('[role="dialog"]')
const labelId = content?.getAttribute('aria-labelledby')
expect(labelId).toBeTruthy()
const titleEl = document.getElementById(labelId!)
expect(titleEl?.textContent).toBe(TITLE_TEXT)
})
})
describe('when no title is provided', () => {
it('should warn to the console', async () => {
render(NoTitleDrawerTest)
await fireEvent.click(document.querySelector('button')!)
await nextTick()
expect(consoleWarnMock).toHaveBeenCalled()
})
})
})- Step 2: Run tests to confirm they fail
cd packages/core && pnpm test -- --run src/Drawer/Drawer.test.tsExpected: Tests fail because the Drawer components are not yet wired up or there are import issues.
- Step 3: Fix any import issues and run tests again
cd packages/core && pnpm test -- --run src/Drawer/Drawer.test.tsExpected: All tests pass.
- Step 4: Commit
git add packages/core/src/Drawer/Drawer.test.ts
git commit -m "test(Drawer): add integration tests"Files:
-
Create:
packages/core/src/Drawer/story/_Drawer.vue -
Create:
packages/core/src/Drawer/story/Drawer.story.vue -
Step 1: Create
_Drawer.vue(the reusable story component)
<!-- packages/core/src/Drawer/story/_Drawer.vue -->
<script setup lang="ts">
import { ref } from 'vue'
import {
DrawerClose,
DrawerContent,
DrawerDescription,
DrawerHandle,
DrawerOverlay,
DrawerPortal,
DrawerRoot,
DrawerTitle,
DrawerTrigger,
} from '..'
const open = ref(false)
</script>
<template>
<DrawerRoot v-model:open="open">
<DrawerTrigger
class="inline-flex h-10 items-center justify-center rounded-md bg-white px-4 text-sm font-medium shadow ring-1 ring-black/10 hover:bg-gray-50"
>
Open Drawer
</DrawerTrigger>
<DrawerPortal>
<DrawerOverlay class="fixed inset-0 bg-black/40 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0" />
<DrawerContent class="fixed bottom-0 left-0 right-0 mt-24 flex h-auto flex-col rounded-t-[10px] bg-white outline-none">
<DrawerHandle class="mx-auto mt-4 h-1.5 w-12 flex-shrink-0 rounded-full bg-gray-300" />
<div class="flex-1 rounded-t-[10px] p-6">
<DrawerTitle class="mb-2 text-xl font-semibold text-gray-900">
Drawer Title
</DrawerTitle>
<DrawerDescription class="text-sm text-gray-600">
This drawer slides up from the bottom. Swipe down to dismiss.
</DrawerDescription>
<div class="mt-6 flex justify-end">
<DrawerClose class="rounded-md bg-gray-900 px-4 py-2 text-sm font-medium text-white hover:bg-gray-700">
Close
</DrawerClose>
</div>
</div>
</DrawerContent>
</DrawerPortal>
</DrawerRoot>
</template>- Step 2: Create
Drawer.story.vue
<!-- packages/core/src/Drawer/story/Drawer.story.vue -->
<script setup lang="ts">
import DrawerDemo from './_Drawer.vue'
</script>
<template>
<Story title="Drawer" :layout="{ type: 'single', iframe: true }">
<Variant title="Default">
<DrawerDemo />
</Variant>
</Story>
</template>- Step 3: Commit
git add packages/core/src/Drawer/story/
git commit -m "feat(Drawer): add story"- Step 1: Run all Drawer tests
cd packages/core && pnpm test -- --run src/Drawer/Drawer.test.tsExpected: All tests pass.
- Step 2: Run the full test suite
pnpm testExpected: All tests pass (no regressions).
- Step 3: Type check
cd packages/core && pnpm type-checkExpected: No type errors.
- Step 4: Final commit
git add -A
git commit -m "feat(Drawer): complete Drawer component implementation"