Skip to content

Latest commit

 

History

History
2241 lines (1892 loc) · 65.3 KB

File metadata and controls

2241 lines (1892 loc) · 65.3 KB

Drawer Implementation Plan

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 Map

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'

Task 1: Utils, types, and CSS var constants

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"

Task 2: DrawerRoot — context + state

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"

Task 3: Simple presentational components — Trigger, Close, Title, Description, Portal

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"

Task 4: DrawerOverlay — backdrop with swipe-progress CSS var

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"

Task 5: useSwipeDismiss composable — core gesture system

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"

Task 6: useDrawerSnapPoints 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"

Task 7: DrawerContentImpl — FocusScope + DismissableLayer + gestures + CSS vars

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"

Task 8: DrawerContent — Presence wrapper + modal/non-modal split

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"

Task 9: DrawerHandle — visible drag grip

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"

Task 10: DrawerSwipeArea — invisible swipe-to-open zone

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"

Task 11: DrawerProvider, DrawerIndent, DrawerIndentBackground

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"

Task 12: index.ts exports + register in packages/core/src/index.ts

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"

Task 13: Tests

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.ts

Expected: 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.ts

Expected: All tests pass.

  • Step 4: Commit
git add packages/core/src/Drawer/Drawer.test.ts
git commit -m "test(Drawer): add integration tests"

Task 14: Story

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"

Task 15: Final verification

  • Step 1: Run all Drawer tests
cd packages/core && pnpm test -- --run src/Drawer/Drawer.test.ts

Expected: All tests pass.

  • Step 2: Run the full test suite
pnpm test

Expected: All tests pass (no regressions).

  • Step 3: Type check
cd packages/core && pnpm type-check

Expected: No type errors.

  • Step 4: Final commit
git add -A
git commit -m "feat(Drawer): complete Drawer component implementation"