{ "$schema": "https://ui.shadcn.com/schema/registry-item.json", "name": "drawer-action", "type": "registry:ui", "title": "DrawerAction", "description": "A single action in a BottomDrawer footer: plain, form-submit, or tap-again-confirm.", "categories": [ "actions", "overlays" ], "registryDependencies": [ "https://whiskeyjack.net/r/announce.json", "https://whiskeyjack.net/r/utils.json" ], "files": [ { "path": "components/ui/drawer-action.tsx", "type": "registry:ui", "target": "components/ui/drawer-action.tsx", "content": "import * as React from \"react\";\nimport { cn } from \"@/lib/utils\";\nimport { announce } from \"@/lib/announce\";\n\nexport type DrawerActionTone = \"danger\" | \"warning\";\n\nexport interface DrawerActionProps {\n /** Icon shown above the label (mobile) or beside it (desktop). Phosphor or any node; sized to 20px. */\n icon: React.ReactNode;\n /** Text label rendered below the icon. */\n label: string;\n /** Click handler for plain actions (e.g. Cancel). */\n onClick?: () => void;\n /**\n * Button type. Use `\"submit\"` together with `form` so a Save action can\n * submit a `
` rendered in the drawer's scrollable content (the footer\n * sits outside that form in the DOM).\n */\n type?: \"button\" | \"submit\";\n /** Associates a `type=\"submit\"` action with a form by its `id`. */\n form?: string;\n /** Disable the action (e.g. Save until the form is dirty/valid). */\n disabled?: boolean;\n /**\n * Turns this into a tap-again-to-confirm action. The first activation arms it\n * (icon + label swap to this confirm variant, tinted by `tone`); the second\n * runs `onConfirm`. Pair with `armed` / `onArmedChange` to keep sibling\n * destructive actions mutually exclusive and to reset on drawer close.\n */\n confirm?: {\n icon: React.ReactNode;\n label: string;\n /** @default \"danger\" */\n tone?: DrawerActionTone;\n };\n onConfirm?: () => void;\n /** Controlled armed state. When omitted the component manages its own. */\n armed?: boolean;\n onArmedChange?: (armed: boolean) => void;\n /** Accessible label when the visible label needs supplementing. */\n \"aria-label\"?: string;\n className?: string;\n /** Inline styles for the root element. */\n style?: React.CSSProperties;\n}\n\n/**\n * A single action in a `BottomDrawer` footer: an icon stacked above the label\n * on mobile (bottom-nav item style) and inline beside it on desktop, mirroring\n * how the app nav shifts from the mobile bottom bar to the desktop header.\n * Footers lay these out in an even flex row (most-destructive leftmost), e.g.\n * `Delete | Reset | Cancel | Save`.\n *\n * Two modes:\n * - **Plain** -- `onClick` (Cancel), or `type=\"submit\"` + `form` (Save).\n * - **Confirm** -- pass `confirm` + `onConfirm` for tap-again-to-confirm.\n * At rest the action is untinted; only the armed confirmation step tints\n * (`tone=\"danger\"` red, `tone=\"warning\"` amber).\n *\n * @example\n * ```tsx\n * } label={t('common.cancel')} onClick={onClose} />\n * } label={t('common.save')} type=\"submit\" form=\"edit-goal\" disabled={!dirty} />\n * } label={t('settings.removeGoal')}\n * confirm={{ icon: , label: t('common.confirm'), tone: 'danger' }}\n * onConfirm={handleRemove} armed={removeArmed} onArmedChange={setRemoveArmed}\n * />\n * ```\n */\nexport const DrawerAction = React.forwardRef(function DrawerAction({\n icon,\n label,\n onClick,\n type = \"button\",\n form,\n disabled,\n confirm,\n onConfirm,\n armed: controlledArmed,\n onArmedChange,\n className,\n style,\n ...rest\n}, ref) {\n const [internalArmed, setInternalArmed] = React.useState(false);\n const isControlled = controlledArmed !== undefined;\n const armed = isControlled ? controlledArmed : internalArmed;\n\n const setArmed = React.useCallback(\n (next: boolean) => {\n if (!isControlled) setInternalArmed(next);\n onArmedChange?.(next);\n },\n [isControlled, onArmedChange]\n );\n\n const isConfirm = Boolean(confirm && onConfirm);\n\n const handleClick = () => {\n if (isConfirm) {\n if (armed) {\n onConfirm!();\n setArmed(false);\n } else {\n setArmed(true);\n // Announce the confirm label assertively so screen-reader users hear\n // \"press again to confirm\" before the destructive second activation.\n announce(confirm!.label, \"assertive\");\n }\n return;\n }\n onClick?.();\n };\n\n const showConfirm = isConfirm && armed;\n const tone = confirm?.tone ?? \"danger\";\n\n return (\n \n \n {showConfirm ? confirm!.icon : icon}\n \n \n {showConfirm ? confirm!.label : label}\n \n \n );\n});\n\nDrawerAction.displayName = \"DrawerAction\"\n" } ], "docs": "Modal actions belong in the drawer's sticky footer, never in scrollable content. Lay them out in an even row, most-destructive leftmost and primary rightmost: Delete, Reset, Cancel, Save. A Save uses type=\"submit\" with form=\"\" so it can submit a form rendered in drawer content, and stays disabled until the form is dirty and valid.", "meta": { "group": "actions", "related": [ "bottom-drawer", "confirm-button" ], "exports": [ "DrawerAction" ], "siteSlug": "drawer-action" } }