# Settings

A settings area with a side menu, one form, a save bar and a danger zone.

```tsx
import * as React from 'react'
import { BellRing, CreditCard, Globe, Lock, SlidersHorizontal, TriangleAlert, Users, Webhook } from 'lucide-react'

import {
  ActionRow,
  Button,
  Checkbox,
  ConfirmDialog,
  Field,
  FormCard,
  FormRow,
  InnerMenu,
  Input,
  KeyValueEditor,
  PageContainer,
  PageHeader,
  RadioCardGroup,
  SaveBar,
  Select,
  SelectContent,
  SelectItem,
  SelectTrigger,
  SelectValue,
  Textarea,
  getErrorMessage,
  toast,
  useKeyValueRows,
  validateIdentifierKey,
  type KeyValuePair,
  type NavGroup,
  type RadioCardOption,
} from '../index'

import { ExampleApp } from './example-app'

/* ---------------------------------------------------------------------------------------------- */
/* Example data                                                                                    */
/* ---------------------------------------------------------------------------------------------- */

type SettingsSection = 'general' | 'notifications' | 'billing' | 'webhooks' | 'danger'
type Visibility = 'private' | 'workspace' | 'public'

/** Everything the settings form edits, except the webhook headers (kept by `useKeyValueRows`). */
interface SettingsValues {
  name: string
  slug: string
  description: string
  defaultRole: string
  visibility: Visibility
  weeklyDigest: boolean
  mentions: boolean
  invoiceReminders: boolean
  company: string
  billingEmail: string
  taxId: string
  country: string
  address: string
  webhookUrl: string
}

const SAVED_VALUES: SettingsValues = {
  name: 'Acme',
  slug: 'acme',
  description: 'Product, growth and finance teams of Acme Inc.',
  defaultRole: 'editor',
  visibility: 'workspace',
  weeklyDigest: true,
  mentions: true,
  invoiceReminders: false,
  company: 'Acme Inc.',
  billingEmail: 'billing@acme.example',
  taxId: '',
  country: 'us',
  address: '500 Market Street\nSan Francisco, CA 94105',
  webhookUrl: 'https://hooks.acme.example/workspace-events',
}

const SAVED_HEADERS: KeyValuePair[] = [
  { key: 'X-Acme-Source', value: 'workspace-events' },
  { key: 'Authorization', value: 'Bearer whk_demo_3f6a91c2', secret: true },
]

/** Module-level (stable) options, so `useKeyValueRows` does not revalidate on every render. */
const HEADER_OPTIONS = { validateKey: validateIdentifierKey }

/** Title and description of every section: the page header follows the current one. */
export const SECTIONS: Record<SettingsSection, { title: string; description: string }> = {
  general: { title: 'General', description: 'The identity of the workspace and the defaults of new projects.' },
  notifications: { title: 'Notifications', description: 'Emails the workspace sends to its members.' },
  billing: { title: 'Billing contact', description: 'Who receives the invoices, and what is printed on them.' },
  webhooks: { title: 'Webhooks', description: 'Notify your own systems when something changes in the workspace.' },
  danger: { title: 'Danger zone', description: 'Irreversible actions. Each one asks for a confirmation.' },
}

/** Sections switched in place: items without `href` are buttons, and `value` marks the current one. */
const MENU_GROUPS: NavGroup[] = [
  {
    id: 'workspace',
    items: [
      { id: 'general', label: 'General', icon: <SlidersHorizontal /> },
      { id: 'notifications', label: 'Notifications', icon: <BellRing /> },
      { id: 'billing', label: 'Billing contact', icon: <CreditCard /> },
    ],
  },
  { id: 'developers', label: 'Developers', items: [{ id: 'webhooks', label: 'Webhooks', icon: <Webhook /> }] },
  { id: 'advanced', label: 'Advanced', items: [{ id: 'danger', label: 'Danger zone', icon: <TriangleAlert /> }] },
]

const VISIBILITY_OPTIONS: RadioCardOption<Visibility>[] = [
  { value: 'private', label: 'Private', description: 'Only invited members.', icon: <Lock /> },
  { value: 'workspace', label: 'Workspace', description: 'Every member of Acme.', icon: <Users /> },
  { value: 'public', label: 'Public', description: 'Anyone with the link.', icon: <Globe /> },
]

const ROLES = [
  { value: 'viewer', label: 'Viewer' },
  { value: 'editor', label: 'Editor' },
  { value: 'admin', label: 'Admin' },
]

const COUNTRIES = [
  { value: 'us', label: 'United States' },
  { value: 'ca', label: 'Canada' },
  { value: 'fr', label: 'France' },
  { value: 'de', label: 'Germany' },
  { value: 'jp', label: 'Japan' },
]

/** Stands in for a network request. */
const wait = (ms: number) => new Promise<void>((resolve) => setTimeout(resolve, ms))

type SettingsErrors = Partial<Record<keyof SettingsValues, string>>

function validate(values: SettingsValues): SettingsErrors {
  const errors: SettingsErrors = {}
  if (values.name.trim() === '') errors.name = 'Enter a workspace name.'
  if (values.slug === '') errors.slug = 'Enter a URL slug.'
  else if (!/^[a-z0-9]+(-[a-z0-9]+)*$/.test(values.slug)) errors.slug = 'Use lowercase letters, digits and single hyphens.'
  if (!/^\S+@\S+\.\S+$/.test(values.billingEmail)) errors.billingEmail = 'Enter a valid email address.'
  if (values.webhookUrl !== '' && !values.webhookUrl.startsWith('https://')) errors.webhookUrl = 'Use an https:// URL.'
  return errors
}

/* ---------------------------------------------------------------------------------------------- */
/* Sections                                                                                        */
/* ---------------------------------------------------------------------------------------------- */

interface SectionProps {
  values: SettingsValues
  errors: SettingsErrors
  /** Updates one value of the form. */
  set: <K extends keyof SettingsValues>(key: K, value: SettingsValues[K]) => void
}

/**
 * Label-left rows in cards: `FormCard` + `FormRow` around Input, Textarea, Select and RadioCardGroup.
 * A row with `htmlFor` gives its single control `id`, `aria-describedby` and `aria-invalid`; a
 * composite control (`Select`) takes them through the render function.
 */
function GeneralSection({ values, errors, set }: SectionProps) {
  return (
    <div className="flex flex-col gap-6">
      {/* `asDiv`: the page owns the single <form>, so the cards must not nest forms. */}
      <FormCard asDiv title="Workspace profile" description="How the workspace appears to its members.">
        <FormRow label="Name" htmlFor="ws-name" error={errors.name}>
          <Input value={values.name} onChange={(event) => set('name', event.target.value)} />
        </FormRow>
        <FormRow
          label="URL slug"
          description="Part of every link to the workspace: acme.example/w/your-slug."
          htmlFor="ws-slug"
          error={errors.slug}
        >
          <Input
            mono
            value={values.slug}
            onChange={(event) => set('slug', event.target.value)}
            autoCapitalize="off"
            spellCheck={false}
          />
        </FormRow>
        <FormRow label="Description" description="Shown on the workspace overview." htmlFor="ws-description">
          <Textarea rows={3} value={values.description} onChange={(event) => set('description', event.target.value)} />
        </FormRow>
      </FormCard>

      <FormCard asDiv title="Defaults" description="Applied to new members and new projects.">
        <FormRow
          label="Default role"
          description="Given to members invited without an explicit role."
          htmlFor="ws-role"
          controlClassName="max-w-xs"
        >
          {(control) => (
            <Select value={values.defaultRole} onValueChange={(role) => set('defaultRole', role)}>
              <SelectTrigger {...control} className="w-full">
                <SelectValue />
              </SelectTrigger>
              <SelectContent>
                {ROLES.map((role) => (
                  <SelectItem key={role.value} value={role.value}>
                    {role.label}
                  </SelectItem>
                ))}
              </SelectContent>
            </Select>
          )}
        </FormRow>
        {/* Wide control: a `vertical` row. A radio group has no single focus target, so no `htmlFor`. */}
        <FormRow label="Project visibility" description="Who can see a project right after it is created." layout="vertical">
          <RadioCardGroup
            aria-label="Project visibility"
            size="sm"
            appearance="soft"
            columns={3}
            options={VISIBILITY_OPTIONS}
            value={values.visibility}
            onValueChange={(visibility) => set('visibility', visibility)}
          />
        </FormRow>
      </FormCard>
    </div>
  )
}

/**
 * On/off settings saved with the rest of the form: one `FormRow` per `Checkbox` (a `Switch` is for a
 * setting applied on change).
 */
function NotificationsSection({ values, set }: SectionProps) {
  const rows = [
    { key: 'weeklyDigest', id: 'notify-digest', label: 'Weekly digest', description: 'A summary of project activity, every Monday morning.' },
    { key: 'mentions', id: 'notify-mentions', label: 'Mentions', description: 'When a member is mentioned in a comment.' },
    { key: 'invoiceReminders', id: 'notify-invoices', label: 'Invoice reminders', description: 'Three days before an invoice is due, to the billing contact.' },
  ] as const
  return (
    <FormCard asDiv title="Email notifications" description="Members can still mute them from their own profile.">
      {rows.map((row) => (
        <FormRow key={row.key} label={row.label} description={row.description} htmlFor={row.id} controlClassName="md:items-end">
          <Checkbox checked={values[row.key]} onCheckedChange={(checked) => set(row.key, checked === true)} />
        </FormRow>
      ))}
    </FormCard>
  )
}

/** Stacked fields in a dense grid: `Field` wires label, hint, error and ids for each control. */
function BillingSection({ values, errors, set }: SectionProps) {
  return (
    <FormCard asDiv title="Invoice details" description="Printed on every invoice of the workspace.">
      {/* Non-row content of a FormCard brings its own padding. */}
      <div className="grid gap-5 px-5 py-5 sm:grid-cols-2 md:px-6">
        <Field label="Company name">
          <Input value={values.company} onChange={(event) => set('company', event.target.value)} autoComplete="organization" />
        </Field>
        <Field label="Billing email" hint="Invoices and receipts are sent here." error={errors.billingEmail}>
          <Input
            type="email"
            value={values.billingEmail}
            onChange={(event) => set('billingEmail', event.target.value)}
            autoComplete="email"
          />
        </Field>
        <Field label="Tax ID" optional hint="VAT, GST or EIN number.">
          <Input mono value={values.taxId} onChange={(event) => set('taxId', event.target.value)} />
        </Field>
        {/* Composite control: the render function hands the ids to the focusable trigger. */}
        <Field label="Country">
          {(control) => (
            <Select value={values.country} onValueChange={(country) => set('country', country)}>
              <SelectTrigger {...control} className="w-full">
                <SelectValue />
              </SelectTrigger>
              <SelectContent>
                {COUNTRIES.map((country) => (
                  <SelectItem key={country.value} value={country.value}>
                    {country.label}
                  </SelectItem>
                ))}
              </SelectContent>
            </Select>
          )}
        </Field>
        <Field label="Address" className="sm:col-span-2">
          <Textarea rows={3} value={values.address} onChange={(event) => set('address', event.target.value)} />
        </Field>
      </div>
    </FormCard>
  )
}

/* ---------------------------------------------------------------------------------------------- */
/* Page                                                                                            */
/* ---------------------------------------------------------------------------------------------- */

/** Props of the Settings example. */
export interface SettingsExampleProps {
  /** Section shown first. The `InnerMenu` then switches sections in place. */
  defaultSection?: SettingsSection
  /** Called with the target `href` when a link of the app is followed (a real app navigates). */
  onNavigate?: (href: string) => void
  /** Called with the saved values and webhook headers once a save succeeds. */
  onSave?: (values: SettingsValues, headers: KeyValuePair[]) => void
  /** Called once the deletion of the workspace is confirmed. */
  onDeleteWorkspace?: () => void
}

/**
 * A settings area: an `InnerMenu` switching sections in place, one form made of `FormCard`s with a
 * sticky `SaveBar`, and a danger zone whose actions go through a `ConfirmDialog`.
 */
export function SettingsExample({ defaultSection = 'general', onNavigate, onSave, onDeleteWorkspace }: SettingsExampleProps) {
  const [section, setSection] = React.useState<SettingsSection>(defaultSection)
  // `saved` is the server state, `values` the edits: the form is dirty while they differ.
  const [saved, setSaved] = React.useState(SAVED_VALUES)
  const [values, setValues] = React.useState(SAVED_VALUES)
  const [savedHeaders, setSavedHeaders] = React.useState(SAVED_HEADERS)
  const headers = useKeyValueRows(savedHeaders, HEADER_OPTIONS)
  const [saving, setSaving] = React.useState(false)
  const [saveError, setSaveError] = React.useState<string | null>(null)

  const errors = validate(values)
  const invalid = Object.keys(errors).length > 0 || !headers.valid
  const dirty =
    headers.dirty || (Object.keys(saved) as (keyof SettingsValues)[]).some((key) => values[key] !== saved[key])

  const set: SectionProps['set'] = (key, value) => setValues((current) => ({ ...current, [key]: value }))

  const reset = () => {
    setValues(saved)
    headers.reset(savedHeaders)
    setSaveError(null)
  }

  const save = async () => {
    if (!dirty || invalid || saving) return
    setSaving(true)
    setSaveError(null)
    try {
      await wait(400)
      setSaved(values)
      // The saved data changed: move the editor's baseline with it.
      setSavedHeaders(headers.pairs)
      headers.reset(headers.pairs)
      onSave?.(values, headers.pairs)
      // Transient feedback after an action: a toast (the Toaster is mounted once, in the app frame).
      toast.success('Workspace settings saved')
    } catch (error) {
      setSaveError(getErrorMessage(error))
    } finally {
      setSaving(false)
    }
  }

  const { title, description } = SECTIONS[section]
  const sectionProps: SectionProps = { values, errors, set }

  return (
    <ExampleApp section="settings" onNavigate={onNavigate}>
      {/* InnerMenu first, then the content. From `md` the content scrolls on its own next to the menu. */}
      <div className="flex flex-1 flex-col md:min-h-0 md:flex-row">
        <InnerMenu
          title="Settings"
          groups={MENU_GROUPS}
          value={section}
          onValueChange={(id) => setSection(id as SettingsSection)}
        />
        <div className="flex min-w-0 flex-1 flex-col md:overflow-y-auto">
          {section === 'danger' ? (
            // Outside the <form>: these actions run on their own, after a confirmation.
            <PageContainer size="narrow">
              <PageHeader title={title} description={description} />
              <FormCard asDiv tone="destructive">
                <ActionRow
                  title="Transfer ownership"
                  description="Hand the workspace over to another admin. You keep the Admin role."
                  action={
                    <ConfirmDialog
                      tone="warning"
                      trigger={<Button variant="warning">Transfer…</Button>}
                      title="Transfer “Acme” to Liam Ortiz?"
                      description="Liam becomes the owner and the billing contact. Only the new owner can transfer it back."
                      confirmLabel="Transfer ownership"
                      cancelLabel="Keep ownership"
                      onConfirm={() => {
                        toast.success('Ownership transferred to Liam Ortiz')
                      }}
                    />
                  }
                />
                <ActionRow
                  title="Delete workspace"
                  description="Permanently deletes the workspace with its projects, members, invoices and API keys."
                  action={
                    <ConfirmDialog
                      trigger={<Button variant="destructive">Delete workspace</Button>}
                      title="Delete workspace “Acme”?"
                      description={
                        <>
                          <p>All projects, members, invoices and API keys of this workspace are permanently deleted.</p>
                          <p>
                            <strong className="font-medium text-foreground">This cannot be undone.</strong>
                          </p>
                        </>
                      }
                      // Typed confirmation: friction reserved for irreversible, high-impact actions.
                      confirmText={saved.slug}
                      confirmLabel="Delete workspace"
                      // A promise: the dialog shows a spinner, then closes itself (or shows the error).
                      onConfirm={async () => {
                        await wait(300)
                        onDeleteWorkspace?.()
                        toast.success('Workspace deleted')
                      }}
                    />
                  }
                />
              </FormCard>
            </PageContainer>
          ) : (
            <form
              noValidate
              aria-label={`${title} settings`}
              className="flex flex-1 flex-col"
              onSubmit={(event) => {
                event.preventDefault()
                void save()
              }}
            >
              <PageContainer size="narrow" className="flex-1 pb-10">
                <PageHeader title={title} description={description} />
                {section === 'general' && <GeneralSection {...sectionProps} />}
                {section === 'notifications' && <NotificationsSection {...sectionProps} />}
                {section === 'billing' && <BillingSection {...sectionProps} />}
                {section === 'webhooks' && (
                  <FormCard asDiv title="Workspace events" description="A POST request is sent for every change in the workspace.">
                    <FormRow
                      label="Endpoint URL"
                      description="Leave it empty to turn the webhook off."
                      htmlFor="webhook-url"
                      error={errors.webhookUrl}
                    >
                      <Input
                        type="url"
                        mono
                        value={values.webhookUrl}
                        onChange={(event) => set('webhookUrl', event.target.value)}
                        placeholder="https://example.com/hooks/acme"
                      />
                    </FormRow>
                    <FormRow
                      label="Request headers"
                      description="Sent with every request. Secret values stay masked until revealed."
                      layout="vertical"
                    >
                      <KeyValueEditor
                        value={headers.rows}
                        onValueChange={headers.setRows}
                        errors={headers.errors}
                        validateKey={validateIdentifierKey}
                        disabled={saving}
                        keyLabel="Header"
                        keyPlaceholder="X-Header-Name"
                        addLabel="Add header"
                        itemNoun={{ one: 'header', other: 'headers' }}
                        listLabel="Request headers"
                        emptyMessage="No headers yet."
                      />
                    </FormRow>
                  </FormCard>
                )}
              </PageContainer>
              {/* Save is the form's submit button (no `onSave`), so Enter in a field saves too. */}
              <SaveBar
                sticky
                dirty={dirty}
                invalid={invalid}
                saving={saving}
                error={saveError}
                hint="All changes saved"
                onReset={reset}
              />
            </form>
          )}
        </div>
      </div>
    </ExampleApp>
  )
}
```

