Settings Layout
block / layout
Settings shell with section navigation and a save bar that owns its own dirty state, awaits the save, and puts server field errors back on the fields.
- type
- block
- files
- 2
- registry deps
- badge, button, input, label, select, skeleton, switch, textarea, @automindz/confirm-dialog, @automindz/empty-state
- npm deps
- lucide-react
installs
- lib/automindz/settings-sections.ts
- components/automindz/settings-layout.tsx
- Sections are data; `fields` is what the save bar diffs
- Dirty state is computed, not passed — there is no `dirty` prop to get wrong
- Awaited save: pending, a transient confirmation, and a failure that keeps your values
- Server `fieldErrors` land on the fields, first one focused, unmapped ones still shown
- Per-section save by default; `saveScope="all"` is one prop, not a fork
- beforeunload covers tab close; router navigation is an exposed seam, not a pretence
sections is the config: id, label, description, icon, badge, and the fields the bar diffs. Pass initialValues; the layout owns the working copy, so there is no dirty prop to get wrong. onSave is awaited — return { fieldErrors } and they land on the fields, values intact. beforeunload covers tab close, but App Router nav cannot be intercepted here: hold onDirtyChange and call ref.requestLeave() in your guard. Never rename the lib — shadcn add rewrites imports by basename.
Variations
one state per section
- 01Dirty state, from the values
- 02A save that fails
- 03Server field errors
- 04Discard, and leaving a dirty section
- 05One bar for the whole form
- 06Read-only, with the reason
- 07Loading
- 08A section with nothing in it yet
- 09The navigation guard, honestly
Dirty state, from the values
Change the workspace name: the bar appears and counts what changed, not that something changed. Change it back and the bar goes. There is no dirty prop to pass — the layout holds the working copy and diffs it against initialValues over the declared fields, so a leftover key or a field dropped from the config cannot pin the bar open with nothing on screen to explain it. Save settles in three beats: pending, a transient Saved, then gone. The baseline moves on success, so a caller that never hands back fresh values still ends up with a clean form.
A save that fails
The same form with a handler that rejects. Everything you typed is still there — a save that clears the form on error is the failure this item exists to prevent. The reason lands on the bar, Save becomes Try again, and the fieldset unlocks so the value can be fixed and sent again. Nothing is logged to a console and nothing is delegated to a toast that outlives the form.
Server field errors
The part hand-rolled versions skip. onSave returns { fieldErrors } keyed by field id: each message lands under its own field with aria-invalid and aria-describedby, the summary above lists them in declaration order, every entry moves focus to its field, and the first one is focused for you. This variant runs saveScope="all", so change something in General and something in Billing, then save from Billing — the layout switches back to General, because an error you cannot see is an error you cannot fix. vat_number is rejected too and no field is called that: it is listed as unmapped rather than dropped.
Discard, and leaving a dirty section
Discard asks first, through @automindz/confirm-dialog rather than a second dialog of our own, and names the fields it is about to throw away. The section rail is the other half: on the default per-section scope, walking away from unsaved changes asks the same question — that is in-app navigation this component can actually intercept, so it does.
One bar for the whole form
saveScope="all". The bar follows you between sections and counts everything unsaved, and the rail stops asking, because nothing is being left behind. Per-section is the default: a count you cannot see on screen is a count you cannot check before pressing Save. This is the same component with one prop changed, not a fork.
Read-only, with the reason
A viewer without permission to edit. Every control is really disabled and the reason is stated once at the top, wired to the fieldset with aria-describedby so it is announced on entry rather than buried in a title on each field. The save bar is absent, because there is nothing it could do. Compare the two fields that stay blocked even for an editor: Data region and Purchase order number carry their own disabledReason in the other variants.
Loading
Skeletons at the height of the real rows — a label line and a 32px control per field, as many rows as the section has — so the fields land without the page moving under them. The rail keeps rendering: the section list is config and is known before the values are.
A section with nothing in it yet
Audit log has no fields and no content. That is a state, not an oversight, so it says which plan unlocks it instead of rendering an empty column with a save bar under it. Driven here through activeSection + onActiveSectionChange, which is how a section gets deep-linked from the URL.
The navigation guard, honestly
beforeunload covers tab close and reload by itself. In-app App Router navigation cannot be intercepted from inside a component, so the seam is exposed instead of faked: onDirtyChange hands you the flag — the strip above is holding it — and ref.requestLeave() opens the same confirmation the rail uses and resolves once the user gives the changes up. Edit a field, then press Back to overview.
Your guard sees nothing unsaved
