@comitor/ui 1.7.0 → 1.8.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -8,8 +8,8 @@ component ghép sẵn và khung ứng dụng (shell) kiểu workspace × app.
8
8
  - Token brand nằm ở **`styles.css`** — đổi token, bump version, mọi app nhận cùng một thay đổi.
9
9
  - Server-component-friendly: `"use client"` chỉ nằm ở file thật sự cần.
10
10
 
11
- > Trạng thái: `1.0.0` — 52 file primitive (Tier 1) · 31 file composite (Tier 2) · 25 file shell
12
- > (Tier 3) · 4 file chart.
11
+ > Trạng thái: `1.7.1` — 52 file primitive (Tier 1) · 31 file composite (Tier 2) · 25 file shell
12
+ > (Tier 3) · 4 file chart · 5 file uploader. API chốt từ `1.0.0`.
13
13
  >
14
14
  > Tài liệu đầy đủ: **https://design-system.comitor.ai** —
15
15
  > [Get started](https://design-system.comitor.ai/get-started) ·
@@ -49,6 +49,8 @@ pnpm add @uppy/core @uppy/xhr-upload # cho @comitor/ui/uploader
49
49
  | `next-themes` | `>=0.4` | — | `/shell` (context singleton — app và package phải dùng **cùng một bản**) |
50
50
  | `recharts` | `>=2.15.0` | — | `/chart` (kiểu đã dựng cho **cả 2.x lẫn 3.x**, xem ghi chú dưới) |
51
51
  | `react-hook-form` | `^7.54.0` | — | `/form` (context singleton) |
52
+ | `@uppy/core` | `^6.0.0` | — | `/uploader` |
53
+ | `@uppy/xhr-upload` | `^6.0.0` | — | `/uploader` |
52
54
 
53
55
  > **Vì sao `recharts` và `lucide-react` là `>=` chứ không phải `^`.** `^2.15.0` loại thẳng recharts
54
56
  > 3 — mà 3.x chạy tốt với gói này: kiểu của tầng `/chart` không dẫn xuất `payload`/`label` từ
@@ -103,7 +105,7 @@ buộc ở thẻ `<html>`: https://design-system.comitor.ai/shell
103
105
 
104
106
  ## 3. Entry point
105
107
 
106
- Package có **5 entry**, tách theo peer optional mà mỗi tầng kéo theo:
108
+ Package có **6 entry**, tách theo peer optional mà mỗi tầng kéo theo:
107
109
 
108
110
  | Entry | Nội dung | Peer optional kéo theo |
109
111
  |---|---|---|
@@ -114,8 +116,10 @@ Package có **5 entry**, tách theo peer optional mà mỗi tầng kéo theo:
114
116
  | `@comitor/ui/form` | Binding shadcn ⇄ react-hook-form | `react-hook-form` |
115
117
  | `@comitor/ui/tokens` | Token dạng object TS — cho nơi không có Tailwind: canvas, email HTML, PDF | không có |
116
118
 
117
- ⚠ **20 tên xuất hiện ở nhiều hơn một entry**, và `export *` chỉ báo đỏ khi trùng trong **cùng một**
118
- entry — trùng xuyên entry thì im lặng hoàn toàn. Đúng **2** trong số đó là hai cài đặt khác nhau
119
+ ⚠ **Một số tên xuất hiện ở nhiều hơn một entry**, và `export *` chỉ báo đỏ khi trùng trong **cùng
120
+ một** entry — trùng xuyên entry thì im lặng hoàn toàn. (Đếm chính xác bao nhiêu tên thì tuỳ cách
121
+ đếm — "cùng ký tự" hay "cùng binding sau khi giải alias" cho hai kết quả khác nhau — nên đừng ghim
122
+ một con số ở đây. Bất biến cần giữ: cùng tên ⇒ **cùng một binding**.) Đúng **2** trong số đó là hai cài đặt khác nhau
119
123
  (`FormField` / `FormFieldProps`: ở Tier 2 là bố cục form 12 cột, ở `/form` là wrapper của
120
124
  `Controller`); 18 tên còn lại trỏ về cùng một binding nên lấy ở đâu cũng như nhau. Bảng đầy đủ, kèm
121
125
  hai ca **không** trùng tên hay bị tưởng nhầm (`Toaster` ⇄ `SonnerToaster`, và `useIsMac` chỉ xuất ở
@@ -155,7 +159,7 @@ https://design-system.comitor.ai/foundations/server-client
155
159
 
156
160
  ## 5. Bảng component
157
161
 
158
- Số file nguồn hiện tại: **52 primitive · 31 composite · 25 shell · 4 chart**. Mỗi component có trang
162
+ Số file nguồn hiện tại: **52 primitive · 31 composite · 25 shell · 4 chart · 5 uploader**. Mỗi component có trang
159
163
  riêng kèm demo, bảng prop và cạm bẫy của nó:
160
164
 
161
165
  | Tầng | Là gì | Trang |
@@ -219,21 +223,26 @@ https://design-system.comitor.ai/foundations/colors
219
223
  ## 7. Kiểm định trước mỗi lần phát hành
220
224
 
221
225
  Mỗi bản publish đi qua cùng một chuỗi cổng (`prepublishOnly` nối chúng lại): typecheck → build →
222
- `verify:directives` → `smoke:entries` → `check:exports` → `test:nav-active` → `test:a11y-gate` →
223
- `lint:a11y`. Những cổng đáng để người dùng gói biết là chúng tồn tại:
226
+ `verify:directives` → `smoke:entries` → `check:exports` → `test:nav-active` → `test:grid-contract`
227
+ → `test:a11y-gate` → `lint:a11y`. Những cổng đáng để người dùng gói biết là chúng tồn tại:
224
228
 
225
229
  - **`lint:a11y` — hợp đồng màu.** Quét **toàn bộ** `src/**/*.{ts,tsx}` theo QUY TẮC (không theo danh
226
230
  sách phát hiện) và đối chiếu với `styles.css`: token TÔ NỀN đem làm màu chữ/icon/viền là sai
227
231
  *vai trò*, và mọi cặp chữ/nền phải đạt 4,5:1 (chữ) hoặc 3:1 (thành phần phi văn bản) ở **cả**
228
- light lẫn dark. Cổng đo **bảng tương phản cao** (`:root` + `[data-contrast="high"]`), không đo
229
- bảng mặc định — bảng mặc định là bản duyệt và đã được chấp nhận với đúng những con số dưới ngưỡng
230
- ở §6; bắt nó là bắt một quyết định, và một cổng lúc nào cũng đỏ thì lần sau không ai chạy nữa.
231
- Mọi loại phát hiện đều `exit 1`, kể cả lớp dùng bảng màu mặc định của Tailwind và miễn trừ đã
232
- thừa; miễn trừ mỗi mục bắt buộc kèm lý do bằng chữ.
232
+ light lẫn dark. **Bảng màu là một TRỤC** (từ `1.6.0`): cổng đo cả bốn tổ hợp bảng × theme, nhưng
233
+ chỉ **CHẶN** ở bảng tương phản cao. Bảng mặc định vào một sổ riêng, IN RA mỗi lần chạy nhưng
234
+ không làm đỏ — nó là bản duyệt và đã được chấp nhận với đúng những con số dưới ngưỡng ở §6; bắt
235
+ nó là bắt một quyết định, và một cổng lúc nào cũng đỏ thì lần sau không ai chạy nữa.
236
+ ⚠ **Cổng xanh vì thế KHÔNG có nghĩa bảng mặc định đạt** — đọc khối "BẢNG MÀU MẶC ĐỊNH: N cặp dưới
237
+ ngưỡng" mà nó in ra (hôm nay N = 384, trong đó 78% nằm ở ba token: `text-muted-foreground`,
238
+ `text-destructive-ink`, `border-input`). Trước `1.6.0` cổng còn không ĐO bảng mặc định lần nào,
239
+ tức nó in ✓ cho một bảng nó chưa từng nhìn.
240
+ Ở bảng tương phản cao thì mọi loại phát hiện đều `exit 1`, kể cả lớp dùng bảng màu mặc định của
241
+ Tailwind và miễn trừ đã thừa; miễn trừ mỗi mục bắt buộc kèm lý do bằng chữ.
233
242
  - **`verify:directives` — ranh giới server/client.** File nguồn nào mở đầu bằng `"use client"` thì
234
243
  file `dist/` tương ứng phải mở đầu y hệt (thiếu file build cũng tính là hỏng), và không hằng dữ
235
244
  liệu thuần nào được kẹt trong file client (§4.2).
236
- - **`smoke:entries`** nạp cả 5 entry bằng Node ESM thuần, qua đúng `exports` map (§3).
245
+ - **`smoke:entries`** nạp cả 6 entry bằng Node ESM thuần, qua đúng `exports` map (§3).
237
246
  - **`test:a11y-gate`** tự kiểm chính cổng màu (cổng còn bắt đủ không); **`test:nav-active`** khoá
238
247
  toàn bộ luật active matching của sidebar dưới dạng hàm thuần (§4.1).
239
248
 
@@ -21,7 +21,7 @@ function RadioGroupItem({ className, ...props }) {
21
21
  {
22
22
  "data-slot": "radio-group-indicator",
23
23
  className: "relative flex items-center justify-center",
24
- children: /* @__PURE__ */ jsx(CircleIcon, { className: "fill-control-on absolute top-1/2 left-1/2 size-2 -translate-x-1/2 -translate-y-1/2" })
24
+ children: /* @__PURE__ */ jsx(CircleIcon, { className: "fill-control-on stroke-control-on absolute top-1/2 left-1/2 size-2 -translate-x-1/2 -translate-y-1/2" })
25
25
  }
26
26
  )
27
27
  }