Change a field to see the save bar change. Use the side menu to open another section.

The example has no router. A click on a link shows a message with the target of the link.

## Components

The code under the preview is the page. The frame around it is `ExampleApp`. The [Dashboard](/docs/examples/dashboard#the-frame) example tells how the frame works.

| Region | Components |
| --- | --- |
| Section menu | [Inner Menu](/docs/components/inner-menu) |
| Page column | [Page](/docs/components/page): `PageContainer`, `PageHeader` |
| Rows with the label on the left | [Form Card](/docs/components/form-card): `FormCard`, `FormRow` |
| Controls | [Input](/docs/components/input), [Textarea](/docs/components/textarea), [Select](/docs/components/select), [Checkbox](/docs/components/checkbox), [Radio Card Group](/docs/components/radio-card-group) |
| Stacked fields | [Field](/docs/components/field) |
| Request headers | [Key Value Editor](/docs/components/key-value-editor) |
| Save | [Save Bar](/docs/components/save-bar), [Toast](/docs/components/toast) |
| Danger zone | `FormCard`, `ActionRow`, [Confirm Dialog](/docs/components/confirm-dialog) |

## Design rules

- **The menu changes the section in place.** The items of `InnerMenu` have no `href`. `value` and `onValueChange` hold the current section. On a phone, the menu is a row of tabs.
- **One form for the page.** The cards have `asDiv` because the page holds the only `<form>`.
- **One save bar for all the sections of the form.** The sticky `SaveBar` gets `dirty`, `invalid` and `saving`. Its "Save changes" button is the submit button of the form.
- **A checkbox for a value that the form saves.** A [Switch](/docs/components/switch) is for a setting that applies immediately.
- **A hook holds the headers.** `useKeyValueRows` gives the rows and the errors to `KeyValueEditor`. It gives the dirty state to the save bar.
- **The danger zone is not in the form.** Each `ActionRow` opens a `ConfirmDialog`. To delete the workspace, the user must type its slug.

## Sections

The `defaultSection` prop of the example sets the first section.

| `defaultSection` | The section shows |
| --- | --- |
| `general` | Rows with an input, a textarea, a select and radio cards. This is the default. |
| `notifications` | One checkbox for each row. |
| `billing` | Stacked fields in a grid of two columns. |
| `webhooks` | A URL field and the editor of the request headers. |
| `danger` | Two actions, each with a confirmation. |
