199 lines
5.9 KiB
TypeScript
199 lines
5.9 KiB
TypeScript
'use client'
|
|
|
|
/**
|
|
* FuseButton — destructive / consequential action confirmation.
|
|
*
|
|
* UX:
|
|
* 1. Press-and-hold the button. A progress bar fills over `holdMs` (default 700 ms).
|
|
* 2. Hold until the bar reaches 100 % → button enters the "primed" state.
|
|
* 3. Release the pointer (press-end). On release, `onCommit` fires.
|
|
* 4. If the pointer leaves the button or is released early, the action is cancelled
|
|
* and the fuse resets — no commit happens.
|
|
* 5. After commit, the button shows `doneLabel` for `undoMs` (default 5000 ms) and
|
|
* emits a Sonner toast with an optional "Rückgängig" action bound to `onUndo`.
|
|
* If `onUndo` is omitted the toast is a plain success message.
|
|
*
|
|
* Why: prevents accidental approvals / deletions. A single stray click cannot trigger
|
|
* the action — the user must deliberately hold and release.
|
|
*/
|
|
|
|
import * as React from 'react'
|
|
import { Button } from '@/components/ui/button'
|
|
import { toast } from 'sonner'
|
|
import { cn } from '@/lib/utils'
|
|
import { Check, Loader2, Undo2 } from 'lucide-react'
|
|
|
|
type Phase = 'idle' | 'pressing' | 'primed' | 'committing' | 'done'
|
|
|
|
export interface FuseButtonProps
|
|
extends Omit<React.ComponentProps<typeof Button>, 'onClick' | 'onPointerDown' | 'onPointerUp' | 'onPointerLeave'> {
|
|
/** Initial label, shown when idle / pressing. */
|
|
label: string
|
|
/** Label shown after commit has fired. */
|
|
doneLabel: string
|
|
/** Fires on press-end after the hold threshold was reached. */
|
|
onCommit: () => Promise<void> | void
|
|
/** Optional undo handler. When provided, the success toast shows a "Rückgängig" action. */
|
|
onUndo?: () => Promise<void> | void
|
|
/** How long the user must hold before the fuse is primed (ms). */
|
|
holdMs?: number
|
|
/** How long the done-state + undo toast is shown (ms). */
|
|
undoMs?: number
|
|
}
|
|
|
|
export function FuseButton({
|
|
label,
|
|
doneLabel,
|
|
onCommit,
|
|
onUndo,
|
|
holdMs = 700,
|
|
undoMs = 5000,
|
|
disabled,
|
|
variant = 'default',
|
|
className,
|
|
children,
|
|
...rest
|
|
}: FuseButtonProps) {
|
|
const [phase, setPhase] = React.useState<Phase>('idle')
|
|
const [progress, setProgress] = React.useState(0)
|
|
const holdRef = React.useRef<ReturnType<typeof setInterval> | null>(null)
|
|
const startRef = React.useRef<number>(0)
|
|
const primedRef = React.useRef(false)
|
|
|
|
const clearHold = React.useCallback(() => {
|
|
if (holdRef.current) {
|
|
clearInterval(holdRef.current)
|
|
holdRef.current = null
|
|
}
|
|
}, [])
|
|
|
|
React.useEffect(() => {
|
|
return () => clearHold()
|
|
}, [clearHold])
|
|
|
|
// Reset done-state after undoMs so the button becomes usable again.
|
|
React.useEffect(() => {
|
|
if (phase !== 'done') return
|
|
const t = setTimeout(() => {
|
|
setPhase('idle')
|
|
setProgress(0)
|
|
primedRef.current = false
|
|
}, undoMs)
|
|
return () => clearTimeout(t)
|
|
}, [phase, undoMs])
|
|
|
|
const handlePointerDown = (e: React.PointerEvent<HTMLButtonElement>) => {
|
|
if (disabled || phase === 'committing' || phase === 'done') return
|
|
e.preventDefault()
|
|
primedRef.current = false
|
|
setPhase('pressing')
|
|
setProgress(0)
|
|
startRef.current = Date.now()
|
|
clearHold()
|
|
holdRef.current = setInterval(() => {
|
|
const elapsed = Date.now() - startRef.current
|
|
const pct = Math.min(100, (elapsed / holdMs) * 100)
|
|
setProgress(pct)
|
|
if (pct >= 100 && !primedRef.current) {
|
|
primedRef.current = true
|
|
setPhase('primed')
|
|
}
|
|
}, 16)
|
|
}
|
|
|
|
const handlePointerUp = async () => {
|
|
if (phase === 'idle' || phase === 'done' || phase === 'committing') return
|
|
clearHold()
|
|
if (!primedRef.current) {
|
|
// Released too early — cancel.
|
|
setPhase('idle')
|
|
setProgress(0)
|
|
return
|
|
}
|
|
primedRef.current = false
|
|
setProgress(100)
|
|
setPhase('committing')
|
|
try {
|
|
await onCommit()
|
|
setPhase('done')
|
|
toast.success(doneLabel, {
|
|
duration: undoMs,
|
|
action: onUndo
|
|
? {
|
|
label: (
|
|
<>
|
|
<Undo2 className="size-3.5" />
|
|
Rückgängig
|
|
</>
|
|
),
|
|
onClick: async () => {
|
|
try {
|
|
await onUndo()
|
|
setPhase('idle')
|
|
setProgress(0)
|
|
toast.success('Aktion rückgängig gemacht')
|
|
} catch {
|
|
toast.error('Rückgängig fehlgeschlagen')
|
|
}
|
|
},
|
|
}
|
|
: undefined,
|
|
})
|
|
} catch {
|
|
setPhase('idle')
|
|
setProgress(0)
|
|
toast.error('Aktion fehlgeschlagen')
|
|
}
|
|
}
|
|
|
|
const handlePointerLeave = () => {
|
|
// Leaving the button while holding cancels the fuse.
|
|
if (phase === 'pressing' || phase === 'primed') {
|
|
clearHold()
|
|
primedRef.current = false
|
|
setPhase('idle')
|
|
setProgress(0)
|
|
}
|
|
}
|
|
|
|
const displayLabel = phase === 'done' ? doneLabel : label
|
|
const busy = phase === 'committing'
|
|
const holding = phase === 'pressing' || phase === 'primed'
|
|
|
|
return (
|
|
<Button
|
|
type="button"
|
|
variant={phase === 'primed' ? 'default' : variant}
|
|
disabled={disabled || busy}
|
|
onPointerDown={handlePointerDown}
|
|
onPointerUp={handlePointerUp}
|
|
onPointerLeave={handlePointerLeave}
|
|
className={cn(
|
|
'relative overflow-hidden select-none touch-none',
|
|
holding && 'cursor-grabbing',
|
|
className,
|
|
)}
|
|
{...rest}
|
|
>
|
|
{/* Progress fill overlay */}
|
|
<span
|
|
aria-hidden
|
|
className={cn(
|
|
'pointer-events-none absolute inset-y-0 left-0 bg-foreground/15 transition-[width] duration-75',
|
|
phase === 'done' && 'bg-foreground/10',
|
|
)}
|
|
style={{ width: `${phase === 'done' ? 100 : progress}%` }}
|
|
/>
|
|
<span className="relative z-10 flex items-center gap-1.5">
|
|
{busy ? (
|
|
<Loader2 className="size-4 animate-spin" />
|
|
) : phase === 'done' ? (
|
|
<Check className="size-4" />
|
|
) : null}
|
|
{displayLabel}
|
|
{children}
|
|
</span>
|
|
</Button>
|
|
)
|
|
}
|