Cài đặt

Cả design system là một gói npm công khai. Cài nó vào bất kỳ dự án React 19 + Tailwind v4 nào là có mọi component trên site này — không registry riêng, không khoá bản quyền, không tài khoản.

@comitor/ui trên npm →Mới làm quen với hệ thống? Đọc phần giới thiệu trước

1. Cài gói

Peer tuỳ chọn tính theo từng entry — entry nào không import thì bỏ qua peer của nó.

terminal
# the package
pnpm add @comitor/ui

# required peers
pnpm add react react-dom lucide-react
pnpm add -D tailwindcss @tailwindcss/postcss

# optional peers — only for the entry you use
pnpm add next next-themes   # @comitor/ui/shell
pnpm add recharts           # @comitor/ui/chart
pnpm add @tanstack/react-table  # @comitor/ui/data-grid
pnpm add react-hook-form    # @comitor/ui/form
pnpm add @uppy/core @uppy/xhr-upload  # @comitor/ui/uploader

# @comitor/ui/editor and @comitor/ui/chat — every @tiptap package at ONE version
pnpm add @tiptap/core @tiptap/pm @tiptap/react @tiptap/starter-kit @tiptap/markdown   @tiptap/extensions @tiptap/extension-list @tiptap/extension-table @tiptap/extension-image   @tiptap/extension-mention @tiptap/suggestion @tiptap/extension-paragraph   @tiptap/extension-code-block-lowlight lowlight highlight.js
PropKiểuMặc địnhMô tả
react, react-dom^19.0.0requiredLuôn cần.
tailwindcss^4.1.0requiredLuôn cần — styles.css chỉ có nghĩa khi Tailwind v4 biên dịch nó.
lucide-react>=0.540.0requiredLuôn cần. Dùng chung để bundle không bao giờ mang hai bộ icon. Bản 1.x đã bỏ icon thương hiệu.
next>=15optionalChỉ cho @comitor/ui/shell.
next-themes>=0.4optionalChỉ cho @comitor/ui/shell. Context singleton — app và gói phải resolve về cùng một bản.
recharts>=2.15.0optionalChỉ cho @comitor/ui/chart. Có kiểu cho cả 2.x lẫn 3.x.
@tanstack/react-table>=8.21.0 <9optionalChỉ cho @comitor/ui/data-grid. Là peer chứ không phải dependency, để ColumnDef của app và của lưới resolve về cùng một bản kiểu.
react-hook-form^7.54.0optionalChỉ cho @comitor/ui/form. Context singleton, cùng lưu ý như next-themes.
@uppy/core, @uppy/xhr-upload^6.0.0optionalChỉ cho @comitor/ui/uploader. Uppy chạy headless — markup vẫn là của gói.
@tiptap/* (13 packages)^3.31.0optionalChỉ cho @comitor/ui/editor và @comitor/ui/chat — và tất cả phải cùng MỘT phiên bản: chúng ghim @tiptap/core và @tiptap/pm đúng số đó, và extension tự viết phải dùng chính bản đó.
lowlight^3.0.0optionalChỉ cho @comitor/ui/editor và @comitor/ui/chat — tô màu khối mã. Nó kéo theo highlight.js ^11, một peer của extension code-block của Tiptap, nên cài cả gói đó.

2. Nạp stylesheet

Đây là bước hay sai nhất, và khi sai thì trông như một lỗi hoàn toàn khác.

app/globals.css
/* app/globals.css — exactly these two lines, in this order */
@import "tailwindcss";
@import "@comitor/ui/styles.css";

Thứ tự quan trọng, và file không được nạp ở đâu khác.

  • Tailwind phải được import trước. Stylesheet của gói phần lớn là @theme, @custom-variant và @source — những at-rule vô nghĩa nếu Tailwind không biên dịch chúng. Đảo hai dòng là mọi utility biến mất.
  • Đừng bao giờ import nó từ layout.tsx. Biến CSS vẫn vào, nên màu trông gần đúng, nhưng không utility nào được sinh ra và bố cục vỡ.
  • Đừng thêm token, mã hex hay utility riêng của app vào file này. Gói là nguồn chuẩn duy nhất cho tất cả những thứ đó.
postcss.config.mjs
/* postcss.config.mjs — Tailwind v4 needs one plugin and nothing else.
   No autoprefixer, no postcss-import; v4 absorbed both. */
