# Save Bar

A footer for an editor or a long form, with the save status, Cancel and Save.

```tsx
import * as React from 'react'
import { Card, CardContent, CardHeader, CardTitle, Field, Input, SaveBar, Textarea, toast } from 'ferry-ui'

const INITIAL = {
  subject: 'Your invoice from Acme',
  message: 'Hello,\n\nYour invoice is ready. Thank you for your order.',
}

export default function SaveBarHero() {
  const [saved, setSaved] = React.useState(INITIAL)
  const [draft, setDraft] = React.useState(INITIAL)
  const [saving, setSaving] = React.useState(false)
  const dirty = draft.subject !== saved.subject || draft.message !== saved.message

  function save() {
    setSaving(true)
    // Stands for a request to the server.
    window.setTimeout(() => {
      setSaved(draft)
      setSaving(false)
      toast.success('Template saved')
    }, 1000)
  }

  return (
    <Card className="mx-auto w-full max-w-xl">
      <CardHeader>
        <CardTitle>Invoice email</CardTitle>
      </CardHeader>
      <CardContent className="flex flex-col gap-5">
        <Field label="Subject">
          <Input value={draft.subject} onChange={(event) => setDraft({ ...draft, subject: event.target.value })} />
        </Field>
        <Field label="Message">
          <Textarea value={draft.message} onChange={(event) => setDraft({ ...draft, message: event.target.value })} />
        </Field>
      </CardContent>
      <SaveBar
        dirty={dirty}
        saving={saving}
        hint="All changes saved"
        onReset={() => setDraft(saved)}
        onSave={save}
      />
    </Card>
  )
}
```

## Usage guidelines

- **For an editor or a long form.** The bar shows the status on the left and the buttons on the right.
- **A settings card has a smaller footer.** If the card shows no save error and has one save action, use `FormActions` of [Form Card](/docs/components/form-card).
- **A switch saves on a change.** Do not add a save bar for a setting that applies immediately.
- **A destructive action is not a save.** Use [Confirm Dialog](/docs/components/confirm-dialog) for it.

## Anatomy

Import the component. A save bar has one part. Put it at the bottom of a card, a panel or a section.

```tsx title="Anatomy"

<SaveBar dirty={false} saving={false} onReset={() => {}} onSave={() => {}} />
```

The props set the status and the state of Save.

| Props | Status | Save |
| --- | --- | --- |
| `dirty={false}` | The `hint` text. | Disabled. |
| `dirty` | "Unsaved changes". | Enabled. |
| `dirty` and `invalid` | The status and `invalidMessage`. | Disabled. |
| `saving` | No change. | A spinner. |
| `error` | The error message. | No change. |

## Examples

### Live validation

Set `invalid` while a field has an error. Save stays disabled until each value is correct.

```tsx
import * as React from 'react'
import { Card, CardContent, Field, Input, SaveBar, toast } from 'ferry-ui'

const EMAIL = /^[^\s@]+@[^\s@]+\.[^\s@]+$/

export default function SaveBarInvalid() {
  const [saved, setSaved] = React.useState('billing@example.com')
  const [email, setEmail] = React.useState('billing@example')
  // Live validation: the code checks the value on each change.
  const invalid = !EMAIL.test(email)

  return (
    <Card className="mx-auto w-full max-w-xl">
      <CardContent>
        <Field label="Billing email" error={invalid ? 'Enter a valid email address.' : undefined}>
          <Input type="email" value={email} onChange={(event) => setEmail(event.target.value)} />
        </Field>
      </CardContent>
      <SaveBar
        dirty={email !== saved}
        invalid={invalid}
        onReset={() => setEmail(saved)}
        onSave={() => {
          setSaved(email)
          toast.success('Email saved')
        }}
      />
    </Card>
  )
}
```

### Save error

Pass the message of the last error to `error`. The message replaces the status. Use [getErrorMessage](/docs/utilities/get-error-message) to get a message from an error.

