Directional edge drawer with handle drag-to-dismiss, built on the @foldkit/ui Dialog submodel.

Preview
Style
Basic
With Handle

Drag the handle to dismiss. Release past the threshold to close.

Sides
src/demo/views/drawer.ts444 lines
import { Match as M, Option, Schema as S } from 'effect'
import { Command, Subscription, Update } from 'foldkit'
import { evo } from 'foldkit/struct'
import { defineMessageUnion } from 'foldkit/message'
import type { Html, HtmlBuilder } from 'foldkit/html'

import { button } from '../../generated/registry/ui/button'
import * as Drawer from '../../generated/registry/ui/drawer'

import { defineSlice, type UpdateReturn } from '../slice'
import type { Model, Message as AppMessage } from '../assemble'

const Message = defineMessageUnion({
  GotDrawerBasicMessage: { message: Drawer.Message },
  GotDrawerLeftMessage: { message: Drawer.Message },
  GotDrawerRightMessage: { message: Drawer.Message },
  GotDrawerUpMessage: { message: Drawer.Message },
  ClickedOpenBasicDrawer: {},
  ClickedOpenLeftDrawer: {},
  ClickedOpenRightDrawer: {},
  ClickedOpenUpDrawer: {},
})

type DrawerMessage =
  | typeof Message.GotDrawerBasicMessage.Type
  | typeof Message.GotDrawerLeftMessage.Type
  | typeof Message.GotDrawerRightMessage.Type
  | typeof Message.GotDrawerUpMessage.Type

type Render = Drawer.DrawerContent<AppMessage>

const deliveryContent = (
  { closeButton, title, description }: Render,
  h: HtmlBuilder<AppMessage>,
): ReadonlyArray<Html | string> => [
  Drawer.header(
    {},
    [
      Drawer.title({ attributes: title }, ['Pick a delivery time'], h),
      Drawer.description(
        { attributes: description },
        ['We’ll prepare your order as soon as possible.'],
        h,
      ),
    ],
    h,
  ),
  h.div(
    [h.Class('flex-1 overflow-y-auto p-4')],
    [
      h.div(
        [h.Class('grid gap-2')],
        [
          h.label(
            [
              h.Class(
                'flex items-center gap-3 rounded-lg border p-3 text-sm has-[input:checked]:border-primary has-[input:checked]:bg-accent',
              ),
              h.For('drawer-delivery-asap'),
            ],
            [
              h.div(
                [h.Class('flex flex-1 flex-col gap-0.5')],
                [
                  h.span(
                    [h.Class('flex items-center gap-2 font-medium')],
                    [
                      'Standard delivery',
                      h.span(
                        [
                          h.Class(
                            'inline-flex items-center rounded-md bg-secondary px-2 py-0.5 text-xs font-medium text-secondary-foreground',
                          ),
                        ],
                        ['Fastest'],
                      ),
                    ],
                  ),
                  h.span(
                    [h.Class('text-xs text-muted-foreground')],
                    ['25–35 min · Driver assigned now'],
                  ),
                ],
              ),
              h.input([
                h.Attribute('type', 'radio'),
                h.Attribute('name', 'delivery-time'),
                h.Id('drawer-delivery-asap'),
                h.Attribute('value', 'asap'),
                h.Attribute('checked', ''),
                h.Class('size-4'),
              ]),
            ],
          ),
          h.label(
            [
              h.Class(
                'flex items-center gap-3 rounded-lg border p-3 text-sm has-[input:checked]:border-primary has-[input:checked]:bg-accent',
              ),
              h.For('drawer-delivery-5-00'),
            ],
            [
              h.div(
                [h.Class('flex flex-1 flex-col gap-0.5')],
                [
                  h.span([h.Class('font-medium')], ['5:00 PM – 5:15 PM']),
                  h.span([h.Class('text-xs text-muted-foreground')], ['Prep starts at 4:45 PM']),
                ],
              ),
              h.input([
                h.Attribute('type', 'radio'),
                h.Attribute('name', 'delivery-time'),
                h.Id('drawer-delivery-5-00'),
                h.Attribute('value', '5-00'),
                h.Class('size-4'),
              ]),
            ],
          ),
        ],
      ),
    ],
  ),
  Drawer.footer(
    {},
    [
      h.button(
        [
          h.Class(
            'inline-flex h-[34px] w-full items-center justify-center rounded-lg bg-primary px-3 text-sm font-medium text-primary-foreground hover:bg-primary/90',
          ),
        ],
        ['Confirm Delivery Time'],
      ),
      Drawer.closeButton({ attributes: closeButton }, ['Cancel'], h),
    ],
    h,
  ),
]

const sideContent = (
  headline: string,
  subhead: string,
  { closeButton, title, description }: Render,
  h: HtmlBuilder<AppMessage>,
): ReadonlyArray<Html | string> => [
  Drawer.header(
    {},
    [
      Drawer.title({ attributes: title }, [headline], h),
      Drawer.description({ attributes: description }, [subhead], h),
    ],
    h,
  ),
  h.div(
    [h.Class('flex-1 p-4')],
    [
      h.div(
        [
          h.Class(
            'size-full rounded-2xl bg-muted group-data-[swipe-axis=x]/drawer-popup:size-full group-data-[swipe-axis=y]/drawer-popup:h-80 group-data-[swipe-axis=y]/drawer-popup:w-full',
          ),
        ],
        [],
      ),
    ],
  ),
  Drawer.footer({}, [Drawer.closeButton({ attributes: closeButton }, ['Close'], h)], h),
]

