Files
accounted/components/ui/destructive-confirm-dialog.tsx
T
e51a2c8102 refactor(design): no amber boxes, attention is one ochre sentence (#1562)
The founder wants the yellow boxes gone everywhere. The design system
already agreed: status colors are data, not chrome (convention 12) and
attention is a single ochre sentence, never a banner (convention 6).
This enforces it:

- ConfirmationDialog: the amber warning panel is now an AttnLine, and
  the hardcoded Swedish default warningText is gone: it injected an
  immutability warning into dialogs whose authors never asked for one
  (every current caller passes the prop explicitly, so no behavior
  change at any call site).
- Badge warning variant: amber fill replaced with a hairline chip and
  ochre text.
- DestructiveConfirmDialog warning variant: neutral icon disc, default
  primary confirm button (only --destructive survives as chrome).
- BankSyncStatusChip stale state: same neutral shape as the healthy
  chip, ochre text carries the signal.
- SandboxBanner: solid amber bar becomes secondary-on-border chrome.
- BankIdAuth, BankIdCompanyPicker, SessionTimeoutModal: the last three
  raw-amber (bg-amber-*) holdouts moved onto tokens, the company-picker
  banner becoming a plain AttnLine.
- Mechanical sweep of the ~58 hand-rolled bg-warning/border-warning
  boxes across 45 files: fills to bg-muted/30 (icon discs bg-muted),
  borders to border-border, text-warning-foreground to text-attn. The
  account-class dots in account-number.tsx keep bg-warning: they are
  data indicators, not chrome.

Co-authored-by: Jakob Wennberg <311770904+jakobwennberg-oss@users.noreply.github.com>
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-08-13 11:20:16 +02:00

184 lines
4.9 KiB
TypeScript

'use client'
import { useState, useCallback, useRef } from 'react'
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
DialogDescription,
DialogFooter,
} from '@/components/ui/dialog'
import { Button } from '@/components/ui/button'
import { AlertTriangle, Loader2 } from 'lucide-react'
import { cn } from '@/lib/utils'
interface DestructiveConfirmDialogProps {
open: boolean
onOpenChange: (open: boolean) => void
title: string
description: string
confirmLabel?: string
cancelLabel?: string
variant?: 'destructive' | 'warning'
onConfirm: () => void | Promise<void>
}
export function DestructiveConfirmDialog({
open,
onOpenChange,
title,
description,
confirmLabel = 'Bekräfta',
cancelLabel = 'Avbryt',
variant = 'destructive',
onConfirm,
}: DestructiveConfirmDialogProps) {
const [isLoading, setIsLoading] = useState(false)
const handleConfirm = async () => {
setIsLoading(true)
try {
await onConfirm()
} finally {
setIsLoading(false)
onOpenChange(false)
}
}
return (
<Dialog
open={open}
onOpenChange={(v) => {
if (isLoading) return
onOpenChange(v)
}}
>
<DialogContent className="sm:max-w-md">
<DialogHeader>
<div className="flex items-start gap-4">
<div
className={cn(
'flex-shrink-0 flex items-center justify-center h-10 w-10 rounded-full',
variant === 'destructive'
? 'bg-destructive/10 text-destructive'
: 'bg-muted text-attn'
)}
>
<AlertTriangle className="h-5 w-5" />
</div>
<div className="space-y-1">
<DialogTitle>{title}</DialogTitle>
{/* pre-line so callers can pass newline-separated paragraphs
(e.g. the salary unapprove confirm assembles its copy
dynamically); single-line descriptions render unchanged. */}
<DialogDescription className="whitespace-pre-line">
{description}
</DialogDescription>
</div>
</div>
</DialogHeader>
<DialogFooter className="gap-2 sm:gap-0">
<Button
variant="outline"
onClick={() => onOpenChange(false)}
disabled={isLoading}
className="min-h-11 w-full sm:w-auto"
>
{cancelLabel}
</Button>
{/* Warning-variant confirms use the default primary button: in
chrome only --destructive survives as a colored action. */}
<Button
variant={variant === 'destructive' ? 'destructive' : 'default'}
onClick={handleConfirm}
disabled={isLoading}
className="min-h-11 w-full sm:w-auto"
>
{isLoading ? (
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
) : null}
{confirmLabel}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
)
}
interface ConfirmOptions {
title: string
description: string
confirmLabel?: string
cancelLabel?: string
variant?: 'destructive' | 'warning'
}
interface UseDestructiveConfirmReturn {
dialogProps: DestructiveConfirmDialogProps
confirm: (options: ConfirmOptions) => Promise<boolean>
}
/**
* Hook that returns a `confirm()` function as a drop-in replacement for `window.confirm()`.
* Returns `Promise<boolean>`: true if user confirms, false if they cancel.
*
* Usage:
* ```
* const { dialogProps, confirm } = useDestructiveConfirm()
*
* async function handleDelete() {
* const ok = await confirm({ title: '...', description: '...' })
* if (!ok) return
* // proceed with deletion
* }
*
* return <><DestructiveConfirmDialog {...dialogProps} /></>
* ```
*/
export function useDestructiveConfirm(): UseDestructiveConfirmReturn {
const [open, setOpen] = useState(false)
const [options, setOptions] = useState<ConfirmOptions>({
title: '',
description: '',
})
const resolveRef = useRef<((value: boolean) => void) | null>(null)
const confirm = useCallback((opts: ConfirmOptions): Promise<boolean> => {
setOptions(opts)
setOpen(true)
return new Promise<boolean>((resolve) => {
resolveRef.current = resolve
})
}, [])
const handleOpenChange = useCallback((v: boolean) => {
setOpen(v)
if (!v && resolveRef.current) {
resolveRef.current(false)
resolveRef.current = null
}
}, [])
const handleConfirm = useCallback(() => {
if (resolveRef.current) {
resolveRef.current(true)
resolveRef.current = null
}
}, [])
return {
dialogProps: {
open,
onOpenChange: handleOpenChange,
title: options.title,
description: options.description,
confirmLabel: options.confirmLabel,
cancelLabel: options.cancelLabel,
variant: options.variant,
onConfirm: handleConfirm,
},
confirm,
}
}