Admin Tabbed Editor
Full-page editor with horizontal tabs, tab-scoped content panels, and a sticky save/cancel footer. Unsaved-changes guard fires on tab switch and cancel; save supports async with pending state.
Preview
Account settings
Update your account preferences. Switching tabs with unsaved edits opens the guard.
Installation
npx visor add --block admin-tabbed-editorThis copies files into your project:
blocks/admin-tabbed-editor/admin-tabbed-editor.tsx— the block componentblocks/admin-tabbed-editor/admin-tabbed-editor.module.css— the styles
The registry pulls in page-header, tabs, button, and confirm-dialog as dependencies.
Usage
'use client';
import * as React from 'react';
import { AdminTabbedEditor } from '@/blocks/admin-tabbed-editor/admin-tabbed-editor';
import { Input } from '@/components/ui/input/input';
import { Label } from '@/components/ui/label/label';
interface Profile {
displayName: string;
email: string;
bio: string;
}
export function ProfileEditor({ profile }: { profile: Profile }) {
const [draft, setDraft] = React.useState(profile);
const dirty =
draft.displayName !== profile.displayName ||
draft.email !== profile.email ||
draft.bio !== profile.bio;
async function handleSave() {
const res = await fetch('/api/profile', {
method: 'PATCH',
body: JSON.stringify(draft),
});
if (!res.ok) throw new Error('Failed to save');
}
return (
<AdminTabbedEditor
title="Profile"
description="Manage your public profile."
dirty={dirty}
onSave={handleSave}
onCancel={() => setDraft(profile)}
tabs={[
{
id: 'general',
label: 'General',
content: (
<>
<Label htmlFor="displayName">Display name</Label>
<Input
id="displayName"
value={draft.displayName}
onChange={(e) =>
setDraft({ ...draft, displayName: e.target.value })
}
/>
</>
),
},
{
id: 'contact',
label: 'Contact',
content: (
<>
<Label htmlFor="email">Email</Label>
<Input
id="email"
type="email"
value={draft.email}
onChange={(e) =>
setDraft({ ...draft, email: e.target.value })
}
/>
</>
),
},
]}
/>
);
}Anatomy
The block is a three-zone column:
- PageHeader — title, eyebrow, description, breadcrumb, and header actions slot. Does not scroll.
- Tabs — a horizontal
TabsListfollowed by oneTabsContentper tab. The active panel scrolls with the page. - Footer — sticky row with
Cancelon the left, optionalfooterStatusin the middle, andSaveon the right. Always visible inside the scroll container.
The three guarantees
1. Sticky save/cancel footer
The footer uses position: sticky; bottom: 0 so it stays pinned inside the block's scroll container — it never escapes into viewport space, which keeps docs previews and embedded usages predictable. The footer stays visible regardless of which tab is active.
2. Unsaved-changes guard
When dirty is true, switching tabs opens a ConfirmDialog. On discard, the pending tab id is committed; on keep-editing, the current tab stays active. The Cancel button runs through the same guard before calling onCancel. Customize the copy with unsavedGuardTitle, unsavedGuardDescription, unsavedGuardConfirmLabel, and unsavedGuardCancelLabel.
Dirty state is owned by the consumer — the block never clears it. After a successful save, your handler should set dirty to false by resyncing the draft with the committed record.
3. Async save handler
onSave can return a Promise. While the promise is pending the save button sets aria-busy and disables itself. On resolve the pending state clears; on reject the pending state clears and the rejection is re-thrown so your error boundary or global handler can react. The save button is disabled whenever dirty is false.
Controlled vs uncontrolled tabs
- Uncontrolled — pass
defaultActiveTab(or omit it to default to the first tab). The block tracks the active tab internally. - Controlled — pass
activeTaband handleonActiveTabChange. Useful when the tab should be driven by the URL or persisted across reloads.
Accessibility
- The footer is a
role="group"witharia-label="Editor actions"so assistive tech groups the buttons. - The save button reports
aria-busyanddisabledwhile the async save is in flight. - The unsaved-changes guard is a nested dialog — Radix handles focus trap stacking through the ConfirmDialog primitive.
- Radix Tabs handles keyboard navigation for the tab list (arrow keys, Home, End).
Props
See AdminTabbedEditorProps in blocks/admin-tabbed-editor/admin-tabbed-editor.tsx for the full list. In summary:
- Header —
title(required),eyebrow,description,breadcrumb,headerActions. - Tabs —
tabs(required),activeTab,defaultActiveTab,onActiveTabChange. - Actions —
onSave,onCancel,saveLabel,cancelLabel. - State —
dirty,busy,disabled,footerStatus,hideFooter. - Unsaved guard —
unsavedGuardTitle,unsavedGuardDescription,unsavedGuardConfirmLabel,unsavedGuardCancelLabel.
Each tab is a { id, label, content, icon?, badge?, disabled? } object.
About Blocks
Blocks are complete UI patterns made up of multiple Visor components. Unlike individual components, blocks represent larger, composed sections of UI — such as admin shells, login forms, or dashboard panels.
Blocks are copy-and-own, just like components. Install them into your project and customize freely.
Admin Shell — Editorial Density Showcase
Hardening showcase composing AdminShell in the admin-v7-r3 editorial-density pattern — WorkspaceSwitcher in the logo slot, ChromeButton cluster in topbarEnd, eyebrow-grouped nav, sidebar footer with Avatar + Kbd.
Tier-limits editor (admin-tabbed-editor showcase)
Showcase composing admin-tabbed-editor as a tier-limits editor — Free / Pro / Enterprise tabs, Field-built form rows per tier, single savebar. Verifies editorial density and unsaved-changes guard against a real admin use case.