const drawerSubmodel = (
  model: Drawer.Model,
  content: (render: Render, h: HtmlBuilder<AppMessage>) => ReadonlyArray<Html | string>,
  toParentMessage: (message: Drawer.Message) => DrawerMessage,
  h: HtmlBuilder<AppMessage>,
): Html =>
  h.submodel({
    slotId: model.id,
    model,
    view: Drawer.view,
    viewInputs: Drawer.styledViewInputs({ content }, h),
    toParentMessage,
  })

export const drawerView = (model: Model, h: HtmlBuilder<AppMessage>): Html =>
  h.div(
    [h.Class('flex w-full flex-col gap-8')],
    [
      h.div(
        [h.Class('flex w-full flex-col gap-2')],
        [
          h.div([h.Class('px-1 text-xs font-medium text-muted-foreground')], ['Basic']),
          h.div(
            [h.Class('flex flex-col items-start gap-4')],
            [
              button<AppMessage>(
                { variant: 'outline', onClick: Message.ClickedOpenBasicDrawer() },
                'Open Drawer',
                h,
              ),
              drawerSubmodel(
                model.drawerBasic,
                deliveryContent,
                (message) => Message.GotDrawerBasicMessage({ message }),
                h,
              ),
            ],
          ),
        ],
      ),
      h.div(
        [h.Class('flex w-full flex-col gap-2')],
        [
          h.div([h.Class('px-1 text-xs font-medium text-muted-foreground')], ['With Handle']),
          h.div(
            [h.Class('flex flex-col items-start gap-4')],
            [
              h.p(
                [h.Class('text-sm text-muted-foreground')],
                ['Drag the handle to dismiss. Release past the threshold to close.'],
              ),
              button<AppMessage>(
                { variant: 'outline', onClick: Message.ClickedOpenBasicDrawer() },
                'Open Drawer With Handle',
                h,
              ),
            ],
          ),
        ],
      ),
      h.div(
        [h.Class('flex w-full flex-col gap-2')],
        [
          h.div([h.Class('px-1 text-xs font-medium text-muted-foreground')], ['Sides']),
          h.div(
            [h.Class('flex flex-wrap gap-2')],
            [
              button<AppMessage>(
                { variant: 'outline', onClick: Message.ClickedOpenLeftDrawer() },
                'Open Left Drawer',
                h,
              ),
              button<AppMessage>(
                { variant: 'outline', onClick: Message.ClickedOpenRightDrawer() },
                'Open Right Drawer',
                h,
              ),
              button<AppMessage>(
                { variant: 'outline', onClick: Message.ClickedOpenUpDrawer() },
                'Open Top Drawer',
                h,
              ),
              drawerSubmodel(
                model.drawerLeft,
                (render, h) => sideContent('Move Goal', 'Set your daily activity goal.', render, h),
                (message) => Message.GotDrawerLeftMessage({ message }),
                h,
              ),
              drawerSubmodel(
                model.drawerRight,
                (render, h) =>
                  sideContent('Notifications', 'Choose what you hear about.', render, h),
                (message) => Message.GotDrawerRightMessage({ message }),
                h,
              ),
              drawerSubmodel(
                model.drawerUp,
                (render, h) =>
                  sideContent('Filters', 'Narrow the list without losing context.', render, h),
                (message) => Message.GotDrawerUpMessage({ message }),
                h,
              ),
            ],
          ),
        ],
      ),
    ],
  )

const fields = {
  drawerBasic: Drawer.Model,
  drawerLeft: Drawer.Model,
  drawerRight: Drawer.Model,
  drawerUp: Drawer.Model,
}

const stateSchema = S.Struct(fields)
type State = typeof stateSchema.Type

const foldDrawerOutMessage = M.type<Drawer.OutMessage>().pipe(
  M.withReturnType<Update.Step<State, unknown>>(),
  M.tagsExhaustive({
    Opened: () => (state) => ({ model: state }),
    Closed: () => (state) => ({ model: state }),
  }),
)

const foldDrawer = (
  read: (state: State) => Drawer.Model,
  write: (state: State, next: Drawer.Model) => State,
  toParentMessage: (message: Drawer.Message) => DrawerMessage,
) =>
  Update.foldChild({
    update: Drawer.update,
    read: (state: State) => Option.some(read(state)),
    write: (state, next) => write(state, next),
    toParentMessage,
    foldOutMessage: foldDrawerOutMessage,
  })

const openDrawer = (
  read: (state: State) => Drawer.Model,
  write: (state: State, next: Drawer.Model) => State,
  toParentMessage: (message: Drawer.Message) => DrawerMessage,
  model: State,
): UpdateReturn => {
  const { model: next, commands = [] } = Drawer.open(read(model))
  return {
    model: write(model, next),
    commands: Command.mapMessages(commands, (message) => toParentMessage(message)),
  }
}

