# SaveBar

A floating bar that exists only while a form has unsaved changes, counts them,
can show exactly what they are, and confirms in place when it is done.

Self-contained on purpose. It imports nothing but React and its own stylesheet:
no icon library, no preprocessor variables, no i18n framework, no assumptions
about the host's markup. Copy the folder into another project and it works.

```
SaveBar/
  index.js          public exports
  SaveBar.jsx       the island and its confirmation lifecycle
  useDirtyState.js  baseline tracking and diffing, usable on its own
  SaveBar.css       all styling, themed with custom properties
  README.md
```

## Quick use

```jsx
import SaveBar, { useDirtyState } from './components/common/SaveBar';

function Settings() {
  const [ form, setForm ] = useState( null );
  const [ loading, setLoading ] = useState( true );
  const [ saving, setSaving ] = useState( false );

  const { changes, baseline, commit } = useDirtyState( form, { ready: ! loading } );

  return (
    <form onSubmit={ handleSubmit }>
      {/* fields */}

      <SaveBar
        changes={ changes }
        saving={ saving }
        onSave={ async () => { await save( form ); commit( form ); } }
        onDiscard={ () => setForm( baseline ) }
      />
    </form>
  );
}
```

Two integration styles:

- **Pass `onSave`** and the bar owns the button. This is the simple case.
- **Omit `onSave`** and the button renders as `type="submit"`, so an existing
  form handler stays in charge. Bump `savedSignal` when that handler succeeds so
  the bar knows to confirm.

## Props

| Prop | Type | Default | Notes |
|---|---|---|---|
| `changes` | `Array` | `[]` | `{ key, label, from, to }` per changed field |
| `saving` | `boolean` | `false` | Shows the spinner, disables both buttons |
| `onSave` | `Function` | - | Omit to render the button as `type="submit"`. Return `false` to suppress the confirmation |
| `onDiscard` | `Function` | - | Omitting it hides the Discard button |
| `savedSignal` | `number` | `0` | Bump on success when the parent form owns the save |
| `labels` | `Object` | English | See below |
| `accent` | `string` | `#4f46e5` | Any CSS colour. Drives the dot, the primary button and the "to" values |
| `align` | `center \| end` | `center` | |
| `className` | `string` | `''` | Extra class on the island |

### Labels

`unsaved`, `saved` and `reverted` are **functions of the count**, not templates,
so the host applies its own plural rules. Languages with more than two plural
forms cannot be served by a ternary on the word.

```jsx
labels={ {
  save: __( 'Save changes', 'my-plugin' ),
  saving: __( 'Saving...', 'my-plugin' ),
  discard: __( 'Discard', 'my-plugin' ),
  unsaved: ( n ) => sprintf( _n( '%d unsaved change', '%d unsaved changes', n, 'my-plugin' ), n ),
  saved:   ( n ) => sprintf( _n( '%d change saved', '%d changes saved', n, 'my-plugin' ), n ),
  reverted:( n ) => sprintf( _n( '%d change reverted', '%d changes reverted', n, 'my-plugin' ), n ),
} }
```

## useDirtyState

```js
const { changes, dirty, baseline, commit, reset } = useDirtyState( form, {
  ready,          // false while loading; the baseline is taken on the first ready render
  resolveLabel,   // ( key ) => string
  formatValue,    // ( value ) => string
  ignore,         // string[] of keys that never count
} );
```

The baseline is taken **after** loading, never on mount: on mount a form still
holds its empty defaults and every field would read as changed.

Values are compared on their **readable form**, not with `!==`. Settings
round-trip through an API, so a switch saved as `true` returns as `1` or `"1"`,
and a strict compare would report every field as edited the moment the page
loaded.

`defaultResolveLabel` reads the label out of the DOM rather than from a lookup
table, because state keys and captions are frequently different words
(`enable_should_register` is captioned "Require registration"). A list that
renames the settings somebody just touched is worse than no list. It checks both
`id` and `name`: form libraries disagree about which carries the key, and
checking one finds a fraction of the fields while looking like it works.

If the host's markup differs, pass your own:

```js
useDirtyState( form, {
  resolveLabel: ( key ) => defaultResolveLabel( key, [ '.my-field-row' ] ),
} );
```

## Theming

One declaration, no build step:

```css
.my-plugin .savebar,
.my-plugin .savebar-list {
  --savebar-accent: #085df2;
  --savebar-accent-hover: #0043bb;
  --savebar-font: -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif;
}
```

Or per instance: `<SaveBar accent="#085df2" />`.

Available: `--savebar-accent`, `--savebar-accent-hover`, `--savebar-radius`, `--savebar-surface`,
`--savebar-ring`, `--savebar-shadow`, `--savebar-text`, `--savebar-muted`, `--savebar-font`.

## Behaviour worth knowing before changing it

Each of these exists because its absence was a visible bug.

- **The confirmation is held 2000ms, then 320ms to leave.** At 1400ms somebody
  who pressed Save and glanced at the setting they changed came back to find it
  already gone, which reads as no confirmation at all.
- **The last non-zero count is retained.** A save empties the list one render
  before the confirmation is set, and "0 changes saved" is never right.
- **`busy` is separate from the confirmation.** Without it the island has
  nothing to show for one render after the save resolves, so it leaves and
  immediately returns. Three transitions for one save reads as blinking.
- **The list is collapsed whenever the bar goes away**, so it never reopens
  against a different set of changes.
- **Discard has its own words and colour.** It used to put the form back and say
  nothing, which is indistinguishable from a control that did not work.
- **Nothing may shrink below its own text.** Flex items are allowed to, and the
  result was a label wrapping onto three lines inside a pill.
- **The `savebar` prefix is longer than it needs to be, on purpose.** Class
  names and `@keyframes` names share one global namespace with every other
  stylesheet on the page, and a WordPress admin screen loads around fifty of
  them. The first draft used `sb`, which nothing on this site happened to
  collide with, but two letters is not a namespace anywhere it gets reused.
  Do not shorten it.

## Accessibility

- The island is `role="status"`, so the confirmation is announced.
- The count button carries `aria-expanded`; the list is a labelled `region`.
- Every icon is `aria-hidden`; the labels carry the meaning.
- Targets reach 44px on coarse pointers.
- Reduced motion removes the pulse, the spinner and the entry animation.

## Not included

No toast, deliberately. You pressed a button here, so your eyes are here; a
toast in the opposite corner asks somebody to look away from the control they
just used to find out whether it worked. Saving used to do both at once and say
the same thing twice for one event.