```tsx
import * as React from 'react'
import { Card, CardContent, Field, Input, SaveBar, getErrorMessage } from 'ferry-ui'

// Stands for a request that the server refuses.
const refuse = () =>
  new Promise<void>((_, reject) => {
    window.setTimeout(() => reject(new Error('The server refused the request. Try again.')), 1000)
  })

export default function SaveBarError() {
  const [name, setName] = React.useState('Billing portal 2')
  const [saving, setSaving] = React.useState(false)
  const [error, setError] = React.useState<string>()

  async function save() {
    setSaving(true)
    setError(undefined)
    try {
      await refuse()
    } catch (err) {
      setError(getErrorMessage(err))
    } finally {
      setSaving(false)
    }
  }

  return (
    <Card className="mx-auto w-full max-w-xl">
      <CardContent>
        <Field label="Project name">
          <Input value={name} onChange={(event) => setName(event.target.value)} />
        </Field>
      </CardContent>
      <SaveBar
        dirty={name !== 'Billing portal'}
        saving={saving}
        error={error}
        onReset={() => {
          setName('Billing portal')
          setError(undefined)
        }}
        onSave={() => void save()}
      />
    </Card>
  )
}
```

### Second save action

`extraAction` adds a second button that saves and does one more step. This button becomes the primary button. Its `hint` shows in a [Tooltip](/docs/components/tooltip).

```tsx
import * as React from 'react'
import { Card, CardContent, Field, Input, SaveBar, toast } from 'ferry-ui'
import { Send } from 'lucide-react'

export default function SaveBarExtraAction() {
  const [saved, setSaved] = React.useState('Release notes')
  const [title, setTitle] = React.useState('Release notes for March')
  const [pending, setPending] = React.useState<'draft' | 'publish'>()

  function save(kind: 'draft' | 'publish') {
    setPending(kind)
    // Stands for a request to the server.
    window.setTimeout(() => {
      setSaved(title)
      setPending(undefined)
      toast.success(kind === 'draft' ? 'Draft saved' : 'Page published')
    }, 1000)
  }

  return (
    <Card className="mx-auto w-full max-w-xl">
      <CardContent>
        <Field label="Page title">
          <Input value={title} onChange={(event) => setTitle(event.target.value)} />
        </Field>
      </CardContent>
      <SaveBar
        dirty={title !== saved}
        saving={pending === 'draft'}
        saveLabel="Save draft"
        onReset={() => setTitle(saved)}
        onSave={() => save('draft')}
        extraAction={{
          label: 'Save and publish',
          icon: <Send />,
          hint: 'Saves the page, then shows it to the customers',
          loading: pending === 'publish',
          onClick: () => save('publish'),
        }}
      />
    </Card>
  )
}
```

### Inline variant

The `bar` variant has a border, a tinted background and its padding. The `inline` variant has only the row. Use `inline` in a container that has its own footer.

```tsx
import * as React from 'react'
import { Card, CardContent, CardFooter, CardHeader, CardTitle, Checkbox, Label, SaveBar, toast } from 'ferry-ui'

export default function SaveBarInline() {
  const [saved, setSaved] = React.useState(true)
  const [digest, setDigest] = React.useState(true)

  return (
    <Card className="mx-auto w-full max-w-xl">
      <CardHeader>
        <CardTitle>Notifications</CardTitle>
      </CardHeader>
      <CardContent>
        <Label className="font-normal">
          <Checkbox checked={digest} onCheckedChange={(checked) => setDigest(checked === true)} />
          Send a summary each Monday
        </Label>
      </CardContent>
      {/* The footer of the card has the border and the padding: the bar adds only the row. */}
      <CardFooter>
        <SaveBar
          variant="inline"
          dirty={digest !== saved}
          onReset={() => setDigest(saved)}
          onSave={() => {
            setSaved(digest)
            toast.success('Notifications saved')
          }}
        />
      </CardFooter>
    </Card>
  )
}
```

### Sticky bar in a form

`sticky` keeps the bar at the bottom of the parent that scrolls. Use it with the `bar` variant.

If you leave out `onSave`, Save submits the form around the bar. <Kbd>Enter</Kbd> in a field then saves too.