const liftDrawerSubscriptions = (
  name: string,
  read: (model: State) => Drawer.Model,
  toParentMessage: (message: Drawer.Message) => DrawerMessage,
) => {
  const lifted = Subscription.lift({
    dragPointer: Drawer.subscriptions.dragPointer,
    dragEscape: Drawer.subscriptions.dragEscape,
  })<State, DrawerMessage>({
    toChildModel: read,
    toParentMessage,
  })
  return {
    [`${name}DragPointer`]: lifted.dragPointer,
    [`${name}DragEscape`]: lifted.dragEscape,
  }
}

const drawerSubscriptions = Subscription.aggregate<State, DrawerMessage>()(
  liftDrawerSubscriptions(
    'drawerBasic',
    (model) => model.drawerBasic,
    (message) => Message.GotDrawerBasicMessage({ message }),
  ),
  liftDrawerSubscriptions(
    'drawerLeft',
    (model) => model.drawerLeft,
    (message) => Message.GotDrawerLeftMessage({ message }),
  ),
  liftDrawerSubscriptions(
    'drawerRight',
    (model) => model.drawerRight,
    (message) => Message.GotDrawerRightMessage({ message }),
  ),
  liftDrawerSubscriptions(
    'drawerUp',
    (model) => model.drawerUp,
    (message) => Message.GotDrawerUpMessage({ message }),
  ),
)

export { drawerSubscriptions as subscriptions }

export const slice = defineSlice({
  fields,
  init: {
    drawerBasic: Drawer.init({ id: 'drawer-basic', swipeDirection: 'down', isHandleVisible: true }),
    drawerLeft: Drawer.init({ id: 'drawer-left', swipeDirection: 'left', isHandleVisible: true }),
    drawerRight: Drawer.init({
      id: 'drawer-right',
      swipeDirection: 'right',
      isHandleVisible: true,
    }),
    drawerUp: Drawer.init({ id: 'drawer-up', swipeDirection: 'up', isHandleVisible: true }),
  },
  messages: [
    Message.GotDrawerBasicMessage,
    Message.GotDrawerLeftMessage,
    Message.GotDrawerRightMessage,
    Message.GotDrawerUpMessage,
    Message.ClickedOpenBasicDrawer,
    Message.ClickedOpenLeftDrawer,
    Message.ClickedOpenRightDrawer,
    Message.ClickedOpenUpDrawer,
  ],
  handlers: (model: State) => ({
    GotDrawerBasicMessage: (payload: typeof Message.GotDrawerBasicMessage.Type): UpdateReturn =>
      foldDrawer(
        (state) => state.drawerBasic,
        (state, next) => evo(state, { drawerBasic: () => next }),
        (message) => Message.GotDrawerBasicMessage({ message }),
      )(model, payload.message),
    GotDrawerLeftMessage: (payload: typeof Message.GotDrawerLeftMessage.Type): UpdateReturn =>
      foldDrawer(
        (state) => state.drawerLeft,
        (state, next) => evo(state, { drawerLeft: () => next }),
        (message) => Message.GotDrawerLeftMessage({ message }),
      )(model, payload.message),
    GotDrawerRightMessage: (payload: typeof Message.GotDrawerRightMessage.Type): UpdateReturn =>
      foldDrawer(
        (state) => state.drawerRight,
        (state, next) => evo(state, { drawerRight: () => next }),
        (message) => Message.GotDrawerRightMessage({ message }),
      )(model, payload.message),
    GotDrawerUpMessage: (payload: typeof Message.GotDrawerUpMessage.Type): UpdateReturn =>
      foldDrawer(
        (state) => state.drawerUp,
        (state, next) => evo(state, { drawerUp: () => next }),
        (message) => Message.GotDrawerUpMessage({ message }),
      )(model, payload.message),
    ClickedOpenBasicDrawer: (): UpdateReturn =>
      openDrawer(
        (state) => state.drawerBasic,
        (state, next) => evo(state, { drawerBasic: () => next }),
        (message) => Message.GotDrawerBasicMessage({ message }),
        model,
      ),
    ClickedOpenLeftDrawer: (): UpdateReturn =>
      openDrawer(
        (state) => state.drawerLeft,
        (state, next) => evo(state, { drawerLeft: () => next }),
        (message) => Message.GotDrawerLeftMessage({ message }),
        model,
      ),
    ClickedOpenRightDrawer: (): UpdateReturn =>
      openDrawer(
        (state) => state.drawerRight,
        (state, next) => evo(state, { drawerRight: () => next }),
        (message) => Message.GotDrawerRightMessage({ message }),
        model,
      ),
    ClickedOpenUpDrawer: (): UpdateReturn =>
      openDrawer(
        (state) => state.drawerUp,
        (state, next) => evo(state, { drawerUp: () => next }),
        (message) => Message.GotDrawerUpMessage({ message }),
        model,
      ),
  }),
  samples: [Message.ClickedOpenBasicDrawer()],
})

This code is shown verbatim — it’s the exact file used for this preview. Much of the surrounding wiring (slice, fields, messages, handlers) belongs to this site’s demo harness; you only need the view and the state that matters for your own app. Adapt what you need.

Installation

Add this component to your project:

pnpm dlx shadcn@latest add @foldcn/drawer

Source

The component ships as plain source — no build step, no wrapper. Copy it and make it yours.

registry/default/ui/drawer.ts544 lines
/** Stateful submodel — import the whole module as a namespace and wire its
 *  Model/Message/init/update/subscriptions into your app:
 *  `import * as Drawer from '@/components/ui/drawer'`
 */
