Notifications and feedback

Tell people what happened, in the place and at the volume each message deserves.

BetaSince 1.0.0
On this page

Import

import {
  Alert,
  Badge,
  Button,
  Column,
  DescriptionList,
  Link,
  List,
  RelativeTime,
  Row,
  Select,
  Text,
  TextField,
  ToastProvider,
  useToast,
} from '@envsemble/envui';

Usage

useToast() returns show, dismiss and clear. One ToastProvider at the application's root holds the toasts.

  • show({ tone, message, action }) shows a toast and returns its id; tone is neutral, success, info or danger.
  • A success or neutral toast closes after its duration, 5 seconds by default.
  • A danger toast, and one with an action, stays until dismissed unless duration is set.
  • The provider's limit, 3 by default, sets how many show at a time.
SaveConfirmation.tsx
import { useEffect, useRef, useState, type FormEvent } from 'react';
import {
  Button,
  Select,
  Column,
  Row,
  Text,
  TextField,
  ToastProvider,
  useToast,
} from '@envsemble/envui';
import { IconSend } from '@envsemble/envui/icons/Send';

const isEmail = (value: string) => /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(value.trim());

function InviteForm() {
  const toast = useToast();
  const [email, setEmail] = useState('jordan.kim@example.com');
  const [error, setError] = useState<string>();
  // Says a refused submit's error where focus can't: the outcome itself goes in the toast.
  const [status, setStatus] = useState('');
  const [sending, setSending] = useState(false);
  const field = useRef<HTMLInputElement>(null);
  const timer = useRef<number>(undefined);
  useEffect(() => () => window.clearTimeout(timer.current), []);

  const submit = (event: FormEvent<HTMLFormElement>) => {
    event.preventDefault();
    if (!isEmail(email)) {
      // A problem with a field shows on the field, never in a toast.
      const message = 'Enter an email address like name@example.com';
      setError(message);
      // Focusing the field reads its error. After Enter in the field, focus is already there and
      // focusing it again reads nothing, so only then does the status say the error.
      if (document.activeElement === field.current) setStatus(message);
      else {
        setStatus('');
        field.current?.focus();
      }
      return;
    }
    setStatus('');
    setSending(true);
    // The invitation goes to a server; a timer stands in for it here.
    timer.current = window.setTimeout(() => {
      setSending(false);
      // People started this, so a short success notification is enough; it closes by itself.
      toast.show({ tone: 'success', message: `Invitation sent to ${email.trim()}.` });
      setEmail('');
    }, 1000);
  };

  return (
    <form noValidate onSubmit={submit}>
      {/* envUI has no form column yet: one column, kept to a width that reads easily. */}
      <Column gap={5} style={{ maxInlineSize: '32rem' }}>
        <TextField
          ref={field}
          label="Email address"
          type="email"
          name="email"
          autoComplete="off"
          spellCheck={false}
          value={email}
          error={error}
          onValueChange={(value) => {
            setEmail(value);
            if (isEmail(value)) {
              setError(undefined);
              setStatus('');
            }
          }}
        />
        <Select label="Role" name="role" defaultValue="editor" items={roles} />
        <Row wrap gap={3}>
          {/* The button shows the work while it runs; the toast says how it ended. */}
          <Button type="submit" startIcon={<IconSend />} pending={sending}>
            Send invitation
          </Button>
        </Row>
        <Text role="status" emphasis="muted">
          {status}
        </Text>
      </Column>
    </form>
  );
}

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

const roles = [
  { value: 'viewer', label: 'Viewer' },
  { value: 'editor', label: 'Editor' },
  { value: 'owner', label: 'Owner' },
];

Background work

A toast with an action, such as { label: 'Download', onClick }, stays until dismissed; call toast.dismiss(id) once it has run. Keep the result on the page too, such as a Link with download, since a toast can be dismissed, and say the progress in a role="status" message, as the BackgroundTask example does.

<Link href={`/exports/${file}`} download>
  Download {file}
</Link>

Alerts

A field's error is its error prop, as on the forms page. An Alert on the page from the start takes a tone and actions, and no announce, so it is read with the page; announce gives a warning or danger Alert the alert role, for one that appears because something just happened. After a refused form or failed save, a danger Alert with tabIndex={-1} and a ref takes focus.

<Alert
  tone="warning"
  title="This project is read-only"
  actions={
    <Button variant="secondary" size="sm" onClick={requestAccess}>
      Request edit access
    </Button>
  }
>
  Ask Alex Morgan, its owner, for edit access to change it.
</Alert>

Status and counts

A Badge's tone adds to its words, one tone per status everywhere, such as success for On track and warning for At risk. A NavigationDrawer or NavigationRail destination's count shows its unread items and is said with its label; 0 shows nothing. A count Badge's aria-label says what it counts, such as "12 unread".

Composition

Status in rows is a List whose items take a Badge, with a RelativeTime in their description for when they changed, as the StatusInRows example does; a page's details beside an Alert are a DescriptionList. A notification centre is a Badge with count on its trigger, a Popover and a List of the notifications.

<List.Item label={project.name} description={<RelativeTime value={project.updated} />}>
  <Badge tone="success">On track</Badge>
</List.Item>

Accessibility

You provide

  • One ToastProvider at the root, and messages that make sense out of context.
  • announce only on an Alert that appears because something just happened.
  • An aria-label on every count whose number alone doesn't say what it counts.

envUI provides

  • Toast regions with a polite status, danger toasts announced at once, and F6 to reach them.
  • Info and success Alerts as status messages; warning and danger ones take the alert role only with announce.
  • Badges whose words carry the meaning, and a destination's count in its name, such as Inbox, 12 new.

Changelog

1.0.0

The page is on the figure template, with the feedback and status wording of the content guidelines.