@@ -1 +1 @@
1
- {"version":3,"sources":["../../../src/components/ui/radio-group.tsx"],"sourcesContent":["\"use client\";\n\nimport * as RadioGroupPrimitive from \"@radix-ui/react-radio-group\";\nimport { CircleIcon } from \"lucide-react\";\nimport type * as React from \"react\";\nimport { cn } from \"../../lib/cn.js\";\n\n/*\n * ─ CHẤM ĐÃ CHỌN: `--control-on`, KHÔNG PHẢI `--primary` ─\n *\n * Ô radio để nền trong suốt, nên \"đã chọn hay chưa\" chỉ còn được kể bằng cái CHẤM ở giữa —\n * thông tin thị giác đúng nghĩa của WCAG 1.4.11, ngưỡng 3:1. `fill-primary` (gold #E8B824)\n * trên nền trắng chỉ **1,86:1** ✗: ở light người dùng nhìn ô đã chọn gần như y hệt ô chưa chọn.\n * (Ở dark thì lọt lưới nên lỗi này chỉ lộ ở một nửa số theme.)\n * `--control-on` là token của vai trò \"control ở trạng thái BẬT/ĐÃ CHỌN\"; giá trị của nó đổi theo\n * bảng màu (styles.css khối 5b), nên ĐỪNG viết thẳng `fill-primary` / `bg-gold-300` ở đây.\n * Đo với nền TRANG, lấy ca tệ nhất trong 5 nền (`--muted`) và ca thường gặp (`--background`):\n *\n * bảng MẶC ĐỊNH chấm ⇄ nền trang : 1,73–1,86:1 ✗ (bản duyệt, đã chấp nhận)\n * bảng TƯƠNG PHẢN CAO chấm ⇄ nền trang : 6,09–6,51:1 (light) · 10,80–12,67:1 (dark) ✓\n *\n * ─ LỚP TÔ Ở DARK: `dark:bg-input-fill` ─\n * comitor-ds viết `dark:bg-input/30`, và ở bảng mặc định lớp tô đó là thứ DUY NHẤT còn khoanh\n * vùng ô radio: viền `--input` (#1E2128) trên `--card` (#16181D) chỉ 1,10:1. Nhưng viết thẳng\n * `bg-input/30` thì ở bảng tương phản cao (`--input` = #7E8290) lớp tô sáng hơn mọi nền trang và\n * kéo `--muted-foreground` xuống 3,81 · 3,54 · 3,22:1 ✗ — nên nó đi qua `--input-fill`, biến này\n * trong suốt ở bảng tương phản cao (bên đó viền tự đủ đậm: 4,94:1).\n *\n * Chấm đã chọn tô bằng `fill-control-on` ĐẶT THẲNG trên `<CircleIcon>`, không qua `currentColor`.\n * Bản trước còn `text-control-on` trên Item với lý do \"để icon app truyền vào cũng đúng màu\" —\n * nhưng Item render Indicator cứng bên trong nên không có icon nào của app đi vào đây được, và\n * `text-*` thì cổng tương phản đọc là CHỮ (ngưỡng 4,5:1) trong khi chấm radio là HÌNH (3:1).\n * Một class không ai dùng mà lại làm cổng báo sai chỗ thì bỏ.\n * Viền lỗi dùng `--destructive-ink`: ở bảng mặc định nó bằng `--destructive` (đúng `border-red`\n * của comitor-ds), ở bảng tương phản cao nó là bậc tối riêng (7,15:1 / 6,84:1) — xem hợp đồng\n * màu §1: bậc đi lên nền TRANG luôn là `-ink`.\n */\nexport type RadioGroupProps = React.ComponentProps<typeof RadioGroupPrimitive.Root>;\nexport type RadioGroupItemProps = React.ComponentProps<typeof RadioGroupPrimitive.Item>;\n\nfunction RadioGroup({ className, ...props }: RadioGroupProps) {\n return <RadioGroupPrimitive.Root data-slot=\"radio-group\" className={cn(\"grid gap-3\", className)} {...props} />;\n}\n\nfunction RadioGroupItem({ className, ...props }: RadioGroupItemProps) {\n return (\n <RadioGroupPrimitive.Item\n data-slot=\"radio-group-item\"\n className={cn(\n \"border-choice-edge focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring/50 focus-visible:ring-offset-2 focus-visible:ring-offset-background aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive-ink dark:bg-input-fill aspect-square size-4 shrink-0 rounded-full border shadow-xs transition-colors duration-150 ease-out outline-none disabled:cursor-not-allowed disabled:opacity-50\",\n className\n )}\n {...props}\n >\n <RadioGroupPrimitive.Indicator\n data-slot=\"radio-group-indicator\"\n className=\"relative flex items-center justify-center\"\n >\n <CircleIcon className=\"fill-control-on absolute top-1/2 left-1/2 size-2 -translate-x-1/2 -translate-y-1/2\" />\n </RadioGroupPrimitive.Indicator>\n </RadioGroupPrimitive.Item>\n );\n}\n\nexport { RadioGroup, RadioGroupItem };\n"],"mappings":";AAyCS;AAvCT,YAAY,yBAAyB;AACrC,SAAS,kBAAkB;AAE3B,SAAS,UAAU;AAmCnB,SAAS,WAAW,EAAE,WAAW,GAAG,MAAM,GAAoB;AAC5D,SAAO,oBAAC,oBAAoB,MAApB,EAAyB,aAAU,eAAc,WAAW,GAAG,cAAc,SAAS,GAAI,GAAG,OAAO;AAC9G;AAEA,SAAS,eAAe,EAAE,WAAW,GAAG,MAAM,GAAwB;AACpE,SACE;AAAA,IAAC,oBAAoB;AAAA,IAApB;AAAA,MACC,aAAU;AAAA,MACV,WAAW;AAAA,QACT;AAAA,QACA;AAAA,MACF;AAAA,MACC,GAAG;AAAA,MAEJ;AAAA,QAAC,oBAAoB;AAAA,QAApB;AAAA,UACC,aAAU;AAAA,UACV,WAAU;AAAA,UAEV,8BAAC,cAAW,WAAU,sFAAqF;AAAA;AAAA,MAC7G;AAAA;AAAA,EACF;AAEJ;","names":[]}
1
+ {"version":3,"sources":["../../../src/components/ui/radio-group.tsx"],"sourcesContent":["\"use client\";\n\nimport * as RadioGroupPrimitive from \"@radix-ui/react-radio-group\";\nimport { CircleIcon } from \"lucide-react\";\nimport type * as React from \"react\";\nimport { cn } from \"../../lib/cn.js\";\n\n/*\n * ─ CHẤM ĐÃ CHỌN: `--control-on`, KHÔNG PHẢI `--primary` ─\n *\n * Ô radio để nền trong suốt, nên \"đã chọn hay chưa\" chỉ còn được kể bằng cái CHẤM ở giữa —\n * thông tin thị giác đúng nghĩa của WCAG 1.4.11, ngưỡng 3:1. `fill-primary` (gold #E8B824)\n * trên nền trắng chỉ **1,86:1** ✗: ở light người dùng nhìn ô đã chọn gần như y hệt ô chưa chọn.\n * (Ở dark thì lọt lưới nên lỗi này chỉ lộ ở một nửa số theme.)\n * `--control-on` là token của vai trò \"control ở trạng thái BẬT/ĐÃ CHỌN\"; giá trị của nó đổi theo\n * bảng màu (styles.css khối 5b), nên ĐỪNG viết thẳng `fill-primary` / `bg-gold-300` ở đây.\n * Đo với nền TRANG, lấy ca tệ nhất trong 5 nền (`--muted`) và ca thường gặp (`--background`):\n *\n * bảng MẶC ĐỊNH chấm ⇄ nền trang : 1,73–1,86:1 ✗ (bản duyệt, đã chấp nhận)\n * bảng TƯƠNG PHẢN CAO chấm ⇄ nền trang : 6,09–6,51:1 (light) · 10,80–12,67:1 (dark) ✓\n *\n * ─ LỚP TÔ Ở DARK: `dark:bg-input-fill` ─\n * comitor-ds viết `dark:bg-input/30`, và ở bảng mặc định lớp tô đó là thứ DUY NHẤT còn khoanh\n * vùng ô radio: viền `--input` (#1E2128) trên `--card` (#16181D) chỉ 1,10:1. Nhưng viết thẳng\n * `bg-input/30` thì ở bảng tương phản cao (`--input` = #7E8290) lớp tô sáng hơn mọi nền trang và\n * kéo `--muted-foreground` xuống 3,81 · 3,54 · 3,22:1 ✗ — nên nó đi qua `--input-fill`, biến này\n * trong suốt ở bảng tương phản cao (bên đó viền tự đủ đậm: 4,94:1).\n *\n * ─ CHẤM PHẢI KHAI CẢ `fill-` LẪN `stroke-`, KHÔNG BAO GIỜ CHỈ MỘT ─\n *\n * `<CircleIcon>` là icon lucide, và lucide mặc định `stroke=\"currentColor\"` + `strokeWidth={2}`.\n * Khai mỗi `fill-control-on` là chấm được TÔ gold rồi VIỀN bằng màu chữ kế thừa — hai màu trên\n * một hình 8px.\n *\n * ⚠ Đây là một hồi quy đã xảy ra thật, và nó chui lọt vì lập luận gỡ class chỉ xét MỘT tác dụng.\n * `0.5.0` (765f6e8) gỡ `text-control-on` khỏi Item với lý do \"một class không ai dùng\": Item\n * render Indicator cứng nên không có icon nào của app đi vào, và `text-*` làm cổng tương phản đọc\n * chấm là CHỮ (4,5:1) trong khi nó là HÌNH (3:1). Cả hai vế đều ĐÚNG. Nhưng `text-control-on`\n * còn một tác dụng thứ hai không ai nêu: nó đặt `currentColor` = gold, tức nó chính là thứ đang\n * tô `stroke` mặc định của lucide. Gỡ nó đi thì `currentColor` rơi về `--foreground`.\n *\n * Đo trên app thật (`/tasks/new`, radio \"Loại yêu cầu\", trước khi sửa):\n * fill rgb(232,184,36) #E8B824 ✓ đúng token\n * stroke rgb(14,17,22) #0E1116 ✗ = --foreground, dày 0,67px quanh chấm 8px\n *\n * Nay `stroke-control-on` khai THẲNG, không mượn `currentColor` — vừa trả lại hình đã duyệt, vừa\n * bỏ được sợi dây vô hình giữa một class `text-*` ở Item và màu viền của một icon ở Indicator.\n * ⚠ ĐỪNG \"gọn hơn\" bằng `stroke-none`: nét vẽ nằm giữa đường tròn `r=10` trong viewBox 24, nên\n * bỏ nó là chấm co từ bán kính 11 xuống 10 đơn vị — nhỏ đi 10% đường kính (7,33px → 6,67px) so\n * với bản đã duyệt. Hai class cùng token là đúng ý đồ, không phải thừa.\n * Viền lỗi dùng `--destructive-ink`: ở bảng mặc định nó bằng `--destructive` (đúng `border-red`\n * của comitor-ds), ở bảng tương phản cao nó là bậc tối riêng (7,15:1 / 6,84:1) — xem hợp đồng\n * màu §1: bậc đi lên nền TRANG luôn là `-ink`.\n */\nexport type RadioGroupProps = React.ComponentProps<typeof RadioGroupPrimitive.Root>;\nexport type RadioGroupItemProps = React.ComponentProps<typeof RadioGroupPrimitive.Item>;\n\nfunction RadioGroup({ className, ...props }: RadioGroupProps) {\n return <RadioGroupPrimitive.Root data-slot=\"radio-group\" className={cn(\"grid gap-3\", className)} {...props} />;\n}\n\nfunction RadioGroupItem({ className, ...props }: RadioGroupItemProps) {\n return (\n <RadioGroupPrimitive.Item\n data-slot=\"radio-group-item\"\n className={cn(\n \"border-choice-edge focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring/50 focus-visible:ring-offset-2 focus-visible:ring-offset-background aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive-ink dark:bg-input-fill aspect-square size-4 shrink-0 rounded-full border shadow-xs transition-colors duration-150 ease-out outline-none disabled:cursor-not-allowed disabled:opacity-50\",\n className\n )}\n {...props}\n >\n <RadioGroupPrimitive.Indicator\n data-slot=\"radio-group-indicator\"\n className=\"relative flex items-center justify-center\"\n >\n <CircleIcon className=\"fill-control-on stroke-control-on absolute top-1/2 left-1/2 size-2 -translate-x-1/2 -translate-y-1/2\" />\n </RadioGroupPrimitive.Indicator>\n </RadioGroupPrimitive.Item>\n );\n}\n\nexport { RadioGroup, RadioGroupItem };\n"],"mappings":";AA0DS;AAxDT,YAAY,yBAAyB;AACrC,SAAS,kBAAkB;AAE3B,SAAS,UAAU;AAoDnB,SAAS,WAAW,EAAE,WAAW,GAAG,MAAM,GAAoB;AAC5D,SAAO,oBAAC,oBAAoB,MAApB,EAAyB,aAAU,eAAc,WAAW,GAAG,cAAc,SAAS,GAAI,GAAG,OAAO;AAC9G;AAEA,SAAS,eAAe,EAAE,WAAW,GAAG,MAAM,GAAwB;AACpE,SACE;AAAA,IAAC,oBAAoB;AAAA,IAApB;AAAA,MACC,aAAU;AAAA,MACV,WAAW;AAAA,QACT;AAAA,QACA;AAAA,MACF;AAAA,MACC,GAAG;AAAA,MAEJ;AAAA,QAAC,oBAAoB;AAAA,QAApB;AAAA,UACC,aAAU;AAAA,UACV,WAAU;AAAA,UAEV,8BAAC,cAAW,WAAU,wGAAuG;AAAA;AAAA,MAC/H;AAAA;AAAA,EACF;AAEJ;","names":[]}
@@ -6,8 +6,18 @@ interface SwitchProps extends React.ComponentProps<typeof SwitchPrimitive.Root>
6
6
  size?: "sm" | "md";
7
7
  /** Nhãn đặt cạnh công tắc; có nhãn thì component tự bọc thêm lớp bố cục. */
8
8
  label?: string;
9
- /** Mô tả phụ dưới nhãn. */
10
- description?: string;
9
+ /**
10
+ * Mô tả phụ dưới nhãn.
11
+ *
12
+ * `ReactNode` chứ không phải `string`, và đó là chủ ý: mô tả hay phải mang theo một mảnh giao
13
+ * diện — một dấu "chưa hoạt động", một `<strong>`, một liên kết. Khi kiểu còn là `string`, app
14
+ * buộc phải kéo mô tả RA NGOÀI bố cục của component rồi tự căn lề bằng số đo lấy từ nội thất của
15
+ * gói (`w-9` + `gap-2.5`) — tức chép hằng số riêng của gói vào app, và một bản nâng gói sẽ phá
16
+ * chỗ đó trong im lặng. Đã xảy ra thật ở `comitor-starter`.
17
+ *
18
+ * Không đổi gì ở runtime: giá trị vẫn được render làm children của cùng một `<p>`.
19
+ */
20
+ description?: React.ReactNode;
11
21
  }
12
22
  declare function Switch({ className, size, label, description, id, ...props }: SwitchProps): React.JSX.Element;
13
23
 
