Przejdź do treści

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ę.

Faktura nr 2041 jest gotowa
Ada skomentowała „Strona główna v3”
Tygodniowy raport zużycia
"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 destructive tylko 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ąć.

DobrzeKonsekwencje są jasno opisane, a przycisk mówi, co robi.

Czy na pewno?

Prosimy o potwierdzenie.

Źle„Czy na pewno?” każe ludziom ponownie czytać ekran, żeby ustalić, co właściwie potwierdzają.