UX Standards — 08: UDT Toolbar
Governs: The action toolbar that sits above every Universal Data Table.
Parent rules: See 00-OVERVIEW-AND-CSS-RULES.md and 07-UNIVERSAL-DATA-TABLE.md first.
Visual Anatomy
┌──────── ──────────────────────────────────────────────────────────────────────────┐
│ [↺][⊞][≡] [🔍 Search... ×] [▽][✕][↕] [⬆][⬇] [👁][+][📋][📂][✏][✎][🗑] ◀◀◀▶▶▶[1][20▼]│
│ (collapsed: just icons) │
├──────────────────────────────────────────────────────────────────────────────────┤
│ Active: status: active × created: 2025 × [Clear All] ← filter badges bar │
└──────────────────────────────────────────────────────────────────────────────────┘
Toolbar Container
<div
role="toolbar"
aria-orientation="horizontal"
className="flex w-full flex-col rounded-md border bg-card"
>
<div className="flex flex-wrap items-center gap-2 p-2">
{/* All toolbar buttons */}
</div>
{/* Active filter badges row (renders only when filters are active) */}
</div>
| Property | Value |
|---|---|
| Shape | rounded-md border bg-card |
| Button row | flex flex-wrap items-center gap-2 p-2 |
| Role | role="toolbar" aria-orientation="horizontal" |
Icon Button Standard
Every toolbar button uses this wrapper:
// DataTableIconButton component
<Tooltip>
<TooltipTrigger asChild>
<Button
variant="ghost"
size="icon"
className="h-8 w-8" // Toolbar buttons: 32px (smaller than header 36px)
disabled={disabled}
onClick={onClick}
>
{children}
</Button>
</TooltipTrigger>
<TooltipContent>{tooltip}</TooltipContent>
</Tooltip>
| Property | Value |
|---|---|
| Size | h-8 w-8 (32px — toolbar buttons are smaller than header buttons) |
| Variant | variant="ghost" |
| Icon size | h-4 w-4 |
| Tooltip | Required on every button |
| Disabled tooltip | Shows different message when disabled (e.g., "Select row(s) first") |
Button Order (Left to Right)
Buttons appear in this fixed order. Sections are separated by <Separator orientation="vertical" className="mx-1 h-6" />.
Section 1 — Table Management
| Position | Icon | Tooltip | Condition |
|---|---|---|---|
| 1 | RefreshCw | Reset & Refresh | onRefresh provided |
| 2 | Column selector | Columns | Always |
| 3 | Rows3 | Row density | Always (dropdown) |
Section 2 — Search & Filters
| Position | Element | Behavior |
|---|---|---|
| 4 | Search input | h-8 w-48 lg:w-64 with Search icon left, × clear right |
| 5 | Filter | Advanced Filters — shows badge with active count |
| 6 | FilterX | Clear Filters — disabled when no filters active |
| 7 | ChevronsUpDown / ArrowDownUp | Clear Sort — disabled when no sort active; shows badge with sort column count |
Section 3 — Import / Export
| Position | Icon | Tooltip | Permission |
|---|---|---|---|
| 8 | Upload | Import data | canImport |
| 9 | Download | Export data (dropdown) | canExport |
Section 4 — CRUD Actions
| Position | Icon | Tooltip | Permission | Selection Requirement |
|---|---|---|---|---|
| 10 | Eye | View record | canView | 1+ rows selected |
| 11 | Plus | Add new record | canCreate | None |
| 12 | Copy | Copy to clipboard | canCopy | Exactly 1 row |
| 13 | Files | Duplicate record(s) | canDuplicate | 1+ rows selected |
| 14 | SquarePen | Bulk edit N records | canBulkEdit | 2+ rows selected |
| 15 | Pencil | Edit record | canEdit | 1+ rows selected |
| 16 | Trash2 | Delete | canDelete | 1+ rows selected |
Section 5 — Optional Actions (show only when enabled)
| Position | Icon | Tooltip | Permission | Condition |
|---|---|---|---|---|
| 17 | Paperclip | Upload attachment | canManageAttachments | showAttachmentActions={true} |
| 18 | FileDown | Download attachment | canManageAttachments | showAttachmentActions={true} |
| 19 | Mail | Send email | canSendEmail | showEmailAction={true} |
| 20 | HelpCircle | Help | — | onHelp provided |
Right Side — Pagination Controls
Always right-aligned via <div className="flex-1" /> spacer:
// Pagination (right side of toolbar)
◀◀ ◀ [page input] ▶ ▶▶ | Page X of Y | Rows [select]
| Element | Spec |
|---|---|
| First/Last page | ChevronsLeft / ChevronsRight — border border-input shadow-sm |
| Prev/Next page | ChevronLeft / ChevronRight — border border-input shadow-sm |
| Page input | h-8 w-12 px-1 text-center number input, press Enter to jump |
| Page summary | whitespace-nowrap text-sm — "Page X of Y" |
| Rows per page | Select with h-8 w-16 trigger, default options: 10, 20, 30, 40, 50 |
Export Dialog
When the Export button is clicked, a dialog appears to configure export options:
<Dialog open={exportDialogOpen}>
<DialogContent className="sm:max-w-md">
<DialogHeader>
<DialogTitle>Export Data</DialogTitle>
<DialogDescription>Choose which records and columns to include.</DialogDescription>
</DialogHeader>
{/* Records: All records (N) | Selected records only (N) */}
<RadioGroup ...>
{/* Columns: Visible columns only | All columns */}
<RadioGroup ...>
<DialogFooter>
<Button variant="outline">Cancel</Button>
<Button>Export</Button>
</DialogFooter>
</DialogContent>
</Dialog>
Export formats: CSV, TSV, Excel, JSON, XML, PDF (shown in dropdown menu from the Download icon).
Active Filter Badges Row
When column filters are active, a second row appears below the button row:
{isFiltered && (
<div className="flex flex-wrap items-center gap-2 border-t bg-muted/50 p-2">
<span className="text-sm text-muted-foreground">Active:</span>
{activeFilters.map((filter) => (
<Badge key={filter.id} variant="secondary" className="gap-1">
{filter.id}: {formatFilterValue(filter.value)}
<button
className="ml-1 hover:text-destructive"
onClick={() => table.getColumn(filter.id)?.setFilterValue(undefined)}
>
<X className="h-3 w-3" />
</button>
</Badge>
))}
<Button variant="ghost" size="sm" className="h-6 text-xs" onClick={onResetFilters}>
Clear All
</Button>
</div>
)}
| Element | Spec |
|---|---|
| Container | border-t bg-muted/50 p-2 |
| Label | text-sm text-muted-foreground — "Active:" |
| Filter badges | Badge variant="secondary" with × remove button |
| Clear all | Button variant="ghost" size="sm" h-6 text-xs |
Search Input
<div className="relative">
<Search className="absolute left-2.5 top-1/2 h-4 w-4 -translate-y-1/2 text-muted-foreground" />
<Input
value={searchValue}
onChange={(e) => setSearchValue(e.target.value)}
placeholder="Search..."
className="h-8 w-48 pl-8 lg:w-64"
/>
{searchValue && (
<button
type="button"
onClick={() => setSearchValue('')}
className="absolute right-2 top-1/2 -translate-y-1/2 text-muted-foreground hover:text-foreground"
>
<X className="h-3 w-3" />
</button>
)}
</div>
| Property | Value |
|---|---|
| Height | h-8 |
| Width | w-48 (mobile) → lg:w-64 (desktop) |
| Left padding | pl-8 (makes room for search icon) |
| Search icon | Search h-4 w-4 — absolute left-2.5 top-1/2 -translate-y-1/2 |
| Clear button | X h-3 w-3 — appears only when searchValue is non-empty |
Permission System
All action buttons check the permissions prop:
// Default (all enabled):
permissions = {
canCreate: true,
canView: true,
canEdit: true,
canDelete: true,
canExport: true,
canImport: true,
canBulkEdit: true,
canCopy: true,
canDuplicate: true,
canManageAttachments: true,
canSendEmail: true,
}
// Per-page override:
<DataTableToolbar
table={table}
permissions={{
canCreate: userCan('create', 'User'),
canEdit: userCan('edit', 'User'),
canDelete: userCan('delete', 'User'),
canExport: true,
canImport: false,
canBulkEdit: false,
canCopy: false,
canDuplicate: false,
}}
/>
Buttons are not rendered at all when the corresponding permission is false — they don't appear as disabled. Only selection-dependent buttons use the disabled state.
Selection-Dependent States
| Button | Enabled When |
|---|---|
| View | 1+ rows selected |
| Copy | Exactly 1 row selected |
| Duplicate | 1+ rows selected |
| Bulk Edit | 2+ rows selected |
| Edit | 1+ rows selected |
| Delete | 1+ rows selected |
| Upload Attachment | 1+ rows selected |
| Download Attachment | Exactly 1 row selected |
| Send Email | 1+ rows selected |
When disabled, tooltip shows the reason: "Select row(s) first" or "Select only one row" or "Select 2+ rows".
Toolbar Collapse
The toolbar can be collapsed to hide all controls (useful on mobile or for maximum table space):
// Collapse state managed externally
<DataTableToolbar
isCollapsed={isCollapsed}
onCollapsedChange={setIsCollapsed}
...
/>
When isCollapsed={true}, the toolbar returns null (completely hidden). A toggle button outside the toolbar controls visibility.
Adding Custom Buttons
Use the children prop to inject module-specific buttons before the flex spacer:
<DataTableToolbar table={table} onRefresh={refetch} onAdd={handleAdd}>
{/* Custom button inserted after standard buttons */}
<DataTableIconButton tooltip="Process selected faxes" onClick={handleProcess} disabled={!hasSelection}>
<Zap className="h-4 w-4" />
</DataTableIconButton>
</DataTableToolbar>
Violation Checklist
- All toolbar buttons use
h-8 w-8(not h-9 or h-10) - All buttons have
Tooltipwrappers with descriptive tooltip text - Disabled buttons show a
disabledTooltipexplaining why - Search input is
h-8 w-48 lg:w-64with search icon and clear button - Buttons appear in the specified left-to-right order
- Active filter badges appear in a second row with
border-t bg-muted/50 - Permission-disabled actions are NOT rendered (not just disabled)
- Selection-disabled actions are rendered but disabled with tooltip
- Export opens a dialog (not directly downloads)
- Pagination controls are right-aligned via flex-1 spacer
- Page input is
h-8 w-12 text-centernumber input