@@ -1 +1 @@
1
- {"version":3,"sources":["../../../src/components/ui/switch.tsx"],"sourcesContent":["\"use client\";\n\nimport * as SwitchPrimitive from \"@radix-ui/react-switch\";\nimport * as React from \"react\";\nimport { cn } from \"../../lib/cn.js\";\n\n/* Bảng cỡ tách ra ngoài component để không dựng lại object mỗi lần render. */\nconst trackSizes = {\n sm: \"h-4 w-7\",\n md: \"h-5 w-9\"\n} as const;\n\n/*\n * Quãng dịch của núm phải để hở HAI BÊN BẰNG NHAU: núm nghỉ ở `translate-x-0.5` (2px), nên khi\n * BẬT nó phải dừng ở `rãnh − núm − 2px`, không phải ở một số tròn.\n *\n * md 36 − 16 − 2 = 18px → translate-x-4.5 (trước: translate-x-4 = 16px → hở phải 4px)\n * sm 28 − 12 − 2 = 14px → translate-x-3.5 (trước: translate-x-3 = 12px → hở phải 4px)\n *\n * Bản trước dùng số tròn nên công tắc hở 2px lúc TẮT mà 4px lúc BẬT — mắt bắt được ngay khi hai\n * công tắc khác trạng thái đứng cạnh nhau trong cùng một danh sách.\n */\nconst thumbSizes = {\n sm: \"size-3 data-[state=checked]:translate-x-3.5\",\n md: \"size-4 data-[state=checked]:translate-x-4.5\"\n} as const;\n\nexport interface SwitchProps extends React.ComponentProps<typeof SwitchPrimitive.Root> {\n /** Cỡ công tắc. Mặc định `md`. */\n size?: \"sm\" | \"md\";\n /** Nhãn đặt cạnh công tắc; có nhãn thì component tự bọc thêm lớp bố cục. */\n label?: string;\n /** Mô tả phụ dưới nhãn. */\n description?: string;\n}\n\n/*\n * Style port từ comitor-ds (đổi gray-* → ash-* theo namespace của package).\n *\n * ─ BA LỚP MÀU, BA TOKEN VAI TRÒ — KHÔNG viết thẳng thang màu ─\n *\n * rãnh TẮT `bg-switch-track` (comitor-ds: bg-gray-300 / dark:bg-gray-700)\n * rãnh BẬT `data-[state=checked]:bg-control-on` (comitor-ds: bg-gold-300)\n * núm `bg-control-knob` (comitor-ds: bg-white)\n *\n * Ba token này là chỗ DUY NHẤT phân biệt hai bảng màu của gói (styles.css khối 5b). Viết thẳng\n * `bg-ash-300` / `bg-gold-300` / `bg-white` là khoá cứng bảng mặc định và bảng tương phản cao\n * lặng lẽ không đổi được công tắc — không có gì báo đỏ.\n *\n * ─ CÔNG TẮC LÀ THÀNH PHẦN PHI VĂN BẢN: BA PHÉP ĐO, KHÔNG PHẢI MỘT ─\n *\n * WCAG 1.4.11 đòi 3:1 cho \"thông tin thị giác cần để nhận ra thành phần và TRẠNG THÁI của nó\".\n * Với công tắc, đó là ba cặp:\n *\n * (1) rãnh ⇄ nền trang → có nhìn thấy cái công tắc không\n * (2) núm ⇄ rãnh → núm đang nằm bên nào\n * (3) rãnh BẬT ⇄ rãnh TẮT → hai trạng thái có khác nhau không\n *\n * Số đo của hai bảng — bảng mặc định là bản duyệt comitor-ds, nó KHÔNG đạt (1) và (2), và đó là\n * quyết định đã chấp nhận (xem khối NGUỒN SỰ THẬT đầu styles.css):\n *\n * bảng MẶC ĐỊNH (light) bảng TƯƠNG PHẢN CAO (light · dark)\n * rãnh TẮT ⇄ nền 1,52:1 ✗ 3,83:1 · 4,94:1 ✓\n * rãnh BẬT ⇄ nền 1,86:1 ✗ 6,51:1 · 12,67:1 ✓\n * núm ⇄ rãnh BẬT 1,86:1 ✗ 6,51:1 · 12,67:1 ✓\n *\n * ⚠ Ở bảng tương phản cao, (3) rãnh BẬT ⇄ rãnh TẮT chỉ 1,70:1 (light) / 2,57:1 (dark) — cố ý:\n * hai trạng thái còn khác nhau bằng VỊ TRÍ núm, và cả hai rãnh đều tự đạt ≥3:1 với nền trang lẫn\n * với núm. Không có cặp màu nào vừa đạt (1) vừa đạt (3) khi nền trang là trắng.\n *\n * ⚠ ĐỪNG dùng `--muted-foreground` cho rãnh TẮT dù số có vẻ vừa: đó là bậc CHỮ, nó bị kéo\n * tối/sáng theo nhu cầu đọc chữ (đã đổi một lần #7E8290 → #686C7A / #888D9B), tức ràng buộc\n * \"núm còn nổi trên rãnh\" không có gì bảo vệ.\n */\nfunction Switch({ className, size = \"md\", label, description, id, ...props }: SwitchProps) {\n const generatedId = React.useId();\n const switchId = id ?? generatedId;\n\n const switchElement = (\n <SwitchPrimitive.Root\n data-slot=\"switch\"\n id={switchId}\n className={cn(\n \"peer inline-flex shrink-0 items-center rounded-full bg-switch-track data-[state=checked]:bg-control-on\",\n \"transition-colors duration-150 ease-out\",\n \"focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring/50 focus-visible:ring-offset-2 focus-visible:ring-offset-background\",\n \"disabled:cursor-not-allowed disabled:opacity-50\",\n trackSizes[size],\n className\n )}\n {...props}\n >\n <SwitchPrimitive.Thumb\n data-slot=\"switch-thumb\"\n className={cn(\n \"pointer-events-none block translate-x-0.5 rounded-full bg-control-knob shadow-sm ring-0\",\n \"transition-transform duration-150 ease-out\",\n thumbSizes[size]\n )}\n />\n </SwitchPrimitive.Root>\n );\n\n if (!label && !description) {\n return switchElement;\n }\n\n return (\n <div className=\"flex items-start gap-2.5\">\n {switchElement}\n <div className=\"flex flex-col gap-0.5\">\n {label && (\n <label\n htmlFor={switchId}\n className=\"text-sm font-medium text-foreground cursor-pointer select-none leading-none\"\n >\n {label}\n </label>\n )}\n {description && <p className=\"text-xs text-muted-foreground leading-relaxed\">{description}</p>}\n </div>\n </div>\n );\n}\n\nexport { Switch };\n"],"mappings":";AA4FM,cAkBA,YAlBA;AA1FN,YAAY,qBAAqB;AACjC,YAAY,WAAW;AACvB,SAAS,UAAU;AAGnB,MAAM,aAAa;AAAA,EACjB,IAAI;AAAA,EACJ,IAAI;AACN;AAYA,MAAM,aAAa;AAAA,EACjB,IAAI;AAAA,EACJ,IAAI;AACN;AAiDA,SAAS,OAAO,EAAE,WAAW,OAAO,MAAM,OAAO,aAAa,IAAI,GAAG,MAAM,GAAgB;AACzF,QAAM,cAAc,MAAM,MAAM;AAChC,QAAM,WAAW,MAAM;AAEvB,QAAM,gBACJ;AAAA,IAAC,gBAAgB;AAAA,IAAhB;AAAA,MACC,aAAU;AAAA,MACV,IAAI;AAAA,MACJ,WAAW;AAAA,QACT;AAAA,QACA;AAAA,QACA;AAAA,QACA;AAAA,QACA,WAAW,IAAI;AAAA,QACf;AAAA,MACF;AAAA,MACC,GAAG;AAAA,MAEJ;AAAA,QAAC,gBAAgB;AAAA,QAAhB;AAAA,UACC,aAAU;AAAA,UACV,WAAW;AAAA,YACT;AAAA,YACA;AAAA,YACA,WAAW,IAAI;AAAA,UACjB;AAAA;AAAA,MACF;AAAA;AAAA,EACF;AAGF,MAAI,CAAC,SAAS,CAAC,aAAa;AAC1B,WAAO;AAAA,EACT;AAEA,SACE,qBAAC,SAAI,WAAU,4BACZ;AAAA;AAAA,IACD,qBAAC,SAAI,WAAU,yBACZ;AAAA,eACC;AAAA,QAAC;AAAA;AAAA,UACC,SAAS;AAAA,UACT,WAAU;AAAA,UAET;AAAA;AAAA,MACH;AAAA,MAED,eAAe,oBAAC,OAAE,WAAU,iDAAiD,uBAAY;AAAA,OAC5F;AAAA,KACF;AAEJ;","names":[]}
1
+ {"version":3,"sources":["../../../src/components/ui/switch.tsx"],"sourcesContent":["\"use client\";\n\nimport * as SwitchPrimitive from \"@radix-ui/react-switch\";\nimport * as React from \"react\";\nimport { cn } from \"../../lib/cn.js\";\n\n/* Bảng cỡ tách ra ngoài component để không dựng lại object mỗi lần render. */\nconst trackSizes = {\n sm: \"h-4 w-7\",\n md: \"h-5 w-9\"\n} as const;\n\n/*\n * Quãng dịch của núm phải để hở HAI BÊN BẰNG NHAU: núm nghỉ ở `translate-x-0.5` (2px), nên khi\n * BẬT nó phải dừng ở `rãnh − núm − 2px`, không phải ở một số tròn.\n *\n * md 36 − 16 − 2 = 18px → translate-x-4.5 (trước: translate-x-4 = 16px → hở phải 4px)\n * sm 28 − 12 − 2 = 14px → translate-x-3.5 (trước: translate-x-3 = 12px → hở phải 4px)\n *\n * Bản trước dùng số tròn nên công tắc hở 2px lúc TẮT mà 4px lúc BẬT — mắt bắt được ngay khi hai\n * công tắc khác trạng thái đứng cạnh nhau trong cùng một danh sách.\n */\nconst thumbSizes = {\n sm: \"size-3 data-[state=checked]:translate-x-3.5\",\n md: \"size-4 data-[state=checked]:translate-x-4.5\"\n} as const;\n\nexport interface SwitchProps extends React.ComponentProps<typeof SwitchPrimitive.Root> {\n /** Cỡ công tắc. Mặc định `md`. */\n size?: \"sm\" | \"md\";\n /** Nhãn đặt cạnh công tắc; có nhãn thì component tự bọc thêm lớp bố cục. */\n label?: string;\n /**\n * Mô tả phụ dưới nhãn.\n *\n * `ReactNode` chứ không phải `string`, và đó là chủ ý: mô tả hay phải mang theo một mảnh giao\n * diện — một dấu \"chưa hoạt động\", một `<strong>`, một liên kết. Khi kiểu còn là `string`, app\n * buộc phải kéo mô tả RA NGOÀI bố cục của component rồi tự căn lề bằng số đo lấy từ nội thất của\n * gói (`w-9` + `gap-2.5`) — tức chép hằng số riêng của gói vào app, và một bản nâng gói sẽ phá\n * chỗ đó trong im lặng. Đã xảy ra thật ở `comitor-starter`.\n *\n * Không đổi gì ở runtime: giá trị vẫn được render làm children của cùng một `<p>`.\n */\n description?: React.ReactNode;\n}\n\n/*\n * Style port từ comitor-ds (đổi gray-* → ash-* theo namespace của package).\n *\n * ─ BA LỚP MÀU, BA TOKEN VAI TRÒ — KHÔNG viết thẳng thang màu ─\n *\n * rãnh TẮT `bg-switch-track` (comitor-ds: bg-gray-300 / dark:bg-gray-700)\n * rãnh BẬT `data-[state=checked]:bg-control-on` (comitor-ds: bg-gold-300)\n * núm `bg-control-knob` (comitor-ds: bg-white)\n *\n * Ba token này là chỗ DUY NHẤT phân biệt hai bảng màu của gói (styles.css khối 5b). Viết thẳng\n * `bg-ash-300` / `bg-gold-300` / `bg-white` là khoá cứng bảng mặc định và bảng tương phản cao\n * lặng lẽ không đổi được công tắc — không có gì báo đỏ.\n *\n * ─ CÔNG TẮC LÀ THÀNH PHẦN PHI VĂN BẢN: BA PHÉP ĐO, KHÔNG PHẢI MỘT ─\n *\n * WCAG 1.4.11 đòi 3:1 cho \"thông tin thị giác cần để nhận ra thành phần và TRẠNG THÁI của nó\".\n * Với công tắc, đó là ba cặp:\n *\n * (1) rãnh ⇄ nền trang → có nhìn thấy cái công tắc không\n * (2) núm ⇄ rãnh → núm đang nằm bên nào\n * (3) rãnh BẬT ⇄ rãnh TẮT → hai trạng thái có khác nhau không\n *\n * Số đo của hai bảng — bảng mặc định là bản duyệt comitor-ds, nó KHÔNG đạt (1) và (2), và đó là\n * quyết định đã chấp nhận (xem khối NGUỒN SỰ THẬT đầu styles.css):\n *\n * bảng MẶC ĐỊNH (light) bảng TƯƠNG PHẢN CAO (light · dark)\n * rãnh TẮT ⇄ nền 1,52:1 ✗ 3,83:1 · 4,94:1 ✓\n * rãnh BẬT ⇄ nền 1,86:1 ✗ 6,51:1 · 12,67:1 ✓\n * núm ⇄ rãnh BẬT 1,86:1 ✗ 6,51:1 · 12,67:1 ✓\n *\n * ⚠ Ở bảng tương phản cao, (3) rãnh BẬT ⇄ rãnh TẮT chỉ 1,70:1 (light) / 2,57:1 (dark) — cố ý:\n * hai trạng thái còn khác nhau bằng VỊ TRÍ núm, và cả hai rãnh đều tự đạt ≥3:1 với nền trang lẫn\n * với núm. Không có cặp màu nào vừa đạt (1) vừa đạt (3) khi nền trang là trắng.\n *\n * ⚠ ĐỪNG dùng `--muted-foreground` cho rãnh TẮT dù số có vẻ vừa: đó là bậc CHỮ, nó bị kéo\n * tối/sáng theo nhu cầu đọc chữ (đã đổi một lần #7E8290 → #686C7A / #888D9B), tức ràng buộc\n * \"núm còn nổi trên rãnh\" không có gì bảo vệ.\n */\nfunction Switch({ className, size = \"md\", label, description, id, ...props }: SwitchProps) {\n const generatedId = React.useId();\n const switchId = id ?? generatedId;\n\n const switchElement = (\n <SwitchPrimitive.Root\n data-slot=\"switch\"\n id={switchId}\n className={cn(\n \"peer inline-flex shrink-0 items-center rounded-full bg-switch-track data-[state=checked]:bg-control-on\",\n \"transition-colors duration-150 ease-out\",\n \"focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring/50 focus-visible:ring-offset-2 focus-visible:ring-offset-background\",\n \"disabled:cursor-not-allowed disabled:opacity-50\",\n trackSizes[size],\n className\n )}\n {...props}\n >\n <SwitchPrimitive.Thumb\n data-slot=\"switch-thumb\"\n className={cn(\n \"pointer-events-none block translate-x-0.5 rounded-full bg-control-knob shadow-sm ring-0\",\n \"transition-transform duration-150 ease-out\",\n thumbSizes[size]\n )}\n />\n </SwitchPrimitive.Root>\n );\n\n if (!label && !description) {\n return switchElement;\n }\n\n return (\n <div className=\"flex items-start gap-2.5\">\n {switchElement}\n <div className=\"flex flex-col gap-0.5\">\n {label && (\n <label\n htmlFor={switchId}\n className=\"text-sm font-medium text-foreground cursor-pointer select-none leading-none\"\n >\n {label}\n </label>\n )}\n {description && <p className=\"text-xs text-muted-foreground leading-relaxed\">{description}</p>}\n </div>\n </div>\n );\n}\n\nexport { Switch };\n"],"mappings":";AAsGM,cAkBA,YAlBA;AApGN,YAAY,qBAAqB;AACjC,YAAY,WAAW;AACvB,SAAS,UAAU;AAGnB,MAAM,aAAa;AAAA,EACjB,IAAI;AAAA,EACJ,IAAI;AACN;AAYA,MAAM,aAAa;AAAA,EACjB,IAAI;AAAA,EACJ,IAAI;AACN;AA2DA,SAAS,OAAO,EAAE,WAAW,OAAO,MAAM,OAAO,aAAa,IAAI,GAAG,MAAM,GAAgB;AACzF,QAAM,cAAc,MAAM,MAAM;AAChC,QAAM,WAAW,MAAM;AAEvB,QAAM,gBACJ;AAAA,IAAC,gBAAgB;AAAA,IAAhB;AAAA,MACC,aAAU;AAAA,MACV,IAAI;AAAA,MACJ,WAAW;AAAA,QACT;AAAA,QACA;AAAA,QACA;AAAA,QACA;AAAA,QACA,WAAW,IAAI;AAAA,QACf;AAAA,MACF;AAAA,MACC,GAAG;AAAA,MAEJ;AAAA,QAAC,gBAAgB;AAAA,QAAhB;AAAA,UACC,aAAU;AAAA,UACV,WAAW;AAAA,YACT;AAAA,YACA;AAAA,YACA,WAAW,IAAI;AAAA,UACjB;AAAA;AAAA,MACF;AAAA;AAAA,EACF;AAGF,MAAI,CAAC,SAAS,CAAC,aAAa;AAC1B,WAAO;AAAA,EACT;AAEA,SACE,qBAAC,SAAI,WAAU,4BACZ;AAAA;AAAA,IACD,qBAAC,SAAI,WAAU,yBACZ;AAAA,eACC;AAAA,QAAC;AAAA;AAAA,UACC,SAAS;AAAA,UACT,WAAU;AAAA,UAET;AAAA;AAAA,MACH;AAAA,MAED,eAAe,oBAAC,OAAE,WAAU,iDAAiD,uBAAY;AAAA,OAC5F;AAAA,KACF;AAEJ;","names":[]}
@@ -1,5 +1,5 @@
1
1
  import * as React from 'react';