import { Effect, Option, Schema as S, Stream } from 'effect'
import { Command, Update } from 'foldkit'
import { Dialog as FoldkitDialog } from '@foldkit/ui'
import type { Attribute, ChildAttribute, Html, HtmlBuilder } from 'foldkit/html'
import { defineMessageUnion } from 'foldkit/message'
import { defineView } from 'foldkit/submodel'
import { evo } from 'foldkit/struct'
import * as Subscription from 'foldkit/subscription'

import { cn } from '@/lib/utils'

type Child = Html | string

// A drawer is a Dialog variant docked to a viewport edge with a drag handle,
// mirroring the shadcn base `drawer` (Base UI) surface: swipeDirection,
// showSwipeHandle, and drag-to-dismiss live here. Snap points, nested stacks,
// and non-modal drawers are still out of scope (see the gaps note below).
//
// Drag is a model-owned gesture, not a view effect. Pressing the handle
// enters Dragging; document-level pointermove/pointerup streams (the slider
// precedent) feed MovedPointer/ReleasedPointer; release past
// DISMISS_THRESHOLD_PX closes the nested dialog, anything shorter snaps back.
// While dragging the panel translates via the inline `translate` CSS property
// (kept separate from the token's `transform` string) and the overlay fades
// through the upstream `--drawer-swipe-progress` variable.

export const SwipeDirection = S.Literals(['up', 'right', 'down', 'left'])
export type SwipeDirection = typeof SwipeDirection.Type

const swipeAxis = (direction: SwipeDirection): 'x' | 'y' =>
  direction === 'up' || direction === 'down' ? 'y' : 'x'

const dismissSign = (direction: SwipeDirection): 1 | -1 =>
  direction === 'up' || direction === 'left' ? -1 : 1

/** Inline drag translation for the panel, signed along the dismiss axis.
 *  Positive-only offsets would shove up/left drawers the wrong way. */
const dragTranslate = (direction: SwipeDirection, offset: number): string => {
  const signed = String(dismissSign(direction) * offset)
  return swipeAxis(direction) === 'y' ? `0 ${signed}px` : `${signed}px 0`
}

export const DragState = S.Struct({
  activity: S.Literals(['Idle', 'Dragging']),
  originX: S.Number,
  originY: S.Number,
  offset: S.Number,
})
export type DragState = typeof DragState.Type

export const Model = S.Struct({
  id: S.String,
  dialog: FoldkitDialog.Model,
  swipeDirection: SwipeDirection,
  isHandleVisible: S.Boolean,
  drag: DragState,
})
export type Model = typeof Model.Type

export const Message = defineMessageUnion({
  GotDialogMessage: { message: FoldkitDialog.Message },
  PressedHandle: { clientX: S.Number, clientY: S.Number },
  MovedPointer: { clientX: S.Number, clientY: S.Number },
  ReleasedPointer: {},
  CancelledDrag: {},
})
export type Message = typeof Message.Type

export const OutMessage = FoldkitDialog.OutMessage
export type OutMessage = typeof OutMessage.Type

export type InitConfig = Readonly<{
  id: string
  swipeDirection?: SwipeDirection
  /** When true, the view renders the grab handle that starts the drag gesture. */
  isHandleVisible?: boolean
  isOpen?: boolean
  focusSelector?: string
}>

const idleDrag = (): DragState => ({ activity: 'Idle', originX: 0, originY: 0, offset: 0 })

export const init = (config: InitConfig): Model => ({
  id: config.id,
  dialog: FoldkitDialog.init({
    id: config.id,
    isAnimated: true,
    isOpen: config.isOpen ?? false,
    // oxlint-disable-next-line anti-slop/no-conditional-empty-object-spread
    ...(config.focusSelector === undefined ? {} : { focusSelector: config.focusSelector }),
  }),
  swipeDirection: config.swipeDirection ?? 'down',
  isHandleVisible: config.isHandleVisible ?? false,
  drag: idleDrag(),
})

/** Release offset in px that dismisses the drawer. Anything shorter snaps back. */
export const DISMISS_THRESHOLD_PX = 96

type UpdateReturn = Update.ReturnWithOutMessage<Model, Message, OutMessage>

const liftDialogReturn = (
  model: Model,
  result: Update.ReturnWithOutMessage<
    typeof FoldkitDialog.Model.Type,
    typeof FoldkitDialog.Message.Type,
    OutMessage
  >,
): UpdateReturn => {
  const next = {
    model: evo(model, { dialog: () => result.model }),
    commands: Command.mapMessages(result.commands ?? [], (message) =>
      Message.GotDialogMessage({ message }),
    ),
  }
  return result.outMessage === undefined ? next : { ...next, outMessage: result.outMessage }
}

/** Processes a Drawer Message. Dialog traffic routes to the nested dialog and
 *  any dialog close also parks an in-flight drag, so backdrop/Esc dismissal
 *  can never strand the panel mid-translate. */
