Component
Confirm morph
A button that morphs in place through confirm, pending and done steps, with undo.
- #arc
- #component
- #confirm
- #button
- #destructive
- #animation
1280px
Install
One command installs the files and packages this item needs.
TerminalTerminalTerminalbash
npx shadcn@latest add https://dsui.vercel.app/r/confirm-morph.jsonFiles
components/arc/confirm-morph/confirm-morph.module.csscomponents/arc/confirm-morph/confirm-morph.tsx
npm packages
motion@^14.2.0
Registry items it pulls in
Source
View as Markdownconfirm-morph.module.cssconfirm-morph.module.cssconfirm-morph.module.csscss
/* The root holds the control in the flow of the page; the pill inside it springs between faces. */
.root {
position: relative;
display: inline-flex;
min-width: 0;
max-width: 100%;
vertical-align: middle;
color: var(--foreground);
font-family: var(--font-body);
font-size: var(--text-sm);
letter-spacing: var(--tracking-body);
line-height: 20px;
-webkit-tap-highlight-color: transparent;
}
/*
* One neutral pill for every state. Only its width moves; corners, text and border never scale, so nothing distorts.
* Every face sits at its natural width against the pill's leading edge, so text never slides or squeezes: the question
* starts where the label was, and the springing edge reveals the rest. The border is an overlay so it never changes a measurement.
*/
.surface {
position: relative;
display: flex;
height: var(--control-height-sm);
max-width: 100%;
justify-content: flex-start;
overflow: clip;
border-radius: var(--radius-pill);
background: var(--surface-raised);
box-shadow: var(--shadow-resting);
transition: background-color var(--duration-standard) var(--ease-standard), box-shadow var(--duration-standard) var(--ease-standard);
}
.surface::after { content: ""; position: absolute; inset: 0; border: 1px solid var(--border); border-radius: inherit; pointer-events: none; transition: border-color var(--duration-standard) var(--ease-standard); }
.root[data-disabled][data-state="idle"] .surface { box-shadow: none; }
.root[data-disabled][data-state="idle"] .surface::after { border-color: var(--border-subtle); }
.face { display: flex; flex: none; height: 100%; align-items: center; gap: 2px; padding: 0 4px; white-space: nowrap; }
.face[data-face="idle"] { padding: 0; }
/* A leaving face steps out of the flow, exactly where it was, and fades while the pill springs to the next one. */
.face[inert] { position: absolute; top: 0; inset-inline-start: 0; pointer-events: none; }
.part { display: inline-flex; height: 100%; align-items: center; }
.trigger {
display: inline-flex;
height: 100%;
align-items: center;
gap: 8px;
padding: 0 16px 0 14px;
border: 0;
border-radius: var(--radius-pill);
background: transparent;
color: var(--foreground);
cursor: pointer;
font: inherit;
font-weight: 500;
transition: background-color var(--duration-fast) var(--ease-standard), color var(--duration-fast) var(--ease-standard);
}
.trigger:active:not(:disabled) { background: color-mix(in oklab, var(--foreground) 9%, transparent); transition-duration: 0ms; }
.trigger:disabled { color: var(--text-muted); cursor: not-allowed; }
.trigger:disabled .icon { opacity: .7; }
.icon { display: grid; width: 16px; height: 16px; place-items: center; color: var(--text-secondary); }
.trigger:disabled .icon { color: inherit; }
.icon > svg { width: 16px; height: 16px; }
.prompt { max-width: 16rem; overflow: hidden; padding: 0 8px 0 12px; color: var(--foreground); font-weight: 500; font-variant-numeric: tabular-nums; }
.prompt > span { overflow: hidden; text-overflow: ellipsis; }
.secondary,
.confirm {
position: relative;
display: inline-flex;
flex: none;
height: calc(var(--control-height-sm) - 8px);
align-items: center;
justify-content: center;
padding: 0 12px;
border: 0;
border-radius: var(--radius-pill);
cursor: pointer;
font: inherit;
font-weight: 500;
transition: background-color var(--duration-fast) var(--ease-standard), color var(--duration-fast) var(--ease-standard);
touch-action: manipulation;
}
.secondary { background: transparent; color: var(--text-secondary); }
.face:is([data-face="done"], [data-face="error"]) .secondary { color: var(--foreground); }
.secondary:active { background: color-mix(in oklab, var(--foreground) 11%, transparent); color: var(--foreground); transition-duration: 0ms; }
/* The only coloured thing in the control: danger for a destructive answer, the accent for a reversible one. */
.confirm { overflow: clip; background: color-mix(in oklab, var(--danger) 13%, transparent); color: var(--danger); }
.confirm:active { background: color-mix(in oklab, var(--danger) 22%, transparent); transition-duration: 0ms; }
.root[data-tone="neutral"] .confirm { background: var(--accent); color: var(--accent-foreground); }
.root[data-tone="neutral"] .confirm:active { background: var(--accent-strong); }
/* Hold: a solid copy of the button, label and all, revealed from the left as the hold fills; the label inverts as the edge passes. */
.root[data-mode="hold"] .confirm { -webkit-user-select: none; user-select: none; -webkit-touch-callout: none; }
.fill { position: absolute; inset: 0; display: inline-flex; align-items: center; justify-content: center; background: var(--danger); color: var(--background); pointer-events: none; }
.root[data-tone="neutral"] .fill { background: var(--accent-strong); color: var(--accent-foreground); }
.status { gap: 8px; padding: 0 10px 0 12px; color: var(--foreground); font-weight: 500; font-variant-numeric: tabular-nums; }
.status > svg { flex: none; }
.statusText { display: inline-flex; align-items: baseline; gap: .25em; }
.face[data-face="pending"] .status { padding-inline: 12px 16px; color: var(--text-secondary); }
.face[data-face="done"]:not(:has(.secondary)) .status { padding-right: 14px; }
.face[data-face="counting"] .status { padding-right: 6px; color: var(--text-secondary); }
.alert { color: var(--danger); }
.check { width: 18px; height: 18px; }
.checkDisc { fill: var(--success); }
.checkTick { stroke: var(--background); }
.spinner { animation: spin .7s linear infinite; }
@keyframes spin { to { transform: rotate(360deg); } }
.ring { width: 16px; height: 16px; }
.ringTrack { stroke: var(--border); }
.ringFill { stroke: var(--danger); }
.root[data-tone="neutral"] .ringFill { stroke: var(--accent); }
/* Seconds keep one tabular width while the digits swap. */
.seconds { display: inline-grid; min-width: 1ch; color: var(--foreground); font-variant-numeric: tabular-nums; }
.digit { grid-area: 1 / 1; }
/* No focus rings anywhere in the control; keyboard focus reads through fill (below). */
.root, .root :focus { outline: none; }
.srOnly { position: absolute; width: 1px; height: 1px; margin: -1px; padding: 0; overflow: hidden; clip-path: inset(50%); white-space: nowrap; border: 0; }
@media (hover: hover) and (pointer: fine) {
.trigger:hover:not(:disabled):not(:active) { background: color-mix(in oklab, var(--foreground) 5%, transparent); }
.secondary:hover:not(:active) { background: color-mix(in oklab, var(--foreground) 7%, transparent); color: var(--foreground); }
.confirm:hover:not(:active) { background: color-mix(in oklab, var(--danger) 18%, transparent); }
.root[data-tone="neutral"] .confirm:hover:not(:active) { background: var(--accent-strong); }
}
/* Keyboard focus reads through fill, never a ring. */
.trigger:focus-visible { background: color-mix(in oklab, var(--foreground) 6%, transparent); }
.secondary:focus-visible { background: color-mix(in oklab, var(--foreground) 8%, transparent); color: var(--foreground); }
.confirm:focus-visible { background: color-mix(in oklab, var(--danger) 20%, transparent); }
.root[data-tone="neutral"] .confirm:focus-visible { background: var(--accent-strong); }
@media (prefers-reduced-motion: reduce) {
.surface, .surface::after, .trigger, .secondary, .confirm { transition-duration: 0ms; }
.spinner { animation-duration: 1.6s; }
}
@media (prefers-contrast: more) {
.surface::after { border-color: var(--border-strong); }
.confirm { background: color-mix(in oklab, var(--danger) 22%, transparent); }
}
confirm-morph.tsxconfirm-morph.tsxconfirm-morph.tsxtsx
"use client";
import { useCallback, useEffect, useId, useImperativeHandle, useLayoutEffect, useRef, useState, useSyncExternalStore } from "react";
import type { KeyboardEvent as ReactKeyboardEvent, PointerEvent as ReactPointerEvent, ReactNode, Ref } from "react";
import { AnimatePresence, animate, motion, useIsPresent, useMotionValue, useMotionValueEvent, useTransform } from "motion/react";
import { useReducedMotion } from "../lib/use-reduced-motion";
import type { AnimationPlaybackControls, MotionValue, Transition, Variants } from "motion/react";
import { motionTokens } from "../lib/motion-tokens";
import styles from "./confirm-morph.module.css";
import { UiIcon } from "../theme/ui-icon";
import { playUiSound } from "../sound/play";
/** Where the control is in its life: resting, asking, working, finished, or failed. */
export type ConfirmMorphState = "idle" | "confirming" | "pending" | "done" | "error";
/** How the question is answered: a press, a press held until the fill completes, or a press followed by a short countdown. */
export type ConfirmMorphMode = "press" | "hold" | "countdown";
/**
* A button for destructive or important actions that asks in place. Pressing it morphs the same pill into an inline question
* with Cancel and Confirm, then into a spinner, then into a result with Undo. The width springs to each face while the faces
* crossfade at their natural width, so text never squashes and nothing around the control jumps. Escape, an outside press,
* or the timeout all return it to rest.
* Use it where a modal dialog would be heavy: deleting a project, revoking access, discarding a draft.
*/
export interface ConfirmMorphProps {
/** The resting label, 예: "프로젝트 삭제하기". */
label: ReactNode;
/** A plain icon before the resting label. */
icon?: ReactNode;
/** The question shown while confirming, 예: "파일 3개를 삭제할까요?". 기본값은 라벨 뒤에 물음표를 붙인 문구예요. */
prompt?: ReactNode;
confirmLabel?: string;
cancelLabel?: string;
/** Shown beside the spinner while `onConfirm` resolves. */
pendingLabel?: string;
doneLabel?: string;
errorLabel?: string;
retryLabel?: string;
undoLabel?: string;
/** Shown beside the spinner while `onUndo` resolves. */
undoingLabel?: string;
/**
* `danger` (default) gives the confirm button the danger colour, for actions that destroy something. `neutral` gives it the
* accent, for important actions that can be reversed. The resting button is neutral either way.
*/
tone?: "danger" | "neutral";
/** `press` confirms on a press. `hold` asks for a press held for `holdDuration`. `countdown` confirms, then counts down `countdown` seconds that the cancel button or Escape can still stop. */
confirmMode?: ConfirmMorphMode;
/** Milliseconds the confirm button must be held in `hold` mode. */
holdDuration?: number;
/** Seconds counted down before the action runs in `countdown` mode. */
countdown?: number;
/** Text before the seconds in `countdown` mode, 예: "삭제까지". 기본값은 진행 중 라벨 뒤에 콜론을 붙인 문구예요. */
countdownLabel?: string;
/** Runs on confirm. Return a promise to show the pending face; a rejection shows the error face with a retry button. */
onConfirm?: () => void | Promise<unknown>;
/** Offering it adds an undo button to the result. Return a promise to show a pending face while it runs. */
onUndo?: () => void | Promise<unknown>;
onCancel?: () => void;
/** Controlled state. Pair it with `onStateChange`. */
state?: ConfirmMorphState;
/** Starting state when uncontrolled. */
defaultState?: ConfirmMorphState;
onStateChange?: (state: ConfirmMorphState) => void;
/** Milliseconds before an unanswered question returns to rest. Resting the pointer on the control pauses it. 0 turns it off. */
confirmTimeout?: number;
/** Milliseconds a result stays before returning to rest. Resting the pointer on the control pauses it. 0 turns it off. */
resultTimeout?: number;
/** A press outside the control cancels an open question. Defaults to true. */
cancelOnOutsidePress?: boolean;
disabled?: boolean;
className?: string;
/** Receives the root element, which also takes focus while the action is pending. */
ref?: Ref<HTMLDivElement>;
}
/** A press this soon after the question appears is the tail of the press that opened it, not an answer. */
const ARM_MS = 280;
const { blur } = motionTokens;
type Bezier = [number, number, number, number];
const enter = [...motionTokens.ease.enter] as Bezier;
const standard = [...motionTokens.ease.standard] as Bezier;
/** Duration springs restated as stiffness and damping, so a retarget mid-flight keeps the velocity it already has. */
const physical = ({ visualDuration, bounce }: { visualDuration: number; bounce: number }): Transition => {
const root = 2 * Math.PI / (visualDuration * 1.2);
return { type: "spring", stiffness: root * root, damping: 2 * (1 - bounce) * root, mass: 1 };
};
/** Growing follows new content with a little life; shrinking settles without overshoot. */
const GROW = physical(motionTokens.spring.morph), SHRINK = physical(motionTokens.spring.smooth);
const subscribe = () => () => {};
function useReducedFlag() {
const hydrated = useSyncExternalStore(subscribe, () => true, () => false);
return !!useReducedMotion() && hydrated;
}
/*
* Faces never move and never scale: each one sits at its natural width, centred in the pill, so the baseline stays put and
* the pill's clip reveals it as the width springs. The old face fades out quickly; the new one's parts arrive in reading
* order, each clearing a small blur.
*/
const faceVariants: Variants = {
hidden: { opacity: 1 },
shown: { opacity: 1, transition: { staggerChildren: motionTokens.stagger.item, delayChildren: .05 } },
gone: { opacity: 0, filter: `blur(${blur.subtle}px)`, transition: { duration: motionTokens.duration.instant, ease: standard } },
};
const partVariants: Variants = {
hidden: { opacity: 0, filter: `blur(${blur.soft}px)` },
shown: { opacity: 1, filter: "blur(0px)", transition: { duration: motionTokens.duration.moderate, ease: enter } },
};
const reducedFace: Variants = { hidden: { opacity: 0 }, shown: { opacity: 1, transition: { duration: motionTokens.duration.instant } }, gone: { opacity: 0, transition: { duration: motionTokens.duration.instant } } };
const reducedPart: Variants = { hidden: { opacity: 1 }, shown: { opacity: 1 } };
type FaceKey = ConfirmMorphState | "counting";
function Face({ id, reduced, onSize, children, labelledBy }: { id: FaceKey; reduced: boolean; onSize: (id: FaceKey, width: number) => void; children: ReactNode; labelledBy?: string }) {
const ref = useRef<HTMLDivElement>(null);
const present = useIsPresent();
useLayoutEffect(() => {
const node = ref.current;
if (!node || !present) return;
// Fractional widths, so the spring lands exactly where the pill's natural width is and the hand back to auto is seamless.
const report = () => onSize(id, node.getBoundingClientRect().width);
report();
if (typeof ResizeObserver === "undefined") return;
const observer = new ResizeObserver(report);
observer.observe(node);
return () => observer.disconnect();
}, [id, onSize, present]);
return <motion.div ref={ref} className={styles.face} data-face={id} role={labelledBy ? "group" : undefined} aria-labelledby={labelledBy}
variants={reduced ? reducedFace : faceVariants} initial="hidden" animate="shown" exit="gone" inert={!present || undefined}>{children}</motion.div>;
}
function Part({ reduced, className, children }: { reduced: boolean; className?: string; children: ReactNode }) {
return <motion.span className={[styles.part, className].filter(Boolean).join(" ")} variants={reduced ? reducedPart : partVariants}>{children}</motion.span>;
}
/** A success disc that pops in with a tick drawing across it, the moment the action lands. */
function Check({ reduced }: { reduced: boolean }) {
return <svg className={styles.check} viewBox="0 0 18 18" fill="none" aria-hidden="true">
<motion.circle className={styles.checkDisc} cx="9" cy="9" r="8" style={{ transformOrigin: "9px 9px" }}
initial={reduced ? false : { scale: .5, opacity: 0 }} animate={{ scale: 1, opacity: 1 }} transition={{ scale: physical(motionTokens.spring.snappy), opacity: { duration: motionTokens.duration.instant } }} />
<motion.path className={styles.checkTick} d="M5.6 9.3 7.8 11.4 12.4 6.7" strokeWidth="1.9" strokeLinecap="round" strokeLinejoin="round"
initial={reduced ? false : { pathLength: 0 }} animate={{ pathLength: 1 }} transition={{ duration: motionTokens.duration.moderate, ease: enter, delay: motionTokens.duration.fast }} />
</svg>;
}
/** A ring that drains with the countdown. */
function Ring({ progress }: { progress: MotionValue<number> }) {
return <svg className={styles.ring} viewBox="0 0 16 16" fill="none" aria-hidden="true">
<circle className={styles.ringTrack} cx="8" cy="8" r="6.25" strokeWidth="1.75" />
<motion.circle className={styles.ringFill} cx="8" cy="8" r="6.25" strokeWidth="1.75" strokeLinecap="round" style={{ pathLength: progress, rotate: -90, transformOrigin: "8px 8px" }} />
</svg>;
}
/** Seconds that swap in place: the old digit fades up and out, the new one rises in, on tabular figures so the width holds. */
function Seconds({ value, reduced }: { value: number; reduced: boolean }) {
return <span className={styles.seconds}>
<AnimatePresence initial={false} mode="popLayout">
<motion.span key={value} className={styles.digit}
initial={reduced ? { opacity: 0 } : { opacity: 0, y: 6, filter: `blur(${blur.subtle}px)` }}
animate={{ opacity: 1, y: 0, filter: "blur(0px)" }}
exit={reduced ? { opacity: 0 } : { opacity: 0, y: -6, filter: `blur(${blur.subtle}px)` }}
transition={{ duration: motionTokens.duration.standard, ease: enter }}>{value}</motion.span>
</AnimatePresence>
</span>;
}
export function ConfirmMorph({
label, icon, prompt, confirmLabel = "삭제하기", cancelLabel = "취소하기", pendingLabel = "삭제하는 중", doneLabel = "삭제했어요", errorLabel = "끝내지 못했어요",
retryLabel = "다시 시도하기", undoLabel = "되돌리기", undoingLabel = "복원하는 중", tone = "danger", confirmMode = "press", holdDuration = 1000, countdown = 3, countdownLabel,
onConfirm, onUndo, onCancel, state: stateProp, defaultState = "idle", onStateChange, confirmTimeout = 6000, resultTimeout = 5000, cancelOnOutsidePress = true,
disabled = false, className, ref,
}: ConfirmMorphProps) {
const reduced = useReducedFlag();
const uid = useId();
const promptId = `${uid}-prompt`;
const holdHintId = `${uid}-hold`;
const rootRef = useRef<HTMLDivElement>(null);
useImperativeHandle(ref, () => rootRef.current as HTMLDivElement, []);
const [inner, setInner] = useState<ConfirmMorphState>(defaultState);
const state = stateProp ?? inner;
const [working, setWorking] = useState<"confirm" | "undo">("confirm");
const [counting, setCounting] = useState(false);
const [announcement, setAnnouncement] = useState("");
const faceKey: FaceKey = state === "confirming" && counting ? "counting" : state;
const live = useRef({ state, onStateChange, controlled: stateProp !== undefined });
useLayoutEffect(() => { live.current = { state, onStateChange, controlled: stateProp !== undefined }; });
const pendingFocus = useRef(false);
const run = useRef(0);
const askedAt = useRef(0);
const keepFocus = useCallback(() => {
const root = rootRef.current;
// Focus follows the control between faces, but only when it was already inside; a timeout never steals focus from elsewhere.
pendingFocus.current = !!root && (root.contains(document.activeElement) || document.activeElement === document.body);
}, []);
const go = useCallback((next: ConfirmMorphState) => {
if (next !== "confirming") setCounting(false);
if (next === live.current.state) return;
keepFocus();
if (next === "confirming") askedAt.current = performance.now();
if (!live.current.controlled) setInner(next);
live.current.state = next;
live.current.onStateChange?.(next);
}, [keepFocus]);
// A controlled parent can move the state on its own; the countdown never survives that.
const [seenState, setSeenState] = useState(state);
if (seenState !== state) {
setSeenState(state);
if (state !== "confirming") setCounting(false);
}
const toIdle = useCallback(() => { run.current++; go("idle"); }, [go]);
const perform = useCallback(async (kind: "confirm" | "undo") => {
const handler = kind === "confirm" ? onConfirm : onUndo;
const token = ++run.current;
playUiSound(kind === "confirm" ? "confirm" : "click");
setWorking(kind);
setCounting(false);
let result: void | Promise<unknown> | undefined;
try { result = handler?.(); }
catch { playUiSound("error"); go("error"); setAnnouncement(errorLabel); return; }
if (result && typeof (result as Promise<unknown>).then === "function") {
go("pending");
setAnnouncement(kind === "confirm" ? pendingLabel : undoingLabel);
try { await result; }
catch {
if (token !== run.current) return;
playUiSound("error");
go("error");
setAnnouncement(errorLabel);
return;
}
if (token !== run.current) return;
}
if (kind === "undo") { go("idle"); setAnnouncement("되돌렸어요"); return; }
go("done");
setAnnouncement(onUndo ? `${doneLabel}. ${undoLabel} 버튼을 쓸 수 있어요.` : doneLabel);
}, [doneLabel, errorLabel, go, onConfirm, onUndo, pendingLabel, undoLabel, undoingLabel]);
const cancel = useCallback((quiet?: unknown) => { if (quiet !== true) playUiSound("close"); onCancel?.(); toIdle(); setAnnouncement("취소했어요"); }, [onCancel, toIdle]);
const expire = useRef(() => {});
useLayoutEffect(() => { expire.current = () => { if (live.current.state === "confirming") cancel(true); else toIdle(); }; });
/* The shape: one pill whose width springs to whichever face is current. At rest it is auto, so it renders right before hydration. */
const width = useMotionValue<number | "auto">("auto");
const target = useRef(0);
const flight = useRef(0);
// Only the present face reports (a leaving face stops observing), so every report is the current face's width.
const onFaceSize = useCallback((_id: FaceKey, w: number) => {
if (Math.abs(w - target.current) < .25) return;
const from = target.current;
target.current = w;
if (!from || reduced) { flight.current++; width.jump("auto"); return; }
const now = width.get();
if (now === "auto") width.jump(from);
const token = ++flight.current;
animate(width, w, w > (typeof now === "number" ? now : from) ? GROW : SHRINK).then(() => { if (token === flight.current) width.jump("auto"); });
}, [reduced, width]);
/* The timeout runs as an invisible clock. A pointer resting on the control holds it, and so does a hidden tab. */
const drain = useMotionValue(1);
const clock = useRef<AnimationPlaybackControls | null>(null);
const holds = useRef({ hover: false, hidden: false });
const timeout = state === "confirming" && !counting ? confirmTimeout : state === "done" || state === "error" ? resultTimeout : 0;
const sync = useCallback(() => {
const control = clock.current;
if (!control) return;
const held = holds.current.hover || holds.current.hidden;
if (held) control.pause(); else control.play();
}, []);
useEffect(() => {
if (!timeout) return;
drain.jump(1);
const control = animate(drain, 0, { duration: timeout / 1000, ease: "linear" });
clock.current = control;
control.then(() => { if (clock.current === control) { clock.current = null; expire.current(); } });
sync();
return () => { if (clock.current === control) clock.current = null; control.stop(); };
}, [drain, state, sync, timeout]);
useEffect(() => {
const onVisibility = () => { holds.current.hidden = document.hidden; sync(); };
document.addEventListener("visibilitychange", onVisibility);
return () => document.removeEventListener("visibilitychange", onVisibility);
}, [sync]);
/* Countdown: a short grace period after the answer, drawn as a draining ring and ticking seconds. */
const remaining = useMotionValue(1);
const [seconds, setSeconds] = useState(countdown);
const performRef = useRef(perform);
useLayoutEffect(() => { performRef.current = perform; });
useMotionValueEvent(remaining, "change", value => setSeconds(Math.max(1, Math.ceil(value * countdown))));
useEffect(() => {
if (!counting) return;
remaining.jump(1);
const control = animate(remaining, 0, { duration: countdown, ease: "linear" });
control.then(() => { void performRef.current("confirm"); });
return () => control.stop();
}, [counting, countdown, remaining]);
/* Hold: the confirm button fills with the danger colour from the left while pressed, and drains back if let go early. */
const hold = useMotionValue(0);
const holding = useRef<AnimationPlaybackControls | null>(null);
const fillClip = useTransform(hold, value => `inset(0 ${(1 - value) * 100}% 0 0 round 999px)`);
const startHold = () => {
if (holding.current) return;
const control = animate(hold, 1, { duration: holdDuration / 1000 * (1 - hold.get()), ease: "linear" });
holding.current = control;
control.then(() => {
if (holding.current !== control) return;
holding.current = null;
void perform("confirm");
});
};
const releaseHold = () => {
const control = holding.current;
if (!control) return;
holding.current = null;
control.stop();
if (hold.get() < 1) {
animate(hold, 0, reduced ? { duration: 0 } : motionTokens.spring.settle);
setAnnouncement(`계속 눌러서 확인해 주세요`);
}
};
useEffect(() => { if (state !== "confirming") { holding.current?.stop(); holding.current = null; hold.jump(0); } }, [hold, state]);
const answer = () => {
if (performance.now() - askedAt.current < ARM_MS) return;
if (confirmMode === "countdown") {
playUiSound("click");
keepFocus();
setCounting(true);
setAnnouncement(`${countdownLabel ?? `${pendingLabel}:`} ${countdown}초`);
return;
}
void perform("confirm");
};
// An outside press answers the question with no.
useEffect(() => {
if (state !== "confirming" || !cancelOnOutsidePress) return;
const down = (event: PointerEvent) => { if (!rootRef.current?.contains(event.target as Node)) cancel(); };
document.addEventListener("pointerdown", down);
return () => document.removeEventListener("pointerdown", down);
}, [cancel, cancelOnOutsidePress, state]);
// Focus moves with the morph: Confirm while asking, the root while counting or working, Undo or Retry on a result, the trigger at rest.
useLayoutEffect(() => {
if (!pendingFocus.current) return;
pendingFocus.current = false;
const root = rootRef.current;
if (!root) return;
const face = root.querySelector<HTMLElement>(`[data-face="${faceKey}"]`);
const autofocus = face?.querySelector<HTMLElement>("[data-autofocus]:not(:disabled)");
(autofocus ?? root).focus({ preventScroll: true });
}, [faceKey]);
const onKeyDown = (event: ReactKeyboardEvent<HTMLDivElement>) => {
if (event.key === "Escape") {
if (state === "confirming") { event.preventDefault(); event.stopPropagation(); cancel(); }
else if (state === "done" || state === "error") { event.preventDefault(); event.stopPropagation(); toIdle(); }
return;
}
// Enter answers yes from anywhere in the question except a button of its own (Cancel keeps meaning Cancel).
if (event.key === "Enter" && state === "confirming" && !event.repeat && !(event.target instanceof HTMLButtonElement)) {
event.preventDefault();
if (counting) void perform("confirm");
else if (confirmMode !== "hold") answer();
}
};
const holdKeys = (event: ReactKeyboardEvent<HTMLButtonElement>, down: boolean) => {
if (event.key !== "Enter" && event.key !== " ") return;
event.preventDefault();
if (down) { if (!event.repeat) startHold(); }
else releaseHold();
};
const holdPointer = (event: ReactPointerEvent<HTMLButtonElement>) => {
if (event.button !== 0) return;
event.currentTarget.setPointerCapture?.(event.pointerId);
startHold();
};
const shownPrompt = prompt ?? <>{label}?</>;
const shownCountdown = countdownLabel ?? `${pendingLabel}:`;
const confirmButton = confirmMode === "hold"
? <button type="button" className={styles.confirm} data-autofocus aria-describedby={holdHintId}
onPointerDown={holdPointer} onPointerUp={releaseHold} onPointerCancel={releaseHold} onLostPointerCapture={releaseHold}
onKeyDown={event => holdKeys(event, true)} onKeyUp={event => holdKeys(event, false)} onBlur={releaseHold} onContextMenu={event => event.preventDefault()}>
<span>{confirmLabel}</span>
<motion.span className={styles.fill} style={{ clipPath: fillClip }} aria-hidden="true"><span>{confirmLabel}</span></motion.span>
</button>
: <button type="button" className={styles.confirm} data-autofocus onClick={answer} onKeyDown={event => { if (event.repeat) event.preventDefault(); }}>{confirmLabel}</button>;
const face = (() => {
switch (faceKey) {
case "confirming": return <>
<Part reduced={reduced} className={styles.prompt}><span id={promptId}>{shownPrompt}</span></Part>
<Part reduced={reduced}><button type="button" className={styles.secondary} onClick={cancel}>{cancelLabel}</button></Part>
<Part reduced={reduced}>{confirmButton}</Part>
{confirmMode === "hold" && <span id={holdHintId} className={styles.srOnly}>길게 눌러 주세요</span>}
</>;
case "counting": return <>
<Part reduced={reduced} className={styles.status}>
<Ring progress={remaining} />
<span className={styles.statusText}>{shownCountdown} <Seconds value={seconds} reduced={reduced} /></span>
</Part>
<Part reduced={reduced}><button type="button" className={styles.secondary} onClick={cancel}>{cancelLabel}</button></Part>
</>;
case "pending": return <Part reduced={reduced} className={styles.status}>
<UiIcon iconRole="loading" className={styles.spinner} size={16} strokeWidth={1.75} aria-hidden="true" />
<span className={styles.statusText}>{working === "undo" ? undoingLabel : pendingLabel}</span>
</Part>;
case "done": return <>
<Part reduced={reduced} className={styles.status}><Check reduced={reduced} /><span className={styles.statusText}>{doneLabel}</span></Part>
{onUndo && <Part reduced={reduced}><button type="button" className={styles.secondary} data-autofocus onClick={() => void perform("undo")}>{undoLabel}</button></Part>}
</>;
case "error": return <>
<Part reduced={reduced} className={styles.status}><UiIcon iconRole="alert" className={styles.alert} size={16} strokeWidth={1.75} aria-hidden="true" /><span className={styles.statusText}>{errorLabel}</span></Part>
<Part reduced={reduced}><button type="button" className={styles.secondary} data-autofocus onClick={() => void perform(working)}>{retryLabel}</button></Part>
</>;
default: return <Part reduced={reduced}><button type="button" className={styles.trigger} data-autofocus disabled={disabled}
onClick={() => { playUiSound("click"); setAnnouncement(typeof shownPrompt === "string" ? shownPrompt : ""); go("confirming"); }}>
{icon && <span className={styles.icon} aria-hidden="true">{icon}</span>}
<span>{label}</span>
</button></Part>;
}
})();
return <div ref={rootRef} className={[styles.root, className].filter(Boolean).join(" ")} data-state={state} data-face={faceKey} data-tone={tone} data-mode={confirmMode}
data-disabled={disabled || undefined} tabIndex={-1} onKeyDown={onKeyDown} aria-busy={state === "pending" || undefined}
onPointerEnter={() => { holds.current.hover = true; sync(); }} onPointerLeave={() => { holds.current.hover = false; sync(); }}
onPointerCancel={() => { holds.current.hover = false; sync(); }}>
<motion.div className={styles.surface} style={{ width }}>
<AnimatePresence initial={false}>
<Face key={faceKey} id={faceKey} reduced={reduced} onSize={onFaceSize} labelledBy={faceKey === "confirming" ? promptId : undefined}>{face}</Face>
</AnimatePresence>
</motion.div>
<span className={styles.srOnly} role="status" aria-live="polite">{announcement}</span>
</div>;
}
export default ConfirmMorph;