Composites

Tầng 2 là phần của gói thật sự nói về Comitor: 48 mẫu mà mọi app trong hệ sinh thái đều lặp lại — một màn hình danh sách, một chip trạng thái, một hộp xác nhận, một khoảng ngày — ráp từ các primitive Tầng 1 và phát hành từ entry @comitor/ui, cùng entry với các primitive. Có bốn ngoại lệ kéo theo một peer tuỳ chọn nên nằm sau entry riêng: Upload, Data Grid, hai trình soạn thảo văn bản — Markdown Editor và Rich Text Editor — và Chat, dựng trên hai trình soạn thảo đó.

Không cài thêm gì, không import thêm gì: import { Button, DataTable } from '@comitor/ui' là một dòng với tới cả hai tầng.

Mười tám mẫu trong số này đến ở 2.1.0, chuyển từ Dice UI — Kanban, Stepper, Timeline, Time Picker, Tags Input, Mention, Action Bar, Phone Input, Mask Input, Rating, Circular Progress, Banner, Responsive Dialog, Tour, Key Value, Cropper, Scroll Spy và Badge Overflow. Gói xếp chúng vào Tầng 1, cạnh các primitive; site này để chúng ở đây vì mỗi cái là một tổ hợp nhiều phần đã ra sẵn quyết định — đúng việc của tầng này. Cùng entry, cùng dòng import, không peer mới phải cài. Thêm ba cái ở 3.15.0 — Color Picker, Color Swatch và Compare Slider. Mỗi bản chuyển đã sửa gì khi vào gói thì xem ở nhật ký thay đổi.

Thứ gì thuộc tầng này

Một primitive render một control. Một composite đã ra sẵn một quyết định — và giá trị nằm ở quyết định, không ở markup.

Quyết định, không phải markup

Header trang thụt vào bao nhiêu, bảng trống nói gì, chữ trạng thái chạy ở bậc tương phản nào, một con số tăng là tin tốt hay xấu. Tự dựng lại một composite nghĩa là quyết định lại tất cả, mỗi app một kiểu.

Không phụ thuộc framework

