a6c0f3de-eeb0-4d76-a57f-f600dd133c06

This commit is contained in:
Z User committed 2026-10-28 15:39:30 +00:00
1 parent 2aa3496a90
commit 762f3d7373
141 files changed
+4485 -69

No files matched your search

+198
View File
@@ -0,0 +1,198 @@
'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>
)
}