Destructive actions

Let people delete, remove or archive with as little friction as is safe.

BetaSince 1.0.0
On this page

Import

import {
  Button,
  Checkbox,
  Column,
  ConfirmButton,
  ConfirmDialog,
  List,
  Row,
  Text,
  TextField,
  ToastProvider,
  useToast,
} from '@envsemble/envui';

Usage

Act at once and offer Undo: useToast().show({ message, action: { label: 'Undo', onClick } }) returns the toast's id, and the action's onClick puts the item back and calls toast.dismiss(id).

  • A toast with an action stays until dismissed, so keep what was archived reachable elsewhere too.
  • Move focus to the next row, or the list, with tabIndex={-1}, when the action removes its own control.
UndoInstead.tsx
import { useEffect, useRef, useState } from 'react';
import { Button, List, ToastProvider, useToast } from '@envsemble/envui';
import { IconArchive } from '@envsemble/envui/icons/Archive';

function Projects() {
  const toast = useToast();
  const [projects, setProjects] = useState(initialProjects);
  const list = useRef<HTMLUListElement>(null);
  // An archived row takes its button with it, so focus moves to the next row's button, or to the
  // list once it is empty.
  const focusNext = useRef<(() => void) | null>(null);
  useEffect(() => {
    focusNext.current?.();
    focusNext.current = null;
  });

  const archive = (name: string) => {
    const index = projects.indexOf(name);
    focusNext.current = () => {
      const buttons = list.current?.querySelectorAll('button') ?? [];
      (buttons[Math.min(index, buttons.length - 1)] ?? list.current)?.focus();
    };
    // The project is archived at once, with no question first: Undo brings it back.
    setProjects((current) => current.filter((project) => project !== name));
    const id = toast.show({
      message: `${name} archived.`,
      action: {
        label: 'Undo',
        onClick: () => {
          setProjects((current) =>
            initialProjects.filter((project) => project === name || current.includes(project)),
          );
          toast.dismiss(id);
        },
      },
    });
  };

  return (
    <List.Root ref={list} aria-label="Projects" tabIndex={-1}>
      {projects.map((project) => (
        <List.Item
          key={project}
          label={project}
          end={
            <Button
              variant="secondary"
              size="sm"
              startIcon={<IconArchive />}
              aria-label={`Archive ${project}`}
              onClick={() => archive(project)}
            >
              Archive
            </Button>
          }
        />
      ))}
      {projects.length === 0 && <List.Item label="Every project is archived." />}
    </List.Root>
  );
}

export default function UndoInstead() {
  // An application has one ToastProvider at its root; the example brings its own.
  return (
    <ToastProvider>
      <Projects />
    </ToastProvider>
  );
}

const initialProjects = [
  'Website refresh',
  'Quarterly report',
  'Onboarding guide',
  'Mobile release',
];

Confirm in place or in a dialog

A ConfirmButton runs onConfirm on its second press and shows confirmLabel, such as "Remove file", while armed. A ConfirmDialog with tone="danger" takes a title, a description and a confirmLabel, and puts focus on Cancel.

  • Opened from a Menu.Item, pass the menu's trigger as finalFocus, and put the item last, after a Menu.Separator, labelled with its consequence.
  • Return the action's promise from onConfirm: the dialog waits, pending, and stays open with error set when it fails.
<ConfirmDialog
  open={open}
  onOpenChange={setOpen}
  tone="danger"
  title="Delete Website refresh?"
  description="This deletes the project for everyone. You can’t undo this."
  confirmLabel="Delete project"
  onConfirm={() => deleteProject(project.id)}
/>

Type to confirm

Put a TextField for the name inside the ConfirmDialog, as the TypeToConfirm example does, and pass confirmDisabled while the name doesn't match: the confirm button is disabled, and Enter in the field doesn't confirm until it does.

<ConfirmDialog
  open={open}
  onOpenChange={setOpen}
  tone="danger"
  title="Delete Data migration?"
  confirmLabel="Delete project"
  confirmDisabled={typed.trim() !== 'Data migration'}
  onConfirm={() => deleteProject(project.id)}
>
  <TextField
    label="Project name"
    description="Enter Data migration to confirm."
    value={typed}
    onValueChange={setTyped}
  />
</ConfirmDialog>

Composition

Lay the items out as a List, each List.Item taking its trigger at its end: a Button, an IconButton named with aria-label, a ConfirmButton or a Menu. The danger tone, tone="danger", goes only on what deletes or ends something.

Bulk delete selects rows with Checkboxes and names the count in an outline danger Button, the dialog's title and its confirmLabel, as the BulkDelete example does. Its finalFocus returns focus to the button, or to the list, with tabIndex={-1}, once the button has gone with the deleted selection.

Accessibility

You provide

  • A name for every icon-only trigger, such as "Remove annual-review.pdf".
  • finalFocus, and focus for when the action removes its own control.
  • Dismissing the undo toast once Undo has run, with dismiss(id).

envUI provides

  • ConfirmDialog as an alertdialog, named and described, with focus on Cancel for tone="danger"; its pending state and an error announced as an alert.
  • ConfirmButton's armed state in its label, disarmed on Esc, on blur and after armedDuration.
  • Toasts announced politely and kept while they have an action.

Changelog

1.0.0

The page is on the figure template, with the confirmations of the content guidelines; confirming in place and from a menu are on Button and ConfirmDialog.