2
- import { FontSizeMode } from './display-axis-constants.js';
2
+ import { DensityMode, FontSizeMode } from './display-axis-constants.js';
3
3
 
4
4
  /**
5
5
  * Hai control của trục MẬT ĐỘ và trục CỠ CHỮ.
@@ -16,8 +16,21 @@ import { FontSizeMode } from './display-axis-constants.js';
16
16
  */
17
17
  interface DensityToggleProps {
18
18
  label?: string;
19
- description?: string;
19
+ description?: React.ReactNode;
20
20
  className?: string;
21
+ /**
22
+ * Gọi SAU khi người dùng đổi — cùng khuôn với `onContrastChange` của `ContrastToggle`.
23
+ *
24
+ * ⚠ Đây là callback THÔNG BÁO, không phải `onChange` của một component controlled. Giá trị CÓ
25
+ * HIỆU LỰC vẫn nằm ở `localStorage` của gói và vẫn do provider quản; prop này chỉ cho app biết
26
+ * "vừa có thay đổi" để nó ghi một MẶC ĐỊNH theo tài khoản ở nơi khác (Comitor.Account), hiện một
27
+ * toast, hoặc ghi log.
28
+ *
29
+ * Vì sao KHÔNG làm controlled: trục hiển thị phải có hiệu lực trước lượt sơn đầu tiên, và điều đó
30
+ * chỉ làm được bằng script chống nháy đọc `localStorage` — một giá trị đi từ server xuống prop
31
+ * luôn tới SAU lượt sơn ấy. Xem `display-axis-provider.tsx`.
32
+ */
33
+ onDensityChange?: (mode: DensityMode) => void;
21
34
  }
22
35
  /**
23
36
  * Nút gạt "bố cục gọn".
@@ -27,13 +40,15 @@ interface DensityToggleProps {
27
40
  * thanh "Có thay đổi chưa lưu" sáng lên cho một thay đổi đã có hiệu lực, và nút "Hoàn tác" trong
28
41
  * toast sẽ lùi bản nháp mà KHÔNG lùi được trục — giao diện tự mâu thuẫn với chính nó.
29
42
  */
30
- declare function DensityToggle({ label, description, className }: DensityToggleProps): React.JSX.Element;
43
+ declare function DensityToggle({ label, description, className, onDensityChange }: DensityToggleProps): React.JSX.Element;
31
44
  interface FontSizeControlProps {
32
45
  /** Nhãn của cả nhóm, đọc lên cho trình đọc màn hình. */
33
46
  label?: string;
34
47
  /** Nhãn từng mức — THỨ TỰ theo `FONT_SIZE_MODES` (`sm`, `md`, `lg`). */
35
48
  optionLabels?: Record<FontSizeMode, string>;
36
49
  className?: string;
50
+ /** Gọi SAU khi người dùng đổi — xem giải thích ở `DensityToggleProps.onDensityChange`. */
51
+ onFontSizeChange?: (mode: FontSizeMode) => void;
37
52
  }
38
53
  /**
39
54
  * Chọn cỡ chữ — ba mức, một hàng nút.
@@ -46,6 +61,6 @@ interface FontSizeControlProps {
46
61
  * Cỡ chữ của CHÍNH các nút này cố định `text-sm`: nếu chúng phóng theo lựa chọn thì bấm "Lớn" sẽ
47
62
  * làm cả hàng nút nhảy chỗ ngay dưới ngón tay.
48
63
  */
49
- declare function FontSizeControl({ label, optionLabels, className }: FontSizeControlProps): React.JSX.Element;
64
+ declare function FontSizeControl({ label, optionLabels, className, onFontSizeChange }: FontSizeControlProps): React.JSX.Element;
50
65
 
51
66
  export { DensityToggle, type DensityToggleProps, FontSizeControl, type FontSizeControlProps };
