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.

Decisions, not 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.

No framework dependency

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.

Colour through roles

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ành

    Draft the Q3 handover note

    Nguyễn Đức Thành · updated

    Open
  • Trần Minh Anh

    Reconcile August invoices

    Trần Minh Anh · updated

    Blocked
  • Lê Thu Hà

    Ship the payroll export

    Lê Thu Hà · updated

    Done
  • Phạm Quốc Bảo

    Review the vendor contract

    Phạm Quốc Bảo · updated

    Open

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.

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.

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.

Feedback

What the app says back. Each one is a decision already made about how confirmation, absence and asynchronous news look across the ecosystem.

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.

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.

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.

tsx
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 the DEFAULT_* 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. resultSummary goes further and returns a ReactNode: the number needs its own element to hold tabular-nums so it stops jittering as the count changes.
  • A standalone prop beats labels. unitLabel, label and searchPlaceholder stay separate props and win. They vary by content — “contacts”, “invoices” — while labels varies by language. One bag per locale, one prop per table.
  • Not every composite has a bag. CopyButton, ConfirmDialog, InlineEdit, CommandPalette and AvatarGroup take plain string props that happen to default to Vietnamese — label="Sao chép", confirmLabel="Xác nhận" — so pass English ones. FilterChips is 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”), and allLabel, the reset chip that showAll adds (“Tất cả”). The first of those is invisible in a screenshot, which is exactly how it survives a translation pass.
  • locale is a different axis from labels. A date-fns locale carries date formatting, never interface strings. Switching DatePicker to English means passing both: locale for the calendar and the field order, labels for the three sr-only strings it cannot reach. RelativeTime takes the same locale, 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.

tsx
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.

PageHeaderPageContainerRouteTabsStatCardStatusPillEmptyStateLetterAvatarIconAvatar

The 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

  • FormField from @comitor/ui is 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. FormField from @comitor/ui/form is a different component with the same name — the shadcn wrapper around react-hook-form's Controller. They can be used together, never imported together.
  • Toaster and toast() from @comitor/ui are the everyday pair, with Comitor status icons and no next-themes dependency. SonnerToaster in @comitor/ui/shell is 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. RouteTabs is a <nav> of links marked with aria-current="page" rather than a tablist, because there is no tabpanel on the page for aria-controls to point at, and a roving tablist would swallow the arrow keys a list of links should leave alone. FilterChips uses aria-pressed on 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 -ink step 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. IconComponent receives aria-hidden from 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 an sr-only span that the labels bag can translate.
  • LetterAvatar always emits the full name in an sr-only span. The initials themselves are aria-hidden — “NT” read aloud is noise — and an image branch uses alt="" so the name is not announced twice.
  • State that takes time says so. ConfirmDialog and InlineEdit stay open and busy while a returned promise settles, so the control cannot be fired twice. SearchFilterBar puts its result count in a role="status" region with a reserved height, so filtering announces the new count without the layout jumping underneath the pointer.
  • RelativeTime renders a real <time> with a machine-readable dateTime and an absolute timestamp in title — and falls back to a plain span for an invalid date, rather than publishing Invalid Date as structured data.
  • useIsMac returns false on 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