const config = {
  plugins: {
    "@tailwindcss/postcss": {}
  }
}

export default config

Lệnh import đó làm gì với markup của app

Mọi thứ ở trên là để nạp được file. Nạp xong, khối @layer base của nó tạo kiểu cho cả phần tử của app, không chỉ phần tử đến từ gói — bốn luật mà không class nào của app yêu cầu, và không luật nào tự lên tiếng.

@comitor/ui/styles.css
/* @comitor/ui/styles.css — the whole @layer base block */
@layer base {
  /* Default border colour for EVERY element. */
  * { @apply border-border outline-ring/50; }

  body { @apply bg-background text-foreground font-sans antialiased; }

  /* Tailwind v4 dropped this from preflight; the package puts it back. */
  button:not(:disabled):not([aria-disabled="true"]),
  [role="button"]:not(:disabled):not([aria-disabled="true"]),
  label[for] { cursor: pointer; }

  /* The focus signature: 2px, offset 2px, --ring. */
  :focus-visible { @apply outline-2 outline-offset-2 outline-ring; }
}
  • Luật * là luật hay gây chuyện. --border là màu đường kẻ trang trí, 1,16:1 và cố ý nằm dưới mọi ngưỡng tương phản — nên một điều khiển viết border mà không gọi tên màu sẽ có viền không nhìn thấy, và không gì báo lỗi. Điều khiển luôn gọi tên một màu: border-input, hoặc border-choice-edge cho checkbox hay radio. Xem Màu sắc.
  • Focus là một dấu hiệu chung của cả trang. Mọi phần tử focus được đều có outline --ring 2px, lệch 2px, mà app không phải thêm class nào. Component theo quy ước khác thì chủ động tắt nó — hàng của menu và command thay nó bằng nền --accent, vì focus ở đó di chuyển theo phím mũi tên.
  • body mang nền, màu chữ, font và khử răng cưa, nên một trang chỉ render chữ đã đúng bộ màu.

Luật con trỏ, một ví dụ cụ thể (1.7.1)

Preflight của Tailwind v3 có button, [role="button"] { cursor: pointer }. Tailwind v4 bỏ nó để theo mặc định của trình duyệt, nên trên v4 mọi <button> tính ra cursor: default — không lỗi build, không cảnh báo, không gì để grep. Trên một thanh công cụ thật, <a> có bàn tay còn hai <button> cạnh nó thì không, dù cả ba trông y hệt nhau; khi đó một nút ghost trên nền phẳng đọc như một nhãn. Gói khôi phục luật này ở lớp base chứ không ở Button, nên cả những <button> thô app tự viết cũng được cùng luật che — rải cursor-pointer bằng tay chỉ giữ được tới component kế tiếp mà ai đó viết.

MarkupCon trỏ tính ra
<button>pointer
<button disabled>default
<button aria-disabled="true">1.7.0 cho pointerdefault
<button role="button" disabled>1.7.0 cho pointerdefault
<div role="button">pointer
<div role="button" aria-disabled="true">auto
<label for="email">pointer
<label> (no for)default
<fieldset disabled> <button>default

Disabled bị loại hai lần vì đó là hai phép thử chứ không phải một — và cả hai phép thử nằm trên cả hai selector. :not(:disabled) lo cho điều khiển native; :not([aria-disabled="true"]) lo cho điều khiển Radix, vốn cố ý vẫn focus được nên không bao giờ mang thuộc tính mà phép thử thứ nhất tìm. Giá trị lúc tắt khác nhau chỉ vì các phần tử có mặc định user-agent khác nhau — default cho button, auto cho div; không cái nào là bàn tay. Một điều khiển trong <fieldset disabled> khớp :disabled theo spec HTML, nên một màn chỉ đọc dựng theo cách đó không mời bấm vào đâu.

1.7.0 ghép mỗi selector với một phép thử, và đó chính là lỗi. Bản đó phát hành button:not(:disabled), [role="button"]:not([aria-disabled="true"]) — nên aria-disabled chỉ loại được nhánh [role="button"], và một <button aria-disabled="true"> thường — đúng hình Radix mà phép thử thứ hai được viết cho — vẫn giữ bàn tay. 1.7.1 đặt cả hai phép thử lên cả hai selector.