export const update = (model: Model, message: Message): UpdateReturn => {
  switch (message._tag) {
    case 'GotDialogMessage': {
      const result = liftDialogReturn(model, FoldkitDialog.update(model.dialog, message.message))
      return result.outMessage?._tag === 'Closed'
        ? { ...result, model: evo(result.model, { drag: () => idleDrag() }) }
        : result
    }
    case 'PressedHandle': {
      if (!model.dialog.isOpen) return { model }
      return {
        model: evo(model, {
          drag: () => ({
            activity: 'Dragging' as const,
            originX: message.clientX,
            originY: message.clientY,
            offset: 0,
          }),
        }),
      }
    }
    case 'MovedPointer': {
      if (model.drag.activity !== 'Dragging') return { model }
      const delta =
        swipeAxis(model.swipeDirection) === 'y'
          ? message.clientY - model.drag.originY
          : message.clientX - model.drag.originX
      return {
        model: evo(model, {
          drag: () => ({
            ...model.drag,
            offset: Math.max(0, dismissSign(model.swipeDirection) * delta),
          }),
        }),
      }
    }
    case 'ReleasedPointer': {
      if (model.drag.activity !== 'Dragging') return { model }
      if (model.drag.offset < DISMISS_THRESHOLD_PX) {
        return { model: evo(model, { drag: () => idleDrag() }) }
      }
      const parked = evo(model, { drag: () => idleDrag() })
      return liftDialogReturn(parked, FoldkitDialog.close(parked.dialog))
    }
    case 'CancelledDrag': {
      return { model: evo(model, { drag: () => idleDrag() }) }
    }
  }
}

export const open = (model: Model): UpdateReturn =>
  liftDialogReturn(model, FoldkitDialog.open(model.dialog))

export const close = (model: Model): UpdateReturn =>
  liftDialogReturn(model, FoldkitDialog.close(model.dialog))

export const titleId = (model: Model): string => FoldkitDialog.titleId(model.dialog)

export const descriptionId = (model: Model): string => FoldkitDialog.descriptionId(model.dialog)

const DragActivity = S.Literals(['Idle', 'Dragging'])

/** Document-level drag streams, gated on an active handle drag. Pointercancel
 *  maps to CancelledDrag: an interrupted gesture snaps back instead of
 *  dismissing on an ambiguous end. */
export const subscriptions = Subscription.make<Model, Message>()((entry) => ({
  dragPointer: entry(
    { dragActivity: DragActivity },
    {
      modelToDependencies: (model) => ({ dragActivity: model.drag.activity }),
      dependenciesToStream: ({ dragActivity }): Stream.Stream<Message> => {
        const pointerEvents = Stream.merge(
          Stream.merge(
            Stream.fromEventListener(document, 'pointermove').pipe(
              Stream.mapEffect((event) =>
                Effect.sync(() => {
                  if (!(event instanceof PointerEvent)) return Option.none()
                  return Option.some(
                    Message.MovedPointer({ clientX: event.clientX, clientY: event.clientY }),
                  )
                }),
              ),
              Stream.filter(Option.isSome),
              Stream.map((option) => option.value),
            ),
            Stream.fromEventListener(document, 'pointerup').pipe(
              Stream.map(() => Message.ReleasedPointer()),
            ),
          ),
          Stream.fromEventListener(document, 'pointercancel').pipe(
            Stream.map(() => Message.CancelledDrag()),
          ),
        )

        const documentDragStyles = Stream.callback(() =>
          Effect.acquireRelease(
            Effect.sync(() => {
              document.documentElement.style.setProperty('user-select', 'none')
              document.documentElement.style.setProperty('-webkit-user-select', 'none')
              const cursorStyle = document.createElement('style')
              cursorStyle.textContent = '* { cursor: grabbing !important; }'
              document.head.appendChild(cursorStyle)
              return cursorStyle
            }),
            (cursorStyle) =>
              Effect.sync(() => {
                document.documentElement.style.removeProperty('user-select')
                document.documentElement.style.removeProperty('-webkit-user-select')
                cursorStyle.remove()
              }),
          ).pipe(Effect.flatMap(() => Effect.never)),
        )

        // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: Stream.when widens the element type
        return Stream.when(
          Stream.merge(pointerEvents, documentDragStyles),
          Effect.sync(() => dragActivity === 'Dragging'),
        ) as Stream.Stream<Message>
      },
    },
  ),
  dragEscape: entry(
    { dragActivity: DragActivity },
    {
      modelToDependencies: (model) => ({ dragActivity: model.drag.activity }),
      dependenciesToStream: ({ dragActivity }): Stream.Stream<Message> =>
        // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: Stream.when widens the element type
        Stream.when(
          Stream.fromEventListener(document, 'keydown').pipe(
            Stream.filter(
              (event): event is KeyboardEvent =>
                event instanceof KeyboardEvent && event.key === 'Escape',
            ),
            Stream.map(() => Message.CancelledDrag()),
          ),
          Effect.sync(() => dragActivity === 'Dragging'),
        ) as Stream.Stream<Message>,
    },
  ),
}))

// foldcn gaps vs upstream: Base UI's Drawer is a gesture drawer with snap
// points, nested stacks, and swipe-progress-driven overlay opacity. This port
// covers drag-to-dismiss from the handle plus all four swipe directions.
// Snap points, nested stacks, and non-modal drawers stay out of scope: their
// class lines (--stack-*, --peek, data-nested-*, data-snap-points,
// --drawer-swipe-strength, data-starting/ending-style) are dropped below, and
// the swipe-movement vars (--drawer-swipe-movement-x/y,
// --drawer-snap-point-offset) are unused because the drag offset travels on
// the inline `translate` property instead. One frame of style, no token
// string touched. The enter/exit slide reuses the dialog adaptation:
// [data-enter][data-closed] / [data-leave][data-closed] are the faithful
// one-frame equivalents of data-starting-style / data-ending-style, sliding
// the panel from (and back to) --closed-transform via its own transform
// transition.