```tsx
import * as React from 'react'
import { Card, Field, Input, SaveBar, Textarea, toast } from 'ferry-ui'

const INITIAL = { company: 'Acme', email: 'billing@example.com', address: '12 Market Street\nSpringfield' }

export default function SaveBarSticky() {
  const [saved, setSaved] = React.useState(INITIAL)
  const [draft, setDraft] = React.useState(INITIAL)
  const dirty = draft.company !== saved.company || draft.email !== saved.email || draft.address !== saved.address

  function submit(event: React.FormEvent<HTMLFormElement>) {
    event.preventDefault()
    if (!dirty) return
    setSaved(draft)
    toast.success('Billing profile saved')
  }

  return (
    <Card className="mx-auto w-full max-w-xl">
      {/* The form scrolls. The bar has no `onSave`: Save submits the form. */}
      <form className="flex max-h-72 flex-col overflow-y-auto" onSubmit={submit}>
        <div className="flex flex-col gap-5 px-5 py-5 md:px-6">
          <Field label="Company name">
            <Input value={draft.company} onChange={(event) => setDraft({ ...draft, company: event.target.value })} />
          </Field>
          <Field label="Billing email">
            <Input type="email" value={draft.email} onChange={(event) => setDraft({ ...draft, email: event.target.value })} />
          </Field>
          <Field label="Billing address">
            <Textarea value={draft.address} onChange={(event) => setDraft({ ...draft, address: event.target.value })} />
          </Field>
        </div>
        <SaveBar sticky dirty={dirty} hint="These values show on each invoice" onReset={() => setDraft(saved)} />
      </form>
    </Card>
  )
}
```

## Accessibility

- The error has `role="alert"`. Screen readers read the message when it shows.
- While a save is in progress, the other buttons are disabled.
- A `hint` on `extraAction` needs a `TooltipProvider` above the bar.

## API reference

`SaveBar` also accepts each attribute of the `<div>` element.

| Prop | Type | Default | Description |
| --- | --- | --- | --- |
| `dirty` (required) | `boolean` |  | The edited data differs from its saved state: enables Save / Cancel and shows the unsaved status. |
| `invalid` | `boolean` | `false` | The edits have validation errors (live validation): Save stays disabled and `invalidMessage` joins the unsaved status. `FormActions` differs on purpose: there `invalid` reports a failed submit and Save stays enabled. |
| `saving` | `boolean` | `false` | A save is in flight: spinner on Save, every other button disabled. |
| `error` | `ReactNode` |  | Last save error (use `getErrorMessage(err)`). Replaces the status on the left, announced as an alert. |
| `hint` | `ReactNode` |  | Neutral summary shown on the left while there is nothing to save ("12 headers", "All changes saved"). |
| `onReset` | `(() => void)` |  | Shows a Cancel button that discards the edits. Omit it to hide Cancel. |
| `onSave` | `(() => void)` |  | Called by the Save button. When omitted, Save is a `type="submit"` button and the enclosing form's `onSubmit` runs (so Enter in a field saves too). Do not pass both `onSave` and handle `onSubmit`. |
| `saveLabel` | `ReactNode` | `Save changes` | Save button text. Default "Save changes". |
| `cancelLabel` | `ReactNode` | `Cancel` | Cancel button text. Default "Cancel". |
| `unsavedLabel` | `ReactNode` | `Unsaved changes` | Status shown on the left while `dirty`, after an amber dot. Default "Unsaved changes". |
| `invalidMessage` | `ReactNode` | `fix the highlighted fields` | Appended to the unsaved status when `invalid`. Default "fix the highlighted fields". |
| `extraAction` | `SaveBarAction` |  | An extra save flavour rendered last as the primary button; Save is then demoted to the default style. Use it when saving can also trigger a follow-up step the user should choose explicitly. |
| `variant` | `"inline" \| "bar"` | `bar` | `bar` (default) draws its own top border, tinted strip and padding: put it at the bottom of a card, panel or page section. `inline` renders only the row, for a container that already provides that chrome (e.g. a form card's footer slot). |
| `sticky` | `boolean` | `false` | Stick to the bottom of the nearest scrolling ancestor so the actions stay reachable in long forms. Use it with the `bar` variant, whose opaque strip hides the content scrolling underneath; with `inline`, make the container itself sticky instead. |

### SaveBarAction

The type of the `extraAction` object.

| Key | Type | Role |
| --- | --- | --- |
| `label` | `ReactNode` | The text of the button. Required. |
| `onClick` | `() => void` | Runs the save and the second step. Required. |
| `icon` | `ReactNode` | The icon before the text. |
| `hint` | `ReactNode` | The text of the tooltip. |
| `loading` | `boolean` | Shows a spinner and disables the other buttons. |
