Skip to main content

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>
PropertyValue
Shaperounded-md border bg-card
Button rowflex flex-wrap items-center gap-2 p-2
Rolerole="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>
PropertyValue
Sizeh-8 w-8 (32px — toolbar buttons are smaller than header buttons)
Variantvariant="ghost"
Icon sizeh-4 w-4
TooltipRequired on every button
Disabled tooltipShows 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

PositionIconTooltipCondition
1RefreshCwReset & RefreshonRefresh provided
2Column selectorColumnsAlways
3Rows3Row densityAlways (dropdown)

Section 2 — Search & Filters

PositionElementBehavior
4Search inputh-8 w-48 lg:w-64 with Search icon left, × clear right
5FilterAdvanced Filters — shows badge with active count
6FilterXClear Filters — disabled when no filters active
7ChevronsUpDown / ArrowDownUpClear Sort — disabled when no sort active; shows badge with sort column count

Section 3 — Import / Export

PositionIconTooltipPermission
8UploadImport datacanImport
9DownloadExport data (dropdown)canExport

Section 4 — CRUD Actions

PositionIconTooltipPermissionSelection Requirement
10EyeView recordcanView1+ rows selected
11PlusAdd new recordcanCreateNone
12CopyCopy to clipboardcanCopyExactly 1 row
13FilesDuplicate record(s)canDuplicate1+ rows selected
14SquarePenBulk edit N recordscanBulkEdit2+ rows selected
15PencilEdit recordcanEdit1+ rows selected
16Trash2DeletecanDelete1+ rows selected

Section 5 — Optional Actions (show only when enabled)

PositionIconTooltipPermissionCondition
17PaperclipUpload attachmentcanManageAttachmentsshowAttachmentActions={true}
18FileDownDownload attachmentcanManageAttachmentsshowAttachmentActions={true}
19MailSend emailcanSendEmailshowEmailAction={true}
20HelpCircleHelponHelp 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]
ElementSpec
First/Last pageChevronsLeft / ChevronsRightborder border-input shadow-sm
Prev/Next pageChevronLeft / ChevronRightborder border-input shadow-sm
Page inputh-8 w-12 px-1 text-center number input, press Enter to jump
Page summarywhitespace-nowrap text-sm — "Page X of Y"
Rows per pageSelect 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>
)}
ElementSpec
Containerborder-t bg-muted/50 p-2
Labeltext-sm text-muted-foreground — "Active:"
Filter badgesBadge variant="secondary" with × remove button
Clear allButton 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>
PropertyValue
Heighth-8
Widthw-48 (mobile) → lg:w-64 (desktop)
Left paddingpl-8 (makes room for search icon)
Search iconSearch h-4 w-4absolute left-2.5 top-1/2 -translate-y-1/2
Clear buttonX 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

ButtonEnabled When
View1+ rows selected
CopyExactly 1 row selected
Duplicate1+ rows selected
Bulk Edit2+ rows selected
Edit1+ rows selected
Delete1+ rows selected
Upload Attachment1+ rows selected
Download AttachmentExactly 1 row selected
Send Email1+ 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 Tooltip wrappers with descriptive tooltip text
  • Disabled buttons show a disabledTooltip explaining why
  • Search input is h-8 w-48 lg:w-64 with 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-center number input