export const drawerClass = 'bg-transparent p-0 open:block'

/** Upstream DrawerPrimitive.Backdrop string plus the swipe-progress opacity
 *  (live now that the drag owns --drawer-swipe-progress) and the
 *  data-swiping duration cut. The iOS absolute-positioning quirk line is
 *  verbatim; it needs `body { position: relative; }` per the upstream docs. */
export const drawerBackdropClass =
  'bg-black/10 supports-backdrop-filter:backdrop-blur-xs fixed inset-0 z-50 min-h-dvh opacity-[max(var(--drawer-overlay-min-opacity,0),calc(1-var(--drawer-swipe-progress)))] transition-opacity duration-450 ease-[cubic-bezier(0.32,0.72,0,1)] select-none data-swiping:duration-0 data-enter:data-closed:opacity-0 data-leave:data-closed:opacity-0 supports-[-webkit-touch-callout:none]:absolute'

/** Upstream DrawerPrimitive.Popup string, direction-agnostic lines only: base,
 *  full bleed, sizing (minus the snap-points height line), [--bleed:3rem],
 *  the data-swiping duration cut, and the enter/leave closed-transform slide.
 *  Direction docks in drawerPanelDirectionClass. */
export const drawerPanelBaseClass =
  'bg-popover text-popover-foreground text-sm data-[swipe-direction=down]:rounded-t-xl data-[swipe-direction=down]:border-t data-[swipe-direction=left]:rounded-r-xl data-[swipe-direction=left]:border-r data-[swipe-direction=right]:rounded-l-xl data-[swipe-direction=right]:border-l data-[swipe-direction=up]:rounded-b-xl data-[swipe-direction=up]:border-b group/drawer-popup pointer-events-auto fixed z-50 m-(--drawer-inset,0px) flex h-(--drawer-content-height) max-h-(--drawer-content-max-height,none) min-h-0 w-(--drawer-content-width,auto) transform-[translate3d(var(--translate-x,0px),var(--translate-y,0px),0)_scale(var(--stack-scale))] flex-col transition-[transform,height,opacity,filter] duration-450 ease-[cubic-bezier(0.22,1,0.36,1)] will-change-transform outline-none select-none [interpolate-size:allow-keywords] after:pointer-events-none after:absolute after:bg-(--drawer-bleed-background,var(--color-popover)) data-[swipe-axis=x]:after:inset-y-0 data-[swipe-axis=x]:after:w-(--bleed) data-[swipe-axis=y]:after:inset-x-0 data-[swipe-axis=y]:after:h-(--bleed) data-[swipe-direction=down]:after:top-full data-[swipe-direction=left]:after:right-full data-[swipe-direction=right]:after:left-full data-[swipe-direction=up]:after:bottom-full [--drawer-content-height:var(--drawer-height,auto)] data-[swipe-axis=x]:[--drawer-content-width:75%] data-[swipe-axis=y]:[--drawer-content-max-height:calc(100dvh-6rem)] data-[swipe-axis=x]:sm:[--drawer-content-width:24rem] [--bleed:3rem] data-swiping:duration-0 data-enter:data-closed:transform-(--closed-transform) data-leave:data-closed:transform-(--closed-transform)'

/** Upstream per-direction Popup lines, verbatim: edge dock, transform origin,
 *  closed-transform, plus the axis placement. The --translate movement lines
 *  stay dropped (the drag offset travels on the inline `translate` property). */
export const drawerPanelDirectionClass: Record<SwipeDirection, string> = {
  down: 'data-[swipe-axis=y]:inset-x-0 data-[swipe-direction=down]:bottom-0 data-[swipe-direction=down]:origin-bottom data-[swipe-direction=down]:[--closed-transform:translate3d(0,calc(100%+var(--drawer-inset,0px)+2px),0)]',
  up: 'data-[swipe-axis=y]:inset-x-0 data-[swipe-direction=up]:top-0 data-[swipe-direction=up]:origin-top data-[swipe-direction=up]:[--closed-transform:translate3d(0,calc(-100%-var(--drawer-inset,0px)-2px),0)]',
  left: 'data-[swipe-axis=x]:inset-y-0 data-[swipe-axis=x]:flex-row data-[swipe-direction=left]:left-0 data-[swipe-direction=left]:origin-left data-[swipe-direction=left]:[--closed-transform:translate3d(calc(-100%-var(--drawer-inset,0px)-2px),0,0)]',
  right:
    'data-[swipe-axis=x]:inset-y-0 data-[swipe-axis=x]:flex-row data-[swipe-direction=right]:right-0 data-[swipe-direction=right]:origin-right data-[swipe-direction=right]:[--closed-transform:translate3d(calc(100%+var(--drawer-inset,0px)+2px),0,0)]',
}

/** Upstream DrawerContent string, verbatim. The group-data-swiping line goes
 *  live during a handle drag (text selection locks while swiping). */
