a6c0f3de-eeb0-4d76-a57f-f600dd133c06
This commit is contained in:
1 parent
2aa3496a90
commit
762f3d7373
141 files changed
+4485
-69
No files matched your search
@@ -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>
|
||||
)
|
||||
}
|
||||
Reference in new issue
Block a user