{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "action-sheet",
  "type": "registry:ui",
  "title": "Action Sheet",
  "description": "Rises from the bottom, swipes away, and holds for slow actions.",
  "categories": [
    "menus"
  ],
  "dependencies": [
    "@base-ui/react",
    "motion"
  ],
  "registryDependencies": [
    "https://www.stealth.pm/r/tokens.json",
    "https://www.stealth.pm/r/cn.json",
    "https://www.stealth.pm/r/motion.json",
    "https://www.stealth.pm/r/use-controllable-state.json"
  ],
  "files": [
    {
      "path": "components/ui/action-sheet.tsx",
      "content": "\"use client\";\nimport { Drawer } from \"@base-ui/react/drawer\";\nimport { AnimatePresence, motion, useReducedMotion } from \"motion/react\";\nimport { createContext, useContext, useId, useState } from \"react\";\nimport { cn } from \"@/lib/cn\";\nimport { ease } from \"@/lib/motion\";\nimport { useControllableState } from \"@/lib/use-controllable-state\";\n\ntype Ctx = {\n  close: () => void;\n  busy: string | null;\n  setBusy: (id: string | null) => void;\n  contained: boolean;\n};\nconst SheetContext = createContext<Ctx | null>(null);\nconst PortalTarget = createContext<HTMLElement | undefined>(undefined);\nconst useSheet = () => {\n  const ctx = useContext(SheetContext);\n  if (!ctx) throw new Error(\"ActionSheet parts must be used inside <ActionSheet>\");\n  return ctx;\n};\n\nexport type ActionSheetProps = Omit<Drawer.Root.Props, \"open\" | \"defaultOpen\" | \"onOpenChange\" | \"swipeDirection\" | \"children\"> & {\n  children?: React.ReactNode;\n  open?: boolean;\n  defaultOpen?: boolean;\n  onOpenChange?: (open: boolean) => void;\n  /**\n   * Render inside this element instead of over the page: the backdrop and sheet\n   * fill it, and page scroll stays unlocked. Give it `position: relative` and\n   * `overflow: hidden`.\n   */\n  container?: HTMLElement | null;\n};\n\n/** A list of actions that rises from the bottom edge: the phone's version of a menu. */\nexport function ActionSheet({ open, defaultOpen = false, onOpenChange, container, modal, children, ...rest }: ActionSheetProps) {\n  const [isOpen, setOpen] = useControllableState({ value: open, defaultValue: defaultOpen, onChange: onOpenChange });\n  const [busy, setBusy] = useState<string | null>(null);\n  const contained = container !== undefined;\n  return (\n    <SheetContext.Provider value={{ close: () => setOpen(false), busy, setBusy, contained }}>\n      <Drawer.Root\n        open={isOpen}\n        onOpenChange={(next) => setOpen(next)}\n        onOpenChangeComplete={(next) => !next && setBusy(null)}\n        swipeDirection=\"down\"\n        modal={modal ?? (contained ? \"trap-focus\" : true)}\n        {...rest}\n      >\n        <PortalTarget.Provider value={container ?? undefined}>{children}</PortalTarget.Provider>\n      </Drawer.Root>\n    </SheetContext.Provider>\n  );\n}\n\nexport type ActionSheetTriggerProps = Drawer.Trigger.Props;\n\n/** Opens the sheet. Unstyled: pass `render` to use your own button, or style it with className. */\nexport function ActionSheetTrigger(props: ActionSheetTriggerProps) {\n  return <Drawer.Trigger {...props} />;\n}\n\nexport type ActionSheetContentProps = Omit<Drawer.Popup.Props, \"title\"> & {\n  /** A short line naming what the actions apply to. */\n  title?: React.ReactNode;\n  /** One more line of context under the title. */\n  description?: React.ReactNode;\n  /** Accessible name when there is no visible title. */\n  label?: string;\n  cancelLabel?: string;\n};\n\n/**\n * The sheet: a card of actions and, apart from it, Cancel, where the thumb rests.\n * Swipe it down, tap outside or press Escape to dismiss.\n */\nexport function ActionSheetContent({ title, description, label = \"Actions\", cancelLabel = \"Cancel\", className, children, ...rest }: ActionSheetContentProps) {\n  const { contained } = useSheet();\n  const container = useContext(PortalTarget);\n\n  // Arrow keys walk the actions like a menu; Tab still works as in any dialog.\n  const onKeyDown = (e: React.KeyboardEvent<HTMLDivElement>) => {\n    if (![\"ArrowDown\", \"ArrowUp\", \"Home\", \"End\"].includes(e.key)) return;\n    const buttons = [...e.currentTarget.querySelectorAll<HTMLButtonElement>(\"[data-sheet-action]:not(:disabled):not([aria-disabled=true])\")];\n    if (!buttons.length) return;\n    e.preventDefault();\n    const at = buttons.indexOf(document.activeElement as HTMLButtonElement);\n    const next =\n      e.key === \"Home\" ? 0 : e.key === \"End\" ? buttons.length - 1 : e.key === \"ArrowDown\" ? (at + 1) % buttons.length : (at - 1 + buttons.length) % buttons.length;\n    buttons[next]?.focus();\n  };\n\n  return (\n    <Drawer.Portal container={container}>\n      <Drawer.Backdrop\n        className={cn(\n          contained ? \"absolute\" : \"fixed\",\n          \"inset-0 z-(--z-overlay) bg-overlay opacity-[calc(1-var(--drawer-swipe-progress,0))]\",\n          \"transition-opacity duration-400 ease-drawer data-swiping:duration-0\",\n          \"data-starting-style:opacity-0 data-ending-style:opacity-0 data-ending-style:duration-[calc(var(--drawer-swipe-strength,1)*280ms)]\",\n        )}\n      />\n      <Drawer.Viewport className={cn(contained ? \"absolute\" : \"fixed\", \"inset-0 z-(--z-dialog) flex items-end justify-center\")}>\n        <Drawer.Popup\n          onKeyDown={onKeyDown}\n          className={(state) =>\n            cn(\n              \"pointer-events-none flex w-full max-w-[420px] flex-col gap-2 px-2 pb-[max(0.5rem,env(safe-area-inset-bottom))] outline-none\",\n              // Follows the finger 1:1 while swiped; otherwise rides the sheet curve in and out.\n              \"[transform:translateY(var(--drawer-swipe-movement-y))] transition-transform duration-400 ease-drawer data-swiping:duration-0 data-swiping:select-none\",\n              \"data-starting-style:[transform:translateY(calc(100%+8px))] data-ending-style:[transform:translateY(calc(100%+8px))]\",\n              \"data-ending-style:duration-[calc(var(--drawer-swipe-strength,1)*280ms)]\",\n              typeof className === \"function\" ? className(state) : className,\n            )\n          }\n          {...rest}\n        >\n          <Drawer.Content className=\"pointer-events-auto overflow-hidden rounded-2xl border border-line-2 bg-raised shadow-pop\">\n            {title || description ? (\n              <div className=\"flex flex-col items-center gap-0.5 border-b border-line px-5 py-3.5 text-center\">\n                {title && <Drawer.Title className=\"text-balance text-[13px] font-medium leading-[18px] text-fg-2\">{title}</Drawer.Title>}\n                {description && <Drawer.Description className=\"text-balance text-[12.5px] leading-[18px] text-fg-3\">{description}</Drawer.Description>}\n                {!title && <Drawer.Title className=\"sr-only\">{label}</Drawer.Title>}\n              </div>\n            ) : (\n              <Drawer.Title className=\"sr-only\">{label}</Drawer.Title>\n            )}\n            <div role=\"group\" aria-label={typeof title === \"string\" ? title : label} className=\"flex flex-col divide-y divide-line\">\n              {children}\n            </div>\n          </Drawer.Content>\n          <Drawer.Close data-sheet-action=\"\" className={cn(rowClass, \"pointer-events-auto rounded-2xl text-fg border border-line-2 bg-raised font-medium shadow-pop\")}>\n            {cancelLabel}\n          </Drawer.Close>\n        </Drawer.Popup>\n      </Drawer.Viewport>\n    </Drawer.Portal>\n  );\n}\n\nconst rowClass = cn(\n  \"relative flex min-h-13 w-full select-none flex-col items-center justify-center px-12 py-2 text-center text-[15px] leading-5 outline-none\",\n  \"touch-manipulation [-webkit-tap-highlight-color:transparent] transition-colors duration-100\",\n  \"hover:bg-fg/[0.04] active:bg-fg/[0.08] focus-visible:bg-fg/[0.04] focus-visible:outline-solid focus-visible:outline-1 focus-visible:-outline-offset-4 focus-visible:outline-fg-3\",\n  \"disabled:opacity-40 disabled:hover:bg-transparent aria-disabled:opacity-40 aria-disabled:hover:bg-transparent aria-disabled:active:bg-transparent\",\n);\n\nexport type ActionSheetItemProps = Omit<React.ComponentProps<\"button\">, \"onSelect\"> & {\n  /**\n   * Runs the action. Return a promise to keep the sheet up with a spinner on this\n   * row until it settles: it closes on success and says so on the row if it fails.\n   */\n  onSelect?: () => void | Promise<unknown>;\n  /** Destructive: danger text. Put it last. */\n  variant?: \"default\" | \"danger\";\n  /** Shown under the label if the returned promise rejects without a message. */\n  errorLabel?: string;\n};\n\nexport function ActionSheetItem({ onSelect, variant = \"default\", errorLabel = \"Couldn’t finish. Try again.\", disabled, className, children, onClick, ...rest }: ActionSheetItemProps) {\n  const { close, busy, setBusy } = useSheet();\n  const id = useId();\n  const reduce = useReducedMotion();\n  const [error, setError] = useState<string | null>(null);\n  const mine = busy === id;\n  const blocked = busy !== null && !mine;\n\n  return (\n    <button\n      type=\"button\"\n      data-sheet-action=\"\"\n      data-variant={variant}\n      data-busy={mine ? \"\" : undefined}\n      disabled={disabled}\n      aria-disabled={blocked || undefined}\n      aria-busy={mine || undefined}\n      onClick={async (e) => {\n        onClick?.(e);\n        if (e.defaultPrevented || busy !== null) return;\n        setError(null);\n        const result = onSelect?.();\n        if (!result || typeof (result as Promise<unknown>).then !== \"function\") return close();\n        setBusy(id);\n        try {\n          await result;\n          close();\n        } catch (err) {\n          setBusy(null);\n          setError(err instanceof Error && err.message ? err.message : errorLabel);\n        }\n      }}\n      className={cn(rowClass, variant === \"danger\" ? \"text-danger\" : \"text-fg\", mine && \"cursor-progress bg-fg/[0.04]\", className)}\n      {...rest}\n    >\n      <span className=\"max-w-full truncate\">{children}</span>\n      <AnimatePresence initial={false}>\n        {error && (\n          <motion.span\n            key=\"error\"\n            role=\"alert\"\n            initial={reduce ? { opacity: 0 } : { opacity: 0, height: 0 }}\n            animate={{ opacity: 1, height: \"auto\" }}\n            exit={{ opacity: 0, height: 0 }}\n            transition={{ duration: 0.2, ease: ease.out }}\n            className=\"block overflow-hidden text-[12.5px] leading-[18px] text-danger\"\n          >\n            {error}\n          </motion.span>\n        )}\n      </AnimatePresence>\n      <AnimatePresence>\n        {mine && (\n          <motion.span\n            key=\"busy\"\n            aria-hidden\n            initial={{ opacity: 0, scale: reduce ? 1 : 0.6 }}\n            animate={{ opacity: 1, scale: 1 }}\n            exit={{ opacity: 0, transition: { duration: 0.1 } }}\n            transition={{ duration: 0.18, ease: ease.out }}\n            className=\"absolute end-4 top-1/2 -mt-2 grid size-4 place-items-center text-fg-3\"\n          >\n            <svg width=\"16\" height=\"16\" viewBox=\"0 0 16 16\" fill=\"none\" className=\"animate-spin motion-reduce:animate-spin-slow\" aria-hidden>\n              <circle cx=\"8\" cy=\"8\" r=\"6\" stroke=\"currentColor\" strokeOpacity=\"0.25\" strokeWidth=\"1.5\" />\n              <path d=\"M14 8a6 6 0 0 0-6-6\" stroke=\"currentColor\" strokeWidth=\"1.5\" strokeLinecap=\"round\" />\n            </svg>\n          </motion.span>\n        )}\n      </AnimatePresence>\n    </button>\n  );\n}\n",
      "type": "registry:ui"
    }
  ],
  "docs": "https://www.stealth.pm/ui/action-sheet"
}