Không composite nào import next/*. Chỗ nào cần link của router thì nhận prop LinkComponent thay vào. Nhờ vậy entry chính chạy được dưới Vite, Storybook và test thuần — framework nằm ở Tầng 3.

Màu qua vai trò

Mọi tông ở tầng này là token vai trò, không bao giờ là mã hex hay thang thô, nên đi theo bộ màu đang dùng. Chữ và icon đứng riêng dùng bậc -ink; bậc trơn chỉ để tô nền.

Một màn hình danh sách, ráp sẵn

Chạy thật, và chỉ dùng Tầng 2: ô số liệu, thanh tìm kiếm có debounce với chip lọc, hàng có avatar và status pill, và empty state hiện ra khi bộ lọc loại hết mọi thứ. Gõ duc hoặc ha để thấy matchesSearch tìm ra tên viết có dấu. Header trang là mảnh duy nhất bị bỏ ra — trang tài liệu này đã có h1 của nó, còn PageHeader render thêm một cái nữa.

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

Khung trang

Đường viền của một màn hình. Page Header và Route Tabs là tĩnh, nên Server Component render được, và cả hai nhận prop LinkComponent thay vì import next/link — nhờ đó entry chính dùng được ngoài Next. Scroll Spy là mục lục cho màn hình dài quá một lần nhìn.

Dữ liệu

Màn hình danh sách, tách thành các phần dùng riêng được. Sắp xếp, phân trang và lọc đều là controlled — composite render state và báo cho app biết nó đã đổi; việc tải dữ liệu vẫn là của app.

Data Table

Sắp xếp, chọn hàng, ghim cột, skeleton khi tải và empty state có sẵn. Mọi chuỗi, kể cả aria-label, đều ghi đè được.

DataTable · DataTableColumnToggle

Data Grid

Nửa còn lại của cùng bài toán: bảng tính cho màn hình người dùng GÕ vào. Hàng ảo hoá, chín loại ô, chọn theo vùng và clipboard theo kiểu Excel. Composite duy nhất có @tanstack/react-table làm peer, nên nằm sau @comitor/ui/data-grid.

DataGrid · useDataGrid · DataGridFilterMenu · …

Table Pagination

Thanh dưới bảng. Nó tự tính số trang từ total và pageSize, nên app và control không bao giờ lệch nhau một trang.

TablePagination · DEFAULT_PAGE_SIZE_OPTIONS

Search & Filter

Ô tìm kiếm có debounce, một slot cho control lọc, badge đếm bộ lọc đang bật và số kết quả. FilterChips là hàng toggle thường nằm trong slot đó.

SearchFilterBar · FilterChips

Action Bar

Thanh công cụ nổi cho thao tác hàng loạt khi đang chọn hàng. Một phím Escape đã được menu phía trên dùng — hay nhấn giữa chừng khi đang gõ Telex — không còn đóng nó và xoá vùng chọn.

ActionBar · ActionBarSelection · ActionBarItem · …

Kanban

Bảng và pipeline kéo thả bằng chuột, cảm ứng và bàn phím — và thả bằng bàn phím thật sự đổi dữ liệu, điều bản gốc chưa bao giờ làm. Escape đưa thẻ về chỗ cũ.

Kanban · KanbanBoard · KanbanColumn · KanbanItem · KanbanOverlay

Timeline

Dòng hoạt động và lịch sử bản ghi, dọc hoặc ngang, mỗi mục là đã xong, đang làm hoặc chờ.

Timeline · TimelineItem · TimelineDot · TimelineContent · …

Gantt

Gantt công việc và roadmap dự án trong một: thanh theo ngày, bốn loại mũi tên phụ thuộc kèm phát hiện vi phạm, kéo để dời hoặc kéo dài, đường găng do app tính, năm thang từ ngày tới năm — ảo hoá, và điều khiển được bằng bàn phím.

Gantt · GANTT_SCALES · GanttItem · GanttDependency · …

Tree View

Cây đề cương cho bảng phân rã công việc: Tab và Shift+Tab để thụt vào và lùi ra, kéo thả để đổi cha, đánh số 1.2.3 và cột cộng dồn. Một onMove cho mọi thay đổi, và moveTreeItems để áp dụng nó — cả trên server.

TreeView · moveTreeItems · computeTreeRollup · …

Stat Card

Ô KPI với delta tuỳ chọn. Delta tô màu theo hướng nhân với cực tính, vì số ticket quá hạn tăng lên không phải tin tốt.

StatCard · statCardIconVariants

Status Pill

Chip trạng thái ba variant, tô màu từ bảng trạng thái của chính app. Giá trị lạ vẫn được hiện chứ không bị nuốt, nên enum cũ vẫn nhìn thấy được.

StatusPill

Badge Overflow

Hàng chip gập thành “+N” khi hết chỗ — và nói cho trình đọc màn hình biết còn bao nhiêu, thay vì đọc “cộng N”.

BadgeOverflow

Color Swatch

Hiện một màu đã lưu — ô vuông có tên trình đọc màn hình đọc được, nền bàn cờ dưới kênh alpha, ô gạch chéo khi không có màu. Dành cho màu là dữ liệu, không phải màu giao diện.

ColorSwatch

Compare Slider

Trước và sau, ảnh này chồng lên ảnh kia, chia bằng một tay nắm để kéo, bấm hoặc di bằng bàn phím — ngang hoặc dọc, vẫn cuộn được trên điện thoại.

CompareSlider · CompareSliderBefore · CompareSliderAfter · CompareSliderHandle · CompareSliderLabel

Form

Bố cục, khung của form nhiều bước, và các ô nhập quá lớn để làm primitive — bộ chọn, tag, mention, hai trình soạn thảo văn bản và ô soạn chat dựng trên chúng, số điện thoại, số tiền có mask, đánh giá sao, hàng khoá–giá trị, công cụ cắt ảnh, bộ chọn màu. Chúng ghép được với lớp nối react-hook-form ở @comitor/ui/form, nhưng không bắt buộc.

Form Layout

Lưới 12 cột với các phần được nhóm, tuỳ chọn thu gọn được. FormField trao cho control id và phần nối aria để không phải gõ lại.

FormSection · FormField

Stepper

Khung của form nhiều bước: các bước, trạng thái của chúng, Trước và Tiếp — có ngay từ lần render trên server khi các bước được viết ra trong JSX.

Stepper · StepperItem · StepperContent · StepperNext · useStepper · …

Upload

Một hàng cho avatar hay logo, một vùng thả cao cho tài liệu, và hook headless bên dưới khi cần hình thứ ba. Composite duy nhất có entry riêng — @comitor/ui/uploader, với @uppy/core và @uppy/xhr-upload là peer tuỳ chọn; route của app nhận một POST multipart bình thường.

ImageUploadField · FileUpload · useFileUpload

Cropper

Cắt avatar hay logo trước khi tải lên — bằng con trỏ, con lăn, cảm ứng hoặc bàn phím, với + và − để phóng to thu nhỏ.

Cropper · CropperImage · CropperArea · useCropper

Color Picker

Nhập một màu như dữ liệu — màu thương hiệu trên hồ sơ khách hàng — bằng hex, RGB, HSL hoặc HSB, với vùng chọn hai trục dùng được bằng bàn phím. Không dùng để chọn màu giao diện.

ColorPicker · ColorPickerTrigger · ColorPickerContent · ColorPickerArea · …

Combobox

Select tìm kiếm được, chọn một hoặc nhiều, khớp tiếng Việt có dấu hay không dấu. Chế độ async trả truy vấn về để app tự tải.

Combobox · MultiCombobox · SELECT_EMPTY_VALUE

Tags Input

Giá trị tự do thành chip gỡ được — nhãn, danh sách người nhận. Mỗi nút xoá mang tên của tag nó gỡ.

TagsInput · TagsInputItem · TagsInputInput · …

Mention

@-mention trong ô soạn hay ô bình luận, với danh sách gợi ý có tên, điều khiển bằng bàn phím.

Mention · MentionInput · MentionContent · MentionItem

Markdown Editor

Trình soạn thảo kiểu Linear lưu Markdown: viết trên văn bản đã định dạng, / để chèn khối, @ để mention, bong bóng định dạng khi bôi chọn, ảnh, bảng, code tô màu. Nằm sau entry riêng — @comitor/ui/editor, với Tiptap là peer tuỳ chọn.

MarkdownEditor · MarkdownEditorHandle

Rich Text Editor

Cùng lõi đó nhưng lưu HTML, cho nội dung xuất bản: thanh công cụ ghim ở trên, gạch chân, ô bảng chứa được danh sách và ảnh, và schema theo allow-list, loại bỏ script và event handler ngay khi nhận vào.

RichTextEditor · RichTextEditorHandle

Chat

Khung hội thoại cho trợ lý AI: luồng tin dính đáy, tin nhắn vẽ bằng Markdown Editor chỉ đọc kèm nhãn “AI tạo”, các bước của agent, nguồn đánh số, và ô soạn mà Enter là gửi. Nằm sau entry riêng — @comitor/ui/chat, với các peer của trình soạn thảo.

ChatThread · ChatMessage · ChatSteps · ChatSources · ChatComposer

Date Picker

Lịch cộng ô nhập gõ tay cộng preset. Một locale date-fns điều khiển cả lịch lẫn thứ tự các trường, nên en-US gõ MM/dd/yyyy mà không cần cấu hình thêm.

DatePicker · DateRangePicker · dateFormatForLocale

Time Picker

Các đoạn giờ gõ tay cộng bộ chọn cuộn. Đồng hồ theo locale — 2:30 PM bằng tiếng Anh, 14:30 bằng tiếng Việt — còn giá trị luôn là 24 giờ.

TimePicker · TimePickerInput · TimePickerContent · …

Phone Input

Bộ chọn quốc gia và ô số xuất ra E.164 — số trong nước 0912 345 678 thành +84912345678 khi đã chọn quốc gia. Không có quốc gia mặc định: truyền quốc gia của workspace vào.

PhoneInput · PhoneInputCountrySelect · PhoneInputField · usePhoneInput

Mask Input

Tiền tệ, phần trăm và mẫu cố định được định dạng ngay khi gõ, kèm giá trị thô bên cạnh. Đọc đúng số chữ số thập phân thật của từng tiền tệ — VND không có — và mặc định là USD, nên hãy truyền tiền tệ của app.

MaskInput · MASK_PATTERNS · applyMask · …

Rating

Sao, kể cả nửa sao, là một radio group: đúng một sao aria-checked, sao nào cũng có tên, và giá trị ban đầu được gửi cùng form.

Rating · RatingItem · useRating

Key Value

Hàng khoá–giá trị sửa được cho trường tuỳ biến, header và metadata; focus rơi vào hàng mới và không bao giờ tụt về <body>.

KeyValue · KeyValueItem · useKeyValueStore · …

Phản hồi

Những gì app nói lại, và bề mặt nó dùng để nói. Mỗi cái là một quyết định đã chốt về cách xác nhận, trạng thái trống, tiến độ và tin bất đồng bộ trông ra sao trong cả hệ sinh thái — và một hộp thoại cư xử thế nào trên điện thoại.

Empty State

Icon, tiêu đề, mô tả và một hành động. Hình dạng chung của mọi "chưa có gì ở đây", để Tasks, CRM và HR thống nhất trạng thái trống trông thế nào.

EmptyState · IconComponent

Banner

Thông báo toàn workspace đóng được, theo các tông của Alert, đứng một mình hoặc xếp chồng trong hàng đợi ưu tiên — onDismiss của nó không còn bắn mỗi lần cha re-render.

Banner · Banners · useBanners · …

Circular Progress

Tiến độ dạng vòng, có giá trị hoặc không, bên cạnh Progress dạng thanh. Nhãn nằm dưới vòng thay vì dưới con số phần trăm.

CircularProgress · CircularProgressIndicator · …

Confirm Dialog

Alert dialog quanh một promise. Trả promise từ onConfirm thì hộp thoại vẫn mở và bận cho tới khi xong — đóng trước là cách sinh ra xoá hai lần.

ConfirmDialog

Responsive Dialog

Dialog trên desktop và Drawer trên điện thoại từ cùng một bộ phần, focus được đưa vào trong ở cả hai — và không có khung hình nào chớp nhầm cái kia.

ResponsiveDialog · ResponsiveDialogContent · …

Command Palette

Bề mặt Cmd/Ctrl+K: lệnh theo nhóm, nhóm gần đây, gợi ý phím tắt từng mục, và chế độ async tắt lọc cục bộ.

CommandPalette

Tour

Hướng dẫn lần đầu chiếu sáng phần tử thật, cuộn bên trong vùng cuộn của chính app, và chỉ giữ focus khi đang mở.

Tour · TourStep · TourSpotlight · …

Toast

Thông báo xếp chồng với icon trạng thái của Comitor. Tài liệu đặt cùng các primitive — SonnerToaster ở entry shell là bản biết next-themes.

Toaster · toast · toastPromise · dismissToast

Tiện ích

Component nhỏ xuất hiện trên gần như mọi màn hình. Nhỏ không có nghĩa là mỏng: mỗi cái mang sẵn một phương án dự phòng, một locale hay một quy tắc hydrate mà không có nó thì app phải tự khám phá lại.

Module thuần

Không phải component: hằng và hàm thuần, không import React chút nào, nên chạy được trong route handler, trong test hay trong Server Component cũng như trên trình duyệt.

Chữ đi theo một provider, và chuỗi nào cũng đổi được

Mọi chuỗi ở tầng này đến từ một bảng cho mỗi component, kể cả những chuỗi chỉ trình đọc màn hình nghe thấy — aria-label của checkbox trên một hàng bảng, bốn nhãn sr-only trên các mũi tên phân trang, tên của nút mở lịch, câu được đọc lên khi một thẻ đang được kéo qua bảng. Không có provider thì các bảng đó là tiếng Anh; một ComitorLocaleProvider ở gốc đổi tất cả cùng lúc, và đó là cách duy nhất chạm tới những chuỗi không ảnh chụp màn hình nào thấy. Bước ngôn ngữ của hướng dẫn cài đặt nói về chính provider; dưới đây là cách các bảng cư xử khi đã có nó.

Tiếng Việt

trang này: provider ở gốc của site

English

<ComitorLocaleProvider locale="en">

  • labels là một Partial<>. Component trải nó lên bảng của provider, nên ghi đè một khoá thì các khoá còn lại vẫn ở ngôn ngữ đang dùng. Các hằng DEFAULT_*_LABELS vẫn được export — là các bảng tiếng Anh, trong module không có 'use client', đọc được từ code server — còn bảng tiếng Việt của bất kỳ component nào là comitorMessagesVi.<namespace>.
  • Khoá có chèn giá trị là hàm. range(start, end, total, unitLabel), pageStatus(page, pageCount), filterCount(count), pageSizeSelect(unitLabel) — không bao giờ là template có placeholder, vì thứ tự từ không giống nhau giữa các ngôn ngữ. resultSummary đi xa hơn và trả về một ReactNode: con số cần phần tử riêng để giữ tabular-nums, cho nó khỏi rung khi số đếm đổi. Không truyền được hàm từ Server Component, nên khai các khoá này trong một Client Component.
  • Prop riêng thắng labels. unitLabel, label và searchPlaceholder vẫn là prop riêng và được ưu tiên. Chúng đổi theo nội dung — “contacts”, “invoices” — còn các bảng đổi theo ngôn ngữ. Một provider cho mỗi app, một prop cho mỗi bảng.
  • Prop chuỗi đơn cũng theo provider. CopyButton, ConfirmDialog, InlineEdit, CommandPalette và AvatarGroup nhận từng prop chuỗi thay vì một túi — bỏ trống một prop thì nó đọc cùng bảng đó: “Copy” và “Confirm” bằng tiếng Anh, “Sao chép” và “Xác nhận” dưới locale="vi". FilterChips lấy chữ trên chip từ các item được truyền vào; hai chuỗi item không mang được — label, tên của nhóm cho trình đọc màn hình (“Quick filters”), và allLabel, chip đặt lại mà showAll thêm vào (“All”) — lấy từ bảng.
  • Ngày tháng theo cùng provider. DatePicker, RelativeTime và Calendar định dạng bằng locale date-fns của provider — vi cho tiếng Việt, enUS cho mọi tag khác trừ khi provider được truyền dateFnsLocale — và prop locale của riêng chúng vẫn ghi đè ngày tháng của một instance. Định dạng gõ tay cũng theo: MM/dd/yyyy bằng tiếng Anh, dd/MM/yyyy bằng tiếng Việt.

Icon là component, không phải element

Mọi prop icon ở tầng này — EmptyState, PageHeader, StatCard, FormSection, ConfirmDialog, Combobox, FilterChips, CommandPalette — có kiểu IconComponent và nhận chính component. Icon Lucide là một object forwardRef chứ không phải hàm, nên phép kiểm typeof mà một component hay dùng không phân biệt được nó với một element đã render — cả hai đều trả 'object'. Đoán thì sẽ sai mà không báo gì, nên kiểu dữ liệu từ chối việc đoán. Khi cần nội dung tuỳ ý thì có slot riêng — media trên EmptyState, sparkline trên 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 hay client

Tám composite không có 'use client' của riêng mình và render thẳng từ Server Component; mọi thứ khác trên trang này, kể cả mười tám mẫu thêm ở 2.1.0, là Client Component mà trang server vẫn render được — chỉ là không truyền hàm vào được. Đây là quyết định có chủ ý về chi phí, không phải tình cờ: một danh sách hai trăm hàng render bằng Avatar của Radix sẽ là hai trăm đảo client, nên LetterAvatar được viết từ đầu.

PageHeaderPageContainerRouteTabsStatCardStatusPillEmptyStateLetterAvatarIconAvatar

Các hằng là nửa còn lại của chuyện này. Nhãn mặc định, preset ngày, STATUS_TONES, SELECT_EMPTY_VALUE và các helper xử lý chữ đều nằm trong module cố ý không có chỉ thị 'use client', dù component bên cạnh có. Mọi thứ export từ một module client tới Server Component dưới dạng proxy tham chiếu client, đọc ra undefined mà không throw, không cảnh báo — và hằng chính là thứ code server hay với tới, để trải lên một giá trị mặc định hay dựng một danh sách. Để chúng ngoài ranh giới là điều khiến { ...DEFAULT_DATA_TABLE_LABELS } an toàn trên trang server.

Hai cái tên cần phân biệt

  • FormField từ @comitor/ui là composite ở tầng này: một ô lưới có nhãn, mô tả và lỗi, trao cho control id và các thuộc tính aria. FormField từ @comitor/ui/form là một component khác trùng tên — wrapper shadcn quanh Controller của react-hook-form. Dùng chung được, nhưng không bao giờ import chung.
  • Toaster và toast() từ @comitor/ui là cặp dùng hằng ngày, có icon trạng thái của Comitor và không phụ thuộc next-themes. SonnerToaster trong @comitor/ui/shell là bản mỏng biết theme, đặt trong root layout. Cả hai có tài liệu ở trang Toast.

Trợ năng trên toàn tầng

  • Role mô tả đúng điều thực sự xảy ra. RouteTabs là một <nav> gồm các link đánh dấu aria-current="page" chứ không phải tablist, vì trên trang không có tabpanel nào để aria-controls trỏ vào, và một tablist roving sẽ nuốt các phím mũi tên mà một danh sách link nên để yên. FilterChips dùng aria-pressed trên button thật cũng vì lý do đó: nó bật tắt một điều kiện, không đổi panel.
  • Màu không bao giờ là tín hiệu duy nhất, và thứ được tô màu được đo với chính thứ nằm sau nó. Chữ trạng thái chạy ở bậc -ink vì bậc fill đặc chỉ cho 2,69:1 với success và 3,44:1 với info trên tint của chính chúng — cái đầu dưới cả ngưỡng 3:1 cho phần không phải chữ, cả hai đều thấp xa so với 4,5:1 mà chữ của chip phải đạt; bậc ink vượt 4,5:1 ở cả hai theme, hẹp nhất 4,86:1. Viền chip lấy màu ink đậm hết mức, vì ở variant outline viền là đường biên duy nhất của chip.
  • Icon là trang trí cho tới khi chứng minh được điều ngược lại. IconComponent nhận aria-hidden từ composite render nó, còn ý nghĩa do chữ bên cạnh mang. Chỗ nào control chỉ có icon — mũi tên phân trang, nút xoá trong ô tìm kiếm, nút mở lịch — tên nằm trong một span sr-only mà túi labels dịch được.
  • LetterAvatar luôn xuất tên đầy đủ trong một span sr-only. Bản thân chữ viết tắt là aria-hidden — đọc to “NT” chỉ là tạp âm — và nhánh có ảnh dùng alt="" để tên không bị đọc hai lần.
  • Trạng thái cần thời gian thì nói ra. ConfirmDialog và InlineEdit giữ nguyên mở và bận trong lúc promise trả về chưa xong, nên control không bị bấm hai lần. SearchFilterBar đặt số kết quả trong một vùng role="status" có chiều cao dành sẵn, nên khi lọc thì số mới được đọc lên mà bố cục không nhảy dưới con trỏ.
  • RelativeTime render một <time> thật với dateTime máy đọc được và mốc thời gian tuyệt đối trong title — và lùi về span thường khi ngày không hợp lệ, thay vì công bố Invalid Date như dữ liệu có cấu trúc.
  • useIsMac trả false ở lần render đầu, kể cả trên Mac. Server không biết nền tảng, và lệch nhau thì React bỏ cả cây con; hiện “Ctrl” một khung hình trước khi thành “⌘” là cái giá rẻ hơn.

Tầng tiếp theo

Composite lấp đầy một trang. Khung quanh nó — sidebar, header, bộ chuyển workspace, app launcher, và bốn trục hiển thị độc lập — là Tầng 3, cần Next và next-themes nên nằm sau entry riêng.

Shell@comitor/ui/shell