Đáng biết vì sao lỗi lọt qua một lượt đo: sáu trường hợp kiểm lúc đó dùng <div role="button" aria-disabled="true"> cho nhánh aria — đúng selector duy nhất có chốt chặn. Trường hợp nào được chọn cũng là trường hợp qua. Vì thế bảng trên có chín trường hợp, và trường hợp thật mà lượt đo bỏ sót là Calendar: react-day-picker đánh dấu mũi tên chuyển tháng bằng aria-disabled mà không có thuộc tính disabled, nên ở hai đầu của một khoảng giới hạn chúng mời một cú bấm chẳng làm gì.

label[for] có mặt vì một label có đích là một điều khiển gián tiếp: bấm vào nó thì focus hoặc bật tắt thứ nó gọi tên. Một <label> trần không trỏ vào gì và không làm gì, nên cố ý không có luật. Chính trang này là bằng chứng: nó nạp cùng stylesheet, nên di chuột lên bất kỳ nút nào ở đây cũng đã thấy bàn tay.

Một luật nằm ngoài layer: ô nhập 16px trên iOS

Từ 3.13.4, stylesheet có thêm một khối chạm tới markup của app, và nó cố ý nằm ngoài mọi @layer, nên thắng utility mà không cần !important. WebKit trên iOS và iPadOS phóng to cả trang khi một ô có chữ dưới 16px nhận focus, và không thu lại — trên iPhone, một màn đăng nhập phình quá khung nhìn và mất mép phải. Nên chỉ trên hai hệ đó (không trình duyệt nào khác khớp phép thử @supports), mọi ô gõ chữ được giữ ở 16px — hoặc bằng cỡ chữ xung quanh, khi cỡ đó lớn hơn. Android và desktop giữ cỡ mà component đặt: 16px dưới md, 14px từ md trở lên (Input).

@comitor/ui/styles.css
/* @comitor/ui/styles.css — section 5e, outside every @layer (3.13.4) */
@supports (-webkit-touch-callout: none) {
  input:not([type="checkbox"], [type="radio"], [type="range"], [type="file"], [type="color"], [type="hidden"]),
  textarea,
  select,
  [contenteditable="true"] {
    font-size: max(16px, 1em);
  }
}

Vì vậy đừng khoá zoom trong viewport. Đặt maximumScale bằng 1, hoặc userScalable bằng false, trong export viewport của app/layout.tsx — hoặc cùng maximum-scale đó trong một <meta name="viewport"> viết tay — từng là cách quen dùng để né zoom khi focus. Giờ không cần nữa, mà còn gây hại: nó chặn người dùng chụm hai ngón để zoom trên Android (WCAG 1.4.4), còn iOS vốn bỏ qua nó với cử chỉ chụm, nên chỉ còn lại cái hại. Layout có thì bỏ đi; phần còn lại của export — themeColor và các thứ khác — giữ nguyên.

3. Nạp font

Gói trỏ --font-sans vào --font-inter và chờ app định nghĩa biến đó. Để trống thì cả khai báo không hợp lệ lúc tính giá trị — các font dự phòng sau nó không bao giờ được dùng tới.

app/layout.tsx
// app/layout.tsx
import { Inter } from 'next/font/google'

// The package consumes --font-inter; it never loads a font itself.
// The vietnamese subset is not optional — without it Vietnamese
// diacritics fall back to a system font and the text visibly shifts.
const inter = Inter({
  subsets: ['latin', 'vietnamese'],
  weight: ['400', '500', '600', '700', '800'],
  display: 'swap',
  variable: '--font-inter',
})

4. Gắn các provider

Bốn trục hiển thị độc lập, tất cả ở root layout, tất cả theo từng người dùng và từng thiết bị. Mỗi provider tự render script chống nháy của nó — không phải thêm gì vào <head>. Chỉ trục theme là thật sự bắt buộc; bỏ ba trục kia nếu không muốn cung cấp.

app/layout.tsx
// app/layout.tsx
import { ContrastProvider, DensityProvider, FontSizeProvider, ThemeProvider } from '@comitor/ui/shell'
import './globals.css'

