Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
178 changes: 178 additions & 0 deletions packages/app-elements/src/hooks/useConfirmDialog.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,178 @@
import { type FC, useCallback, useState } from "react"
import { Button, type ButtonProps } from "#ui/atoms/Button"
import { Icon, type IconProps } from "#ui/atoms/Icon"
import { Text } from "#ui/atoms/Text"
import { Modal } from "#ui/composite/Modal"
import { toast } from "#ui/composite/Toast"

interface ConfirmDialogConfirmProps {
/** Label for the confirm button */
label: string
/** Button variant. Use `"danger"` for destructive actions and `"primary"` for generic confirmations. */
variant?: ButtonProps["variant"]
/** Async action to execute on confirm */
onClick: () => Promise<unknown>
/** Optional success message to display when the action completes successfully */
onSuccessMessage?: string
}

interface ConfirmDialogProps {
/** Controls visibility of the dialog */
show: boolean
/** Called when the dialog should close */
onClose: () => void
/** Icon name to display at the top of the dialog (from the Icon component set) */
icon: IconProps["name"]
/** Main title shown in the dialog body. */
title: string
/** Optional extra content rendered below the title. */
description?: React.ReactNode
/** Configuration for the confirm (primary action) button */
confirm: ConfirmDialogConfirmProps
/**
* Message shown in the error toast when `confirm.onClick` rejects.
* Can be a static string or a function receiving the caught error.
* Defaults to a generic error message.
*/
errorMessage?: string | ((error: unknown) => string)
/** Optional success message shown when the action completes successfully */
successMessage?: string
}

const DEFAULT_ERROR_MESSAGE = "Something went wrong. Please try again."

/**
* Blocking confirmation dialog built on top of `Modal`.
* The user can only interact via the confirm or cancel buttons — backdrop clicks and
* Escape key are disabled. Both buttons are disabled while the async confirm action
* is pending. On error the dialog closes and an error toast is shown automatically.
*/
const ConfirmDialog: FC<ConfirmDialogProps> = ({
show,
onClose,
icon,
title,
description,
confirm,
errorMessage = DEFAULT_ERROR_MESSAGE,
successMessage,
}) => {
const [isPending, setIsPending] = useState(false)

const handleConfirm = async (): Promise<void> => {
if (isPending) return
setIsPending(true)
try {
await confirm.onClick()
if (successMessage) {
toast(successMessage, { type: "success" })
}
} catch (err) {
const msg =
typeof errorMessage === "function" ? errorMessage(err) : errorMessage
toast(msg, { type: "error" })
} finally {
setIsPending(false)
onClose()
}
}

const handleCancel = (): void => {
if (isPending) return
onClose()
}

return (
<Modal
show={show}
onClose={onClose}
size="x-small"
closeOnBackdrop={false}
closeOnEscape={false}
>
<Modal.Body>
<div className="flex flex-col items-center text-center">
<Icon name={icon} size={32} className="mt-3.5 mb-4 text-gray-400" />
<Text weight="medium" className="text-balance">
{title}
</Text>
<Text variant="info" size="small">
{description}
</Text>
</div>
</Modal.Body>
<Modal.Footer>
<Button
variant={confirm.variant ?? "primary"}
onClick={() => void handleConfirm()}
disabled={isPending}
fullWidth
>
{confirm.label}
</Button>
<Button
variant="secondary"
onClick={handleCancel}
disabled={isPending}
fullWidth
>
Cancel
</Button>
</Modal.Footer>
</Modal>
)
}

type ConfirmDialogRendererProps = Omit<ConfirmDialogProps, "show" | "onClose">

interface ConfirmDialogHook {
/**
* Opens the dialog.
*/
show: () => void
/**
* The dialog renderer. Mount it in your component tree and pass it the
* `icon`, `title`, `description`, `confirm` (and optional `errorMessage`) props.
* Visibility is fully managed by the hook — you only need to call `show()`.
*
* @example
* const { show, ConfirmDialog } = useConfirmDialog()
*
* return (
* <>
* <Button onClick={show}>Delete</Button>
* <ConfirmDialog
* icon="trash"
* title="Delete item"
* description="Are you sure you want to delete this item?"
* confirm={{ label: "Delete", variant: "danger", onClick: handleDelete }}
* />
* </>
* )
*/
ConfirmDialog: FC<ConfirmDialogRendererProps>
}

