Settings

Let people change how the application works for them, and see each change apply.

BetaSince 1.0.0
On this page

Import

import {
  Badge,
  Button,
  Column,
  ConfirmDialog,
  DescriptionList,
  List,
  NavigationDrawer,
  Page,
  PageHeader,
  Radio,
  RadioGroup,
  Row,
  Select,
  Surface,
  Switch,
  Text,
  TextField,
  ToastProvider,
  useThemePreference,
  useToast,
} from '@envsemble/envui';

Usage

A section of settings that apply at once is a section named by its heading, holding a List whose items each take a Switch at their end.

  • Each Switch's onCheckedChange stores the change at once; name it with aria-labelledby and aria-describedby pointing at the row's title and detail.
  • Show a toast through useToast only for what the page can't show.
  • When storing fails, set the Switch back and show a danger toast with a Try again action.
ImmediateToggles.tsx
import { useId, useState } from 'react';
import { List, Column, Switch, Text, ToastProvider, useToast } from '@envsemble/envui';

interface Setting {
  id: string;
  title: string;
  detail: string;
}

/** A section of settings that each apply as they are switched: no Save button. */
function SettingsSection({
  title,
  settings,
  values,
  onChange,
}: {
  title: string;
  settings: readonly Setting[];
  values: Record<string, boolean>;
  onChange: (id: string, on: boolean) => void;
}) {
  const id = useId();
  return (
    <Column render={<section aria-labelledby={`${id}-heading`} />} gap={3}>
      <Text render={<h3 id={`${id}-heading`} />} variant="title">
        {title}
      </Text>
      <List.Root aria-labelledby={`${id}-heading`}>
        {settings.map((setting) => (
          <List.Item
            key={setting.id}
            // A switch is narrow: it keeps to the row's end, and the text wraps beside it.
            style={{ flexWrap: 'nowrap' }}
            label={<span id={`${id}-${setting.id}-title`}>{setting.title}</span>}
            description={<span id={`${id}-${setting.id}-detail`}>{setting.detail}</span>}
            end={
              // The row's title names the switch, and its detail describes it.
              <Switch
                aria-labelledby={`${id}-${setting.id}-title`}
                aria-describedby={`${id}-${setting.id}-detail`}
                checked={values[setting.id]}
                onCheckedChange={(on) => onChange(setting.id, on)}
                style={{ flexShrink: 0 }}
              />
            }
          />
        ))}
      </List.Root>
    </Column>
  );
}

function NotificationSettings() {
  const toast = useToast();
  const [values, setValues] = useState<Record<string, boolean>>({
    comments: true,
    mentions: true,
    summary: true,
    directory: false,
  });
  const change = (id: string, on: boolean) => {
    // The change is saved as it is made; a request to the server would go here.
    setValues((current) => ({ ...current, [id]: on }));
    // Only a change that shows somewhere else gets a notification; the switch itself says the rest.
    if (id === 'directory')
      toast.show({
        message: on
          ? 'Other people at Northwind can now find you in the directory.'
          : 'Only your teams can now find you in the directory.',
      });
  };
  return (
    <Column gap={6}>
      <SettingsSection
        title="Email notifications"
        settings={notifications}
        values={values}
        onChange={change}
      />
      <SettingsSection title="Privacy" settings={privacy} values={values} onChange={change} />
    </Column>
  );
}

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

const notifications: readonly Setting[] = [
  { id: 'comments', title: 'Comments', detail: 'When someone comments on your documents.' },
  {
    id: 'mentions',
    title: 'Mentions',
    detail: 'When someone mentions you in a task or a comment.',
  },
  { id: 'summary', title: 'Weekly summary', detail: 'Your projects’ progress, every Monday.' },
];

const privacy: readonly Setting[] = [
  {
    id: 'directory',
    title: 'Show me in the directory',
    detail: 'Everyone at Northwind can find your name, team and email address.',
  },
];

Save a section

A section that saves together is a form with noValidate and an onSubmit, on a Surface through render.

  • A warning Badge, "Unsaved changes", shows while the draft differs from what was saved.
  • Save is a type="submit" Button with pending while it saves, and Cancel, a secondary Button, puts the saved values back.
  • Say the outcome in a role="status" message, and on a refused Save set the field's error and focus it.
<Surface render={<form noValidate aria-labelledby="profile-heading" onSubmit={submit} />}>
  {changed && <Badge tone="warning">Unsaved changes</Badge>}
  {fields}
  <Button type="submit" pending={saving}>
    Save
  </Button>
  <Button variant="secondary" onClick={cancel}>
    Cancel
  </Button>
</Surface>

Appearance

useThemePreference() returns the preference, system, light or dark, and setPreference, which applies it to the whole page at once through the root ThemePreferenceProvider. The scale, system, comfortable or compact, is stored by the application and passed to the root provider's scale, never to one page or section.

<RadioGroup label="Theme" name="theme" value={preference} onValueChange={setPreference}>
  <Radio value="system" label="System" description="Follows your device’s setting." />
  <Radio value="light" label="Light" />
  <Radio value="dark" label="Dark" />
</RadioGroup>

Danger zone

The danger zone is the last section: a List item whose end is an outline Button with tone="danger", opening a ConfirmDialog with tone="danger". Where the action can't be undone and reaches everyone, the dialog holds a TextField for the name and takes confirmDisabled while the name doesn't match, as the DangerZone example does.

<ConfirmDialog
  open={open}
  onOpenChange={change}
  tone="danger"
  title="Delete the Northwind workspace?"
  confirmLabel="Delete workspace"
  onConfirm={confirm}
>
  <TextField
    ref={field}
    label="Workspace name"
    value={typed}
    error={error}
    onValueChange={setTyped}
  />
</ConfirmDialog>

Composition

Lay the page out as a Page with a PageHeader, its sections in a Column, each a section with a h2 or h3 Text heading and a List of settings. A long page lists its sections in a NavigationDrawer beside them. The anatomy's parts are the section heading, env-text, each setting, env-list-item, its Switch, env-switch, and the danger zone's button, env-button. Text fields, Selects and a RadioGroup sit in a section that saves together; a DescriptionList shows values that are only read.

Accessibility

You provide

  • A heading for every section, and a name for every switch: its label, or aria-labelledby and aria-describedby to the row's title and detail.
  • Storing each change, and a status when a section is saved.
  • One ToastProvider and one ThemePreferenceProvider at the root.

envUI provides

  • Switch as a native checkbox with the switch role, its label stable in both states.
  • RadioGroup as a fieldset with a legend, moved through with the arrow keys.
  • A ConfirmDialog with the alert dialog role, and toasts in a polite live region.

Changelog

1.0.0

The page is on the figure template.