export default function RootLayout({ children }: { children: React.ReactNode }) {
  return (
    // suppressHydrationWarning is required: all four scripts touch <html>
    // before React hydrates, so server and client HTML never match.
    <html lang="en" suppressHydrationWarning className={inter.variable}>
      <body className="min-h-dvh bg-background font-sans text-foreground antialiased">
        <ThemeProvider>
          <ContrastProvider>
            <DensityProvider>
              <FontSizeProvider>{children}</FontSizeProvider>
            </DensityProvider>
          </ContrastProvider>
        </ThemeProvider>
      </body>
    </html>
  )
}

Không dùng Next? Bỏ qua bước này và cả entry /shell — entry chính không phụ thuộc framework nào. Xem Display Axes để biết mỗi trục làm gì.

5. Đặt ngôn ngữ

Từ 3.0.0, mọi chuỗi mà gói render đều là tiếng Anh, trừ khi được bảo khác — chữ hiển thị, chữ trong aria-label và sr-only, cả những lời trình đọc màn hình đọc khi một thẻ đang được kéo. Tiếng Việt có sẵn. Chọn nó một lần, ở root, bằng ComitorLocaleProvider từ entry chính. App chỉ dùng tiếng Anh có thể bỏ qua bước này; mọi app khác thì không.

app/layout.tsx
// app/layout.tsx — a Server Component
import { ComitorLocaleProvider } from '@comitor/ui'
import { ContrastProvider, DensityProvider, FontSizeProvider, ThemeProvider } from '@comitor/ui/shell'

export default function RootLayout({ children }: { children: React.ReactNode }) {
  return (
    // lang is yours to set — the provider never touches <html>, and a screen
    // reader picks its pronunciation rules from this attribute.
    <html lang="vi" suppressHydrationWarning className={inter.variable}>
      <body className="min-h-dvh bg-background font-sans text-foreground antialiased">
        {/* Independent contexts: the nesting order does not matter. */}
        <ComitorLocaleProvider locale="vi">
          <ThemeProvider>
            <ContrastProvider>
              <DensityProvider>
                <FontSizeProvider>{children}</FontSizeProvider>
              </DensityProvider>
            </ContrastProvider>
          </ThemeProvider>
        </ComitorLocaleProvider>
      </body>
    </html>
  )
}

Quên dòng này thì hỏng mà không báo. Tiếng Anh là một mặc định hợp lệ, nên một app tiếng Việt thiếu provider sẽ hiện “Cancel” và “Select…” giữa màn hình tiếng Việt, và đọc tên các nút bằng tiếng Anh — không lỗi build, không cảnh báo console, và không gì mà trình kiểm kiểu nhìn thấy.

App Comitor: ngôn ngữ của người dùng

Trong một sản phẩm Comitor, ngôn ngữ là một cài đặt trên Account của người dùng, đọc qua next-intl — không bao giờ là hằng số. Bọc ở root layout, bên trong NextIntlClientProvider, chứ không chỉ quanh AppShell: trang đăng nhập và trang lỗi render ngoài shell, và các portal mà mọi dialog, popover gắn vào cũng vậy. AppShell có nhận prop locale, nhưng chỉ như lối tắt cho app không có root layout riêng — provider nó dựng không thừa hưởng messages hay dateFnsLocale từ provider ở trên, và thêm hay bỏ prop đó sẽ mount lại cả shell.

app/layout.tsx
// app/layout.tsx — a Comitor app. The language is the USER'S, kept on their
// Account and read through next-intl. Never pin "vi" in a product.
import { NextIntlClientProvider } from 'next-intl'
import { getLocale } from 'next-intl/server'
import { ComitorLocaleProvider } from '@comitor/ui'

export default async function RootLayout({ children }: { children: React.ReactNode }) {
  const locale = await getLocale()

  return (
    <html lang={locale} suppressHydrationWarning>
      <body>
        <NextIntlClientProvider>
          {/* Here, not around AppShell: sign-in, error pages and dialog
              portals all render outside the shell. */}
          <ComitorLocaleProvider locale={locale}>{children}</ComitorLocaleProvider>
        </NextIntlClientProvider>
      </body>
    </html>
  )
}

Locale thay đổi những gì