export const drawerContentClass =
  'flex min-h-0 flex-1 flex-col overflow-hidden overscroll-contain rounded-[inherit] transition-opacity duration-300 ease-[cubic-bezier(0.45,1.005,0,1.005)] select-text group-data-nested-drawer-open/drawer-popup:opacity-0 group-data-nested-drawer-swiping/drawer-popup:opacity-100 group-data-swiping/drawer-popup:select-none'

/** Upstream DrawerSwipeHandle string, verbatim. The order-last lines dock the
 *  handle against the content for left/up drawers. */
export const drawerHandleClass =
  'after:block after:shrink-0 after:rounded-full after:bg-muted group-data-[swipe-axis=y]/drawer-popup:h-3 group-data-[swipe-axis=y]/drawer-popup:w-full group-data-[swipe-axis=y]/drawer-popup:justify-center group-data-[swipe-axis=y]/drawer-popup:after:h-1 group-data-[swipe-axis=y]/drawer-popup:after:w-24 group-data-[swipe-direction=down]/drawer-popup:items-end group-data-[swipe-direction=up]/drawer-popup:items-start group-data-[swipe-axis=x]/drawer-popup:h-full group-data-[swipe-axis=x]/drawer-popup:w-3 group-data-[swipe-axis=x]/drawer-popup:items-center group-data-[swipe-axis=x]/drawer-popup:after:h-24 group-data-[swipe-axis=x]/drawer-popup:after:w-1 group-data-[swipe-direction=left]/drawer-popup:justify-start group-data-[swipe-direction=right]/drawer-popup:justify-end relative z-10 flex shrink-0 cursor-grab transition-opacity duration-200 group-data-nested-drawer-open/drawer-popup:opacity-0 group-data-nested-drawer-swiping/drawer-popup:opacity-100 group-data-[swipe-direction=left]/drawer-popup:order-last group-data-[swipe-direction=up]/drawer-popup:order-last active:cursor-grabbing'

export const drawerHeaderClass =
  'gap-0.5 p-4 pb-0 md:gap-0.5 md:text-left flex shrink-0 flex-col group-data-[swipe-axis=y]/drawer-popup:text-center'

export const drawerTitleClass = 'text-foreground text-base font-medium font-heading'

export const drawerDescriptionClass = 'text-muted-foreground text-sm text-balance'

export const drawerFooterClass = 'gap-2 p-4 pt-0 mt-auto flex shrink-0 flex-col'

/** Upstream DrawerClose is an unstyled passthrough; every base example
 *  styles it as `<Button variant="outline">`. */
export const drawerCloseButtonClass = 'focus-visible:border-ring focus-visible:ring-ring/50 aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive dark:aria-invalid:border-destructive/50 rounded-lg border bg-clip-padding text-sm font-medium focus-visible:ring-3 aria-invalid:ring-3 active:not-aria-[haspopup]:translate-y-px [&_svg:not([class*=\'size-\'])]:size-4 group/button inline-flex shrink-0 items-center justify-center whitespace-nowrap transition-all outline-none select-none [&_svg]:pointer-events-none [&_svg]:shrink-0 disabled:pointer-events-none disabled:opacity-50 aria-disabled:pointer-events-none aria-disabled:opacity-50 data-disabled:pointer-events-none data-disabled:opacity-50 border-border bg-background hover:bg-muted hover:text-foreground dark:bg-input/30 dark:border-input dark:hover:bg-input/50 aria-expanded:bg-muted aria-expanded:text-foreground h-8 gap-1.5 px-2.5 has-data-[icon=inline-end]:pr-2 has-data-[icon=inline-start]:pl-2'

const LEFT_MOUSE_BUTTON = 0

type StyleConfig<M> = Readonly<{
  className?: string
  /** Submodel-provided attributes (from the `styledViewInputs` content
   *  callback render bundle) to merge onto the element. */
  attributes?: ReadonlyArray<Attribute<M> | ChildAttribute>
}>

/** Grab handle (upstream DrawerSwipeHandle; aria-hidden). Starts the
 *  drag-to-dismiss gesture on pointer press; touch scrolling is locked to the
 *  handle so a touch drag moves the panel instead of the page. Rendered by
 *  the view when `isHandleVisible` is set. It carries a Drawer message, so
 *  it can only be built with this module's builder. */
export const handle = (config: StyleConfig<Message>, h: HtmlBuilder<Message>): Html =>
  h.div(
    [
      h.AriaHidden(true),
      h.DataAttribute('slot', 'drawer-swipe-handle'),
      h.Class(cn(drawerHandleClass, config.className)),
      h.Style({ 'touch-action': 'none' }),
      h.OnPointerDown((pointerType, button, _screenX, _screenY, _timeStamp, clientX, clientY) =>
        pointerType === 'mouse' && button !== LEFT_MOUSE_BUTTON
          ? Option.none()
          : Option.some(Message.PressedHandle({ clientX, clientY })),
      ),
      ...(config.attributes ?? []),
    ],
    [],
  )

export const header = <M>(
  config: StyleConfig<M>,
  children: ReadonlyArray<Child>,
  h: HtmlBuilder<M>,
): Html =>
  h.div(
    [h.DataAttribute('slot', 'drawer-header'), h.Class(cn(drawerHeaderClass, config.className))],
    children,
  )