export function useConfirmDialog(): ConfirmDialogHook {
const [isOpen, setIsOpen] = useState(false)

const open = useCallback(() => {
setIsOpen(true)
}, [])

const close = useCallback(() => {
setIsOpen(false)
}, [])

const ConfirmDialogRenderer = useCallback<FC<ConfirmDialogRendererProps>>(
(props) => {
return <ConfirmDialog {...props} show={isOpen} onClose={close} />
},
[isOpen, close],
)

return {
show: open,
ConfirmDialog: ConfirmDialogRenderer,
}
}
1 change: 1 addition & 0 deletions packages/app-elements/src/main.ts
Original file line number Diff line number Diff line change
Expand Up @@ -76,6 +76,7 @@ export {
export { useAppLinking } from "#helpers/useAppLinking"
// Hooks
export { useClickAway } from "#hooks/useClickAway"
export { useConfirmDialog } from "#hooks/useConfirmDialog"
export { useDelayShow } from "#hooks/useDelayShow"
export { useEditMetadataOverlay } from "#hooks/useEditMetadataOverlay"
export { useEditTagsOverlay } from "#hooks/useEditTagsOverlay"
Expand Down
20 changes: 17 additions & 3 deletions packages/app-elements/src/ui/composite/Modal.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,10 @@ export type ModalProps = {
children: React.ReactNode
/** Max width preset */
size?: "large" | "small" | "x-small"
/** Whether clicking the backdrop closes the modal. Defaults to `true`. */
closeOnBackdrop?: boolean
/** Whether pressing Escape closes the modal. Defaults to `true`. */
closeOnEscape?: boolean
}

export const Modal: React.FC<
Expand All @@ -36,7 +40,15 @@ export const Modal: React.FC<
Header: React.FC<React.PropsWithChildren>
Body: React.FC<React.PropsWithChildren>
Footer: React.FC<React.PropsWithChildren>
} = ({ ref, show = false, children, onClose, size = "small" }) => {
} = ({
ref,
show = false,
children,
onClose,
size = "small",
closeOnBackdrop = true,
closeOnEscape = true,
}) => {
const modalId = useId()

useEffect(
Expand Down Expand Up @@ -86,10 +98,12 @@ export const Modal: React.FC<
type="button"
aria-label="Close modal"
className="fixed inset-0 z-60 bg-gray-900/90 animate-backdrop-fade-in cursor-default"
onClick={onClose}
onClick={closeOnBackdrop ? onClose : undefined}
onKeyDown={(e) => {
if (e.key === "Escape" || e.key === "Enter" || e.key === " ") {
onClose()
if (closeOnEscape) {
onClose()
}
}
}}
/>
Expand Down
151 changes: 151 additions & 0 deletions packages/docs/src/stories/hooks/useConfirmDialog.stories.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,151 @@
import {
Description,
Primary,
Stories,
Subtitle,
Title,
} from "@storybook/addon-docs/blocks"
import type { Meta, StoryFn } from "@storybook/react-vite"
import { useConfirmDialog } from "#hooks/useConfirmDialog"
import { Button } from "#ui/atoms/Button"
import { ToastContainer } from "#ui/composite/Toast"

/**
* Hook that returns a blocking confirmation dialog along with a `show()` method to open it.
* Visibility is fully managed internally — the dialog can only be dismissed via the confirm
* or cancel buttons (backdrop click and Escape key are disabled).
*
* The confirm action is an async function; both buttons are disabled while it is pending.
* If the async action rejects, an error toast is shown automatically and the dialog closes.
*
* Example usage:
* ```tsx
* const { show, ConfirmDialog } = useConfirmDialog()
*
* return (
* <>
* <Button onClick={show}>Delete</Button>
* <ConfirmDialog
* icon="trash"
* title="Delete item"
* description="Are you sure you want to delete this item?"
* confirm={{ label: "Delete", variant: "danger", onClick: handleDelete }}
* />
* </>
* )
* ```
*/
const setup: Meta = {
title: "Hooks/useConfirmDialog",
args: {},
argTypes: {
children: {
table: {
disable: true,
},
},
},
parameters: {
docs: {
page: () => (
<>
<Title />
<Subtitle />
<Description />
<ToastContainer />
<Primary />
<Stories includePrimary={false} />
</>
),
source: {
type: "code",
},
},
},
}
export default setup

/**
* Delete confirmation dialog. Use `variant="danger"` for destructive actions.
*/
export const DeleteAction: StoryFn = () => {
const { show, ConfirmDialog } = useConfirmDialog()

return (
<div>
<Button variant="danger" onClick={show}>
Delete item
</Button>
<ConfirmDialog
icon="trash"
title="Delete promotion Welcome Discount 10?"
description="This action can’t be undone."
confirm={{
label: "Delete",
variant: "danger",
onClick: async () => {
await new Promise((resolve) => setTimeout(resolve, 1500))
},
}}
successMessage="Promotion deleted!"
/>
</div>
)
}

/**
* Confirm action dialog. Use `variant="primary"` (the default) for non-destructive confirmations.
*/
export const ConfirmAction: StoryFn = () => {
const { show, ConfirmDialog } = useConfirmDialog()

return (
<div>
<Button onClick={show}>Capture payment</Button>
<ConfirmDialog
icon="check"
title="Do you want to capture the payment of $100.00 for order #12345?"
confirm={{
label: "Capture",
variant: "primary",
onClick: async () => {
await new Promise((resolve) => setTimeout(resolve, 1500))
},
}}
successMessage="Payment captured!"
/>
</div>
)
}

/**
* When the async confirm action rejects, the dialog closes and an error toast is shown.
* A custom `errorMessage` can be provided — either a static string or a function receiving
* the caught error.
*/
export const AsyncError: StoryFn = () => {
const { show, ConfirmDialog } = useConfirmDialog()

return (
<div>
<Button onClick={show}>Trigger failing action</Button>
<ConfirmDialog
icon="warning"
title="Confirm action"
description="This action will fail. Confirm to see the error toast."
confirm={{
label: "Confirm",
variant: "primary",
onClick: async () => {
await new Promise((_, reject) =>
setTimeout(() => reject(new Error("Server error")), 1500),
)
},
}}
errorMessage={(err) =>
err instanceof Error ? err.message : "Something went wrong."
}
/>
</div>
)
}
Loading