locale là một thẻ BCP 47, và nó quyết định hai thứ. Nó chọn bảng chữ có sẵn — thẻ nào bắt đầu bằng vi được tiếng Việt, mọi thẻ khác được tiếng Anh — và nó điều khiển mọi định dạng Intl mà gói dùng: giờ, số trong tooltip biểu đồ, đồng hồ 12 hay 24 giờ. Đổi ngôn ngữ của bản xem trước bên dưới và để ý cả hai. Chữ của chính app — tiêu đề hoá đơn, nhãn các ô — cũng đổi, vì demo này truyền chúng từ từ điển riêng của nó theo cách next-intl làm; provider không bao giờ chạm vào chúng.

Invoice INV-0431

Created

useComitorLocale() → locale “en” · strings “en” · date-fns “en-US”

value → due 2026-10-05 · reminder “14:30”

  • en-GB giữ chữ tiếng Anh và đổi mọi định dạng. Bộ chọn giờ bỏ AM/PM vì Intl cho biết tiếng Anh kiểu Anh dùng đồng hồ 24 giờ. Ô ngày đi theo chỉ vì demo còn truyền dateFnsLocale={enGB}: gói chỉ ánh xạ vi → vi, còn mọi thẻ khác về enUS của date-fns. Thiếu nó, người đọc ở Anh gõ 12/10/2026 và nhận về ngày 10 tháng 12.
  • Giá trị không bao giờ đổi hình. Bộ chọn giờ trả “14:30” dù đang hiện 2:30 PM hay 14:30, và bộ chọn ngày trả về một Date — locale chỉ là phần trình bày, và không thứ gì app lưu phụ thuộc vào nó.
  • Số đếm cũng theo ngôn ngữ. TablePagination và SearchFilterBar nhóm chữ số theo ngôn ngữ của chữ đi kèm: 1,248 ở tiếng Anh, 1.248 ở tiếng Việt. Trước 3.1.0, chúng để nguyên số thô.

Chuỗi nào thắng

Mọi chuỗi trong gói được chọn theo cùng một cách, từ trên xuống:

  1. Prop của chính component

    labels · placeholder · clearLabel · locale · itemLabel…

    Một instance, một ngoại lệ — nút đóng mang tên thứ nó đóng, một đơn vị là nội dung riêng của bảng này. labels là Partial, nên truyền một khoá vẫn giữ các khoá còn lại.

  2. ComitorLocaleProvider gần nhất

    locale · messages · dateFnsLocale

    Ngôn ngữ của mọi thứ bên dưới nó. Một provider thứ hai lồng bên trong cho một vùng của trang một ngôn ngữ khác; provider bên trong thắng trong cây con của nó.

  3. Tiếng Anh

    DEFAULT_COMITOR_LOCALE = “en”

    Thứ hiện ra khi không có provider nào: đúng cho app quốc tế, cho Storybook và cho test — và sai mà không báo với một app tiếng Việt bỏ qua bước này.

Những khoá đặt một con số vào giữa câu là hàm, không phải template — range(start, end, total, unit), selected(count), itemLabel(value, max) — vì trật tự từ quanh một con số không giống nhau ở mọi ngôn ngữ, và tiếng Anh còn phải chọn giữa “1 star” và “2 stars”. Các hằng DEFAULT_*_LABELS vẫn được export, giờ là bảng tiếng Anh; bảng tiếng Việt của component nào cũng là comitorMessagesVi.<namespace>.

Prop mang hàm

Root layout là Server Component, và Server Component trao được cho Client Component chuỗi, số và object thuần — không trao được hàm. Ở đó, điều này loại hai trong ba prop của provider: dateFnsLocale, vì locale của date-fns là một object đầy hàm, và mọi override messages chạm tới một khoá có giá trị là hàm. Truyền một trong hai từ root layout là trang không render nổi: Functions cannot be passed directly to Client Components. Cách sửa là một Client Component nhỏ của riêng app giữ những giá trị đó — cũng là chỗ đúng cho chúng, vì object phải giữ một tham chiếu ổn định: một object messages mới ở mỗi lần render sẽ render lại mọi component đọc chuỗi.

app/comitor-locale.tsx
// app/comitor-locale.tsx — needed as soon as a prop carries a FUNCTION
'use client'

import { enGB } from 'date-fns/locale'
import { ComitorLocaleProvider, type ComitorMessagesOverride } from '@comitor/ui'

