CustomizePanel
betav0.1.0Panel that lets a user choose which items of a collection they see, in what order, and which stay in the main area — table columns (pin mode: Pinned / Scrollable) or toolbar actions and tab bar tabs (show mode: Shown / More). It opens like a dropdown next to the icon button that triggers it and holds every change in a draft until Save; Cancel, × and Escape discard it, and a click outside closes it only while nothing has changed. Each row has a drag handle, a checkbox (visible), its label and a pin toggle. Pinning checks an item, unchecking unpins it; at most `maxPinned` (1–5, default 5) can be pinned, and at the limit the other pins are disabled with a tooltip and a full Pinned section refuses drops. Pinned never scrolls; Scrollable shows at least 5 rows and grows with the space available on screen, up to 9.5 rows (RFC §9 Q34). Above 10 items a search field filters both sections by label: checking and pinning still work, dragging pauses until the search is cleared. Reordering works by pointer (handle only) and keyboard, with screen reader announcements. Controlled and storage-agnostic: `items` in, `onSave` out — the product persists the result. A table's row-selection checkbox column is never an item (RULE-10). New component, no MUI equivalent.
Variants
Click a button to open the panel. It opens next to its button; Save applies the changes, Cancel, × and Escape discard them.
Dark / Light: the same component in both modes — only the colour mode changes; structure and tokens are identical. Switch the page theme with the toggle at the top right to see the panel in the other mode. Products switch modes with the .dark / .light class on the root element.
Saved: Name (pinned), Status, Owner, Due date, Marking definitions and confidence level of the source
Saved: Create (pinned), Export (pinned), Share, Duplicate, Archive
Saved: Overview (pinned), Controls (pinned), Risks, Evidence, Audits
Examples
<CustomizePanel
mode="pin"
items={columns}
defaultItems={defaultColumns}
maxPinned={2}
onSave={setColumns}
>
<CustomizePanelTrigger asChild>
<IconButton aria-label="Customize columns" priority="secondary" icon={<Icon name="sliders-horizontal" size={16} />} />
</CustomizePanelTrigger>
<CustomizePanelContent title="Columns" />
</CustomizePanel>// Toolbar: Shown stays in the toolbar, More goes to the overflow menu
<CustomizePanel mode="show" items={actions} defaultItems={defaultActions} onSave={setActions}>
<CustomizePanelTrigger asChild>
<IconButton aria-label="Customize toolbar" priority="secondary" icon={<Icon name="sliders-horizontal" size={16} />} />
</CustomizePanelTrigger>
<CustomizePanelContent title="Toolbar actions" />
</CustomizePanel>// Tab bar: same component, only the words change (RULE-07)
<CustomizePanelContent
title="Tabs"
labels={{
counter: ({ shown, total, maxPinned }) => `${shown} of ${total} tabs shown. Max ${maxPinned} in the tab bar.`,
emptyPinned: "No tabs in the tab bar. Pin a tab to show it in the tab bar.",
emptyUnpinned: "All tabs are in the tab bar. Unpin a tab to move it to More.",
}}
/>// Items: array order is display order, pinned first. The product stores the result.
const defaultColumns: CustomizePanelItem[] = [
{ id: "name", label: "Name", visible: true, pinned: true },
{ id: "status", label: "Status", visible: true, pinned: false },
{ id: "created", label: "Created", visible: false, pinned: false },
];Props
| Prop | Type |
|---|---|
mode | "pin" | "show" — Wording preset only: "pin" for table columns (Pinned / Scrollable), "show" for toolbars and tab bars (Shown / More). Both look identical (required) |
items | CustomizePanelItem[] — Committed state, { id, label, visible, pinned }. Array order is display order, pinned first; a hidden item is never pinned (required) |
defaultItems | CustomizePanelItem[] — What Reset to default restores into the draft (required) |
onSave | (items: CustomizePanelItem[]) => void — Called once, on Save, with the full ordered list; the panel then closes (required) |
maxPinned | number — Maximum pinned items, 1 to 5 (default: 5). A lower limit suits tables (e.g. 2); a value outside 1–5 is clamped with a dev-only warning (RULE-09) |
open | boolean — Controlled open state |
defaultOpen | boolean — Uncontrolled initial open state (default: false) |
onOpenChange | (open: boolean) => void — Open-state change callback; false on Save, Cancel, × and Escape |
CustomizePanelTrigger.asChild | boolean — Use over the product's IconButton (the trigger gets aria-haspopup=dialog and aria-expanded) |
CustomizePanelContent.title | ReactNode — Header title, e.g. Columns, Toolbar actions, Tabs (required) |
CustomizePanelContent.titleIcon | ReactNode — Optional decorative icon after the title |
CustomizePanelContent.hideCloseButton | boolean — Hides the header × (Cancel and Escape still close). Ignored when the panel is narrower than its 420px design, where Cancel is dropped and × is always shown (default: false) |
CustomizePanelContent.labels | Partial<CustomizePanelLabels> — Every rendered or announced string, English defaults per mode: counter, pinnedSection, unpinnedSection, reset, cancel, save, close, pin, dragHandle, limitReached, emptyPinned, emptyUnpinned, dragInstructions, searchPlaceholder, clearSearch, searchHint, noResults, searchResults, announceLifted, announceMoved, announceDropped, announceCancelled |
CustomizePanelContent.align / side / sideOffset / collisionPadding | Radix Popover positioning (defaults: align "end", sideOffset 4, collisionPadding 16) |
Accessibility
Ratios are computed by pnpm check:contrast from theme.css in both modes, never typed here. The panel is a Radix Popover in modal mode: role=dialog named by the title (aria-labelledby) and described by the counter (aria-describedby); the trigger gets aria-haspopup=dialog, aria-expanded and aria-controls. Focus moves in on open, stays inside, and returns to the trigger on close; outside content is hidden from assistive technology. A click outside closes the panel only while the draft is unchanged, so unsaved changes are never lost to a stray click (RFC §9 Q30). Escape equals Cancel, except during a drag, where it cancels the drag only. Each section is a <ul> labelled by its section label; an empty section renders the Section hint as its only item. Every toggle announces the new count from a hidden live region inside the panel, half a second after the control's own state so a screen reader speaks both (VoiceOver in Chrome dropped an announcement made in the same instant); the visible counter is the dialog's description, not a live region. Row controls are native: the drag handle is a <button> named 'Reorder <label>'; the checkbox is the library Checkbox, named by the row label through aria-labelledby (the label stays clickable); the pin toggle is an IconButton named 'Pin <label>' whose state is aria-pressed, never a renamed button (L21). Pinned and unpinned also differ by glyph shape (pin / pin-off) and by section, never by colour alone (WCAG 1.4.1). At the pin limit the other pin toggles are aria-disabled, not disabled: they keep their tab stop and pointer events so the limit Tooltip is reachable by keyboard and hover, and activation is ignored (IconButton's aria-disabled state, RFC §9 Q24). Pressing one, by pointer or with Enter or Space, keeps its Tooltip open and announces the limit message from the panel's live region. Their --icon-disabled glyph is exempt from contrast (WCAG 1.4.3 Note 1). Reordering is never pointer-only (WCAG 2.1.1): Space or Enter on a handle lifts the row, the Up and Down arrows move it across both sections, Space or Enter drops it, Escape cancels. When Pinned is full, Up stops at the top of Scrollable and the limit message is announced and shown. Lift, move, drop and cancel are announced with the label, position and section from a live region inside the panel. With prefers-reduced-motion, rows move without animation. A single-pointer alternative to dragging (WCAG 2.2 SC 2.5.7) is outside the 2.1 AA target (RFC §9 Q25); crossing sections already has one, the pin button. Above 10 items the library SearchField (role=search, named by `labels.searchPlaceholder`) filters both sections; the number of results is announced politely; Escape in a field holding a query clears it, the next Escape closes the panel as Cancel; while a query is active the drag handles are aria-disabled and do not lift, and their dimmed --icon-disabled glyph is exempt from contrast (WCAG 1.4.3 Note 1). Long labels truncate to one line; the accessible name stays the full label and a Tooltip shows it on hover and on checkbox focus, only while it is actually cut off. The separators and the Section hint's dashed border are decorative and carry no pair. The standard focus ring is used on the handle, checkbox, pin toggle, × and footer buttons. Reused components (Checkbox, IconButton, Button, Tooltip) declare their own pairs.