{ "$schema": "https://ui.shadcn.com/schema/registry-item.json", "name": "async-button", "title": "Async Button", "description": "Button that auto-manages loading state when onClick returns a Promise.", "registryDependencies": [ "button", "@easy-shadcn/use-delay-loading" ], "files": [ { "path": "registry/ui/async-button.tsx", "content": "\"use client\";\n\nimport type React from \"react\";\nimport type { MouseEvent, MouseEventHandler } from \"react\";\nimport { useRef } from \"react\";\nimport { Button } from \"@/components/ui/button\";\nimport { cn } from \"@/lib/utils\";\nimport { useDelayLoading } from \"@/registry/hooks/use-delay-loading\";\n\nconst LoadingIcon = (props: React.SVGProps) => (\n \n \n \n);\n\nexport type AsyncButtonClickHandler = (\n e: MouseEvent\n) => void | Promise;\n\nexport type AsyncButtonProps = Omit<\n React.ComponentProps,\n \"onClick\"\n> & {\n onClick?: AsyncButtonClickHandler;\n /**\n * Controlled loading state. When provided, takes precedence over the\n * auto-managed Promise loading; pass `false` to fully suppress the spinner\n * even while an async `onClick` is in flight. Switching back to undefined\n * (uncontrolled) mid-flight is not supported.\n */\n loading?: boolean;\n /**\n * Icon rendered before `children`. While loading, this slot is replaced by\n * the spinner (taking precedence over `endIcon`).\n */\n startIcon?: React.ReactNode;\n /**\n * Icon rendered after `children`. While loading and `startIcon` is absent,\n * this slot is replaced by the spinner.\n */\n endIcon?: React.ReactNode;\n};\n\nexport const AsyncButton: React.FC = ({\n loading,\n disabled,\n onClick,\n children,\n className,\n startIcon,\n endIcon,\n size,\n ...restProps\n}) => {\n const [innerLoading, setLoading] = useDelayLoading({ loading });\n // Blocks the same-frame double click that can slip in before the disabled\n // attribute lands with the next render.\n const inFlightRef = useRef(false);\n\n const handleClick: MouseEventHandler = (e) => {\n if (!onClick || inFlightRef.current) {\n return;\n }\n const result = onClick(e as MouseEvent);\n if (result instanceof Promise) {\n inFlightRef.current = true;\n setLoading(true);\n result\n .catch((err) => {\n console.error(err);\n })\n .finally(() => {\n inFlightRef.current = false;\n setLoading(false);\n });\n }\n };\n\n let spinnerSlot: \"start\" | \"end\" | \"children\" | \"overlay\" | null = null;\n if (innerLoading) {\n if (size?.startsWith(\"icon\")) {\n spinnerSlot = \"children\";\n } else if (startIcon) {\n spinnerSlot = \"start\";\n } else if (endIcon) {\n spinnerSlot = \"end\";\n } else {\n spinnerSlot = \"overlay\";\n }\n }\n\n const spinner = ;\n\n return (\n \n {spinnerSlot === \"start\" ? spinner : startIcon}\n {spinnerSlot === \"children\" ? spinner : children}\n {spinnerSlot === \"end\" ? spinner : endIcon}\n {spinnerSlot === \"overlay\" && (\n <>\n \n \n \n \n \n )}\n \n );\n};\n", "type": "registry:component", "target": "components/easy/async-button.tsx" } ], "type": "registry:component" }