Composites
Composites
Tier 2 is the part of the package that is actually about Comitor: 21 patterns every app in the ecosystem repeats — a list screen, a status chip, a confirm dialog, a date range — assembled out of the Tier 1 primitives and shipped from the @comitor/ui entry, the same one the primitives come from.
Nothing extra to install and nothing extra to import: import { Button, DataTable } from '@comitor/ui' is one line reaching across both tiers.
What belongs in this tier
A primitive renders a control. A composite has already made a decision — and the value is in the decision, not the markup.
How far a page header is inset, what an empty table says, which contrast step status text runs at, whether a rising number is good news. Rebuilding a composite by hand means re-deciding all of it, differently, in every app.
Not one composite imports next/*. Where one needs a router link it takes a LinkComponent prop instead. That is what keeps the main entry working under Vite, Storybook and plain tests — the framework lives in Tier 3.
Every tone in this tier is a role token, never a hex or a raw scale, so it follows the palette in use. Text and standalone icons take the -ink step; the plain step only ever fills a background.
A list screen, assembled
Live, and made of nothing but Tier 2: stat tiles, a debounced search bar with chip filters, rows carrying an avatar and a status pill, and the empty state that appears when the filters exclude everything. Type duc or ha to see matchesSearch find a name spelled with diacritics. The page header is the one piece left out — this documentation page already owns its h1, and PageHeader renders another.
Open
2
Blocked
1
+1vs last week
Done
1
- Nguyễn Đức ThànhOpen
Draft the Q3 handover note
Nguyễn Đức Thành · updated
- Trần Minh AnhBlocked
Reconcile August invoices
Trần Minh Anh · updated
- Lê Thu HàDone
Ship the payroll export
Lê Thu Hà · updated
- Phạm Quốc BảoOpen
Review the vendor contract
Phạm Quốc Bảo · updated
Page frame
The outline of a screen. Both are static, so a server component can render them, and both take a LinkComponent prop rather than importing next/link — that is what keeps the main entry usable outside Next.
Breadcrumb, title, description and an actions slot; owns the page h1. PageContainer is the matching content frame in four widths.
PageHeader · PageContainer
A tab strip whose items are real routes: a <nav> of links carrying aria-current, not a Radix tablist. Looks identical to Tabs because it reads the same class table.
RouteTabs
Data
The list screen, broken into parts you can use separately. Sorting, paging and filtering are all controlled — the composite renders the state and tells you it changed; fetching stays yours.
Sorting, row selection, pinned columns, skeleton loading and a built-in empty state. Every string, aria-label included, is overridable.
DataTable · DataTableColumnToggle
The bar under a table. It derives the page count from total and pageSize itself, so the app and the control can never disagree by one page.
TablePagination · DEFAULT_PAGE_SIZE_OPTIONS
Debounced search box, a slot for filter controls, an active-filter badge and a result count. FilterChips is the toggle row that usually sits in that slot.
SearchFilterBar · FilterChips
A KPI tile with an optional delta. The delta is coloured by direction times polarity, because a rising count of overdue tickets is not good news.
StatCard · statCardIconVariants
A status chip in three variants, coloured from your own status table. An unknown value is shown rather than swallowed, so stale enums stay visible.
StatusPill
Forms
Layout and the two inputs that were too big to be primitives. These compose with the react-hook-form binding at @comitor/ui/form, but do not require it.
A 12-column grid with grouped, optionally collapsible sections. FormField hands your control its id and aria wiring so you never re-type them.
FormSection · FormField
One row for an avatar or a logo, a tall drop zone for documents, and the headless hook underneath when you need a third shape. The one composite behind its own entry — @comitor/ui/uploader, with @uppy/core and @uppy/xhr-upload as optional peers; your own route receives an ordinary multipart POST.
ImageUploadField · FileUpload · useFileUpload
Searchable select, single or multiple, matching Vietnamese text with or without diacritics. Async mode hands the query back so you can fetch.
Combobox · MultiCombobox · SELECT_EMPTY_VALUE
Calendar plus typed input plus presets. A date-fns locale drives both the calendar and the field order, so en-US types MM/dd/yyyy without extra config.
DatePicker · DateRangePicker · dateFormatForLocale
Feedback
What the app says back. Each one is a decision already made about how confirmation, absence and asynchronous news look across the ecosystem.
Icon, title, description and an action. The shape every "nothing here yet" takes, so Tasks, CRM and HR agree on what absence looks like.
EmptyState · IconComponent
An alert dialog around one promise. Return a promise from onConfirm and it stays open and busy until settled — closing first is how you get double deletes.
ConfirmDialog
The Cmd/Ctrl+K surface: grouped commands, a recents group, per-item shortcut hints, and an async mode that turns off local filtering.
CommandPalette
Stacked notifications with Comitor status icons. Documented with the primitives — SonnerToaster in the shell entry is the next-themes-aware variant.
Toaster · toast · toastPromise · dismissToast
Utilities
Small components that show up on nearly every screen. Small does not mean thin: each one carries a fallback, a locale or a hydration rule you would otherwise rediscover.
Initials taken the Vietnamese way (first letter of the family name plus first letter of the given name) over a tone derived from the name itself.
LetterAvatar · AvatarGroup · getAvatarToneClasses
An app or workspace icon in a coloured identity tile — the sibling of LetterAvatar. The tone is derived from the key, so a new product has a colour with no configuration, and tone overrides it by hand.
IconAvatar · AVATAR_TONE_NAMES
Copy to clipboard with a confirmed state, and a textarea fallback for the contexts where navigator.clipboard is simply absent.
CopyButton
A self-updating <time> element. Pass a locale for anything but Vietnamese, and a now to freeze the reading for fixtures and snapshots.
RelativeTime
Click text, get an input, Enter to save, Escape to cancel. An async onSave keeps the field in place until it resolves instead of flashing the old value.
InlineEdit
Renders "mod+k" as ⌘K or Ctrl+K. useIsMac reports false on the first render on purpose — the server cannot know, and guessing costs you the hydration.
KeyboardHint · useIsMac
Pure modules
Not components: plain constants and functions with no React import at all, so they work in a route handler, a test or a server component as readily as in the browser.
The generic status contract. Your app declares its own states; the package supplies the type and a tone table of token classes. Documented with StatusPill.
STATUS_TONES · getStatusConfig · getStatusClasses · getStatusLabel · sortByStatusPriority
One normalisation rule for the whole package: strip Vietnamese tones, lowercase, trim. Every search box in the ecosystem matches the same way.
removeVietnameseTones · normalizeVi · matchesSearch · getVietnameseInitials · truncate
Strings are Vietnamese, and every one of them moves
The package defaults to Vietnamese, down to the strings only a screen reader ever hears — the checkbox aria-label on a table row, the four sr-only labels on the pagination arrows, the name of the button that opens a calendar. Those are exactly the strings no ordinary prop reaches, which is the whole reason the labels bag exists: without it an English app gets a table that is half translated, and the untranslated half is invisible in a screenshot.
import { TablePagination, type TablePaginationLabels } from '@comitor/ui'
// 1. Partial<> — the component always spreads { ...DEFAULT_*, ...labels },
// so overriding one key never drops the rest.
// 2. Any key with a number or a unit inside it is a FUNCTION, not a string
// with placeholders: word order is not the same in every language, and
// concatenating with + on the app side is how you guarantee it is wrong.
const EN_PAGINATION: Partial<TablePaginationLabels> = {
region: 'Pagination',
unitLabel: 'items',
pageSizePrefix: 'Show',
pageSizeSelect: (unit) => `${unit} per page`,
range: (start, end, total, unit) => `${start}–${end} of ${total} ${unit}`,
pageStatus: (page, count) => `Page ${page} of ${count}`,
firstPage: 'First page',
previousPage: 'Previous page',
nextPage: 'Next page',
lastPage: 'Last page',
}
// 3. A standalone prop beats labels. `unitLabel` in `labels` is the LANGUAGE
// ("items"); the `unitLabel` prop is the CONTENT of this one table
// ("contacts"), so it wins.
<TablePagination {...paging} labels={EN_PAGINATION} unitLabel="contacts" />- It is a
Partial<>. The component spreads{ ...DEFAULT_X_LABELS, ...labels }, so you can override a single key and keep the rest. Import theDEFAULT_*constant when you want to read or extend a default — it is a plain object in a module with no'use client', readable from server code. - Keys that interpolate are functions.
range(start, end, total, unitLabel),pageStatus(page, pageCount),filterCount(count),pageSizeSelect(unitLabel)— never a template with placeholders in it, because word order is not a constant across languages.resultSummarygoes further and returns aReactNode: the number needs its own element to holdtabular-numsso it stops jittering as the count changes. - A standalone prop beats
labels.unitLabel,labelandsearchPlaceholderstay separate props and win. They vary by content — “contacts”, “invoices” — whilelabelsvaries by language. One bag per locale, one prop per table. - Not every composite has a bag.
CopyButton,ConfirmDialog,InlineEdit,CommandPaletteandAvatarGrouptake plain string props that happen to default to Vietnamese —label="Sao chép",confirmLabel="Xác nhận"— so pass English ones.FilterChipsis the same story with a twist: the chip text comes from the items you pass, so most of it is already yours, but the two strings the items cannot carry still default to Vietnamese —label, the screen-reader name of the group (“Bộ lọc nhanh”), andallLabel, the reset chip thatshowAlladds (“Tất cả”). The first of those is invisible in a screenshot, which is exactly how it survives a translation pass. localeis a different axis fromlabels. A date-fns locale carries date formatting, never interface strings. SwitchingDatePickerto English means passing both:localefor the calendar and the field order,labelsfor the threesr-onlystrings it cannot reach.RelativeTimetakes the samelocale, and leaving it out is how an English screen ends up saying “3 phút trước”.
Icons are components, not elements
Every icon prop in this tier — EmptyState, PageHeader, StatCard, FormSection, ConfirmDialog, Combobox, FilterChips, CommandPalette — is typed IconComponent and takes the component itself. A Lucide icon is a forwardRef object rather than a function, so the typeof check a component would reach for cannot tell it apart from an element that has already been rendered — both answer 'object'. Guessing would fail silently, so the type refuses the guess. When you need arbitrary content there is a separate slot for it — media on EmptyState, sparkline on StatCard.
import { Inbox } from 'lucide-react'
import { EmptyState } from '@comitor/ui'
// ✓ the component itself — the composite renders it and sets aria-hidden
<EmptyState icon={Inbox} title="No invoices yet" />
// ✗ an already-rendered element — a type error, on purpose
<EmptyState icon={<Inbox />} title="No invoices yet" />Server or client
Eight composites carry no 'use client' of their own and render straight from a server component. That is a deliberate cost decision, not an accident: a two-hundred-row list rendered with the Radix Avatar would be two hundred client islands, so LetterAvatar is built from scratch instead.
PageHeaderPageContainerRouteTabsStatCardStatusPillEmptyStateLetterAvatarIconAvatarThe constants are the other half of this. Default labels, date presets, STATUS_TONES, SELECT_EMPTY_VALUE and the text helpers all live in modules that deliberately have no 'use client' directive, even though the component beside them does. Everything exported from a client module arrives at a server component as a client-reference proxy that reads as undefined with no throw and no warning — and constants are precisely what server code reaches for, to spread over a default or build a list. Keeping them outside the boundary is what makes { ...DEFAULT_DATA_TABLE_LABELS } safe on a server page.
Two names to keep straight
FormFieldfrom@comitor/uiis the composite on this tier: a grid cell with a label, a description and an error, which hands your control its id and aria attributes.FormFieldfrom@comitor/ui/formis a different component with the same name — the shadcn wrapper around react-hook-form'sController. They can be used together, never imported together.Toasterandtoast()from@comitor/uiare the everyday pair, with Comitor status icons and no next-themes dependency.SonnerToasterin@comitor/ui/shellis the thin theme-aware one that belongs in a root layout. Both are documented on the Toast page.
Accessibility across the tier
- Roles describe what actually happens.
RouteTabsis a<nav>of links marked witharia-current="page"rather than a tablist, because there is no tabpanel on the page foraria-controlsto point at, and a roving tablist would swallow the arrow keys a list of links should leave alone.FilterChipsusesaria-pressedon real buttons for the same reason: it toggles a condition, it does not swap a panel. - Colour is never the only signal, and the coloured thing is measured against what sits behind it. Status text runs on the
-inkstep because the fill step gives 2.69:1 for success and 3.44:1 for info on their own tints — the first below even the 3:1 non-text floor, both well under the 4.5:1 a chip's text is held to; the ink step clears 4.5:1 in both themes, 4.86:1 at its narrowest. Chip borders take the ink colour at full strength, since in the outline variant the border is the only boundary the chip has. - Icons are decoration until proven otherwise.
IconComponentreceivesaria-hiddenfrom the composite that renders it, and the meaning is carried by adjacent text. Where a control is icon-only — the pagination arrows, the clear button in the search box, the calendar trigger — the name lives in ansr-onlyspan that thelabelsbag can translate. LetterAvataralways emits the full name in ansr-onlyspan. The initials themselves arearia-hidden— “NT” read aloud is noise — and an image branch usesalt=""so the name is not announced twice.- State that takes time says so.
ConfirmDialogandInlineEditstay open and busy while a returned promise settles, so the control cannot be fired twice.SearchFilterBarputs its result count in arole="status"region with a reserved height, so filtering announces the new count without the layout jumping underneath the pointer. RelativeTimerenders a real<time>with a machine-readabledateTimeand an absolute timestamp intitle— and falls back to a plain span for an invalid date, rather than publishingInvalid Dateas structured data.useIsMacreturnsfalseon the first render even on a Mac. The server cannot know the platform, and a mismatch makes React discard the subtree; showing “Ctrl” for one frame before it becomes “⌘” is the cheaper trade.
Next tier up
Composites fill a page. The frame around it — sidebar, header, workspace switcher, app launcher, and the four independent display axes — is Tier 3, which needs Next and next-themes and therefore lives behind its own entry.
Shell@comitor/ui/shell