Akcje destrukcyjne
Pozwól cofnąć, gdy to możliwe, pytaj o potwierdzenie, gdy to konieczne, a akcje nieodwracalne celowo zabezpiecz przed przypadkowym uruchomieniem.
Usuwanie, unieważnianie i resetowanie niszczą czyjąś pracę. Chodzi o to, żeby pomyłkę łatwo było naprawić — a tam, gdzie naprawić się jej nie da, żeby trudno było ją popełnić.
Cofanie zamiast potwierdzania
Okno potwierdzenia przy każdym usuwaniu uczy ludzi klikać Tak bez czytania. Jeśli akcję da się odwrócić, wykonaj ją od razu i zaproponuj cofnięcie w powiadomieniu. To szybsze dla 99% osób, które zrobiły to celowo, i bezpieczniejsze dla 1%, które zrobiły to przez pomyłkę.
"use client"
import * as React from "react"
import { ArchiveIcon, InboxIcon } from "lucide-react"
import { Button } from "@/components/ui/button"
import {
Item,
ItemActions,
ItemContent,
ItemDescription,
ItemGroup,
ItemTitle,
} from "@/components/ui/item"
import { toast } from "@/components/ui/toast"
const initial = [
{ id: 1, title: "Faktura nr 2041 jest gotowa", from: "Rozliczenia" },
{ id: 2, title: "Ada skomentowała „Strona główna v3”", from: "Komentarze" },
{ id: 3, title: "Tygodniowy raport zużycia", from: "Raporty" },
]
export default function UndoToast() {
const [items, setItems] = React.useState(initial)
function archive(id: number) {
const index = items.findIndex((item) => item.id === id)
const removed = items[index]
setItems((current) => current.filter((item) => item.id !== id))
const toastId = toast.add({
title: "Zarchiwizowano rozmowę",
actionProps: {
children: "Cofnij",
onClick: () => {
// Closed with the click: a second one would restore the row twice.
toast.close(toastId)
setItems((current) => {
const next = [...current]
next.splice(index, 0, removed)
return next
})
},
},
})
}
return (
<ItemGroup className="w-full max-w-md gap-2">
{items.map((item) => (
<Item key={item.id} variant="outline" size="sm">
<ItemContent>
<ItemTitle>{item.title}</ItemTitle>
<ItemDescription>{item.from}</ItemDescription>
</ItemContent>
<ItemActions>
{/* A word, not a lone icon: archiving isn't universal enough
to go unlabeled. The accessible name adds which message. */}
<Button
variant="ghost"
size="sm"
aria-label={`Archiwizuj „${item.title}”`}
onClick={() => archive(item.id)}
>
<ArchiveIcon data-icon="inline-start" />
Archiwizuj
</Button>
</ItemActions>
</Item>
))}
{items.length === 0 && (
<div className="flex flex-col items-center gap-2 py-8 text-sm text-muted-foreground">
<InboxIcon className="size-5" aria-hidden="true" />
Skrzynka odbiorcza jest pusta.
<Button variant="link" size="sm" onClick={() => setItems(initial)}>
Przywróć przykłady
</Button>
</div>
)}
</ItemGroup>
)
}Potwierdzaj to, czego nie da się cofnąć
Gdy akcja jest nieodwracalna — usunięcie projektu, unieważnienie kluczy, odebranie dostępu członkowi zespołu — poproś o potwierdzenie w AlertDialog:
- Nazwij obiekt w tytule: Usunąć „acme-web”?
- Podaj konsekwencje w opisie: co przepadnie i że nie da się tego cofnąć.
- Nazywaj przyciski czasownikami. Usuń projekt i Zachowaj projekt — nigdy Tak i Nie.
- Używaj wariantu
destructivetylko na przycisku potwierdzającym. - Przy akcjach o wysokiej stawce wymagaj wpisania nazwy. Odruchowe kliknięcie zamienia się wtedy w świadomą decyzję.
"use client"
import * as React from "react"
import { Trash2Icon } from "lucide-react"
import {
AlertDialog,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogMedia,
AlertDialogTitle,
AlertDialogTrigger,
} from "@/components/ui/alert-dialog"
import { Button } from "@/components/ui/button"
import { Field, FieldLabel } from "@/components/ui/field"
import { Input } from "@/components/ui/input"
import { toast } from "@/components/ui/toast"
const PROJECT = "acme-web"
export default function DestructiveConfirm() {
const [open, setOpen] = React.useState(false)
const [value, setValue] = React.useState("")
const [deleting, setDeleting] = React.useState(false)
async function confirm() {
setDeleting(true)
await new Promise((resolve) => setTimeout(resolve, 1000))
setDeleting(false)
setOpen(false)
setValue("")
toast.add({
title: `Usunięto projekt ${PROJECT}`,
description: "Usunięto 14 wdrożeń i 3 domeny.",
})
}
return (
<AlertDialog
open={open}
onOpenChange={(next) => {
// The dialog stays until the request settles: closing wouldn't stop it.
if (deleting) return
setOpen(next)
// Reopened, it asks for the name again.
if (!next) setValue("")
}}
>
{/* The solid destructive variant is kept for the button that confirms. */}
<AlertDialogTrigger
render={
<Button
variant="outline"
className="text-danger-11 hover:text-danger-11"
/>
}
>
<Trash2Icon data-icon="inline-start" />
Usuń projekt
</AlertDialogTrigger>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogMedia variant="destructive">
<Trash2Icon />
</AlertDialogMedia>
<AlertDialogTitle>Usunąć projekt „{PROJECT}”?</AlertDialogTitle>
<AlertDialogDescription>
Spowoduje to trwałe usunięcie 14 wdrożeń i 3 domen. Tej operacji nie można cofnąć.
</AlertDialogDescription>
</AlertDialogHeader>
<Field>
<FieldLabel htmlFor="confirm-name">
Wpisz <span className="font-mono">{PROJECT}</span> w celu potwierdzenia
</FieldLabel>
<Input
id="confirm-name"
autoComplete="off"
value={value}
onChange={(event) => setValue(event.target.value)}
/>
</Field>
<AlertDialogFooter>
<AlertDialogCancel disabled={deleting}>
Zachowaj projekt
</AlertDialogCancel>
<Button
variant="destructive"
disabled={value !== PROJECT}
loading={deleting}
onClick={confirm}
>
Usuń projekt
</Button>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
)
}Rozmieszczenie
- Trzymaj akcje nieodwracalne z dala od często używanych. Przycisk Usuń obok Zapisz prędzej czy później zostanie kliknięty przez pomyłkę.
- W menu umieszczaj takie pozycje na końcu, za separatorem, z
variant="destructive". - Na stronach ustawień zbierz je w strefie niebezpiecznej na dole strony.
Usunąć „acme-web”?
Zostanie usuniętych 14 wdrożeń. Tego nie da się cofnąć.
Czy na pewno?
Prosimy o potwierdzenie.