export const title = <M>(
  config: StyleConfig<M>,
  children: ReadonlyArray<Child>,
  h: HtmlBuilder<M>,
): Html =>
  h.h2(
    [
      ...(config.attributes ?? []),
      h.DataAttribute('slot', 'drawer-title'),
      h.Class(cn(drawerTitleClass, config.className)),
    ],
    children,
  )

export const description = <M>(
  config: StyleConfig<M>,
  children: ReadonlyArray<Child>,
  h: HtmlBuilder<M>,
): Html =>
  h.p(
    [
      ...(config.attributes ?? []),
      h.DataAttribute('slot', 'drawer-description'),
      h.Class(cn(drawerDescriptionClass, config.className)),
    ],
    children,
  )

export const footer = <M>(
  config: StyleConfig<M>,
  children: ReadonlyArray<Child>,
  h: HtmlBuilder<M>,
): Html =>
  h.div(
    [h.DataAttribute('slot', 'drawer-footer'), h.Class(cn(drawerFooterClass, config.className))],
    children,
  )

export const closeButton = <M>(
  config: StyleConfig<M>,
  children: ReadonlyArray<Child>,
  h: HtmlBuilder<M>,
): Html =>
  h.button(
    [
      ...(config.attributes ?? []),
      h.DataAttribute('slot', 'drawer-close'),
      h.Class(cn(drawerCloseButtonClass, config.className)),
    ],
    children,
  )

export type DrawerContent<M> = Readonly<{
  closeButton: ReadonlyArray<Attribute<M> | ChildAttribute>
  title: ReadonlyArray<Attribute<M> | ChildAttribute>
  description: ReadonlyArray<Attribute<M> | ChildAttribute>
}>

export type StyledViewInputs<M> = Readonly<{
  content: (render: DrawerContent<M>, h: HtmlBuilder<M>) => ReadonlyArray<Child>
  className?: string
  backdropClass?: string
  panelClass?: string
}>

/** Render payload Drawer.view feeds its `toView`: the dialog bundles, the
 *  swipe direction, the live drag position, and the prebuilt grab handle. */
export type RenderInput = FoldkitDialog.RenderInfo &
  Readonly<{
    direction: SwipeDirection
    dragOffset: number
    isDragging: boolean
    handle: Html
  }>

export type ViewInputs = Readonly<{
  toView: (render: RenderInput) => Html
}>

/** Renders the drawer by nesting the Dialog submodel. The shell (direction
 *  dock, drag translate, overlay progress) builds in the parent's `toView`,
 *  usually via `styledViewInputs`. */
export const view = defineView<Model, Message, ViewInputs>((model, viewInputs, h) =>
  h.submodel({
    slotId: `${model.id}-dialog`,
    model: model.dialog,
    view: FoldkitDialog.view,
    viewInputs: {
      toView: (render) =>
        viewInputs.toView({
          ...render,
          direction: model.swipeDirection,
          dragOffset: model.drag.offset,
          isDragging: model.drag.activity === 'Dragging',
          handle: model.isHandleVisible ? handle({}, h) : h.empty,
        }),
    },
    toParentMessage: (message) => Message.GotDialogMessage({ message }),
  }),
)

/** Build styled `ViewInputs` for the drawer. Direction and drag position
 *  arrive from the model through `RenderInput`, so the shell always matches
 *  the gesture state with no extra wiring. */
export const styledViewInputs = <M>(
  viewInputs: StyledViewInputs<M>,
  h: HtmlBuilder<M>,
): ViewInputs => ({
  toView: ({
    dialog,
    backdrop,
    panel,
    closeButton,
    title,
    description,
    isVisible,
    direction,
    dragOffset,
    isDragging,
    handle,
  }) =>
    h.dialog(
      [
        ...dialog,
        h.DataAttribute('slot', 'drawer'),
        h.Class(cn(drawerClass, viewInputs.className)),
      ],
      isVisible
        ? [
            h.div([
              ...backdrop,
              h.DataAttribute('slot', 'drawer-overlay'),
              h.Class(cn(drawerBackdropClass, viewInputs.backdropClass)),
              ...(isDragging && dragOffset > 0
                ? [
                    h.Style({
                      '--drawer-swipe-progress': `${String(Math.min(1, dragOffset / DISMISS_THRESHOLD_PX))}`,
                    }),
                  ]
                : []),
            ]),
            h.div(
              [
                ...panel,
                h.DataAttribute('slot', 'drawer-popup'),
                h.DataAttribute('swipe-direction', direction),
                h.DataAttribute('swipe-axis', swipeAxis(direction)),
                h.Class(
                  cn(
                    drawerPanelBaseClass,
                    drawerPanelDirectionClass[direction],
                    viewInputs.panelClass,
                  ),
                ),
                ...(isDragging ? [h.DataAttribute('swiping', '')] : []),
                ...(isDragging && dragOffset > 0
                  ? [h.Style({ translate: dragTranslate(direction, dragOffset) })]
                  : []),
              ],
              [
                handle,
                h.div(
                  [h.DataAttribute('slot', 'drawer-content'), h.Class(drawerContentClass)],
                  viewInputs.content({ closeButton, title, description }, h),
                ),
              ],
            ),
          ]
        : [],
    ),
})