Skip to content
File

Blob: src/client/components/ui/confirm-button.tsx

typescript59 lines
1import { type ReactNode, useEffect, useState } from "react";
2 
3import { Button, type ButtonProps } from "./button";
4 
5export interface ConfirmButtonProps extends Omit<ButtonProps, "onClick" | "children"> {
6 onConfirm: () => void | Promise<void>;
7 children: ReactNode;
8 confirmLabel?: ReactNode;
9 confirmTimeoutSec?: number;
10}
11 
12export const ConfirmButton = ({
13 onConfirm,
14 children,
15 confirmLabel = "Click again to confirm",
16 confirmTimeoutSec = 4,
17 ...rest
18}: ConfirmButtonProps) => {
19 const [armed, setArmed] = useState(false);
20 
21 useEffect(() => {
22 if (!armed) return;
23 const t = window.setTimeout(() => setArmed(false), confirmTimeoutSec * 1000);
24 return () => window.clearTimeout(t);
25 }, [armed, confirmTimeoutSec]);
26 
27 // When armed, the visible label changes ("Click again to confirm"). Mirror
28 // that into aria-label so screen readers announce the armed state instead
29 // of the original action label inherited from the caller.
30 const armedAriaLabel = typeof confirmLabel === "string" ? confirmLabel : undefined;
31 const ariaLabel = armed ? (armedAriaLabel ?? rest["aria-label"]) : rest["aria-label"];
32 
33 return (
34 <Button
35 {...rest}
36 aria-label={ariaLabel}
37 onClick={() => {
38 if (armed) {
39 setArmed(false);
40 void onConfirm();
41 } else {
42 setArmed(true);
43 }
44 }}
45 onBlur={() => {
46 if (armed) setArmed(false);
47 }}
48 onKeyDown={(e) => {
49 if (e.key === "Escape" && armed) {
50 e.preventDefault();
51 setArmed(false);
52 }
53 }}
54 >
55 {armed ? confirmLabel : children}
56 </Button>
57 );
58};