@@ -7,7 +7,8 @@ import { useDensity, useFontSize } from "./display-axis-provider.js";
7
7
  function DensityToggle({
8
8
  label = "B\u1ED1 c\u1EE5c g\u1ECDn",
9
9
  description = "Gi\u1EA3m kho\u1EA3ng c\xE1ch d\u1ECDc gi\u1EEFa c\xE1c d\xF2ng trong b\u1EA3ng, danh s\xE1ch v\xE0 menu.",
10
- className
10
+ className,
11
+ onDensityChange
11
12
  }) {
12
13
  const { density, setDensity, mounted } = useDensity();
13
14
  return /* @__PURE__ */ jsx(
@@ -15,7 +16,11 @@ function DensityToggle({
15
16
  {
16
17
  className,
17
18
  checked: density === "compact",
18
- onCheckedChange: (checked) => setDensity(checked ? "compact" : "comfortable"),
19
+ onCheckedChange: (checked) => {
20
+ const next = checked ? "compact" : "comfortable";
21
+ setDensity(next);
22
+ onDensityChange?.(next);
23
+ },
19
24
  disabled: !mounted,
20
25
  label,
21
26
  description
@@ -30,7 +35,8 @@ const DEFAULT_FONT_SIZE_LABELS = {
30
35
  function FontSizeControl({
31
36
  label = "C\u1EE1 ch\u1EEF",
32
37
  optionLabels = DEFAULT_FONT_SIZE_LABELS,
33
- className
38
+ className,
39
+ onFontSizeChange
34
40
  }) {
35
41
  const { fontSize, setFontSize, mounted } = useFontSize();
36
42
  return /* @__PURE__ */ jsx(
@@ -48,7 +54,10 @@ function FontSizeControl({
48
54
  role: "radio",
49
55
  "aria-checked": selected,
50
56
  disabled: !mounted,
51
- onClick: () => setFontSize(mode),
57
+ onClick: () => {
58
+ setFontSize(mode);
59
+ onFontSizeChange?.(mode);
60
+ },
52
61
  className: cn(
53
62
  "rounded-sm px-3 py-1 text-sm transition-colors duration-150 ease-out",
54
63
  "focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-foreground",
@@ -1 +1 @@
1
- {"version":3,"sources":["../../src/shell/display-axis-controls.tsx"],"sourcesContent":["\"use client\";\n\nimport { Switch } from \"../components/ui/switch.js\";\nimport { cn } from \"../lib/cn.js\";\nimport { FONT_SIZE_MODES, type FontSizeMode } from \"./display-axis-constants.js\";\nimport { useDensity, useFontSize } from \"./display-axis-provider.js\";\n\n/**\n * Hai control của trục MẬT ĐỘ và trục CỠ CHỮ.\n *\n * ─ VÌ SAO CẢ HAI ĐỀU CÓ `disabled={!mounted}` ─\n * Giá trị thật nằm ở localStorage, mà server không đọc được. Render lần đầu ở client PHẢI khớp\n * HTML server sinh ra, nếu không React báo lệch hydration — nên provider giữ giá trị mặc định cho\n * tới khi effect đầu chạy. Trong khoảnh khắc đó control hiện mặc định chứ không hiện lựa chọn\n * thật, và nếu người dùng kịp bấm thì họ ghi đè lên chính lựa chọn của mình. Khoá cho tới khi\n * `mounted` là cách `ContrastToggle` đã dùng.\n *\n * (DOM thật thì không nháy: script chống nháy của provider đã đặt thuộc tính lên `<html>` trước\n * lần sơn đầu tiên. Chỉ CONTROL là phải chờ.)\n */\n\nexport interface DensityToggleProps {\n label?: string;\n description?: string;\n className?: string;\n}\n\n/**\n * Nút gạt \"bố cục gọn\".\n *\n * ⚠ Đây KHÔNG phải một trường của biểu mẫu cài đặt. Nó có hiệu lực NGAY và tự lưu, giống\n * `ThemeToggle`/`ContrastToggle` — đừng đưa nó vào luồng nháp/\"Lưu thay đổi\". Trộn hai kiểu thì\n * thanh \"Có thay đổi chưa lưu\" sáng lên cho một thay đổi đã có hiệu lực, và nút \"Hoàn tác\" trong\n * toast sẽ lùi bản nháp mà KHÔNG lùi được trục — giao diện tự mâu thuẫn với chính nó.\n */\nexport function DensityToggle({\n label = \"Bố cục gọn\",\n description = \"Giảm khoảng cách dọc giữa các dòng trong bảng, danh sách và menu.\",\n className\n}: DensityToggleProps) {\n const { density, setDensity, mounted } = useDensity();\n\n return (\n <Switch\n className={className}\n checked={density === \"compact\"}\n onCheckedChange={(checked) => setDensity(checked ? \"compact\" : \"comfortable\")}\n disabled={!mounted}\n label={label}\n description={description}\n />\n );\n}\n\nexport interface FontSizeControlProps {\n /** Nhãn của cả nhóm, đọc lên cho trình đọc màn hình. */\n label?: string;\n /** Nhãn từng mức — THỨ TỰ theo `FONT_SIZE_MODES` (`sm`, `md`, `lg`). */\n optionLabels?: Record<FontSizeMode, string>;\n className?: string;\n}\n\nconst DEFAULT_FONT_SIZE_LABELS: Record<FontSizeMode, string> = {\n sm: \"Nhỏ\",\n md: \"Vừa\",\n lg: \"Lớn\"\n};\n\n/**\n * Chọn cỡ chữ — ba mức, một hàng nút.\n *\n * `role=\"radiogroup\"` + `aria-checked` chứ không phải ba nút thường: đây là chọn MỘT trong ba, và\n * trình đọc màn hình cần nghe \"2 trên 3\" chứ không nghe ba nút rời rạc. Dùng `<button>` thật (bàn\n * phím tới được bằng Tab) thay vì roving focus của Radix ToggleGroup — nhóm chỉ có ba mục nên\n * roving focus không đáng cái giá thêm một phụ thuộc.\n *\n * Cỡ chữ của CHÍNH các nút này cố định `text-sm`: nếu chúng phóng theo lựa chọn thì bấm \"Lớn\" sẽ\n * làm cả hàng nút nhảy chỗ ngay dưới ngón tay.\n */\nexport function FontSizeControl({\n label = \"Cỡ chữ\",\n optionLabels = DEFAULT_FONT_SIZE_LABELS,\n className\n}: FontSizeControlProps) {\n const { fontSize, setFontSize, mounted } = useFontSize();\n\n return (\n <div\n role=\"radiogroup\"\n aria-label={label}\n className={cn(\"inline-flex items-center gap-1 rounded-md border border-input p-1 text-sm\", className)}\n >\n {FONT_SIZE_MODES.map((mode) => {\n const selected = fontSize === mode;\n return (\n <button\n key={mode}\n type=\"button\"\n role=\"radio\"\n aria-checked={selected}\n disabled={!mounted}\n onClick={() => setFontSize(mode)}\n className={cn(\n \"rounded-sm px-3 py-1 text-sm transition-colors duration-150 ease-out\",\n \"focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-foreground\",\n \"disabled:cursor-not-allowed disabled:opacity-50\",\n selected\n ? \"bg-control-on text-control-check font-medium\"\n : \"text-muted-foreground hover:bg-accent hover:text-foreground\"\n )}\n >\n {optionLabels[mode]}\n </button>\n );\n })}\n </div>\n );\n}\n"],"mappings":";AA2CI;AAzCJ,SAAS,cAAc;AACvB,SAAS,UAAU;AACnB,SAAS,uBAA0C;AACnD,SAAS,YAAY,mBAAmB;AA8BjC,SAAS,cAAc;AAAA,EAC5B,QAAQ;AAAA,EACR,cAAc;AAAA,EACd;AACF,GAAuB;AACrB,QAAM,EAAE,SAAS,YAAY,QAAQ,IAAI,WAAW;AAEpD,SACE;AAAA,IAAC;AAAA;AAAA,MACC;AAAA,MACA,SAAS,YAAY;AAAA,MACrB,iBAAiB,CAAC,YAAY,WAAW,UAAU,YAAY,aAAa;AAAA,MAC5E,UAAU,CAAC;AAAA,MACX;AAAA,MACA;AAAA;AAAA,EACF;AAEJ;AAUA,MAAM,2BAAyD;AAAA,EAC7D,IAAI;AAAA,EACJ,IAAI;AAAA,EACJ,IAAI;AACN;AAaO,SAAS,gBAAgB;AAAA,EAC9B,QAAQ;AAAA,EACR,eAAe;AAAA,EACf;AACF,GAAyB;AACvB,QAAM,EAAE,UAAU,aAAa,QAAQ,IAAI,YAAY;AAEvD,SACE;AAAA,IAAC;AAAA;AAAA,MACC,MAAK;AAAA,MACL,cAAY;AAAA,MACZ,WAAW,GAAG,6EAA6E,SAAS;AAAA,MAEnG,0BAAgB,IAAI,CAAC,SAAS;AAC7B,cAAM,WAAW,aAAa;AAC9B,eACE;AAAA,UAAC;AAAA;AAAA,YAEC,MAAK;AAAA,YACL,MAAK;AAAA,YACL,gBAAc;AAAA,YACd,UAAU,CAAC;AAAA,YACX,SAAS,MAAM,YAAY,IAAI;AAAA,YAC/B,WAAW;AAAA,cACT;AAAA,cACA;AAAA,cACA;AAAA,cACA,WACI,iDACA;AAAA,YACN;AAAA,YAEC,uBAAa,IAAI;AAAA;AAAA,UAfb;AAAA,QAgBP;AAAA,MAEJ,CAAC;AAAA;AAAA,EACH;AAEJ;","names":[]}
1
+ {"version":3,"sources":["../../src/shell/display-axis-controls.tsx"],"sourcesContent":["\"use client\";\n\nimport type * as React from \"react\";\nimport { Switch } from \"../components/ui/switch.js\";\nimport { cn } from \"../lib/cn.js\";\nimport { type DensityMode, FONT_SIZE_MODES, type FontSizeMode } from \"./display-axis-constants.js\";\nimport { useDensity, useFontSize } from \"./display-axis-provider.js\";\n\n/**\n * Hai control của trục MẬT ĐỘ và trục CỠ CHỮ.\n *\n * ─ VÌ SAO CẢ HAI ĐỀU CÓ `disabled={!mounted}` ─\n * Giá trị thật nằm ở localStorage, mà server không đọc được. Render lần đầu ở client PHẢI khớp\n * HTML server sinh ra, nếu không React báo lệch hydration — nên provider giữ giá trị mặc định cho\n * tới khi effect đầu chạy. Trong khoảnh khắc đó control hiện mặc định chứ không hiện lựa chọn\n * thật, và nếu người dùng kịp bấm thì họ ghi đè lên chính lựa chọn của mình. Khoá cho tới khi\n * `mounted` là cách `ContrastToggle` đã dùng.\n *\n * (DOM thật thì không nháy: script chống nháy của provider đã đặt thuộc tính lên `<html>` trước\n * lần sơn đầu tiên. Chỉ CONTROL là phải chờ.)\n */\n\nexport interface DensityToggleProps {\n label?: string;\n description?: React.ReactNode;\n className?: string;\n /**\n * Gọi SAU khi người dùng đổi — cùng khuôn với `onContrastChange` của `ContrastToggle`.\n *\n * ⚠ Đây là callback THÔNG BÁO, không phải `onChange` của một component controlled. Giá trị CÓ\n * HIỆU LỰC vẫn nằm ở `localStorage` của gói và vẫn do provider quản; prop này chỉ cho app biết\n * \"vừa có thay đổi\" để nó ghi một MẶC ĐỊNH theo tài khoản ở nơi khác (Comitor.Account), hiện một\n * toast, hoặc ghi log.\n *\n * Vì sao KHÔNG làm controlled: trục hiển thị phải có hiệu lực trước lượt sơn đầu tiên, và điều đó\n * chỉ làm được bằng script chống nháy đọc `localStorage` — một giá trị đi từ server xuống prop\n * luôn tới SAU lượt sơn ấy. Xem `display-axis-provider.tsx`.\n */\n onDensityChange?: (mode: DensityMode) => void;\n}\n\n/**\n * Nút gạt \"bố cục gọn\".\n *\n * ⚠ Đây KHÔNG phải một trường của biểu mẫu cài đặt. Nó có hiệu lực NGAY và tự lưu, giống\n * `ThemeToggle`/`ContrastToggle` — đừng đưa nó vào luồng nháp/\"Lưu thay đổi\". Trộn hai kiểu thì\n * thanh \"Có thay đổi chưa lưu\" sáng lên cho một thay đổi đã có hiệu lực, và nút \"Hoàn tác\" trong\n * toast sẽ lùi bản nháp mà KHÔNG lùi được trục — giao diện tự mâu thuẫn với chính nó.\n */\nexport function DensityToggle({\n label = \"Bố cục gọn\",\n description = \"Giảm khoảng cách dọc giữa các dòng trong bảng, danh sách và menu.\",\n className,\n onDensityChange\n}: DensityToggleProps) {\n const { density, setDensity, mounted } = useDensity();\n\n return (\n <Switch\n className={className}\n checked={density === \"compact\"}\n onCheckedChange={(checked) => {\n const next: DensityMode = checked ? \"compact\" : \"comfortable\";\n setDensity(next);\n onDensityChange?.(next);\n }}\n disabled={!mounted}\n label={label}\n description={description}\n />\n );\n}\n\nexport interface FontSizeControlProps {\n /** Nhãn của cả nhóm, đọc lên cho trình đọc màn hình. */\n label?: string;\n /** Nhãn từng mức — THỨ TỰ theo `FONT_SIZE_MODES` (`sm`, `md`, `lg`). */\n optionLabels?: Record<FontSizeMode, string>;\n className?: string;\n /** Gọi SAU khi người dùng đổi — xem giải thích ở `DensityToggleProps.onDensityChange`. */\n onFontSizeChange?: (mode: FontSizeMode) => void;\n}\n\nconst DEFAULT_FONT_SIZE_LABELS: Record<FontSizeMode, string> = {\n sm: \"Nhỏ\",\n md: \"Vừa\",\n lg: \"Lớn\"\n};\n\n/**\n * Chọn cỡ chữ — ba mức, một hàng nút.\n *\n * `role=\"radiogroup\"` + `aria-checked` chứ không phải ba nút thường: đây là chọn MỘT trong ba, và\n * trình đọc màn hình cần nghe \"2 trên 3\" chứ không nghe ba nút rời rạc. Dùng `<button>` thật (bàn\n * phím tới được bằng Tab) thay vì roving focus của Radix ToggleGroup — nhóm chỉ có ba mục nên\n * roving focus không đáng cái giá thêm một phụ thuộc.\n *\n * Cỡ chữ của CHÍNH các nút này cố định `text-sm`: nếu chúng phóng theo lựa chọn thì bấm \"Lớn\" sẽ\n * làm cả hàng nút nhảy chỗ ngay dưới ngón tay.\n */\nexport function FontSizeControl({\n label = \"Cỡ chữ\",\n optionLabels = DEFAULT_FONT_SIZE_LABELS,\n className,\n onFontSizeChange\n}: FontSizeControlProps) {\n const { fontSize, setFontSize, mounted } = useFontSize();\n\n return (\n <div\n role=\"radiogroup\"\n aria-label={label}\n className={cn(\"inline-flex items-center gap-1 rounded-md border border-input p-1 text-sm\", className)}\n >\n {FONT_SIZE_MODES.map((mode) => {\n const selected = fontSize === mode;\n return (\n <button\n key={mode}\n type=\"button\"\n role=\"radio\"\n aria-checked={selected}\n disabled={!mounted}\n onClick={() => {\n setFontSize(mode);\n onFontSizeChange?.(mode);\n }}\n className={cn(\n \"rounded-sm px-3 py-1 text-sm transition-colors duration-150 ease-out\",\n \"focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-foreground\",\n \"disabled:cursor-not-allowed disabled:opacity-50\",\n selected\n ? \"bg-control-on text-control-check font-medium\"\n : \"text-muted-foreground hover:bg-accent hover:text-foreground\"\n )}\n >\n {optionLabels[mode]}\n </button>\n );\n })}\n </div>\n );\n}\n"],"mappings":";AA0DI;AAvDJ,SAAS,cAAc;AACvB,SAAS,UAAU;AACnB,SAA2B,uBAA0C;AACrE,SAAS,YAAY,mBAAmB;AA2CjC,SAAS,cAAc;AAAA,EAC5B,QAAQ;AAAA,EACR,cAAc;AAAA,EACd;AAAA,EACA;AACF,GAAuB;AACrB,QAAM,EAAE,SAAS,YAAY,QAAQ,IAAI,WAAW;AAEpD,SACE;AAAA,IAAC;AAAA;AAAA,MACC;AAAA,MACA,SAAS,YAAY;AAAA,MACrB,iBAAiB,CAAC,YAAY;AAC5B,cAAM,OAAoB,UAAU,YAAY;AAChD,mBAAW,IAAI;AACf,0BAAkB,IAAI;AAAA,MACxB;AAAA,MACA,UAAU,CAAC;AAAA,MACX;AAAA,MACA;AAAA;AAAA,EACF;AAEJ;AAYA,MAAM,2BAAyD;AAAA,EAC7D,IAAI;AAAA,EACJ,IAAI;AAAA,EACJ,IAAI;AACN;AAaO,SAAS,gBAAgB;AAAA,EAC9B,QAAQ;AAAA,EACR,eAAe;AAAA,EACf;AAAA,EACA;AACF,GAAyB;AACvB,QAAM,EAAE,UAAU,aAAa,QAAQ,IAAI,YAAY;AAEvD,SACE;AAAA,IAAC;AAAA;AAAA,MACC,MAAK;AAAA,MACL,cAAY;AAAA,MACZ,WAAW,GAAG,6EAA6E,SAAS;AAAA,MAEnG,0BAAgB,IAAI,CAAC,SAAS;AAC7B,cAAM,WAAW,aAAa;AAC9B,eACE;AAAA,UAAC;AAAA;AAAA,YAEC,MAAK;AAAA,YACL,MAAK;AAAA,YACL,gBAAc;AAAA,YACd,UAAU,CAAC;AAAA,YACX,SAAS,MAAM;AACb,0BAAY,IAAI;AAChB,iCAAmB,IAAI;AAAA,YACzB;AAAA,YACA,WAAW;AAAA,cACT;AAAA,cACA;AAAA,cACA;AAAA,cACA,WACI,iDACA;AAAA,YACN;AAAA,YAEC,uBAAa,IAAI;AAAA;AAAA,UAlBb;AAAA,QAmBP;AAAA,MAEJ,CAAC;AAAA;AAAA,EACH;AAEJ;","names":[]}
@@ -18,7 +18,7 @@ export { Sidebar, SidebarProps } from './sidebar.js';
18
18
  export { SidebarGroup, SidebarGroupProps } from './sidebar-group.js';
19
19
  export { SidebarMenuItem, SidebarMenuItemProps } from './sidebar-menu-item.js';
20
20
  export { ThemeProvider, ThemeProviderProps } from './theme-provider.js';
21
- export { ThemeToggle, ThemeToggleLabels, ThemeToggleProps } from './theme-toggle.js';
21
+ export { ThemeChoice, ThemeToggle, ThemeToggleLabels, ThemeToggleProps } from './theme-toggle.js';
22
22
  export { AppDescriptor, DEFAULT_SHELL_LABELS, NavBadgeVariant, NavGroup, NavItem, ShellIcon, ShellLabels, ShellLinkComponent, ShellLinkProps, ShellNav, ShellUser, UserMenuItem, Workspace } from './types.js';
23
23
  export { UserAvatar, UserAvatarProps, UserMenu, UserMenuProps } from './user-menu.js';
24
24
  export { NavSearchParams, appAccentStyle, flattenNavItems, getEntitledApps, getLockedApps, isNavBranchActive, isNavItemActive, resolveCurrentApp, toNavGroups, toSearchParams } from './utils.js';
@@ -4,6 +4,15 @@ import 'lucide-react';
4
4
 
5
5
  /** Chỉ những nhãn ThemeToggle thực sự dùng — để nút này KHÔNG buộc phải nằm trong ShellProvider. */
6
6
  type ThemeToggleLabels = Pick<ShellLabels, "themeLight" | "themeDark" | "themeSystem" | "toggleTheme">;
7
+ /**
8
+ * Ba lựa chọn mà `ThemeToggle` ghi ra — tập ĐÓNG, và `"system"` là một giá trị thật chứ không phải
9
+ * "chưa chọn".
10
+ *
11
+ * ⚠ App nào lưu giá trị này (ví dụ làm mặc định theo tài khoản) phải chấp nhận đủ CẢ BA. Ép nó về
12
+ * hai trạng thái sáng/tối là làm mất lựa chọn "đi theo hệ điều hành" — thứ mà người bật chế độ tối
13
+ * theo giờ trong ngày sẽ mất ngay khi họ đăng nhập ở một máy khác.
14
+ */
15
+ type ThemeChoice = "light" | "dark" | "system";
7
16
  interface ThemeToggleProps {
8
17
  className?: string;
9
18
  /**
@@ -13,6 +22,18 @@ interface ThemeToggleProps {
13
22
  variant?: "icon" | "menu";
14
23
  /** Override chuỗi hiển thị (mặc định tiếng Việt). `AppHeader` truyền labels của shell xuống. */
15
24
  labels?: Partial<ThemeToggleLabels>;
25
+ /**
26
+ * Gọi SAU khi người dùng đổi — cùng khuôn với `onContrastChange` của `ContrastToggle`.
27
+ *
28
+ * ⚠ Callback THÔNG BÁO, không phải `onChange` của component controlled: theme CÓ HIỆU LỰC vẫn do
29
+ * `next-themes` giữ trong `localStorage`. Prop này để app ghi một MẶC ĐỊNH theo tài khoản ở nơi
30
+ * khác. Xem `DensityToggleProps.onDensityChange` cho lý do đầy đủ.
31
+ *
32
+ * ⚠ Nhận đủ ba giá trị kể cả `"system"` — và ở `variant="icon"` thì nó KHÔNG BAO GIỜ phát
33
+ * `"system"`, vì nút ấy chỉ lật sáng ⇄ tối. Muốn người dùng chọn được "hệ thống" thì phải dùng
34
+ * `variant="menu"`.
35
+ */
36
+ onThemeChange?: (theme: ThemeChoice) => void;
16
37
  }
17
38
  /**
18
39
  * Nút đổi giao diện — dùng được ĐỘC LẬP (trang đăng nhập, trang marketing),
@@ -23,6 +44,6 @@ interface ThemeToggleProps {
23
44
  * lần đầu sẽ lệch với HTML do server sinh ra. Trước khi mount hiện ô rỗng cùng
24
45
  * kích thước để layout không nhảy.
25
46
  */
26
- declare function ThemeToggle({ className, variant, labels: labelsProp }: ThemeToggleProps): React.JSX.Element;
47
+ declare function ThemeToggle({ className, variant, labels: labelsProp, onThemeChange }: ThemeToggleProps): React.JSX.Element;
27
48
 
28
- export { ThemeToggle, type ThemeToggleLabels, type ThemeToggleProps };
49
+ export { type ThemeChoice, ThemeToggle, type ThemeToggleLabels, type ThemeToggleProps };
@@ -12,8 +12,12 @@ import {
12
12
  import { Tooltip, TooltipContent, TooltipTrigger } from "../components/ui/tooltip.js";
13
13
  import { cn } from "../lib/cn.js";
14
14
  import { DEFAULT_SHELL_LABELS } from "./types.js";
15
- function ThemeToggle({ className, variant = "icon", labels: labelsProp }) {
15
+ function ThemeToggle({ className, variant = "icon", labels: labelsProp, onThemeChange }) {
16
16
  const { theme, resolvedTheme, setTheme } = useTheme();
17
+ const choose = (next) => {
18
+ setTheme(next);
19
+ onThemeChange?.(next);
20
+ };
17
21
  const labels = { ...DEFAULT_SHELL_LABELS, ...labelsProp };
18
22
  const [mounted, setMounted] = useState(false);
19
23
  useEffect(() => {
@@ -31,15 +35,15 @@ function ThemeToggle({ className, variant = "icon", labels: labelsProp }) {
31
35
  return /* @__PURE__ */ jsxs(DropdownMenu, { children: [
32
36
  /* @__PURE__ */ jsx(DropdownMenuTrigger, { asChild: true, children: /* @__PURE__ */ jsx("button", { type: "button", className: buttonClass, "aria-label": labels.toggleTheme, children: resolvedTheme === "dark" ? /* @__PURE__ */ jsx(Moon, { className: "size-4" }) : /* @__PURE__ */ jsx(Sun, { className: "size-4" }) }) }),
33
37
  /* @__PURE__ */ jsxs(DropdownMenuContent, { align: "end", children: [
34
- /* @__PURE__ */ jsxs(DropdownMenuItem, { onClick: () => setTheme("light"), className: cn(theme === "light" && "bg-accent"), children: [
38
+ /* @__PURE__ */ jsxs(DropdownMenuItem, { onClick: () => choose("light"), className: cn(theme === "light" && "bg-accent"), children: [
35
39
  /* @__PURE__ */ jsx(Sun, { className: "size-4" }),
36
40
  labels.themeLight
37
41
  ] }),
38
- /* @__PURE__ */ jsxs(DropdownMenuItem, { onClick: () => setTheme("dark"), className: cn(theme === "dark" && "bg-accent"), children: [
42
+ /* @__PURE__ */ jsxs(DropdownMenuItem, { onClick: () => choose("dark"), className: cn(theme === "dark" && "bg-accent"), children: [
39
43
  /* @__PURE__ */ jsx(Moon, { className: "size-4" }),
40
44
  labels.themeDark
41
45
  ] }),
42
- /* @__PURE__ */ jsxs(DropdownMenuItem, { onClick: () => setTheme("system"), className: cn(theme === "system" && "bg-accent"), children: [
46
+ /* @__PURE__ */ jsxs(DropdownMenuItem, { onClick: () => choose("system"), className: cn(theme === "system" && "bg-accent"), children: [
43
47
  /* @__PURE__ */ jsx(Monitor, { className: "size-4" }),
44
48
  labels.themeSystem
45
49
  ] })
@@ -48,16 +52,7 @@ function ThemeToggle({ className, variant = "icon", labels: labelsProp }) {
48
52
  }
49
53
  const nextTheme = resolvedTheme === "dark" ? "light" : "dark";
50
54
  return /* @__PURE__ */ jsxs(Tooltip, { children: [
51
- /* @__PURE__ */ jsx(TooltipTrigger, { asChild: true, children: /* @__PURE__ */ jsx(
52
- "button",
53
- {
54
- type: "button",
55
- onClick: () => setTheme(nextTheme),
56
- className: buttonClass,
57
- "aria-label": labels.toggleTheme,
58
- children: resolvedTheme === "dark" ? /* @__PURE__ */ jsx(Sun, { className: "size-4" }) : /* @__PURE__ */ jsx(Moon, { className: "size-4" })
59
- }
60
- ) }),
55
+ /* @__PURE__ */ jsx(TooltipTrigger, { asChild: true, children: /* @__PURE__ */ jsx("button", { type: "button", onClick: () => choose(nextTheme), className: buttonClass, "aria-label": labels.toggleTheme, children: resolvedTheme === "dark" ? /* @__PURE__ */ jsx(Sun, { className: "size-4" }) : /* @__PURE__ */ jsx(Moon, { className: "size-4" }) }) }),
61
56
  /* @__PURE__ */ jsx(TooltipContent, { side: "bottom", children: nextTheme === "dark" ? labels.themeDark : labels.themeLight })
62
57
  ] });
63
58
  }
@@ -1 +1 @@
1
- {"version":3,"sources":["../../src/shell/theme-toggle.tsx"],"sourcesContent":["\"use client\";\n\nimport { Monitor, Moon, Sun } from \"lucide-react\";\nimport { useTheme } from \"next-themes\";\nimport { useEffect, useState } from \"react\";\nimport {\n DropdownMenu,\n DropdownMenuContent,\n DropdownMenuItem,\n DropdownMenuTrigger\n} from \"../components/ui/dropdown-menu.js\";\nimport { Tooltip, TooltipContent, TooltipTrigger } from \"../components/ui/tooltip.js\";\nimport { cn } from \"../lib/cn.js\";\nimport { DEFAULT_SHELL_LABELS, type ShellLabels } from \"./types.js\";\n\n/** Chỉ những nhãn ThemeToggle thực sự dùng — để nút này KHÔNG buộc phải nằm trong ShellProvider. */\nexport type ThemeToggleLabels = Pick<ShellLabels, \"themeLight\" | \"themeDark\" | \"themeSystem\" | \"toggleTheme\">;\n\nexport interface ThemeToggleProps {\n className?: string;\n /**\n * \"icon\" — một nút, bấm là lật Sáng ⇄ Tối (mặc định, dùng ở header).\n * \"menu\" — dropdown 3 lựa chọn Sáng / Tối / Hệ thống.\n */\n variant?: \"icon\" | \"menu\";\n /** Override chuỗi hiển thị (mặc định tiếng Việt). `AppHeader` truyền labels của shell xuống. */\n labels?: Partial<ThemeToggleLabels>;\n}\n\n/**\n * Nút đổi giao diện — dùng được ĐỘC LẬP (trang đăng nhập, trang marketing),\n * không bắt buộc nằm trong `ShellProvider`.\n *\n * Chống lệch hydration bằng cờ `mounted`: theme thật chỉ biết được ở client\n * (đọc localStorage/`prefers-color-scheme`), render thẳng icon theo theme ngay\n * lần đầu sẽ lệch với HTML do server sinh ra. Trước khi mount hiện ô rỗng cùng\n * kích thước để layout không nhảy.\n */\nexport function ThemeToggle({ className, variant = \"icon\", labels: labelsProp }: ThemeToggleProps) {\n const { theme, resolvedTheme, setTheme } = useTheme();\n const labels: ThemeToggleLabels = { ...DEFAULT_SHELL_LABELS, ...labelsProp };\n const [mounted, setMounted] = useState(false);\n\n useEffect(() => {\n setMounted(true);\n }, []);\n\n const buttonClass = cn(\n \"flex size-9 items-center justify-center rounded-md text-muted-foreground\",\n \"transition-colors duration-150 ease-out hover:bg-muted hover:text-foreground\",\n className\n );\n\n if (!mounted) {\n return <span className={buttonClass} aria-hidden />;\n }\n\n if (variant === \"menu\") {\n return (\n <DropdownMenu>\n <DropdownMenuTrigger asChild>\n <button type=\"button\" className={buttonClass} aria-label={labels.toggleTheme}>\n {resolvedTheme === \"dark\" ? <Moon className=\"size-4\" /> : <Sun className=\"size-4\" />}\n </button>\n </DropdownMenuTrigger>\n <DropdownMenuContent align=\"end\">\n <DropdownMenuItem onClick={() => setTheme(\"light\")} className={cn(theme === \"light\" && \"bg-accent\")}>\n <Sun className=\"size-4\" />\n {labels.themeLight}\n </DropdownMenuItem>\n <DropdownMenuItem onClick={() => setTheme(\"dark\")} className={cn(theme === \"dark\" && \"bg-accent\")}>\n <Moon className=\"size-4\" />\n {labels.themeDark}\n </DropdownMenuItem>\n <DropdownMenuItem onClick={() => setTheme(\"system\")} className={cn(theme === \"system\" && \"bg-accent\")}>\n <Monitor className=\"size-4\" />\n {labels.themeSystem}\n </DropdownMenuItem>\n </DropdownMenuContent>\n </DropdownMenu>\n );\n }\n\n const nextTheme = resolvedTheme === \"dark\" ? \"light\" : \"dark\";\n\n return (\n <Tooltip>\n <TooltipTrigger asChild>\n <button\n type=\"button\"\n onClick={() => setTheme(nextTheme)}\n className={buttonClass}\n aria-label={labels.toggleTheme}\n >\n {resolvedTheme === \"dark\" ? <Sun className=\"size-4\" /> : <Moon className=\"size-4\" />}\n </button>\n </TooltipTrigger>\n <TooltipContent side=\"bottom\">{nextTheme === \"dark\" ? labels.themeDark : labels.themeLight}</TooltipContent>\n </Tooltip>\n );\n}\n"],"mappings":";AAsDW,cAYD,YAZC;AApDX,SAAS,SAAS,MAAM,WAAW;AACnC,SAAS,gBAAgB;AACzB,SAAS,WAAW,gBAAgB;AACpC;AAAA,EACE;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,OACK;AACP,SAAS,SAAS,gBAAgB,sBAAsB;AACxD,SAAS,UAAU;AACnB,SAAS,4BAA8C;AAyBhD,SAAS,YAAY,EAAE,WAAW,UAAU,QAAQ,QAAQ,WAAW,GAAqB;AACjG,QAAM,EAAE,OAAO,eAAe,SAAS,IAAI,SAAS;AACpD,QAAM,SAA4B,EAAE,GAAG,sBAAsB,GAAG,WAAW;AAC3E,QAAM,CAAC,SAAS,UAAU,IAAI,SAAS,KAAK;AAE5C,YAAU,MAAM;AACd,eAAW,IAAI;AAAA,EACjB,GAAG,CAAC,CAAC;AAEL,QAAM,cAAc;AAAA,IAClB;AAAA,IACA;AAAA,IACA;AAAA,EACF;AAEA,MAAI,CAAC,SAAS;AACZ,WAAO,oBAAC,UAAK,WAAW,aAAa,eAAW,MAAC;AAAA,EACnD;AAEA,MAAI,YAAY,QAAQ;AACtB,WACE,qBAAC,gBACC;AAAA,0BAAC,uBAAoB,SAAO,MAC1B,8BAAC,YAAO,MAAK,UAAS,WAAW,aAAa,cAAY,OAAO,aAC9D,4BAAkB,SAAS,oBAAC,QAAK,WAAU,UAAS,IAAK,oBAAC,OAAI,WAAU,UAAS,GACpF,GACF;AAAA,MACA,qBAAC,uBAAoB,OAAM,OACzB;AAAA,6BAAC,oBAAiB,SAAS,MAAM,SAAS,OAAO,GAAG,WAAW,GAAG,UAAU,WAAW,WAAW,GAChG;AAAA,8BAAC,OAAI,WAAU,UAAS;AAAA,UACvB,OAAO;AAAA,WACV;AAAA,QACA,qBAAC,oBAAiB,SAAS,MAAM,SAAS,MAAM,GAAG,WAAW,GAAG,UAAU,UAAU,WAAW,GAC9F;AAAA,8BAAC,QAAK,WAAU,UAAS;AAAA,UACxB,OAAO;AAAA,WACV;AAAA,QACA,qBAAC,oBAAiB,SAAS,MAAM,SAAS,QAAQ,GAAG,WAAW,GAAG,UAAU,YAAY,WAAW,GAClG;AAAA,8BAAC,WAAQ,WAAU,UAAS;AAAA,UAC3B,OAAO;AAAA,WACV;AAAA,SACF;AAAA,OACF;AAAA,EAEJ;AAEA,QAAM,YAAY,kBAAkB,SAAS,UAAU;AAEvD,SACE,qBAAC,WACC;AAAA,wBAAC,kBAAe,SAAO,MACrB;AAAA,MAAC;AAAA;AAAA,QACC,MAAK;AAAA,QACL,SAAS,MAAM,SAAS,SAAS;AAAA,QACjC,WAAW;AAAA,QACX,cAAY,OAAO;AAAA,QAElB,4BAAkB,SAAS,oBAAC,OAAI,WAAU,UAAS,IAAK,oBAAC,QAAK,WAAU,UAAS;AAAA;AAAA,IACpF,GACF;AAAA,IACA,oBAAC,kBAAe,MAAK,UAAU,wBAAc,SAAS,OAAO,YAAY,OAAO,YAAW;AAAA,KAC7F;AAEJ;","names":[]}
1
+ {"version":3,"sources":["../../src/shell/theme-toggle.tsx"],"sourcesContent":["\"use client\";\n\nimport { Monitor, Moon, Sun } from \"lucide-react\";\nimport { useTheme } from \"next-themes\";\nimport { useEffect, useState } from \"react\";\nimport {\n DropdownMenu,\n DropdownMenuContent,\n DropdownMenuItem,\n DropdownMenuTrigger\n} from \"../components/ui/dropdown-menu.js\";\nimport { Tooltip, TooltipContent, TooltipTrigger } from \"../components/ui/tooltip.js\";\nimport { cn } from \"../lib/cn.js\";\nimport { DEFAULT_SHELL_LABELS, type ShellLabels } from \"./types.js\";\n\n/** Chỉ những nhãn ThemeToggle thực sự dùng — để nút này KHÔNG buộc phải nằm trong ShellProvider. */\nexport type ThemeToggleLabels = Pick<ShellLabels, \"themeLight\" | \"themeDark\" | \"themeSystem\" | \"toggleTheme\">;\n\n/**\n * Ba lựa chọn mà `ThemeToggle` ghi ra — tập ĐÓNG, và `\"system\"` là một giá trị thật chứ không phải\n * \"chưa chọn\".\n *\n * ⚠ App nào lưu giá trị này (ví dụ làm mặc định theo tài khoản) phải chấp nhận đủ CẢ BA. Ép nó về\n * hai trạng thái sáng/tối là làm mất lựa chọn \"đi theo hệ điều hành\" — thứ mà người bật chế độ tối\n * theo giờ trong ngày sẽ mất ngay khi họ đăng nhập ở một máy khác.\n */\nexport type ThemeChoice = \"light\" | \"dark\" | \"system\";\n\nexport interface ThemeToggleProps {\n className?: string;\n /**\n * \"icon\" — một nút, bấm là lật Sáng ⇄ Tối (mặc định, dùng ở header).\n * \"menu\" — dropdown 3 lựa chọn Sáng / Tối / Hệ thống.\n */\n variant?: \"icon\" | \"menu\";\n /** Override chuỗi hiển thị (mặc định tiếng Việt). `AppHeader` truyền labels của shell xuống. */\n labels?: Partial<ThemeToggleLabels>;\n /**\n * Gọi SAU khi người dùng đổi — cùng khuôn với `onContrastChange` của `ContrastToggle`.\n *\n * ⚠ Callback THÔNG BÁO, không phải `onChange` của component controlled: theme CÓ HIỆU LỰC vẫn do\n * `next-themes` giữ trong `localStorage`. Prop này để app ghi một MẶC ĐỊNH theo tài khoản ở nơi\n * khác. Xem `DensityToggleProps.onDensityChange` cho lý do đầy đủ.\n *\n * ⚠ Nhận đủ ba giá trị kể cả `\"system\"` — và ở `variant=\"icon\"` thì nó KHÔNG BAO GIỜ phát\n * `\"system\"`, vì nút ấy chỉ lật sáng ⇄ tối. Muốn người dùng chọn được \"hệ thống\" thì phải dùng\n * `variant=\"menu\"`.\n */\n onThemeChange?: (theme: ThemeChoice) => void;\n}\n\n/**\n * Nút đổi giao diện — dùng được ĐỘC LẬP (trang đăng nhập, trang marketing),\n * không bắt buộc nằm trong `ShellProvider`.\n *\n * Chống lệch hydration bằng cờ `mounted`: theme thật chỉ biết được ở client\n * (đọc localStorage/`prefers-color-scheme`), render thẳng icon theo theme ngay\n * lần đầu sẽ lệch với HTML do server sinh ra. Trước khi mount hiện ô rỗng cùng\n * kích thước để layout không nhảy.\n */\nexport function ThemeToggle({ className, variant = \"icon\", labels: labelsProp, onThemeChange }: ThemeToggleProps) {\n const { theme, resolvedTheme, setTheme } = useTheme();\n\n /*\n * Gói hai lời gọi lại làm một: bốn chỗ đổi theme trong file này (ba mục menu + nút lật) đều phải\n * báo, và một chỗ quên báo là một đường mà app không bao giờ ghi được mặc định — thứ chỉ lộ ra\n * khi người dùng đăng nhập ở máy khác.\n */\n const choose = (next: ThemeChoice) => {\n setTheme(next);\n onThemeChange?.(next);\n };\n const labels: ThemeToggleLabels = { ...DEFAULT_SHELL_LABELS, ...labelsProp };\n const [mounted, setMounted] = useState(false);\n\n useEffect(() => {\n setMounted(true);\n }, []);\n\n const buttonClass = cn(\n \"flex size-9 items-center justify-center rounded-md text-muted-foreground\",\n \"transition-colors duration-150 ease-out hover:bg-muted hover:text-foreground\",\n className\n );\n\n if (!mounted) {\n return <span className={buttonClass} aria-hidden />;\n }\n\n if (variant === \"menu\") {\n return (\n <DropdownMenu>\n <DropdownMenuTrigger asChild>\n <button type=\"button\" className={buttonClass} aria-label={labels.toggleTheme}>\n {resolvedTheme === \"dark\" ? <Moon className=\"size-4\" /> : <Sun className=\"size-4\" />}\n </button>\n </DropdownMenuTrigger>\n <DropdownMenuContent align=\"end\">\n <DropdownMenuItem onClick={() => choose(\"light\")} className={cn(theme === \"light\" && \"bg-accent\")}>\n <Sun className=\"size-4\" />\n {labels.themeLight}\n </DropdownMenuItem>\n <DropdownMenuItem onClick={() => choose(\"dark\")} className={cn(theme === \"dark\" && \"bg-accent\")}>\n <Moon className=\"size-4\" />\n {labels.themeDark}\n </DropdownMenuItem>\n <DropdownMenuItem onClick={() => choose(\"system\")} className={cn(theme === \"system\" && \"bg-accent\")}>\n <Monitor className=\"size-4\" />\n {labels.themeSystem}\n </DropdownMenuItem>\n </DropdownMenuContent>\n </DropdownMenu>\n );\n }\n\n const nextTheme: ThemeChoice = resolvedTheme === \"dark\" ? \"light\" : \"dark\";\n\n return (\n <Tooltip>\n <TooltipTrigger asChild>\n <button type=\"button\" onClick={() => choose(nextTheme)} className={buttonClass} aria-label={labels.toggleTheme}>\n {resolvedTheme === \"dark\" ? <Sun className=\"size-4\" /> : <Moon className=\"size-4\" />}\n </button>\n </TooltipTrigger>\n <TooltipContent side=\"bottom\">{nextTheme === \"dark\" ? labels.themeDark : labels.themeLight}</TooltipContent>\n </Tooltip>\n );\n}\n"],"mappings":";AAsFW,cAYD,YAZC;AApFX,SAAS,SAAS,MAAM,WAAW;AACnC,SAAS,gBAAgB;AACzB,SAAS,WAAW,gBAAgB;AACpC;AAAA,EACE;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,OACK;AACP,SAAS,SAAS,gBAAgB,sBAAsB;AACxD,SAAS,UAAU;AACnB,SAAS,4BAA8C;AA+ChD,SAAS,YAAY,EAAE,WAAW,UAAU,QAAQ,QAAQ,YAAY,cAAc,GAAqB;AAChH,QAAM,EAAE,OAAO,eAAe,SAAS,IAAI,SAAS;AAOpD,QAAM,SAAS,CAAC,SAAsB;AACpC,aAAS,IAAI;AACb,oBAAgB,IAAI;AAAA,EACtB;AACA,QAAM,SAA4B,EAAE,GAAG,sBAAsB,GAAG,WAAW;AAC3E,QAAM,CAAC,SAAS,UAAU,IAAI,SAAS,KAAK;AAE5C,YAAU,MAAM;AACd,eAAW,IAAI;AAAA,EACjB,GAAG,CAAC,CAAC;AAEL,QAAM,cAAc;AAAA,IAClB;AAAA,IACA;AAAA,IACA;AAAA,EACF;AAEA,MAAI,CAAC,SAAS;AACZ,WAAO,oBAAC,UAAK,WAAW,aAAa,eAAW,MAAC;AAAA,EACnD;AAEA,MAAI,YAAY,QAAQ;AACtB,WACE,qBAAC,gBACC;AAAA,0BAAC,uBAAoB,SAAO,MAC1B,8BAAC,YAAO,MAAK,UAAS,WAAW,aAAa,cAAY,OAAO,aAC9D,4BAAkB,SAAS,oBAAC,QAAK,WAAU,UAAS,IAAK,oBAAC,OAAI,WAAU,UAAS,GACpF,GACF;AAAA,MACA,qBAAC,uBAAoB,OAAM,OACzB;AAAA,6BAAC,oBAAiB,SAAS,MAAM,OAAO,OAAO,GAAG,WAAW,GAAG,UAAU,WAAW,WAAW,GAC9F;AAAA,8BAAC,OAAI,WAAU,UAAS;AAAA,UACvB,OAAO;AAAA,WACV;AAAA,QACA,qBAAC,oBAAiB,SAAS,MAAM,OAAO,MAAM,GAAG,WAAW,GAAG,UAAU,UAAU,WAAW,GAC5F;AAAA,8BAAC,QAAK,WAAU,UAAS;AAAA,UACxB,OAAO;AAAA,WACV;AAAA,QACA,qBAAC,oBAAiB,SAAS,MAAM,OAAO,QAAQ,GAAG,WAAW,GAAG,UAAU,YAAY,WAAW,GAChG;AAAA,8BAAC,WAAQ,WAAU,UAAS;AAAA,UAC3B,OAAO;AAAA,WACV;AAAA,SACF;AAAA,OACF;AAAA,EAEJ;AAEA,QAAM,YAAyB,kBAAkB,SAAS,UAAU;AAEpE,SACE,qBAAC,WACC;AAAA,wBAAC,kBAAe,SAAO,MACrB,8BAAC,YAAO,MAAK,UAAS,SAAS,MAAM,OAAO,SAAS,GAAG,WAAW,aAAa,cAAY,OAAO,aAChG,4BAAkB,SAAS,oBAAC,OAAI,WAAU,UAAS,IAAK,oBAAC,QAAK,WAAU,UAAS,GACpF,GACF;AAAA,IACA,oBAAC,kBAAe,MAAK,UAAU,wBAAc,SAAS,OAAO,YAAY,OAAO,YAAW;AAAA,KAC7F;AAEJ;","names":[]}
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@comitor/ui",
3
- "version": "1.7.0",
3
+ "version": "1.8.0",
4
4
  "description": "Comitor design system — Shadcn/UI tùy biến theo brand Comitor + component dùng chung cho hệ sinh thái SaaS (Tasks, Chat, CRM, HR…)",
5
5
  "license": "MIT",
6
6
  "type": "module",
@@ -57,20 +57,6 @@
57
57
  "./styles.css": "./styles.css",
58
58
  "./package.json": "./package.json"
59
59
  },
60
- "scripts": {
61
- "build": "tsup",
62
- "typecheck": "tsc --noEmit",
63
- "lint": "biome check --error-on-warnings .",
64
- "lint:fix": "biome check --write .",
65
- "lint:a11y": "node scripts/check-contrast.mjs",
66
- "test:a11y-gate": "node scripts/a11y/self-test.mjs",
67
- "test:nav-active": "node scripts/check-nav-active.mjs",
68
- "verify:directives": "node scripts/verify-directives.mjs",
69
- "smoke:entries": "node scripts/smoke-entries.mjs",
70
- "check:exports": "publint --strict && attw --pack . --profile esm-only --exclude-entrypoints styles.css",
71
- "prepublishOnly": "pnpm run typecheck && pnpm run build && pnpm run verify:directives && pnpm run smoke:entries && pnpm run check:exports && pnpm run test:nav-active && pnpm run test:grid-contract && pnpm run test:a11y-gate && pnpm run lint:a11y",
72
- "test:grid-contract": "node scripts/check-grid-contract.mjs"
73
- },
74
60
  "peerDependencies": {
75
61
  "@uppy/core": "^6.0.0",
76
62
  "@uppy/xhr-upload": "^6.0.0",
@@ -162,5 +148,18 @@
162
148
  "tailwindcss": "^4.2.0",
163
149
  "tsup": "^8.3.5",
164
150
  "typescript": "^5.7.0"
151
+ },
152
+ "scripts": {
153
+ "build": "tsup",
154
+ "typecheck": "tsc --noEmit",
155
+ "lint": "biome check --error-on-warnings .",
156
+ "lint:fix": "biome check --write .",
157
+ "lint:a11y": "node scripts/check-contrast.mjs",
158
+ "test:a11y-gate": "node scripts/a11y/self-test.mjs",
159
+ "test:nav-active": "node scripts/check-nav-active.mjs",
160
+ "verify:directives": "node scripts/verify-directives.mjs",
161
+ "smoke:entries": "node scripts/smoke-entries.mjs",
162
+ "check:exports": "publint --strict && attw --pack . --profile esm-only --exclude-entrypoints styles.css",
163
+ "test:grid-contract": "node scripts/check-grid-contract.mjs"
165
164
  }
166
- }
165
+ }
package/styles.css CHANGED
@@ -935,15 +935,46 @@
935
935
  *
936
936
  * ── VÌ SAO Ở ĐÂY, KHÔNG PHẢI RẢI VÀO TỪNG COMPONENT ─────────────────────────────────────
937
937
  * Vì đây là hành vi của TẦNG NỀN, không phải của một component. Đã đếm: 18 file trong `src/`
938
- * render ~35 `<button>` thô ngoài `<Button>`, và không file nào khai `cursor-pointer`. Rải tay
939
- * là bỏ sót ngay ở component tiếp theo có người viết, và không cổng nào bắt được cái bỏ sót đó.
938
+ * render 36 `<button>` thô ngoài `<Button>`, và chỉ 4 file khai `cursor-pointer` — ba trong số
939
+ * đó nằm trên `label[for]` nên nay là thừa, cái thứ tư (`data-table.tsx`, dòng bấm được) thì
940
+ * luật này KHÔNG phủ vì `<tr>` không phải button. Rải tay là bỏ sót ngay ở component tiếp theo
941
+ * có người viết, và không cổng nào bắt được cái bỏ sót đó.
940
942
  *
941
- * `:not(:disabled)` và `:not([aria-disabled="true"])`: control bị vô hiệu KHÔNG được mời bấm.
942
- * Hai điều kiện chứ không một — Radix vô hiệu bằng `aria-disabled` ở những chỗ vẫn cần giữ
943
- * focus, và ở đó thuộc tính `disabled` không có mặt.
943
+ * ── HAI ĐIỀU KIỆN, VÀ CẢ HAI ÁP CHO CẢ HAI SELECTOR ─────────────────────────────────────
944
+ * Control bị vô hiệu KHÔNG được mời bấm. Cần `:not(:disabled)` VÀ `:not([aria-disabled])`:
945
+ * Radix (và react-day-picker) vô hiệu bằng `aria-disabled` ở những chỗ vẫn cần giữ focus, và
946
+ * ở đó thuộc tính `disabled` không có mặt.
947
+ *
948
+ * ⚠ `1.7.0` ghi đúng câu trên trong comment nhưng viết SAI xuống selector: mỗi selector chỉ
949
+ * mang MỘT guard (`button:not(:disabled)` + `[role="button"]:not([aria-disabled="true"])`).
950
+ * Nên `<button aria-disabled="true">` — đúng khuôn Radix mà hai guard sinh ra để xử lý — vẫn
951
+ * nhận bàn tay; chiều ngược lại `<button role="button" disabled>` cũng lọt qua nhánh hai.
952
+ * Ca thật đã đọc trong `react-day-picker@9` (`dist/esm/DayPicker.js`):
953
+ * · dòng 255 và 282 — mũi tên "tháng trước / tháng sau" khi KHÔNG còn tháng để đi:
954
+ * `aria-disabled: true` kèm `tabIndex: -1`, KHÔNG có thuộc tính `disabled`. Đây là ca xảy
955
+ * ra thường xuyên — mọi lịch có `startMonth`/`endMonth` hoặc khoảng giới hạn đều chạm nó.
956
+ * · dòng 320 — ô ngày: ngày bị khoá mà KHÔNG được focus dùng thuộc tính `disabled` thật,
957
+ * chỉ ngày bị khoá ĐANG focus mới dùng `aria-disabled`. Tức ô ngày dính hẹp hơn mũi tên.
958
+ * Bài học: một comment nói "hai điều kiện" không kiểm chứng được cái selector bên dưới nó.
959
+ *
960
+ * ── TÁM CA, ĐO TRÊN TRÌNH DUYỆT ─────────────────────────────────────────────────────────
961
+ * Đo lại bằng `getComputedStyle(el).cursor` sau khi sửa luật này. Hai ca đánh dấu ✗→✓ là hai
962
+ * ca `1.7.0` trả sai; ai đổi luật này phải chạy lại CẢ TÁM, đừng chọn vài ca dễ.
963
+ *
964
+ * <button> pointer
965
+ * <button disabled> default
966
+ * <button aria-disabled="true"> default ✗→✓
967
+ * <button role="button" disabled> default ✗→✓
968
+ * <div role="button"> pointer
969
+ * <div role="button" aria-disabled="true"> auto
970
+ * <label for="…"> pointer
971
+ * <label> (không for) default
972
+ *
973
+ * `<fieldset disabled>` cũng đúng theo đặc tả: control hậu duệ khớp `:disabled`, nên màn hình
974
+ * chỉ-đọc dựng bằng khuôn đó không mời bấm gì. (comitor-starter dùng đúng khuôn này.)
944
975
  */
945
- button:not(:disabled),
946
- [role="button"]:not([aria-disabled="true"]),
976
+ button:not(:disabled):not([aria-disabled="true"]),
977
+ [role="button"]:not(:disabled):not([aria-disabled="true"]),
947
978
  label[for] {
948
979
  cursor: pointer;
949
980
  }