// Module scope: one stable reference. A fresh object on every render
// re-renders every component that reads a string.
const MESSAGES: ComitorMessagesOverride = {
  shell: { workspace: 'Company' },
  tablePagination: { range: (start, end, total, unit) => `${start}–${end} / ${total} ${unit}` },
}

export function ComitorLocale({ locale, children }: { locale: string; children: React.ReactNode }) {
  return (
    <ComitorLocaleProvider
      locale={locale}
      messages={MESSAGES}
      // The package maps only vi → vi and everything else → enUS for date-fns.
      dateFnsLocale={locale === 'en-GB' ? enGB : undefined}
    >
      {children}
    </ComitorLocaleProvider>
  )
}

// app/layout.tsx (still a Server Component) then renders
//   <ComitorLocale locale={locale}>{children}</ComitorLocale>

Đổi vài chữ, hoặc thêm một ngôn ngữ

messages là override theo namespace — mỗi component một namespace, đặt theo tên nó (dataTable, shell, kanban…) — và trong một namespace thì theo khoá. Dùng nó cho những chữ mà lĩnh vực của app gọi khác đi (“Company” thay cho “Workspace”), hoặc để cung cấp nguyên một bảng cho ngôn ngữ mà gói không có sẵn. Bắt đầu từ comitorMessagesEn thì trình kiểm kiểu liệt kê mọi khoá chưa dịch; nửa Intl của locale đã chạy với mọi thẻ.

app/comitor-locale.tsx
'use client'

import { ComitorLocaleProvider, comitorMessagesEn, type ComitorMessages } from '@comitor/ui'

// A language the package does not ship: start from the English table so the
// type checker tells you every key you have not translated yet.
const MESSAGES_FR: ComitorMessages = {
  ...comitorMessagesEn,
  confirmDialog: { ...comitorMessagesEn.confirmDialog, confirmLabel: 'Confirmer', cancelLabel: 'Annuler' },
  tablePagination: {
    ...comitorMessagesEn.tablePagination,
    range: (start, end, total, unit) => `${start}–${end} sur ${total} ${unit}`,
  },
  // …one namespace per component
}

// "fr" is not a built-in table, so the strings come from messages — while
// Intl formatting (times, numbers, the 24-hour clock) already follows "fr".
<ComitorLocaleProvider locale="fr" messages={MESSAGES_FR}>{children}</ComitorLocaleProvider>

Vùng không phải là ngôn ngữ

Hai mặc định cố ý không suy ra từ locale, vì chúng mô tả doanh nghiệp ở đâu chứ không phải người đọc nói tiếng gì. PhoneInput không có quốc gia mặc định, và MaskInput định dạng USD trừ khi được bảo khác. Đọc cả hai từ cài đặt của workspace.

region settings
// Country and currency belong to the WORKSPACE, not to the reader's language:
// a Vietnamese speaker on an English interface can still invoice in VND.
<PhoneInput defaultCountry={workspace.country} />      // no default — "VN" is not assumed
<MaskInput mask="currency" currency={workspace.currency} />  // defaults to USD, like upstream

// ⚠ With no country selected, PhoneInput leaves a domestic number such as
// 0912 345 678 as typed instead of emitting +84912345678.

Ngoài React, và ngoài cây component

Server Component, template email hay route handler không có context để đọc, nên các bảng chữ cũng được export dưới dạng dữ liệu thuần: getComitorMessages(locale, override?) trả về đúng bảng đã gộp mà provider dựng, từ một module không có 'use client' phía sau.

app/invoices/[id]/page.tsx
// app/invoices/[id]/page.tsx — a Server Component, or an email template, or a
// route handler: no React context there, so read the table directly.
import { getLocale } from 'next-intl/server'
import { getComitorMessages } from '@comitor/ui'

export default async function InvoicePage() {
  const locale = await getLocale()
  const t = getComitorMessages(locale) // a pure module — no 'use client' behind it

  return <p>{t.tablePagination.pageStatus(1, 12)}</p> // "Trang 1 / 12" for vi
}
  • Đặt toaster bên trong provider. toastPromise chạy ngoài React, nên khi không được truyền chữ loading, nó mượn ngôn ngữ của toaster đang render nó — Toaster từ @comitor/ui hoặc SonnerToaster của shell. Toaster của shell chỉ chuyển ngôn ngữ sang từ 3.0.1; ở 3.0.0, mặc định của nó vẫn là tiếng Anh.
  • DataGrid theo cùng provider. Prop locale riêng của nó và DataGridLocaleProvider vẫn thắng khi có mặt — xem Data Grid.
  • Tự đặt <html lang>. Provider đổi chuỗi và định dạng, không bao giờ đổi thuộc tính ngôn ngữ của tài liệu — mà thuộc tính đó là thứ trình đọc màn hình dùng để chọn cách phát âm.

