A toolbar floating at the top or bottom of its parent while a form has unsaved changes, with a live message and Reset and Save buttons that are disabled while saving.
Installation#
pnpm dlx shadcn@latest add @zui/unsaved-changes-toolbarThe CLI writes components/feedback/unsaved-changes-toolbar.tsx and installs Button,
Spinner, Text and Utils.
Usage#
import {
UnsavedChangesToolbar,
UnsavedChangesToolbarActions,
UnsavedChangesToolbarMessage,
UnsavedChangesToolbarReset,
UnsavedChangesToolbarSave,
} from '@/components/feedback/unsaved-changes-toolbar';{
isDirty && (
<UnsavedChangesToolbar state={isSaving ? 'saving' : 'default'}>
<UnsavedChangesToolbarMessage>
{isSaving ? 'Saving your changes...' : 'You have unsaved changes.'}
</UnsavedChangesToolbarMessage>
<UnsavedChangesToolbarActions>
<UnsavedChangesToolbarReset onPress={reset}>Reset</UnsavedChangesToolbarReset>
<UnsavedChangesToolbarSave onPress={save}>{isSaving ? 'Saving...' : 'Save changes'}</UnsavedChangesToolbarSave>
</UnsavedChangesToolbarActions>
</UnsavedChangesToolbar>
);
}The toolbar is absolutely positioned, so render it inside a parent that fills the screen. Render it only while the form
differs from its saved values, and write every word: the message and the labels change with the state. Use error
after a save fails, with Try again on Save, and attention when the person tries to leave with changes unsaved.
Composition#
UnsavedChangesToolbar
|-- UnsavedChangesToolbarMessage
`-- UnsavedChangesToolbarActions
|-- UnsavedChangesToolbarReset
`-- UnsavedChangesToolbarSaveAPI reference#
UnsavedChangesToolbar#
Takes every prop of View, plus:
| Prop | Type | Default |
|---|---|---|
side | 'top' | 'bottom' | 'bottom' |
state | 'default' | 'saving' | 'error' | 'attention' | 'default' |
It is a group for a screen reader, named by its message, through aria-labelledby on Android and
through accessibilityLabel set to the message's text on iOS, which does not read aria-labelledby; pass aria-label
or accessibilityLabel to name it yourself. It sits 16px from the screen's side edge and from both sides, and on a
screen wider than 512px (max-w-lg) it is centred at that width. A testID goes to the group, and
<testID>-wrapper to the full-width box that places it. className styles the group, and the wrapper places it. error and attention turn the message destructive, and
attention turns the border destructive too.
UnsavedChangesToolbarMessage#
Takes every prop of Text except children, plus children: string.
It names the group, and is a text with accessibilityLiveRegion="polite", which Android reads out when it changes.
The first message is passed to AccessibilityInfo.announceForAccessibility when the toolbar appears, on both
platforms. On iOS a later change is passed to it as well, since iOS does not read the live region, so each platform
speaks a change once.
UnsavedChangesToolbarActions#
Takes every prop of View. Its children are the Reset and Save buttons.
UnsavedChangesToolbarReset#
Takes every prop of Button except variant, size and children, plus children: string.
It is a small ghost button, disabled while the toolbar's state is saving.
UnsavedChangesToolbarSave#
Takes every prop of Button except variant, size and children, plus children: string.
It is a small button. While the toolbar's state is saving it is disabled and draws a
Spinner before its label, which you change to say so, such as Saving.... A disabled
Pressable is still an element a screen reader reaches, announced as disabled, so a screen reader's focus stays on
Save. The spinner is hidden from a screen reader, which hears the message.