6. Render thử

Thiết lập chỉ có vậy. Mọi component đến từ cùng một lệnh import.

Invite a teammate

They will get access to this workspace.

Active

Chín entry

Cách chia không phải để cho gọn. Gói build một ES module cho mỗi file nguồn, nên một barrel re-export một file có import next sẽ buộc mọi nơi dùng gói phải cài Next, nếu không thì hỏng lúc resolve. Vì thế mỗi peer tuỳ chọn nằm sau một cửa riêng.

PropKiểuMặc địnhMô tả
@comitor/uiTier 1 + Tier 2—Primitive, composite, cn, và token thiết kế. Đây là entry được import nhiều nhất.
@comitor/ui/shellTier 3next, next-themesAppShell, Sidebar, AppHeader, năm provider của trục hiển thị, và dấu hiệu thương hiệu.
@comitor/ui/chartchartsrechartsChart* (shadcn) và Comitor*Chart, cùng CHART_COLORS.
@comitor/ui/data-gridspreadsheet grid@tanstack/react-tableDataGrid, useDataGrid, các menu thanh công cụ và hook undo/redo — nhập liệu hàng loạt với hàng ảo hoá và clipboard theo kiểu Excel.
@comitor/ui/formformsreact-hook-formPhần nối của shadcn: Form, FormField (lớp bọc Controller), FormMessage…
@comitor/ui/uploaderuploads@uppy/core, @uppy/xhr-uploadFileUpload, ImageUploadField và hook useFileUpload — Uppy chạy headless, nên markup là của gói.
@comitor/ui/editortext editors@tiptap/*, lowlightMarkdownEditor (lưu Markdown, bubble kiểu Linear) và RichTextEditor (lưu HTML, thanh công cụ cố định), trên cùng một lõi Tiptap.
@comitor/ui/chatAI conversation@tiptap/*, lowlightChatThread, ChatMessage, ChatSteps, ChatSources và ChatComposer — khung của một cuộc hội thoại với trợ lý. Tin nhắn do MarkdownEditor chỉ đọc vẽ ra, nên entry này cần các peer của editor và không gì thêm.
@comitor/ui/tokenstokens—Giá trị token dưới dạng object TS thuần, cho canvas, PDF và email. Không bao giờ dùng cho mã Tailwind — hãy dùng class.
app/(app)/layout.tsx
// Tier 3 — the whole application frame in one component.
import { AppShell } from '@comitor/ui/shell'

export default function AppLayout({ children }: { children: React.ReactNode }) {
  return (
    <AppShell nav={NAV} workspaces={WORKSPACES} apps={APPS} user={USER}>
      {children}
    </AppShell>
  )
}

Ba điều nên biết sớm

Hai mươi tên xuất hiện ở hơn một entry

export * chỉ báo lỗi tên trùng trong cùng một entry. Một tên được export từ hai entry khác nhau thì hoàn toàn im lặng — không lỗi build, không cảnh báo, và hai lệnh import trông y hệt nhau ở chỗ gọi. Nên cách duy nhất để trả lời “đây là một binding hay hai?” là tra. Hai mươi tên ở vào thế đó, và đúng hai trong số đó thật sự rẽ nhánh.

TênỞ @comitor/uiCũng có ởCùng binding?
FormFieldFormFieldPropsComposite tầng 2 — lưới 12 cột chứa nhãn, điều khiển, mô tả và lỗi.@comitor/ui/formLớp bọc quanh Controller của react-hook-form, tức API của shadcn.Không — hai component khác nhau
normalizeVigetVietnameseInitialsĐịnh nghĩa một lần, trong các tiện ích chữ của tầng 2.@comitor/ui/shellRe-export đúng hàm đó. Shell không giữ bản sao riêng.Có — một thân hàm
goldashbrandNeutralaccentaccentInkaccentForegroundsemantichighContraststatuschartlayouttypographyThemeModegetAvatarToneColorsgetAvatarToneIndexAvatarToneColorsEntry chính re-export nguyên module token.@comitor/ui/tokensCùng module đó, thu hẹp chỉ còn các hằng, cho nơi không có Tailwind.Có — một module, hai cửa

Luật dùng: hằng ngày lấy binding ở @comitor/ui, và chỉ dùng tới /form khi app đã dùng react-hook-form. Chỉ FormField và FormFieldProps là hai bản hiện thực — đừng bao giờ import cả hai. Mọi tên khác trong bảng là một binding nhìn qua hai cửa, nên dùng cửa nào cũng chỉ đổi dòng import. Điều đó đáng giữ: getVietnameseInitials từng có hai thân hàm viết hoa khác nhau, và cùng một tên lặng lẽ trả về chữ viết tắt khác nhau tuỳ nơi import.

Hai trường hợp suýt trùng không có trong danh sách. Toaster và SonnerToaster là hai component khác nhau với hai tên cố ý khác nhau, nên không bao giờ đụng nhau — cái đầu nhận prop theme, cái sau tự đọc next-themes. Còn useIsMac chỉ có ở @comitor/ui; /shell dùng nó bên trong nhưng không re-export.

Server Component render được gói, nhưng không cấu hình gói bằng hàm được

Phần lớn component mang sẵn 'use client' bên trong gói, nên một trang server render chúng mà không cần chỉ thị riêng — phần đó tự chạy. Thứ không qua được từ server sang client là hàm: một nhãn chèn số, một hàm định dạng, một locale date-fns. Trình kiểm kiểu chấp nhận cả ba, rồi trang không render được. Hook (useRating, useStepper, useComitorLocale…) cố ý ném lỗi trên server. Bản đồ đầy đủ thứ gì chạy ở đâu nằm ở trang Server và client.

app/products/[id]/page.tsx
// app/products/[id]/page.tsx — a Server Component
import { Rating } from '@comitor/ui'

// ✓ strings, numbers and booleans cross the server → client boundary
<Rating value={4.5} step={0.5} readOnly aria-label="Rated 4.5 out of 5" />

// ✗ a function does not. The page fails to render with
//   "Functions cannot be passed directly to Client Components…"
<Rating value={4.5} step={0.5} readOnly itemLabel={(value) => `${value} étoiles`} />

// Same for a date-fns locale object, a labels key that interpolates a number,
// a formatter. Move the function into a component that starts with
// 'use client', and render THAT from the server page.

Cả chín entry nạp được dưới Node ESM thuần

Không chỉ trong bundler. Mỗi entry tự resolve qua map exports của gói dưới Node, và đó là thứ app dựa vào mỗi khi gói được dùng ngoài một bản build: một script render email hay PDF từ /tokens, một test runner trong môi trường node, một bước sinh mã. Import theo tên gói thay vì thò vào dist/ — đường dẫn sâu bỏ qua map exports và không phải entry được hỗ trợ.

scripts/render-email.mjs
// A plain Node script — no bundler, no transpile step, no framework.
// Import by package NAME so resolution goes through the exports map;
// reaching into dist/ bypasses it and is not supported.
import { semantic, gold } from '@comitor/ui/tokens'
import { getVietnameseInitials } from '@comitor/ui'

// /shell resolves too, and it is the one you would bet against:
// it reaches for next/*. Every internal specifier in the package
// carries its .js extension, which is what Node demands and what
// a bundler would have papered over.
import { isNavItemActive } from '@comitor/ui/shell'

const cell = `<td style="background:${semantic.light.card};color:${gold[500]}">`
console.log(getVietnameseInitials('Nguyễn Văn An')) // NA

Tính chất này đáng nói ra vì nó quá dễ mất. Bundler tự bù phần đuôi file bị thiếu; Node thì không, nên chỉ một specifier nội bộ viết next/link thay vì next/link.js là vô hình trong mọi app Next nhưng làm sập một script — ERR_MODULE_NOT_FOUND lúc import, trước khi bất kỳ dòng mã nào của app chạy. Một cổng phát hành nạp cả chín entry theo tên và đánh trượt bản phát hành nếu entry nào không lên, nên điều này đúng cho chính phiên bản được cài chứ không chỉ cho bản đã được test.

Đi tiếp đâu