@comitor/ui 0.8.0 → 0.9.1
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 +7 -3
- package/dist/components/composite/date-picker-constants.d.ts +54 -2
- package/dist/components/composite/date-picker-constants.js +11 -1
- package/dist/components/composite/date-picker-constants.js.map +1 -1
- package/dist/components/composite/date-picker.d.ts +23 -3
- package/dist/components/composite/date-picker.js +28 -17
- package/dist/components/composite/date-picker.js.map +1 -1
- package/dist/components/composite/relative-time.d.ts +17 -3
- package/dist/components/composite/relative-time.js +4 -3
- package/dist/components/composite/relative-time.js.map +1 -1
- package/dist/components/ui/skeleton.d.ts +16 -0
- package/dist/components/ui/skeleton.js +1 -1
- package/dist/components/ui/skeleton.js.map +1 -1
- package/dist/index.d.ts +2 -1
- package/dist/shell/app-launcher.js +42 -5
- package/dist/shell/app-launcher.js.map +1 -1
- package/package.json +14 -15
package/README.md
CHANGED
|
@@ -451,7 +451,7 @@ import không đổi:
|
|
|
451
451
|
| `CHART_COLORS.gold` | `undefined` | `"var(--chart-1)"` |
|
|
452
452
|
| `CHART_SERIES_COLORS[0]` | `undefined` | `"var(--chart-1)"` |
|
|
453
453
|
| `DEFAULT_DATA_TABLE_LABELS.emptyTitle` | `undefined` | `"Chưa có dữ liệu"` |
|
|
454
|
-
| `VI_DATE_FORMAT` | `undefined` | `"dd/MM/yyyy"` |
|
|
454
|
+
| `VI_DATE_FORMAT` · `dateFormatForLocale(vi)` | `undefined` | `"dd/MM/yyyy"` |
|
|
455
455
|
| `DEFAULT_DATE_PRESETS.length` | `0` | `3` |
|
|
456
456
|
| `DEFAULT_DATE_RANGE_PRESETS.length` | `0` | `5` |
|
|
457
457
|
| `DEFAULT_PAGE_SIZE_OPTIONS[0]` | `undefined` | `20` |
|
|
@@ -508,6 +508,10 @@ shadcn/ui style `new-york`, đã tùy biến token theo brand Comitor.
|
|
|
508
508
|
`Alert` có ba variant: `default` · `warning` · `destructive` — hai variant sau tô CHỮ bằng bậc `-ink`
|
|
509
509
|
trên `bg-card`, không tô nền màu (bậc tô nền làm chữ chỉ được 1,59:1 ở light ✗).
|
|
510
510
|
|
|
511
|
+
`Skeleton` tô bằng `--muted` (xám trung tính), KHÔNG phải `--accent`: bậc nhạt của brand là kem
|
|
512
|
+
ngả vàng ở bảng light, nên cả một trang đang tải sẽ loang vàng — và đúng bằng màu mà `Switch`/`Tabs`
|
|
513
|
+
dùng để nói "đang bật". Ở dark hai token vốn trùng giá trị nên không đổi gì.
|
|
514
|
+
|
|
511
515
|
`Progress` có `tone`: `default` · `success` · `warning` · `destructive`. Tông `warning` KHÔNG chỉ đổi
|
|
512
516
|
màu: nó kèm vạch chéo (`hazard-stripes`) khoét rãnh xuyên phần đã chạy. Bắt buộc phải thế, vì brand
|
|
513
517
|
của Comitor là gold còn warning là yellow — `--warning-ink` ⇄ `--primary-ink` chỉ 1,00:1 ở bảng tương
|
|
@@ -566,11 +570,11 @@ Giá trị thật của package: pattern lặp đi lặp lại ở mọi app, gh
|
|
|
566
570
|
| `RouteTabs` | Tab mà mỗi mục là một **route thật** — `<nav>` chứa link, `aria-current="page"`. Dùng chung bảng lớp với `Tabs` nên nhìn không khác; đừng dùng `Tabs` cho việc này (nó dựng `role="tablist"` + `aria-controls` trỏ tới tabpanel cùng trang, mà ở đây không có). ⚠ Trên Next phải truyền `LinkComponent={Link}` |
|
|
567
571
|
| `FormSection`, `FormField` | Bố cục form 12 cột; uỷ quyền label/mô tả/lỗi xuống `Field` |
|
|
568
572
|
| `Combobox`, `MultiCombobox` | Chọn có tìm kiếm, **khớp không dấu tiếng Việt**. `aria-invalid` đổi trigger sang **viền đỏ** (`--destructive-ink`), cùng tín hiệu với `Input`; nhận cả `boolean` lẫn `"true"`/`"false"` để dùng thẳng `aria-invalid={!!errors.x}` |
|
|
569
|
-
| `DatePicker`, `DateRangePicker` | Chọn ngày/khoảng ngày, locale `vi`,
|
|
573
|
+
| `DatePicker`, `DateRangePicker` | Chọn ngày/khoảng ngày, preset dựng sẵn. **`locale?: Locale`** (date-fns, mặc định `vi`) đổi CẢ lịch lẫn thứ tự ngày/tháng của ô nhập: định dạng suy từ locale bằng `dateFormatForLocale()`, nên `en-US` ra `MM/dd/yyyy` mà không phải khai gì thêm. Ba nhãn `sr-only` override bằng `labels` |
|
|
570
574
|
| `LetterAvatar`, `AvatarGroup` | Avatar chữ cái theo tên tiếng Việt (tông màu deterministic), nhóm người tham gia. Lấy tone bằng `getAvatarToneClasses(name)`; bản hex cho canvas/email/PDF là `getAvatarToneColors(name, mode)` ở `/tokens` — **cùng một bảng tone**, cùng một hàm băm |
|
|
571
575
|
| `ConfirmDialog`, `CommandPalette` | Hộp xác nhận, bảng lệnh `⌘K` |
|
|
572
576
|
| `Toaster`, `toast()`, `toastPromise()`, `dismissToast()` | Thông báo có icon theo trạng thái |
|
|
573
|
-
| `CopyButton`, `RelativeTime`, `InlineEdit`, `KeyboardHint` | Tiện ích nhỏ dùng khắp nơi. `RelativeTime` nhận `now?: Date` để đo từ một mốc CỐ ĐỊNH thay vì đồng hồ thật — dùng cho dữ liệu đứng yên (bản mẫu, ảnh chụp báo cáo, test); có `now` thì nó tự tắt hẹn giờ cập nhật và kết quả trở nên tất định |
|
|
577
|
+
| `CopyButton`, `RelativeTime`, `InlineEdit`, `KeyboardHint` | Tiện ích nhỏ dùng khắp nơi. `RelativeTime` nhận **`locale?: Locale`** (date-fns, mặc định `vi`) — app đa ngôn ngữ phải truyền, không thì giao diện tiếng Anh vẫn hiện "3 phút trước". Và `now?: Date` để đo từ một mốc CỐ ĐỊNH thay vì đồng hồ thật — dùng cho dữ liệu đứng yên (bản mẫu, ảnh chụp báo cáo, test); có `now` thì nó tự tắt hẹn giờ cập nhật và kết quả trở nên tất định |
|
|
574
578
|
| `useIsMac` | Hook đi kèm `KeyboardHint` — `true` khi chạy trên macOS (render đầu luôn `false`, xem §3). **Cửa xuất duy nhất của cả gói**; `/shell` không re-export |
|
|
575
579
|
|
|
576
580
|
Kèm 2 module thuần (không phải component, dùng được cả hai phía):
|
|
@@ -1,7 +1,59 @@
|
|
|
1
|
+
import { Locale } from 'date-fns';
|
|
1
2
|
import { DateRange } from 'react-day-picker';
|
|
2
3
|
|
|
3
|
-
/**
|
|
4
|
+
/**
|
|
5
|
+
* Định dạng ngày và preset mặc định của `DatePicker` / `DateRangePicker`.
|
|
6
|
+
*
|
|
7
|
+
* Module **CỐ Ý không có `"use client"`** — xem `src/chart/chart-colors.ts` để biết lý do đầy
|
|
8
|
+
* đủ: mọi export của một file `"use client"` đến tay Server Component dưới dạng
|
|
9
|
+
* client-reference proxy, đọc ra `undefined` mà KHÔNG throw, KHÔNG warning. Hằng dữ liệu thuần
|
|
10
|
+
* (nhãn mặc định, preset, sentinel) là thứ trang server hay đọc nhất — spread để override một
|
|
11
|
+
* khoá, đối chiếu giá trị, dựng danh sách — nên phải sống ngoài ranh giới client.
|
|
12
|
+
*
|
|
13
|
+
* ⚠ Barrel `src/index.ts` xuất module này TRỰC TIẾP. `date-picker.tsx` chỉ được `import`,
|
|
14
|
+
* KHÔNG được `export` lại (re-export qua file client là vô hiệu, và đụng tên với `export *`).
|
|
15
|
+
*/
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* Định dạng ngày của Việt Nam.
|
|
19
|
+
*
|
|
20
|
+
* ⚠ KHÔNG dùng nó làm định dạng mặc định nữa — `DatePicker`/`DateRangePicker` suy định dạng từ
|
|
21
|
+
* `locale` bằng `dateFormatForLocale()` bên dưới, nên một app tiếng Anh ra `MM/dd/yyyy` thay vì
|
|
22
|
+
* ngày Việt Nam. Hằng này giữ lại vì nó là API công khai và vẫn đúng khi app cần ghim cứng ngày
|
|
23
|
+
* kiểu Việt Nam ở một chỗ cụ thể (hoá đơn in ra, tệp xuất cho cơ quan thuế…).
|
|
24
|
+
*/
|
|
4
25
|
declare const VI_DATE_FORMAT = "dd/MM/yyyy";
|
|
26
|
+
/**
|
|
27
|
+
* Định dạng ngày SỐ của một `locale` date-fns — dùng cho cả `format`, `parse` và chuỗi gợi ý
|
|
28
|
+
* trong ô nhập, nên ba thứ đó không bao giờ lệch nhau.
|
|
29
|
+
*
|
|
30
|
+
* `formatLong.date({ width: "short" })` là nguồn: `vi` cho `dd/MM/y`, `en-US` cho `MM/dd/yyyy`,
|
|
31
|
+
* `ja` cho `y/MM/dd`. Một chữ `y` đơn được nong thành `yyyy` — không phải để đổi kết quả (`y` in
|
|
32
|
+
* ra "2026" y hệt) mà vì chuỗi này CÒN LÀM PLACEHOLDER: gợi ý "dd/MM/y" dạy người dùng gõ sai.
|
|
33
|
+
*
|
|
34
|
+
* Nhờ vậy `vi` cho ra đúng `dd/MM/yyyy` như hằng bên trên — đổi sang cách suy này không làm đổi
|
|
35
|
+
* một pixel nào ở các app đang chạy tiếng Việt.
|
|
36
|
+
*/
|
|
37
|
+
declare function dateFormatForLocale(locale: Locale): string;
|
|
38
|
+
/**
|
|
39
|
+
* Nhãn chỉ dành cho TRÌNH ĐỌC MÀN HÌNH của `DatePicker` — không hiện thành chữ trên giao diện.
|
|
40
|
+
*
|
|
41
|
+
* Vì sao vẫn phải override được: chúng là chuỗi tiếng Việt cứng nằm trong `sr-only`, nên một app
|
|
42
|
+
* tiếng Anh trông thì đúng nhưng ĐỌC ra vẫn là tiếng Việt — lỗi không ai thấy trên ảnh chụp và
|
|
43
|
+
* cũng không có gì báo đỏ.
|
|
44
|
+
*
|
|
45
|
+
* ⚠ `locale` KHÔNG dịch mấy nhãn này, và không thể: locale của date-fns chỉ mang cách viết ngày
|
|
46
|
+
* tháng, không mang chuỗi giao diện. Đổi ngôn ngữ app thì phải truyền CẢ `locale` lẫn `labels`.
|
|
47
|
+
*/
|
|
48
|
+
interface DatePickerLabels {
|
|
49
|
+
/** Nút xoá ngày đang chọn (`DatePicker`) */
|
|
50
|
+
clear: string;
|
|
51
|
+
/** Nút xoá khoảng ngày đang chọn (`DateRangePicker`) */
|
|
52
|
+
clearRange: string;
|
|
53
|
+
/** Nút mở lịch (`DatePicker`; `DateRangePicker` mở lịch bằng chính ô của nó nên không dùng khoá này) */
|
|
54
|
+
openCalendar: string;
|
|
55
|
+
}
|
|
56
|
+
declare const DEFAULT_DATE_PICKER_LABELS: DatePickerLabels;
|
|
5
57
|
interface DatePreset {
|
|
6
58
|
id: string;
|
|
7
59
|
label: string;
|
|
@@ -18,4 +70,4 @@ declare const DEFAULT_DATE_PRESETS: DatePreset[];
|
|
|
18
70
|
/** Preset mặc định cho ô chọn khoảng ngày (báo cáo, bộ lọc danh sách). */
|
|
19
71
|
declare const DEFAULT_DATE_RANGE_PRESETS: DateRangePreset[];
|
|
20
72
|
|
|
21
|
-
export { DEFAULT_DATE_PRESETS, DEFAULT_DATE_RANGE_PRESETS, type DatePreset, type DateRangePreset, VI_DATE_FORMAT };
|
|
73
|
+
export { DEFAULT_DATE_PICKER_LABELS, DEFAULT_DATE_PRESETS, DEFAULT_DATE_RANGE_PRESETS, type DatePickerLabels, type DatePreset, type DateRangePreset, VI_DATE_FORMAT, dateFormatForLocale };
|
|
@@ -1,5 +1,13 @@
|
|
|
1
1
|
import { endOfMonth, startOfDay, startOfMonth, subDays, subMonths } from "date-fns";
|
|
2
2
|
const VI_DATE_FORMAT = "dd/MM/yyyy";
|
|
3
|
+
function dateFormatForLocale(locale) {
|
|
4
|
+
return locale.formatLong.date({ width: "short" }).replace(/y+/g, (run) => run.length === 1 ? "yyyy" : run);
|
|
5
|
+
}
|
|
6
|
+
const DEFAULT_DATE_PICKER_LABELS = {
|
|
7
|
+
clear: "X\xF3a ng\xE0y",
|
|
8
|
+
clearRange: "X\xF3a kho\u1EA3ng ng\xE0y",
|
|
9
|
+
openCalendar: "M\u1EDF l\u1ECBch"
|
|
10
|
+
};
|
|
3
11
|
const DEFAULT_DATE_PRESETS = [
|
|
4
12
|
{ id: "today", label: "H\xF4m nay", getValue: () => startOfDay(/* @__PURE__ */ new Date()) },
|
|
5
13
|
{ id: "yesterday", label: "H\xF4m qua", getValue: () => startOfDay(subDays(/* @__PURE__ */ new Date(), 1)) },
|
|
@@ -32,8 +40,10 @@ const DEFAULT_DATE_RANGE_PRESETS = [
|
|
|
32
40
|
}
|
|
33
41
|
];
|
|
34
42
|
export {
|
|
43
|
+
DEFAULT_DATE_PICKER_LABELS,
|
|
35
44
|
DEFAULT_DATE_PRESETS,
|
|
36
45
|
DEFAULT_DATE_RANGE_PRESETS,
|
|
37
|
-
VI_DATE_FORMAT
|
|
46
|
+
VI_DATE_FORMAT,
|
|
47
|
+
dateFormatForLocale
|
|
38
48
|
};
|
|
39
49
|
//# sourceMappingURL=date-picker-constants.js.map
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"sources":["../../../src/components/composite/date-picker-constants.ts"],"sourcesContent":["/**\n * Định dạng ngày và preset mặc định của `DatePicker` / `DateRangePicker`.\n *\n * Module **CỐ Ý không có `\"use client\"`** — xem `src/chart/chart-colors.ts` để biết lý do đầy\n * đủ: mọi export của một file `\"use client\"` đến tay Server Component dưới dạng\n * client-reference proxy, đọc ra `undefined` mà KHÔNG throw, KHÔNG warning. Hằng dữ liệu thuần\n * (nhãn mặc định, preset, sentinel) là thứ trang server hay đọc nhất — spread để override một\n * khoá, đối chiếu giá trị, dựng danh sách — nên phải sống ngoài ranh giới client.\n *\n * ⚠ Barrel `src/index.ts` xuất module này TRỰC TIẾP. `date-picker.tsx` chỉ được `import`,\n * KHÔNG được `export` lại (re-export qua file client là vô hiệu, và đụng tên với `export *`).\n */\nimport { endOfMonth, startOfDay, startOfMonth, subDays, subMonths } from \"date-fns\";\nimport type { DateRange } from \"react-day-picker\";\n\n
|
|
1
|
+
{"version":3,"sources":["../../../src/components/composite/date-picker-constants.ts"],"sourcesContent":["/**\n * Định dạng ngày và preset mặc định của `DatePicker` / `DateRangePicker`.\n *\n * Module **CỐ Ý không có `\"use client\"`** — xem `src/chart/chart-colors.ts` để biết lý do đầy\n * đủ: mọi export của một file `\"use client\"` đến tay Server Component dưới dạng\n * client-reference proxy, đọc ra `undefined` mà KHÔNG throw, KHÔNG warning. Hằng dữ liệu thuần\n * (nhãn mặc định, preset, sentinel) là thứ trang server hay đọc nhất — spread để override một\n * khoá, đối chiếu giá trị, dựng danh sách — nên phải sống ngoài ranh giới client.\n *\n * ⚠ Barrel `src/index.ts` xuất module này TRỰC TIẾP. `date-picker.tsx` chỉ được `import`,\n * KHÔNG được `export` lại (re-export qua file client là vô hiệu, và đụng tên với `export *`).\n */\nimport { endOfMonth, type Locale, startOfDay, startOfMonth, subDays, subMonths } from \"date-fns\";\nimport type { DateRange } from \"react-day-picker\";\n\n/**\n * Định dạng ngày của Việt Nam.\n *\n * ⚠ KHÔNG dùng nó làm định dạng mặc định nữa — `DatePicker`/`DateRangePicker` suy định dạng từ\n * `locale` bằng `dateFormatForLocale()` bên dưới, nên một app tiếng Anh ra `MM/dd/yyyy` thay vì\n * ngày Việt Nam. Hằng này giữ lại vì nó là API công khai và vẫn đúng khi app cần ghim cứng ngày\n * kiểu Việt Nam ở một chỗ cụ thể (hoá đơn in ra, tệp xuất cho cơ quan thuế…).\n */\nexport const VI_DATE_FORMAT = \"dd/MM/yyyy\";\n\n/**\n * Định dạng ngày SỐ của một `locale` date-fns — dùng cho cả `format`, `parse` và chuỗi gợi ý\n * trong ô nhập, nên ba thứ đó không bao giờ lệch nhau.\n *\n * `formatLong.date({ width: \"short\" })` là nguồn: `vi` cho `dd/MM/y`, `en-US` cho `MM/dd/yyyy`,\n * `ja` cho `y/MM/dd`. Một chữ `y` đơn được nong thành `yyyy` — không phải để đổi kết quả (`y` in\n * ra \"2026\" y hệt) mà vì chuỗi này CÒN LÀM PLACEHOLDER: gợi ý \"dd/MM/y\" dạy người dùng gõ sai.\n *\n * Nhờ vậy `vi` cho ra đúng `dd/MM/yyyy` như hằng bên trên — đổi sang cách suy này không làm đổi\n * một pixel nào ở các app đang chạy tiếng Việt.\n */\nexport function dateFormatForLocale(locale: Locale): string {\n return locale.formatLong.date({ width: \"short\" }).replace(/y+/g, (run) => (run.length === 1 ? \"yyyy\" : run));\n}\n\n/**\n * Nhãn chỉ dành cho TRÌNH ĐỌC MÀN HÌNH của `DatePicker` — không hiện thành chữ trên giao diện.\n *\n * Vì sao vẫn phải override được: chúng là chuỗi tiếng Việt cứng nằm trong `sr-only`, nên một app\n * tiếng Anh trông thì đúng nhưng ĐỌC ra vẫn là tiếng Việt — lỗi không ai thấy trên ảnh chụp và\n * cũng không có gì báo đỏ.\n *\n * ⚠ `locale` KHÔNG dịch mấy nhãn này, và không thể: locale của date-fns chỉ mang cách viết ngày\n * tháng, không mang chuỗi giao diện. Đổi ngôn ngữ app thì phải truyền CẢ `locale` lẫn `labels`.\n */\nexport interface DatePickerLabels {\n /** Nút xoá ngày đang chọn (`DatePicker`) */\n clear: string;\n /** Nút xoá khoảng ngày đang chọn (`DateRangePicker`) */\n clearRange: string;\n /** Nút mở lịch (`DatePicker`; `DateRangePicker` mở lịch bằng chính ô của nó nên không dùng khoá này) */\n openCalendar: string;\n}\n\nexport const DEFAULT_DATE_PICKER_LABELS: DatePickerLabels = {\n clear: \"Xóa ngày\",\n clearRange: \"Xóa khoảng ngày\",\n openCalendar: \"Mở lịch\"\n};\n\nexport interface DatePreset {\n id: string;\n label: string;\n /** Trả về ngày (DatePicker) hoặc khoảng ngày (DateRangePicker) tại thời điểm bấm */\n getValue: () => Date;\n}\n\nexport interface DateRangePreset {\n id: string;\n label: string;\n getValue: () => DateRange;\n}\n\n/** Preset mặc định cho ô chọn một ngày. */\nexport const DEFAULT_DATE_PRESETS: DatePreset[] = [\n { id: \"today\", label: \"Hôm nay\", getValue: () => startOfDay(new Date()) },\n { id: \"yesterday\", label: \"Hôm qua\", getValue: () => startOfDay(subDays(new Date(), 1)) },\n { id: \"week-ago\", label: \"7 ngày trước\", getValue: () => startOfDay(subDays(new Date(), 7)) }\n];\n\n/** Preset mặc định cho ô chọn khoảng ngày (báo cáo, bộ lọc danh sách). */\nexport const DEFAULT_DATE_RANGE_PRESETS: DateRangePreset[] = [\n { id: \"today\", label: \"Hôm nay\", getValue: () => ({ from: startOfDay(new Date()), to: startOfDay(new Date()) }) },\n {\n id: \"last-7\",\n label: \"7 ngày qua\",\n getValue: () => ({ from: startOfDay(subDays(new Date(), 6)), to: startOfDay(new Date()) })\n },\n {\n id: \"last-30\",\n label: \"30 ngày qua\",\n getValue: () => ({ from: startOfDay(subDays(new Date(), 29)), to: startOfDay(new Date()) })\n },\n {\n id: \"this-month\",\n label: \"Tháng này\",\n getValue: () => ({ from: startOfMonth(new Date()), to: startOfDay(new Date()) })\n },\n {\n id: \"last-month\",\n label: \"Tháng trước\",\n getValue: () => {\n const previous = subMonths(new Date(), 1);\n return { from: startOfMonth(previous), to: endOfMonth(previous) };\n }\n }\n];\n"],"mappings":"AAYA,SAAS,YAAyB,YAAY,cAAc,SAAS,iBAAiB;AAW/E,MAAM,iBAAiB;AAavB,SAAS,oBAAoB,QAAwB;AAC1D,SAAO,OAAO,WAAW,KAAK,EAAE,OAAO,QAAQ,CAAC,EAAE,QAAQ,OAAO,CAAC,QAAS,IAAI,WAAW,IAAI,SAAS,GAAI;AAC7G;AAqBO,MAAM,6BAA+C;AAAA,EAC1D,OAAO;AAAA,EACP,YAAY;AAAA,EACZ,cAAc;AAChB;AAgBO,MAAM,uBAAqC;AAAA,EAChD,EAAE,IAAI,SAAS,OAAO,cAAW,UAAU,MAAM,WAAW,oBAAI,KAAK,CAAC,EAAE;AAAA,EACxE,EAAE,IAAI,aAAa,OAAO,cAAW,UAAU,MAAM,WAAW,QAAQ,oBAAI,KAAK,GAAG,CAAC,CAAC,EAAE;AAAA,EACxF,EAAE,IAAI,YAAY,OAAO,6BAAgB,UAAU,MAAM,WAAW,QAAQ,oBAAI,KAAK,GAAG,CAAC,CAAC,EAAE;AAC9F;AAGO,MAAM,6BAAgD;AAAA,EAC3D,EAAE,IAAI,SAAS,OAAO,cAAW,UAAU,OAAO,EAAE,MAAM,WAAW,oBAAI,KAAK,CAAC,GAAG,IAAI,WAAW,oBAAI,KAAK,CAAC,EAAE,GAAG;AAAA,EAChH;AAAA,IACE,IAAI;AAAA,IACJ,OAAO;AAAA,IACP,UAAU,OAAO,EAAE,MAAM,WAAW,QAAQ,oBAAI,KAAK,GAAG,CAAC,CAAC,GAAG,IAAI,WAAW,oBAAI,KAAK,CAAC,EAAE;AAAA,EAC1F;AAAA,EACA;AAAA,IACE,IAAI;AAAA,IACJ,OAAO;AAAA,IACP,UAAU,OAAO,EAAE,MAAM,WAAW,QAAQ,oBAAI,KAAK,GAAG,EAAE,CAAC,GAAG,IAAI,WAAW,oBAAI,KAAK,CAAC,EAAE;AAAA,EAC3F;AAAA,EACA;AAAA,IACE,IAAI;AAAA,IACJ,OAAO;AAAA,IACP,UAAU,OAAO,EAAE,MAAM,aAAa,oBAAI,KAAK,CAAC,GAAG,IAAI,WAAW,oBAAI,KAAK,CAAC,EAAE;AAAA,EAChF;AAAA,EACA;AAAA,IACE,IAAI;AAAA,IACJ,OAAO;AAAA,IACP,UAAU,MAAM;AACd,YAAM,WAAW,UAAU,oBAAI,KAAK,GAAG,CAAC;AACxC,aAAO,EAAE,MAAM,aAAa,QAAQ,GAAG,IAAI,WAAW,QAAQ,EAAE;AAAA,IAClE;AAAA,EACF;AACF;","names":[]}
|
|
@@ -1,7 +1,8 @@
|
|
|
1
1
|
import * as React from 'react';
|
|
2
|
+
import { Locale } from 'date-fns';
|
|
2
3
|
import { DateRange } from 'react-day-picker';
|
|
3
4
|
export { DateRange } from 'react-day-picker';
|
|
4
|
-
import { DatePreset, DateRangePreset } from './date-picker-constants.js';
|
|
5
|
+
import { DatePreset, DatePickerLabels, DateRangePreset } from './date-picker-constants.js';
|
|
5
6
|
|
|
6
7
|
interface DatePickerProps {
|
|
7
8
|
value?: Date | null;
|
|
@@ -15,6 +16,20 @@ interface DatePickerProps {
|
|
|
15
16
|
clearable?: boolean;
|
|
16
17
|
/** Cột preset bên trái lịch (mặc định ẩn) */
|
|
17
18
|
presets?: DatePreset[] | false;
|
|
19
|
+
/**
|
|
20
|
+
* Ngôn ngữ của date-fns (mặc định `vi`) — quyết định tên thứ/tháng trong lịch VÀ thứ tự
|
|
21
|
+
* ngày/tháng của ô nhập, qua `dateFormatForLocale()`.
|
|
22
|
+
*
|
|
23
|
+
* App đa ngôn ngữ phải truyền: để mặc định thì giao diện tiếng Anh vẫn hiện lịch tiếng Việt và
|
|
24
|
+
* bắt người dùng gõ `dd/MM/yyyy` — hai lỗi không có gì báo đỏ.
|
|
25
|
+
*/
|
|
26
|
+
locale?: Locale;
|
|
27
|
+
/**
|
|
28
|
+
* Ghi đè định dạng gõ/hiển thị. HIẾM khi cần: bỏ trống thì nó suy từ `locale`, và đó gần như
|
|
29
|
+
* luôn là thứ đúng. Chỉ dùng khi một màn hình phải ghim cứng một định dạng bất kể ngôn ngữ.
|
|
30
|
+
*/
|
|
31
|
+
dateFormat?: string;
|
|
32
|
+
labels?: Partial<DatePickerLabels>;
|
|
18
33
|
className?: string;
|
|
19
34
|
id?: string;
|
|
20
35
|
name?: string;
|
|
@@ -31,7 +46,7 @@ interface DatePickerProps {
|
|
|
31
46
|
* ai chịu nổi. Chuỗi gõ dở KHÔNG bị ghi đè trong lúc gõ; sai định dạng thì trả về giá trị cũ
|
|
32
47
|
* khi rời ô, không âm thầm nuốt dữ liệu.
|
|
33
48
|
*/
|
|
34
|
-
declare function DatePicker({ value, onValueChange, placeholder, disabled, minDate, maxDate, clearable, presets, className, id, name, ...aria }: DatePickerProps): React.JSX.Element;
|
|
49
|
+
declare function DatePicker({ value, onValueChange, placeholder, disabled, minDate, maxDate, clearable, presets, locale, dateFormat, labels, className, id, name, ...aria }: DatePickerProps): React.JSX.Element;
|
|
35
50
|
interface DateRangePickerProps {
|
|
36
51
|
value?: DateRange | null;
|
|
37
52
|
onValueChange: (value: DateRange | null) => void;
|
|
@@ -44,6 +59,11 @@ interface DateRangePickerProps {
|
|
|
44
59
|
presets?: DateRangePreset[] | false;
|
|
45
60
|
/** Số tháng hiện cùng lúc (mặc định 2 trên màn rộng) */
|
|
46
61
|
numberOfMonths?: number;
|
|
62
|
+
/** Ngôn ngữ date-fns (mặc định `vi`) — xem `DatePickerProps.locale`. */
|
|
63
|
+
locale?: Locale;
|
|
64
|
+
/** Ghi đè định dạng ngày; bỏ trống thì suy từ `locale`. */
|
|
65
|
+
dateFormat?: string;
|
|
66
|
+
labels?: Partial<DatePickerLabels>;
|
|
47
67
|
className?: string;
|
|
48
68
|
id?: string;
|
|
49
69
|
"aria-label"?: string;
|
|
@@ -56,6 +76,6 @@ interface DateRangePickerProps {
|
|
|
56
76
|
* Preset đứng trước lịch vì 9/10 lần người dùng muốn "7 ngày qua" chứ không phải một khoảng
|
|
57
77
|
* tuỳ ý — bắt họ bấm hai lần trên lịch mỗi lần xem báo cáo là thuế lặp lại vô ích.
|
|
58
78
|
*/
|
|
59
|
-
declare function DateRangePicker({ value, onValueChange, placeholder, disabled, minDate, maxDate, clearable, presets, numberOfMonths, className, id, ...aria }: DateRangePickerProps): React.JSX.Element;
|
|
79
|
+
declare function DateRangePicker({ value, onValueChange, placeholder, disabled, minDate, maxDate, clearable, presets, numberOfMonths, locale, dateFormat, labels, className, id, ...aria }: DateRangePickerProps): React.JSX.Element;
|
|
60
80
|
|
|
61
81
|
export { DatePicker, type DatePickerProps, DateRangePicker, type DateRangePickerProps };
|
|
@@ -10,8 +10,9 @@ import { Calendar } from "../ui/calendar.js";
|
|
|
10
10
|
import { Input } from "../ui/input.js";
|
|
11
11
|
import { Popover, PopoverContent, PopoverTrigger } from "../ui/popover.js";
|
|
12
12
|
import {
|
|
13
|
+
DEFAULT_DATE_PICKER_LABELS,
|
|
13
14
|
DEFAULT_DATE_RANGE_PRESETS,
|
|
14
|
-
|
|
15
|
+
dateFormatForLocale
|
|
15
16
|
} from "./date-picker-constants.js";
|
|
16
17
|
function isOutOfRange(date, minDate, maxDate) {
|
|
17
18
|
if (minDate && isBefore(date, startOfDay(minDate))) return true;
|
|
@@ -27,31 +28,36 @@ function buildDisabledMatchers(minDate, maxDate) {
|
|
|
27
28
|
function DatePicker({
|
|
28
29
|
value,
|
|
29
30
|
onValueChange,
|
|
30
|
-
placeholder
|
|
31
|
+
placeholder,
|
|
31
32
|
disabled = false,
|
|
32
33
|
minDate,
|
|
33
34
|
maxDate,
|
|
34
35
|
clearable = true,
|
|
35
36
|
presets = false,
|
|
37
|
+
locale = vi,
|
|
38
|
+
dateFormat,
|
|
39
|
+
labels,
|
|
36
40
|
className,
|
|
37
41
|
id,
|
|
38
42
|
name,
|
|
39
43
|
...aria
|
|
40
44
|
}) {
|
|
45
|
+
const pattern = dateFormat ?? dateFormatForLocale(locale);
|
|
46
|
+
const label = { ...DEFAULT_DATE_PICKER_LABELS, ...labels };
|
|
41
47
|
const [open, setOpen] = useState(false);
|
|
42
|
-
const [text, setText] = useState(() => value ? formatDate(value,
|
|
48
|
+
const [text, setText] = useState(() => value ? formatDate(value, pattern, { locale }) : "");
|
|
43
49
|
useEffect(() => {
|
|
44
|
-
setText(value ? formatDate(value,
|
|
45
|
-
}, [value]);
|
|
50
|
+
setText(value ? formatDate(value, pattern, { locale }) : "");
|
|
51
|
+
}, [value, pattern, locale]);
|
|
46
52
|
const commitText = (raw) => {
|
|
47
53
|
const trimmed = raw.trim();
|
|
48
54
|
if (trimmed === "") {
|
|
49
55
|
onValueChange(null);
|
|
50
56
|
return;
|
|
51
57
|
}
|
|
52
|
-
const parsed = parse(trimmed,
|
|
58
|
+
const parsed = parse(trimmed, pattern, /* @__PURE__ */ new Date(), { locale });
|
|
53
59
|
if (!isValid(parsed) || isOutOfRange(parsed, minDate, maxDate)) {
|
|
54
|
-
setText(value ? formatDate(value,
|
|
60
|
+
setText(value ? formatDate(value, pattern, { locale }) : "");
|
|
55
61
|
return;
|
|
56
62
|
}
|
|
57
63
|
onValueChange(startOfDay(parsed));
|
|
@@ -65,7 +71,7 @@ function DatePicker({
|
|
|
65
71
|
name,
|
|
66
72
|
value: text,
|
|
67
73
|
disabled,
|
|
68
|
-
placeholder,
|
|
74
|
+
placeholder: placeholder ?? pattern,
|
|
69
75
|
inputMode: "numeric",
|
|
70
76
|
autoComplete: "off",
|
|
71
77
|
onChange: (event) => setText(event.target.value),
|
|
@@ -91,7 +97,7 @@ function DatePicker({
|
|
|
91
97
|
onClick: () => onValueChange(null),
|
|
92
98
|
children: [
|
|
93
99
|
/* @__PURE__ */ jsx(X, { "aria-hidden": "true", className: "size-3.5" }),
|
|
94
|
-
/* @__PURE__ */ jsx("span", { className: "sr-only", children:
|
|
100
|
+
/* @__PURE__ */ jsx("span", { className: "sr-only", children: label.clear })
|
|
95
101
|
]
|
|
96
102
|
}
|
|
97
103
|
),
|
|
@@ -106,7 +112,7 @@ function DatePicker({
|
|
|
106
112
|
disabled,
|
|
107
113
|
children: [
|
|
108
114
|
/* @__PURE__ */ jsx(CalendarDays, { "aria-hidden": "true", className: "size-4" }),
|
|
109
|
-
/* @__PURE__ */ jsx("span", { className: "sr-only", children:
|
|
115
|
+
/* @__PURE__ */ jsx("span", { className: "sr-only", children: label.openCalendar })
|
|
110
116
|
]
|
|
111
117
|
}
|
|
112
118
|
) }),
|
|
@@ -130,7 +136,7 @@ function DatePicker({
|
|
|
130
136
|
Calendar,
|
|
131
137
|
{
|
|
132
138
|
mode: "single",
|
|
133
|
-
locale
|
|
139
|
+
locale,
|
|
134
140
|
autoFocus: true,
|
|
135
141
|
selected: value ?? void 0,
|
|
136
142
|
defaultMonth: value ?? void 0,
|
|
@@ -146,11 +152,11 @@ function DatePicker({
|
|
|
146
152
|
] })
|
|
147
153
|
] });
|
|
148
154
|
}
|
|
149
|
-
function formatRange(range) {
|
|
155
|
+
function formatRange(range, pattern, locale) {
|
|
150
156
|
if (!range?.from) return null;
|
|
151
|
-
const from = formatDate(range.from,
|
|
157
|
+
const from = formatDate(range.from, pattern, { locale });
|
|
152
158
|
if (!range.to) return from;
|
|
153
|
-
return `${from} \u2013 ${formatDate(range.to,
|
|
159
|
+
return `${from} \u2013 ${formatDate(range.to, pattern, { locale })}`;
|
|
154
160
|
}
|
|
155
161
|
function DateRangePicker({
|
|
156
162
|
value,
|
|
@@ -162,12 +168,17 @@ function DateRangePicker({
|
|
|
162
168
|
clearable = true,
|
|
163
169
|
presets = DEFAULT_DATE_RANGE_PRESETS,
|
|
164
170
|
numberOfMonths = 2,
|
|
171
|
+
locale = vi,
|
|
172
|
+
dateFormat,
|
|
173
|
+
labels,
|
|
165
174
|
className,
|
|
166
175
|
id,
|
|
167
176
|
...aria
|
|
168
177
|
}) {
|
|
178
|
+
const pattern = dateFormat ?? dateFormatForLocale(locale);
|
|
179
|
+
const text = { ...DEFAULT_DATE_PICKER_LABELS, ...labels };
|
|
169
180
|
const [open, setOpen] = useState(false);
|
|
170
|
-
const label = formatRange(value);
|
|
181
|
+
const label = formatRange(value, pattern, locale);
|
|
171
182
|
const presetList = presets === false ? [] : presets;
|
|
172
183
|
const showClear = clearable && Boolean(label) && !disabled;
|
|
173
184
|
return (
|
|
@@ -218,7 +229,7 @@ function DateRangePicker({
|
|
|
218
229
|
Calendar,
|
|
219
230
|
{
|
|
220
231
|
mode: "range",
|
|
221
|
-
locale
|
|
232
|
+
locale,
|
|
222
233
|
autoFocus: true,
|
|
223
234
|
numberOfMonths,
|
|
224
235
|
selected: value ?? void 0,
|
|
@@ -241,7 +252,7 @@ function DateRangePicker({
|
|
|
241
252
|
onClick: () => onValueChange(null),
|
|
242
253
|
children: [
|
|
243
254
|
/* @__PURE__ */ jsx(X, { "aria-hidden": "true", className: "size-3.5" }),
|
|
244
|
-
/* @__PURE__ */ jsx("span", { className: "sr-only", children:
|
|
255
|
+
/* @__PURE__ */ jsx("span", { className: "sr-only", children: text.clearRange })
|
|
245
256
|
]
|
|
246
257
|
}
|
|
247
258
|
)
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"sources":["../../../src/components/composite/date-picker.tsx"],"sourcesContent":["\"use client\";\n\nimport { format as formatDate, isAfter, isBefore, isValid, parse, startOfDay } from \"date-fns\";\nimport { vi } from \"date-fns/locale\";\nimport { CalendarDays, X } from \"lucide-react\";\nimport { useEffect, useState } from \"react\";\nimport type { DateRange, Matcher } from \"react-day-picker\";\nimport { cn } from \"../../lib/cn.js\";\nimport { Button } from \"../ui/button.js\";\nimport { Calendar } from \"../ui/calendar.js\";\nimport { Input } from \"../ui/input.js\";\nimport { Popover, PopoverContent, PopoverTrigger } from \"../ui/popover.js\";\nimport {\n type DatePreset,\n type DateRangePreset,\n DEFAULT_DATE_RANGE_PRESETS,\n VI_DATE_FORMAT\n} from \"./date-picker-constants.js\";\n\nexport type { DateRange };\n\nfunction isOutOfRange(date: Date, minDate?: Date, maxDate?: Date): boolean {\n if (minDate && isBefore(date, startOfDay(minDate))) return true;\n if (maxDate && isAfter(date, startOfDay(maxDate))) return true;\n return false;\n}\n\n/**\n * Chặn ngày ngoài khoảng cho phép — trả về MẢNG matcher, không phải một object.\n *\n * Cạm bẫy react-day-picker: `{ before, after }` trong CÙNG một object là `DateInterval`, nghĩa là\n * \"các ngày NẰM GIỮA\" — ngược hẳn ý định. Tách thành hai matcher riêng mới ra \"trước min HOẶC sau max\".\n */\nfunction buildDisabledMatchers(minDate?: Date, maxDate?: Date): Matcher[] | undefined {\n const matchers: Matcher[] = [];\n if (minDate) matchers.push({ before: startOfDay(minDate) });\n if (maxDate) matchers.push({ after: startOfDay(maxDate) });\n return matchers.length > 0 ? matchers : undefined;\n}\n\nexport interface DatePickerProps {\n value?: Date | null;\n onValueChange: (value: Date | null) => void;\n placeholder?: string;\n disabled?: boolean;\n /** Ngày nhỏ nhất / lớn nhất được chọn */\n minDate?: Date;\n maxDate?: Date;\n /** Hiện nút xoá khi đã có giá trị (mặc định true) */\n clearable?: boolean;\n /** Cột preset bên trái lịch (mặc định ẩn) */\n presets?: DatePreset[] | false;\n className?: string;\n id?: string;\n name?: string;\n \"aria-label\"?: string;\n \"aria-describedby\"?: string;\n \"aria-invalid\"?: true;\n \"aria-required\"?: true;\n}\n\n/**\n * Ô chọn ngày: gõ tay `dd/MM/yyyy` HOẶC bấm lịch.\n *\n * Vì sao vẫn giữ ô gõ tay thay vì chỉ có nút mở lịch: người nhập liệu chuyên nghiệp (kế toán,\n * nhân sự) gõ nhanh hơn bấm nhiều lần, và ngày sinh năm 1975 mà bắt lật lịch 50 năm thì không\n * ai chịu nổi. Chuỗi gõ dở KHÔNG bị ghi đè trong lúc gõ; sai định dạng thì trả về giá trị cũ\n * khi rời ô, không âm thầm nuốt dữ liệu.\n */\nexport function DatePicker({\n value,\n onValueChange,\n placeholder = VI_DATE_FORMAT,\n disabled = false,\n minDate,\n maxDate,\n clearable = true,\n presets = false,\n className,\n id,\n name,\n ...aria\n}: DatePickerProps) {\n const [open, setOpen] = useState(false);\n const [text, setText] = useState(() => (value ? formatDate(value, VI_DATE_FORMAT, { locale: vi }) : \"\"));\n\n // Đồng bộ khi giá trị đổi từ bên ngoài (reset form, chọn preset, load dữ liệu về).\n useEffect(() => {\n setText(value ? formatDate(value, VI_DATE_FORMAT, { locale: vi }) : \"\");\n }, [value]);\n\n const commitText = (raw: string) => {\n const trimmed = raw.trim();\n if (trimmed === \"\") {\n onValueChange(null);\n return;\n }\n const parsed = parse(trimmed, VI_DATE_FORMAT, new Date(), { locale: vi });\n if (!isValid(parsed) || isOutOfRange(parsed, minDate, maxDate)) {\n // Không hợp lệ → khôi phục hiển thị theo giá trị đang có (không đổi dữ liệu).\n setText(value ? formatDate(value, VI_DATE_FORMAT, { locale: vi }) : \"\");\n return;\n }\n onValueChange(startOfDay(parsed));\n };\n\n const presetList = presets === false ? [] : presets;\n\n return (\n <div className={cn(\"relative\", className)}>\n <Input\n id={id}\n name={name}\n value={text}\n disabled={disabled}\n placeholder={placeholder}\n inputMode=\"numeric\"\n autoComplete=\"off\"\n onChange={(event) => setText(event.target.value)}\n onBlur={(event) => commitText(event.target.value)}\n onKeyDown={(event) => {\n if (event.key === \"Enter\") {\n event.preventDefault();\n commitText(text);\n }\n }}\n className=\"pr-16\"\n {...aria}\n />\n\n {/* Nút `size-6`: glyph 16px nằm giữa ô 24px nên nó thụt thêm 4px so với mép ô — cộng với\n `pr-2` của hàng là glyph rơi đúng mốc 12px của `Select`/`Combobox`. Ba loại control này\n hay đứng chung một cột biểu mẫu, lệch vài px là mép phải nhìn răng cưa.\n\n `border border-transparent`: hàng nằm ngoài `Input` nên đo từ mép BORDER, còn icon nằm\n trong ô thì đo từ mép CONTENT — mượn lại viền 1px là hai mốc trùng nhau.\n\n Icon lấy `text-muted-foreground`: `Button variant=\"ghost\"` mặc định là `text-foreground`,\n đậm hơn hẳn chevron của `Select` (vốn `text-muted-foreground`). Chúng là cùng một hạng\n trang trí của ô nhập thì phải cùng một sắc. */}\n {/* `pointer-events-none` ở hàng, `pointer-events-auto` ở từng nút: phần đệm của hàng phủ lên\n dải sát mép phải của ô nhập, để nguyên là bấm vào đó không đặt được con trỏ vào ô. */}\n <div className=\"pointer-events-none absolute inset-y-0 right-0 flex items-center border border-transparent pr-2\">\n {clearable && value && !disabled && (\n <Button\n type=\"button\"\n variant=\"ghost\"\n size=\"icon-sm\"\n className=\"pointer-events-auto size-6 text-muted-foreground hover:text-foreground\"\n onClick={() => onValueChange(null)}\n >\n <X aria-hidden=\"true\" className=\"size-3.5\" />\n <span className=\"sr-only\">Xóa ngày</span>\n </Button>\n )}\n <Popover open={open} onOpenChange={setOpen}>\n <PopoverTrigger asChild>\n <Button\n type=\"button\"\n variant=\"ghost\"\n size=\"icon-sm\"\n className=\"pointer-events-auto size-6 text-muted-foreground hover:text-foreground\"\n disabled={disabled}\n >\n <CalendarDays aria-hidden=\"true\" className=\"size-4\" />\n <span className=\"sr-only\">Mở lịch</span>\n </Button>\n </PopoverTrigger>\n <PopoverContent align=\"end\" className=\"flex w-auto gap-2 p-2\">\n {presetList.length > 0 && (\n <div className=\"flex w-32 flex-col gap-1 border-r border-border pr-2\">\n {presetList.map((preset) => (\n <Button\n key={preset.id}\n type=\"button\"\n variant=\"ghost\"\n size=\"sm\"\n className=\"justify-start\"\n onClick={() => {\n onValueChange(preset.getValue());\n setOpen(false);\n }}\n >\n {preset.label}\n </Button>\n ))}\n </div>\n )}\n <Calendar\n mode=\"single\"\n locale={vi}\n autoFocus\n selected={value ?? undefined}\n defaultMonth={value ?? undefined}\n disabled={buildDisabledMatchers(minDate, maxDate)}\n onSelect={(date) => {\n onValueChange(date ? startOfDay(date) : null);\n setOpen(false);\n }}\n />\n </PopoverContent>\n </Popover>\n </div>\n </div>\n );\n}\n\nexport interface DateRangePickerProps {\n value?: DateRange | null;\n onValueChange: (value: DateRange | null) => void;\n placeholder?: string;\n disabled?: boolean;\n minDate?: Date;\n maxDate?: Date;\n clearable?: boolean;\n /** Cột preset bên trái (mặc định DEFAULT_DATE_RANGE_PRESETS; `false` để tắt) */\n presets?: DateRangePreset[] | false;\n /** Số tháng hiện cùng lúc (mặc định 2 trên màn rộng) */\n numberOfMonths?: number;\n className?: string;\n id?: string;\n \"aria-label\"?: string;\n \"aria-describedby\"?: string;\n \"aria-invalid\"?: true;\n}\n\nfunction formatRange(range: DateRange | null | undefined): string | null {\n if (!range?.from) return null;\n const from = formatDate(range.from, VI_DATE_FORMAT, { locale: vi });\n if (!range.to) return from;\n return `${from} – ${formatDate(range.to, VI_DATE_FORMAT, { locale: vi })}`;\n}\n\n/**\n * Ô chọn khoảng ngày cho báo cáo và bộ lọc danh sách: cột preset + lịch 2 tháng.\n *\n * Preset đứng trước lịch vì 9/10 lần người dùng muốn \"7 ngày qua\" chứ không phải một khoảng\n * tuỳ ý — bắt họ bấm hai lần trên lịch mỗi lần xem báo cáo là thuế lặp lại vô ích.\n */\nexport function DateRangePicker({\n value,\n onValueChange,\n placeholder = \"Chọn khoảng ngày\",\n disabled = false,\n minDate,\n maxDate,\n clearable = true,\n presets = DEFAULT_DATE_RANGE_PRESETS,\n numberOfMonths = 2,\n className,\n id,\n ...aria\n}: DateRangePickerProps) {\n const [open, setOpen] = useState(false);\n const label = formatRange(value);\n const presetList = presets === false ? [] : presets;\n\n const showClear = clearable && Boolean(label) && !disabled;\n\n return (\n /* Nút xóa nằm NGOÀI trigger (anh em), không lồng trong <button> — vừa hợp lệ về HTML vừa\n đến được bằng phím Tab. */\n <div className=\"relative w-full\">\n <Popover open={open} onOpenChange={setOpen}>\n <PopoverTrigger asChild>\n <Button\n type=\"button\"\n id={id}\n variant=\"outline\"\n disabled={disabled}\n className={cn(\n // `px-3` chứ không phải `px-4` của `Button`: đây là một Ô NHẬP nằm cùng cột với\n // `Input`/`Select`/`Combobox`, và cả ba đều thụt 12px. `pr-10` = 12 + 24 (nút xóa)\n // + 4, chỉ cần khi nút xóa có mặt.\n \"w-full justify-start gap-2 px-3 font-normal\",\n !label && \"text-muted-foreground\",\n showClear && \"pr-10\",\n className\n )}\n {...aria}\n >\n <CalendarDays aria-hidden=\"true\" className=\"size-4 shrink-0 text-muted-foreground\" />\n <span className=\"truncate\">{label ?? placeholder}</span>\n </Button>\n </PopoverTrigger>\n <PopoverContent align=\"start\" className=\"flex w-auto flex-col gap-2 p-2 sm:flex-row\">\n {presetList.length > 0 && (\n <div className=\"flex flex-row flex-wrap gap-1 sm:w-36 sm:flex-col sm:border-r sm:border-border sm:pr-2\">\n {presetList.map((preset) => (\n <Button\n key={preset.id}\n type=\"button\"\n variant=\"ghost\"\n size=\"sm\"\n className=\"justify-start\"\n onClick={() => {\n onValueChange(preset.getValue());\n setOpen(false);\n }}\n >\n {preset.label}\n </Button>\n ))}\n </div>\n )}\n <Calendar\n mode=\"range\"\n locale={vi}\n autoFocus\n numberOfMonths={numberOfMonths}\n selected={value ?? undefined}\n defaultMonth={value?.from ?? undefined}\n disabled={buildDisabledMatchers(minDate, maxDate)}\n onSelect={(range) => onValueChange(range ?? null)}\n />\n </PopoverContent>\n </Popover>\n\n {showClear && (\n // Cùng hình dạng với nút xóa của `DatePicker`: ô 24px ở `right-2`. Là `<Button>` thật nên\n // có viền focus và vùng bấm đạt 24px — bản `<button>` thô trước đây chỉ 18px.\n <Button\n type=\"button\"\n variant=\"ghost\"\n size=\"icon-sm\"\n className=\"absolute right-2 top-1/2 size-6 -translate-y-1/2 text-muted-foreground hover:text-foreground\"\n onClick={() => onValueChange(null)}\n >\n <X aria-hidden=\"true\" className=\"size-3.5\" />\n <span className=\"sr-only\">Xóa khoảng ngày</span>\n </Button>\n )}\n </div>\n );\n}\n"],"mappings":"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","names":[]}
|
|
1
|
+
{"version":3,"sources":["../../../src/components/composite/date-picker.tsx"],"sourcesContent":["\"use client\";\n\nimport { format as formatDate, isAfter, isBefore, isValid, type Locale, parse, startOfDay } from \"date-fns\";\nimport { vi } from \"date-fns/locale\";\nimport { CalendarDays, X } from \"lucide-react\";\nimport { useEffect, useState } from \"react\";\nimport type { DateRange, Matcher } from \"react-day-picker\";\nimport { cn } from \"../../lib/cn.js\";\nimport { Button } from \"../ui/button.js\";\nimport { Calendar } from \"../ui/calendar.js\";\nimport { Input } from \"../ui/input.js\";\nimport { Popover, PopoverContent, PopoverTrigger } from \"../ui/popover.js\";\nimport {\n type DatePickerLabels,\n type DatePreset,\n type DateRangePreset,\n DEFAULT_DATE_PICKER_LABELS,\n DEFAULT_DATE_RANGE_PRESETS,\n dateFormatForLocale\n} from \"./date-picker-constants.js\";\n\nexport type { DateRange };\n\nfunction isOutOfRange(date: Date, minDate?: Date, maxDate?: Date): boolean {\n if (minDate && isBefore(date, startOfDay(minDate))) return true;\n if (maxDate && isAfter(date, startOfDay(maxDate))) return true;\n return false;\n}\n\n/**\n * Chặn ngày ngoài khoảng cho phép — trả về MẢNG matcher, không phải một object.\n *\n * Cạm bẫy react-day-picker: `{ before, after }` trong CÙNG một object là `DateInterval`, nghĩa là\n * \"các ngày NẰM GIỮA\" — ngược hẳn ý định. Tách thành hai matcher riêng mới ra \"trước min HOẶC sau max\".\n */\nfunction buildDisabledMatchers(minDate?: Date, maxDate?: Date): Matcher[] | undefined {\n const matchers: Matcher[] = [];\n if (minDate) matchers.push({ before: startOfDay(minDate) });\n if (maxDate) matchers.push({ after: startOfDay(maxDate) });\n return matchers.length > 0 ? matchers : undefined;\n}\n\nexport interface DatePickerProps {\n value?: Date | null;\n onValueChange: (value: Date | null) => void;\n placeholder?: string;\n disabled?: boolean;\n /** Ngày nhỏ nhất / lớn nhất được chọn */\n minDate?: Date;\n maxDate?: Date;\n /** Hiện nút xoá khi đã có giá trị (mặc định true) */\n clearable?: boolean;\n /** Cột preset bên trái lịch (mặc định ẩn) */\n presets?: DatePreset[] | false;\n /**\n * Ngôn ngữ của date-fns (mặc định `vi`) — quyết định tên thứ/tháng trong lịch VÀ thứ tự\n * ngày/tháng của ô nhập, qua `dateFormatForLocale()`.\n *\n * App đa ngôn ngữ phải truyền: để mặc định thì giao diện tiếng Anh vẫn hiện lịch tiếng Việt và\n * bắt người dùng gõ `dd/MM/yyyy` — hai lỗi không có gì báo đỏ.\n */\n locale?: Locale;\n /**\n * Ghi đè định dạng gõ/hiển thị. HIẾM khi cần: bỏ trống thì nó suy từ `locale`, và đó gần như\n * luôn là thứ đúng. Chỉ dùng khi một màn hình phải ghim cứng một định dạng bất kể ngôn ngữ.\n */\n dateFormat?: string;\n labels?: Partial<DatePickerLabels>;\n className?: string;\n id?: string;\n name?: string;\n \"aria-label\"?: string;\n \"aria-describedby\"?: string;\n \"aria-invalid\"?: true;\n \"aria-required\"?: true;\n}\n\n/**\n * Ô chọn ngày: gõ tay `dd/MM/yyyy` HOẶC bấm lịch.\n *\n * Vì sao vẫn giữ ô gõ tay thay vì chỉ có nút mở lịch: người nhập liệu chuyên nghiệp (kế toán,\n * nhân sự) gõ nhanh hơn bấm nhiều lần, và ngày sinh năm 1975 mà bắt lật lịch 50 năm thì không\n * ai chịu nổi. Chuỗi gõ dở KHÔNG bị ghi đè trong lúc gõ; sai định dạng thì trả về giá trị cũ\n * khi rời ô, không âm thầm nuốt dữ liệu.\n */\nexport function DatePicker({\n value,\n onValueChange,\n placeholder,\n disabled = false,\n minDate,\n maxDate,\n clearable = true,\n presets = false,\n locale = vi,\n dateFormat,\n labels,\n className,\n id,\n name,\n ...aria\n}: DatePickerProps) {\n const pattern = dateFormat ?? dateFormatForLocale(locale);\n const label = { ...DEFAULT_DATE_PICKER_LABELS, ...labels };\n\n const [open, setOpen] = useState(false);\n const [text, setText] = useState(() => (value ? formatDate(value, pattern, { locale }) : \"\"));\n\n // Đồng bộ khi giá trị đổi từ bên ngoài (reset form, chọn preset, load dữ liệu về) — VÀ khi đổi\n // ngôn ngữ giữa chừng: chuỗi đang hiện là `dd/MM/yyyy` mà ô nay chờ `MM/dd/yyyy` thì lần rời ô\n // kế tiếp sẽ đọc sai ngày.\n useEffect(() => {\n setText(value ? formatDate(value, pattern, { locale }) : \"\");\n }, [value, pattern, locale]);\n\n const commitText = (raw: string) => {\n const trimmed = raw.trim();\n if (trimmed === \"\") {\n onValueChange(null);\n return;\n }\n const parsed = parse(trimmed, pattern, new Date(), { locale });\n if (!isValid(parsed) || isOutOfRange(parsed, minDate, maxDate)) {\n // Không hợp lệ → khôi phục hiển thị theo giá trị đang có (không đổi dữ liệu).\n setText(value ? formatDate(value, pattern, { locale }) : \"\");\n return;\n }\n onValueChange(startOfDay(parsed));\n };\n\n const presetList = presets === false ? [] : presets;\n\n return (\n <div className={cn(\"relative\", className)}>\n <Input\n id={id}\n name={name}\n value={text}\n disabled={disabled}\n placeholder={placeholder ?? pattern}\n inputMode=\"numeric\"\n autoComplete=\"off\"\n onChange={(event) => setText(event.target.value)}\n onBlur={(event) => commitText(event.target.value)}\n onKeyDown={(event) => {\n if (event.key === \"Enter\") {\n event.preventDefault();\n commitText(text);\n }\n }}\n className=\"pr-16\"\n {...aria}\n />\n\n {/* Nút `size-6`: glyph 16px nằm giữa ô 24px nên nó thụt thêm 4px so với mép ô — cộng với\n `pr-2` của hàng là glyph rơi đúng mốc 12px của `Select`/`Combobox`. Ba loại control này\n hay đứng chung một cột biểu mẫu, lệch vài px là mép phải nhìn răng cưa.\n\n `border border-transparent`: hàng nằm ngoài `Input` nên đo từ mép BORDER, còn icon nằm\n trong ô thì đo từ mép CONTENT — mượn lại viền 1px là hai mốc trùng nhau.\n\n Icon lấy `text-muted-foreground`: `Button variant=\"ghost\"` mặc định là `text-foreground`,\n đậm hơn hẳn chevron của `Select` (vốn `text-muted-foreground`). Chúng là cùng một hạng\n trang trí của ô nhập thì phải cùng một sắc. */}\n {/* `pointer-events-none` ở hàng, `pointer-events-auto` ở từng nút: phần đệm của hàng phủ lên\n dải sát mép phải của ô nhập, để nguyên là bấm vào đó không đặt được con trỏ vào ô. */}\n <div className=\"pointer-events-none absolute inset-y-0 right-0 flex items-center border border-transparent pr-2\">\n {clearable && value && !disabled && (\n <Button\n type=\"button\"\n variant=\"ghost\"\n size=\"icon-sm\"\n className=\"pointer-events-auto size-6 text-muted-foreground hover:text-foreground\"\n onClick={() => onValueChange(null)}\n >\n <X aria-hidden=\"true\" className=\"size-3.5\" />\n <span className=\"sr-only\">{label.clear}</span>\n </Button>\n )}\n <Popover open={open} onOpenChange={setOpen}>\n <PopoverTrigger asChild>\n <Button\n type=\"button\"\n variant=\"ghost\"\n size=\"icon-sm\"\n className=\"pointer-events-auto size-6 text-muted-foreground hover:text-foreground\"\n disabled={disabled}\n >\n <CalendarDays aria-hidden=\"true\" className=\"size-4\" />\n <span className=\"sr-only\">{label.openCalendar}</span>\n </Button>\n </PopoverTrigger>\n <PopoverContent align=\"end\" className=\"flex w-auto gap-2 p-2\">\n {presetList.length > 0 && (\n <div className=\"flex w-32 flex-col gap-1 border-r border-border pr-2\">\n {presetList.map((preset) => (\n <Button\n key={preset.id}\n type=\"button\"\n variant=\"ghost\"\n size=\"sm\"\n className=\"justify-start\"\n onClick={() => {\n onValueChange(preset.getValue());\n setOpen(false);\n }}\n >\n {preset.label}\n </Button>\n ))}\n </div>\n )}\n <Calendar\n mode=\"single\"\n locale={locale}\n autoFocus\n selected={value ?? undefined}\n defaultMonth={value ?? undefined}\n disabled={buildDisabledMatchers(minDate, maxDate)}\n onSelect={(date) => {\n onValueChange(date ? startOfDay(date) : null);\n setOpen(false);\n }}\n />\n </PopoverContent>\n </Popover>\n </div>\n </div>\n );\n}\n\nexport interface DateRangePickerProps {\n value?: DateRange | null;\n onValueChange: (value: DateRange | null) => void;\n placeholder?: string;\n disabled?: boolean;\n minDate?: Date;\n maxDate?: Date;\n clearable?: boolean;\n /** Cột preset bên trái (mặc định DEFAULT_DATE_RANGE_PRESETS; `false` để tắt) */\n presets?: DateRangePreset[] | false;\n /** Số tháng hiện cùng lúc (mặc định 2 trên màn rộng) */\n numberOfMonths?: number;\n /** Ngôn ngữ date-fns (mặc định `vi`) — xem `DatePickerProps.locale`. */\n locale?: Locale;\n /** Ghi đè định dạng ngày; bỏ trống thì suy từ `locale`. */\n dateFormat?: string;\n labels?: Partial<DatePickerLabels>;\n className?: string;\n id?: string;\n \"aria-label\"?: string;\n \"aria-describedby\"?: string;\n \"aria-invalid\"?: true;\n}\n\nfunction formatRange(range: DateRange | null | undefined, pattern: string, locale: Locale): string | null {\n if (!range?.from) return null;\n const from = formatDate(range.from, pattern, { locale });\n if (!range.to) return from;\n return `${from} – ${formatDate(range.to, pattern, { locale })}`;\n}\n\n/**\n * Ô chọn khoảng ngày cho báo cáo và bộ lọc danh sách: cột preset + lịch 2 tháng.\n *\n * Preset đứng trước lịch vì 9/10 lần người dùng muốn \"7 ngày qua\" chứ không phải một khoảng\n * tuỳ ý — bắt họ bấm hai lần trên lịch mỗi lần xem báo cáo là thuế lặp lại vô ích.\n */\nexport function DateRangePicker({\n value,\n onValueChange,\n placeholder = \"Chọn khoảng ngày\",\n disabled = false,\n minDate,\n maxDate,\n clearable = true,\n presets = DEFAULT_DATE_RANGE_PRESETS,\n numberOfMonths = 2,\n locale = vi,\n dateFormat,\n labels,\n className,\n id,\n ...aria\n}: DateRangePickerProps) {\n const pattern = dateFormat ?? dateFormatForLocale(locale);\n const text = { ...DEFAULT_DATE_PICKER_LABELS, ...labels };\n\n const [open, setOpen] = useState(false);\n const label = formatRange(value, pattern, locale);\n const presetList = presets === false ? [] : presets;\n\n const showClear = clearable && Boolean(label) && !disabled;\n\n return (\n /* Nút xóa nằm NGOÀI trigger (anh em), không lồng trong <button> — vừa hợp lệ về HTML vừa\n đến được bằng phím Tab. */\n <div className=\"relative w-full\">\n <Popover open={open} onOpenChange={setOpen}>\n <PopoverTrigger asChild>\n <Button\n type=\"button\"\n id={id}\n variant=\"outline\"\n disabled={disabled}\n className={cn(\n // `px-3` chứ không phải `px-4` của `Button`: đây là một Ô NHẬP nằm cùng cột với\n // `Input`/`Select`/`Combobox`, và cả ba đều thụt 12px. `pr-10` = 12 + 24 (nút xóa)\n // + 4, chỉ cần khi nút xóa có mặt.\n \"w-full justify-start gap-2 px-3 font-normal\",\n !label && \"text-muted-foreground\",\n showClear && \"pr-10\",\n className\n )}\n {...aria}\n >\n <CalendarDays aria-hidden=\"true\" className=\"size-4 shrink-0 text-muted-foreground\" />\n <span className=\"truncate\">{label ?? placeholder}</span>\n </Button>\n </PopoverTrigger>\n <PopoverContent align=\"start\" className=\"flex w-auto flex-col gap-2 p-2 sm:flex-row\">\n {presetList.length > 0 && (\n <div className=\"flex flex-row flex-wrap gap-1 sm:w-36 sm:flex-col sm:border-r sm:border-border sm:pr-2\">\n {presetList.map((preset) => (\n <Button\n key={preset.id}\n type=\"button\"\n variant=\"ghost\"\n size=\"sm\"\n className=\"justify-start\"\n onClick={() => {\n onValueChange(preset.getValue());\n setOpen(false);\n }}\n >\n {preset.label}\n </Button>\n ))}\n </div>\n )}\n <Calendar\n mode=\"range\"\n locale={locale}\n autoFocus\n numberOfMonths={numberOfMonths}\n selected={value ?? undefined}\n defaultMonth={value?.from ?? undefined}\n disabled={buildDisabledMatchers(minDate, maxDate)}\n onSelect={(range) => onValueChange(range ?? null)}\n />\n </PopoverContent>\n </Popover>\n\n {showClear && (\n // Cùng hình dạng với nút xóa của `DatePicker`: ô 24px ở `right-2`. Là `<Button>` thật nên\n // có viền focus và vùng bấm đạt 24px — bản `<button>` thô trước đây chỉ 18px.\n <Button\n type=\"button\"\n variant=\"ghost\"\n size=\"icon-sm\"\n className=\"absolute right-2 top-1/2 size-6 -translate-y-1/2 text-muted-foreground hover:text-foreground\"\n onClick={() => onValueChange(null)}\n >\n <X aria-hidden=\"true\" className=\"size-3.5\" />\n <span className=\"sr-only\">{text.clearRange}</span>\n </Button>\n )}\n </div>\n );\n}\n"],"mappings":"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","names":[]}
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import * as React from 'react';
|
|
2
2
|
import { ComponentProps } from 'react';
|
|
3
|
+
import { Locale } from 'date-fns';
|
|
3
4
|
|
|
4
5
|
interface RelativeTimeProps extends Omit<ComponentProps<"time">, "dateTime" | "children"> {
|
|
5
6
|
/** Thời điểm cần hiển thị: Date, ISO string hoặc epoch ms */
|
|
@@ -24,7 +25,20 @@ interface RelativeTimeProps extends Omit<ComponentProps<"time">, "dateTime" | "c
|
|
|
24
25
|
* giờ chỉ để render lại y nguyên mỗi phút.
|
|
25
26
|
*/
|
|
26
27
|
updateIntervalMs?: number;
|
|
27
|
-
/**
|
|
28
|
+
/**
|
|
29
|
+
* Ngôn ngữ của date-fns (mặc định `vi`) — quyết định CẢ chuỗi tương đối lẫn mốc tuyệt đối trong
|
|
30
|
+
* tooltip.
|
|
31
|
+
*
|
|
32
|
+
* App đa ngôn ngữ phải truyền: để mặc định thì giao diện tiếng Anh vẫn hiện "3 phút trước".
|
|
33
|
+
*/
|
|
34
|
+
locale?: Locale;
|
|
35
|
+
/**
|
|
36
|
+
* Định dạng của tooltip `title`. Mặc định `"Pp"` — ngày + giờ THEO `locale`, nên nó tự đúng ở
|
|
37
|
+
* mọi ngôn ngữ (vi: `29/08/2026 09:12`; en-US: `08/29/2026, 9:12 AM`).
|
|
38
|
+
*
|
|
39
|
+
* ⚠ Trước `0.9.0` mặc định là `"HH:mm 'ngày' dd/MM/yyyy"` — chuỗi đó nhét sẵn chữ "ngày" tiếng
|
|
40
|
+
* Việt vào mọi ngôn ngữ. Cần lại đúng câu cũ thì truyền chính chuỗi đó.
|
|
41
|
+
*/
|
|
28
42
|
titleFormat?: string;
|
|
29
43
|
/** Nội dung thay thế khi `value` không hợp lệ */
|
|
30
44
|
fallback?: string;
|
|
@@ -40,9 +54,9 @@ interface RelativeTimeProps extends Omit<ComponentProps<"time">, "dateTime" | "c
|
|
|
40
54
|
* Có `suppressHydrationWarning` vì đồng hồ server và client gần như luôn lệch vài giây — trừ khi
|
|
41
55
|
* truyền `now`, lúc đó kết quả tất định và thuộc tính này chỉ còn là vô hại.
|
|
42
56
|
*
|
|
43
|
-
* Hai lối đo dùng CHUNG một cách diễn đạt (`…Strict` của date-fns,
|
|
57
|
+
* Hai lối đo dùng CHUNG một cách diễn đạt (`…Strict` của date-fns, cùng `locale`), nên đổi giữa
|
|
44
58
|
* `now` và đồng hồ thật không làm chữ đổi giọng: vẫn "12 phút trước", không phải "khoảng 12 phút".
|
|
45
59
|
*/
|
|
46
|
-
declare function RelativeTime({ value, now, addSuffix, updateIntervalMs, titleFormat, fallback, className, ...props }: RelativeTimeProps): React.JSX.Element;
|
|
60
|
+
declare function RelativeTime({ value, now, addSuffix, updateIntervalMs, locale, titleFormat, fallback, className, ...props }: RelativeTimeProps): React.JSX.Element;
|
|
47
61
|
|
|
48
62
|
export { RelativeTime, type RelativeTimeProps };
|
|
@@ -11,7 +11,8 @@ function RelativeTime({
|
|
|
11
11
|
now,
|
|
12
12
|
addSuffix = true,
|
|
13
13
|
updateIntervalMs = 6e4,
|
|
14
|
-
|
|
14
|
+
locale = vi,
|
|
15
|
+
titleFormat = "Pp",
|
|
15
16
|
fallback = "\u2014",
|
|
16
17
|
className,
|
|
17
18
|
...props
|
|
@@ -32,11 +33,11 @@ function RelativeTime({
|
|
|
32
33
|
"time",
|
|
33
34
|
{
|
|
34
35
|
dateTime: date.toISOString(),
|
|
35
|
-
title: formatDate(date, titleFormat, { locale
|
|
36
|
+
title: formatDate(date, titleFormat, { locale }),
|
|
36
37
|
suppressHydrationWarning: true,
|
|
37
38
|
className,
|
|
38
39
|
...props,
|
|
39
|
-
children: now ? formatDistanceStrict(date, now, { addSuffix, locale
|
|
40
|
+
children: now ? formatDistanceStrict(date, now, { addSuffix, locale }) : formatDistanceToNowStrict(date, { addSuffix, locale })
|
|
40
41
|
}
|
|
41
42
|
);
|
|
42
43
|
}
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"sources":["../../../src/components/composite/relative-time.tsx"],"sourcesContent":["\"use client\";\n\nimport { format as formatDate, formatDistanceStrict, formatDistanceToNowStrict, isValid } from \"date-fns\";\nimport { vi } from \"date-fns/locale\";\nimport { type ComponentProps, useEffect, useState } from \"react\";\n\nexport interface RelativeTimeProps extends Omit<ComponentProps<\"time\">, \"dateTime\" | \"children\"> {\n /** Thời điểm cần hiển thị: Date, ISO string hoặc epoch ms */\n value: Date | string | number;\n /**\n * Mốc để ĐO TỪ ĐÓ. Bỏ trống = đồng hồ thật (hành vi mặc định, đúng cho bản ghi từ backend).\n *\n * Truyền `now` khi dữ liệu ĐỨNG YÊN — bản mẫu, ảnh chụp báo cáo, kết quả test: đồng hồ thật đo\n * trên mốc cố định thì kết quả trôi mỗi ngày, và mốc nào lỡ nằm ở tương lai in ra \"2 giờ nữa\",\n * một câu vô nghĩa mà TypeScript lẫn linter đều thấy hợp lệ.\n *\n * Có `now` thì component KHÔNG tự cập nhật nữa (xem `updateIntervalMs`) và kết quả trở nên tất\n * định — server và client cho ra cùng một chuỗi.\n */\n now?: Date;\n /** Thêm hậu tố \"trước\"/\"nữa\" (mặc định true) */\n addSuffix?: boolean;\n /**\n * Chu kỳ tự cập nhật (ms). 0 = không tự cập nhật. Mặc định 60_000.\n *\n * ⚠ Bị BỎ QUA khi có `now`: đo từ một mốc cố định thì chuỗi hiển thị không bao giờ đổi, nên hẹn\n * giờ chỉ để render lại y nguyên mỗi phút.\n */\n updateIntervalMs?: number;\n
|
|
1
|
+
{"version":3,"sources":["../../../src/components/composite/relative-time.tsx"],"sourcesContent":["\"use client\";\n\nimport { format as formatDate, formatDistanceStrict, formatDistanceToNowStrict, isValid, type Locale } from \"date-fns\";\nimport { vi } from \"date-fns/locale\";\nimport { type ComponentProps, useEffect, useState } from \"react\";\n\nexport interface RelativeTimeProps extends Omit<ComponentProps<\"time\">, \"dateTime\" | \"children\"> {\n /** Thời điểm cần hiển thị: Date, ISO string hoặc epoch ms */\n value: Date | string | number;\n /**\n * Mốc để ĐO TỪ ĐÓ. Bỏ trống = đồng hồ thật (hành vi mặc định, đúng cho bản ghi từ backend).\n *\n * Truyền `now` khi dữ liệu ĐỨNG YÊN — bản mẫu, ảnh chụp báo cáo, kết quả test: đồng hồ thật đo\n * trên mốc cố định thì kết quả trôi mỗi ngày, và mốc nào lỡ nằm ở tương lai in ra \"2 giờ nữa\",\n * một câu vô nghĩa mà TypeScript lẫn linter đều thấy hợp lệ.\n *\n * Có `now` thì component KHÔNG tự cập nhật nữa (xem `updateIntervalMs`) và kết quả trở nên tất\n * định — server và client cho ra cùng một chuỗi.\n */\n now?: Date;\n /** Thêm hậu tố \"trước\"/\"nữa\" (mặc định true) */\n addSuffix?: boolean;\n /**\n * Chu kỳ tự cập nhật (ms). 0 = không tự cập nhật. Mặc định 60_000.\n *\n * ⚠ Bị BỎ QUA khi có `now`: đo từ một mốc cố định thì chuỗi hiển thị không bao giờ đổi, nên hẹn\n * giờ chỉ để render lại y nguyên mỗi phút.\n */\n updateIntervalMs?: number;\n /**\n * Ngôn ngữ của date-fns (mặc định `vi`) — quyết định CẢ chuỗi tương đối lẫn mốc tuyệt đối trong\n * tooltip.\n *\n * App đa ngôn ngữ phải truyền: để mặc định thì giao diện tiếng Anh vẫn hiện \"3 phút trước\".\n */\n locale?: Locale;\n /**\n * Định dạng của tooltip `title`. Mặc định `\"Pp\"` — ngày + giờ THEO `locale`, nên nó tự đúng ở\n * mọi ngôn ngữ (vi: `29/08/2026 09:12`; en-US: `08/29/2026, 9:12 AM`).\n *\n * ⚠ Trước `0.9.0` mặc định là `\"HH:mm 'ngày' dd/MM/yyyy\"` — chuỗi đó nhét sẵn chữ \"ngày\" tiếng\n * Việt vào mọi ngôn ngữ. Cần lại đúng câu cũ thì truyền chính chuỗi đó.\n */\n titleFormat?: string;\n /** Nội dung thay thế khi `value` không hợp lệ */\n fallback?: string;\n}\n\nfunction toDate(value: Date | string | number): Date {\n return value instanceof Date ? value : new Date(value);\n}\n\n/**\n * Thời gian tương đối kiểu \"3 phút trước\", tự cập nhật theo chu kỳ.\n *\n * Dùng `formatDistanceToNowStrict` (không phải bản thường): bản thường làm tròn kiểu \"khoảng 1\n * tháng\" cho mọi thứ từ 26 ngày đến 45 ngày — trong danh sách công việc thì \"26 ngày trước\" và\n * \"45 ngày trước\" là hai câu chuyện khác hẳn nhau.\n *\n * `title` luôn giữ mốc thời gian tuyệt đối để người dùng rà chuột là biết chính xác.\n * Có `suppressHydrationWarning` vì đồng hồ server và client gần như luôn lệch vài giây — trừ khi\n * truyền `now`, lúc đó kết quả tất định và thuộc tính này chỉ còn là vô hại.\n *\n * Hai lối đo dùng CHUNG một cách diễn đạt (`…Strict` của date-fns, cùng `locale`), nên đổi giữa\n * `now` và đồng hồ thật không làm chữ đổi giọng: vẫn \"12 phút trước\", không phải \"khoảng 12 phút\".\n */\nexport function RelativeTime({\n value,\n now,\n addSuffix = true,\n updateIntervalMs = 60_000,\n locale = vi,\n titleFormat = \"Pp\",\n fallback = \"—\",\n className,\n ...props\n}: RelativeTimeProps) {\n const date = toDate(value);\n const valid = isValid(date);\n\n // Mốc cố định thì chuỗi không bao giờ đổi — tắt hẳn hẹn giờ, kể cả khi người gọi vẫn truyền\n // `updateIntervalMs`. Nếu không, component render lại mỗi phút để in ra đúng chữ cũ.\n const tickIntervalMs = now ? 0 : updateIntervalMs;\n\n const [, setTick] = useState(0);\n\n useEffect(() => {\n if (!valid || !tickIntervalMs) return;\n const timer = setInterval(() => setTick((current) => current + 1), tickIntervalMs);\n return () => clearInterval(timer);\n }, [valid, tickIntervalMs]);\n\n // Ngày hỏng (chuỗi rỗng, null từ API) → KHÔNG render <time>: thẻ này bắt buộc phải có\n // `datetime` hợp lệ, đưa \"Invalid Date\" vào là tạo dữ liệu sai cho trình đọc màn hình và bộ máy tìm kiếm.\n if (!valid) {\n return <span className={className}>{fallback}</span>;\n }\n\n return (\n <time\n dateTime={date.toISOString()}\n title={formatDate(date, titleFormat, { locale })}\n suppressHydrationWarning\n className={className}\n {...props}\n >\n {now\n ? formatDistanceStrict(date, now, { addSuffix, locale })\n : formatDistanceToNowStrict(date, { addSuffix, locale })}\n </time>\n );\n}\n"],"mappings":";AA+FW;AA7FX,SAAS,UAAU,YAAY,sBAAsB,2BAA2B,eAA4B;AAC5G,SAAS,UAAU;AACnB,SAA8B,WAAW,gBAAgB;AA4CzD,SAAS,OAAO,OAAqC;AACnD,SAAO,iBAAiB,OAAO,QAAQ,IAAI,KAAK,KAAK;AACvD;AAgBO,SAAS,aAAa;AAAA,EAC3B;AAAA,EACA;AAAA,EACA,YAAY;AAAA,EACZ,mBAAmB;AAAA,EACnB,SAAS;AAAA,EACT,cAAc;AAAA,EACd,WAAW;AAAA,EACX;AAAA,EACA,GAAG;AACL,GAAsB;AACpB,QAAM,OAAO,OAAO,KAAK;AACzB,QAAM,QAAQ,QAAQ,IAAI;AAI1B,QAAM,iBAAiB,MAAM,IAAI;AAEjC,QAAM,CAAC,EAAE,OAAO,IAAI,SAAS,CAAC;AAE9B,YAAU,MAAM;AACd,QAAI,CAAC,SAAS,CAAC,eAAgB;AAC/B,UAAM,QAAQ,YAAY,MAAM,QAAQ,CAAC,YAAY,UAAU,CAAC,GAAG,cAAc;AACjF,WAAO,MAAM,cAAc,KAAK;AAAA,EAClC,GAAG,CAAC,OAAO,cAAc,CAAC;AAI1B,MAAI,CAAC,OAAO;AACV,WAAO,oBAAC,UAAK,WAAuB,oBAAS;AAAA,EAC/C;AAEA,SACE;AAAA,IAAC;AAAA;AAAA,MACC,UAAU,KAAK,YAAY;AAAA,MAC3B,OAAO,WAAW,MAAM,aAAa,EAAE,OAAO,CAAC;AAAA,MAC/C,0BAAwB;AAAA,MACxB;AAAA,MACC,GAAG;AAAA,MAEH,gBACG,qBAAqB,MAAM,KAAK,EAAE,WAAW,OAAO,CAAC,IACrD,0BAA0B,MAAM,EAAE,WAAW,OAAO,CAAC;AAAA;AAAA,EAC3D;AAEJ;","names":[]}
|
|
@@ -1,6 +1,22 @@
|
|
|
1
1
|
import * as React from 'react';
|
|
2
2
|
|
|
3
3
|
type SkeletonProps = React.ComponentProps<"div">;
|
|
4
|
+
/**
|
|
5
|
+
* Khối chờ tải.
|
|
6
|
+
*
|
|
7
|
+
* ─ `--muted`, KHÔNG PHẢI `--accent` ─
|
|
8
|
+
* `--accent` là bậc nhạt của BRAND: ở bảng light nó là `#FBF5DC`, một lớp kem ngả vàng. Cả một
|
|
9
|
+
* trang đang tải vì thế loang vàng — brand đi nói một câu mà nó không có gì để nói, và nó còn
|
|
10
|
+
* đúng bằng màu mà `Progress`/`Switch`/`Tabs` dùng để nói "cái này ĐANG bật".
|
|
11
|
+
*
|
|
12
|
+
* `--muted` (`#F7F7F8` light · `#1E2128` dark) là bậc XÁM trung tính của cùng hệ mặt phẳng, và ở
|
|
13
|
+
* dark hai token vốn đã trùng giá trị — nên đổi sang nó chỉ rút phần ngả vàng ở light ra, không
|
|
14
|
+
* làm khối chờ nhạt đi (1,07:1 với `--card` so với 1,09:1 của `--accent`).
|
|
15
|
+
*
|
|
16
|
+
* ⚠ Đừng "làm rõ hơn" bằng một bậc đậm hơn (`--border`, `--secondary`): khối chờ là chỗ giữ chỗ,
|
|
17
|
+
* không phải nội dung. Nó đậm ngang chữ thật thì trang đang tải trông như trang đã tải xong mà
|
|
18
|
+
* hỏng — và `animate-pulse` mới là thứ nói "đang chờ", không phải độ đậm.
|
|
19
|
+
*/
|
|
4
20
|
declare function Skeleton({ className, ...props }: SkeletonProps): React.JSX.Element;
|
|
5
21
|
|
|
6
22
|
export { Skeleton, type SkeletonProps };
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { jsx } from "react/jsx-runtime";
|
|
2
2
|
import { cn } from "../../lib/cn.js";
|
|
3
3
|
function Skeleton({ className, ...props }) {
|
|
4
|
-
return /* @__PURE__ */ jsx("div", { "data-slot": "skeleton", className: cn("bg-
|
|
4
|
+
return /* @__PURE__ */ jsx("div", { "data-slot": "skeleton", className: cn("bg-muted animate-pulse rounded-md", className), ...props });
|
|
5
5
|
}
|
|
6
6
|
export {
|
|
7
7
|
Skeleton
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"sources":["../../../src/components/ui/skeleton.tsx"],"sourcesContent":["import type * as React from \"react\";\nimport { cn } from \"../../lib/cn.js\";\n\nexport type SkeletonProps = React.ComponentProps<\"div\">;\n\nfunction Skeleton({ className, ...props }: SkeletonProps) {\n return <div data-slot=\"skeleton\" className={cn(\"bg-
|
|
1
|
+
{"version":3,"sources":["../../../src/components/ui/skeleton.tsx"],"sourcesContent":["import type * as React from \"react\";\nimport { cn } from \"../../lib/cn.js\";\n\nexport type SkeletonProps = React.ComponentProps<\"div\">;\n\n/**\n * Khối chờ tải.\n *\n * ─ `--muted`, KHÔNG PHẢI `--accent` ─\n * `--accent` là bậc nhạt của BRAND: ở bảng light nó là `#FBF5DC`, một lớp kem ngả vàng. Cả một\n * trang đang tải vì thế loang vàng — brand đi nói một câu mà nó không có gì để nói, và nó còn\n * đúng bằng màu mà `Progress`/`Switch`/`Tabs` dùng để nói \"cái này ĐANG bật\".\n *\n * `--muted` (`#F7F7F8` light · `#1E2128` dark) là bậc XÁM trung tính của cùng hệ mặt phẳng, và ở\n * dark hai token vốn đã trùng giá trị — nên đổi sang nó chỉ rút phần ngả vàng ở light ra, không\n * làm khối chờ nhạt đi (1,07:1 với `--card` so với 1,09:1 của `--accent`).\n *\n * ⚠ Đừng \"làm rõ hơn\" bằng một bậc đậm hơn (`--border`, `--secondary`): khối chờ là chỗ giữ chỗ,\n * không phải nội dung. Nó đậm ngang chữ thật thì trang đang tải trông như trang đã tải xong mà\n * hỏng — và `animate-pulse` mới là thứ nói \"đang chờ\", không phải độ đậm.\n */\nfunction Skeleton({ className, ...props }: SkeletonProps) {\n return <div data-slot=\"skeleton\" className={cn(\"bg-muted animate-pulse rounded-md\", className)} {...props} />;\n}\n\nexport { Skeleton };\n"],"mappings":"AAsBS;AArBT,SAAS,UAAU;AAoBnB,SAAS,SAAS,EAAE,WAAW,GAAG,MAAM,GAAkB;AACxD,SAAO,oBAAC,SAAI,aAAU,YAAW,WAAW,GAAG,qCAAqC,SAAS,GAAI,GAAG,OAAO;AAC7G;","names":[]}
|
package/dist/index.d.ts
CHANGED
|
@@ -6,7 +6,7 @@ export { CopyButton, CopyButtonProps } from './components/composite/copy-button.
|
|
|
6
6
|
export { DataTable, DataTableAlign, DataTableColumn, DataTableColumnToggle, DataTableColumnToggleProps, DataTableProps, DataTableSort, SortDirection } from './components/composite/data-table.js';
|
|
7
7
|
export { DEFAULT_DATA_TABLE_LABELS, DataTableLabels } from './components/composite/data-table-constants.js';
|
|
8
8
|
export { DatePicker, DatePickerProps, DateRangePicker, DateRangePickerProps } from './components/composite/date-picker.js';
|
|
9
|
-
export { DEFAULT_DATE_PRESETS, DEFAULT_DATE_RANGE_PRESETS, DatePreset, DateRangePreset, VI_DATE_FORMAT } from './components/composite/date-picker-constants.js';
|
|
9
|
+
export { DEFAULT_DATE_PICKER_LABELS, DEFAULT_DATE_PRESETS, DEFAULT_DATE_RANGE_PRESETS, DatePickerLabels, DatePreset, DateRangePreset, VI_DATE_FORMAT, dateFormatForLocale } from './components/composite/date-picker-constants.js';
|
|
10
10
|
export { EmptyState, EmptyStateProps, IconComponent } from './components/composite/empty-state.js';
|
|
11
11
|
export { FilterChipItem, FilterChips, FilterChipsProps } from './components/composite/filter-chips.js';
|
|
12
12
|
export { FormField, FormFieldColSpan, FormFieldControlProps, FormFieldProps } from './components/composite/form-field.js';
|
|
@@ -83,6 +83,7 @@ export { ThemeMode, accent, accentForeground, accentInk, ash, brandNeutral, char
|
|
|
83
83
|
export { AvatarToneColors, getAvatarToneIndex } from './lib/avatar-tone.js';
|
|
84
84
|
export { DateRange } from 'react-day-picker';
|
|
85
85
|
import 'react';
|
|
86
|
+
import 'date-fns';
|
|
86
87
|
import 'class-variance-authority/types';
|
|
87
88
|
import 'class-variance-authority';
|
|
88
89
|
import 'sonner';
|
|
@@ -62,10 +62,47 @@ function AppLauncherPanel({ variant = "grid", className, onSelected }) {
|
|
|
62
62
|
]
|
|
63
63
|
}
|
|
64
64
|
),
|
|
65
|
-
/* @__PURE__ */ jsxs("span", { className: cn("flex min-w-0 flex-col", variant === "
|
|
66
|
-
/* @__PURE__ */ jsxs("span", { className: "flex
|
|
67
|
-
/* @__PURE__ */ jsx(
|
|
68
|
-
|
|
65
|
+
/* @__PURE__ */ jsxs("span", { className: cn("flex min-w-0 flex-col", variant === "grid" ? "w-full" : "flex-1"), children: [
|
|
66
|
+
/* @__PURE__ */ jsxs("span", { className: cn("flex min-w-0 gap-1.5", variant === "grid" ? "items-start" : "items-center"), children: [
|
|
67
|
+
/* @__PURE__ */ jsx(
|
|
68
|
+
"span",
|
|
69
|
+
{
|
|
70
|
+
className: cn(
|
|
71
|
+
"text-sm font-medium text-foreground",
|
|
72
|
+
/*
|
|
73
|
+
* Lưới: tên ứng dụng dài hơn ô là chuyện thường ("Tài khoản Comitor" ở ô 109px),
|
|
74
|
+
* nên cho hai dòng rồi mới cắt — `grid-cols-3` khai `items-stretch` mặc định nên
|
|
75
|
+
* ô cao thêm một dòng không làm hàng bị so le.
|
|
76
|
+
*
|
|
77
|
+
* `break-words` đi KÈM `line-clamp-2`, không phải phòng xa: `-webkit-line-clamp`
|
|
78
|
+
* chỉ đặt được dấu "…" khi chữ THẬT SỰ tràn sang dòng thứ ba. Một tên không có
|
|
79
|
+
* khoảng trắng thì không xuống dòng được, nên nó nằm gọn một dòng và bị
|
|
80
|
+
* `overflow: hidden` cắt PHẲNG giữa chữ — đo được: `soDong: 1`,
|
|
81
|
+
* `scrollHeight === clientHeight`, không có dấu ba chấm nào. Cho phép ngắt trong
|
|
82
|
+
* từ thì nó thành hai dòng và dấu "…" quay lại.
|
|
83
|
+
*
|
|
84
|
+
* Danh sách: mỗi mục là một hàng ngang trong popover hẹp; hai dòng ở đó làm chiều
|
|
85
|
+
* cao các mục nhấp nhô. Giữ một dòng — `truncate` vốn đã chạy đúng ở đó vì `flex-1`
|
|
86
|
+
* ghim sẵn bề rộng. Cùng lý do tác giả chỉ cho `description` xuống hai dòng ở lưới.
|
|
87
|
+
*/
|
|
88
|
+
variant === "grid" ? "line-clamp-2 break-words" : "truncate"
|
|
89
|
+
),
|
|
90
|
+
children: app.name
|
|
91
|
+
}
|
|
92
|
+
),
|
|
93
|
+
app.badge ? (
|
|
94
|
+
/*
|
|
95
|
+
* `shrink-0` KHÔNG phải để chống bóp méo — đo rồi: có hay không, badge đều 116,8px,
|
|
96
|
+
* vì `whitespace-nowrap` trong `Badge` đã giữ sàn `min-width: auto` bằng trọn bề rộng
|
|
97
|
+
* chữ, và phần co dồn hết sang ô tên (`overflow: hidden` → sàn 0).
|
|
98
|
+
*
|
|
99
|
+
* Nó ở đây vì hai lý do khác: khớp quy ước của mọi chỗ gọi `Badge` khác trong shell
|
|
100
|
+
* (`workspace-switcher`, `sidebar-menu-item` — chỗ này là chỗ DUY NHẤT thiếu), và để
|
|
101
|
+
* lá chắn thôi phụ thuộc vào một lớp ngầm trong `Badge`. Ai đó thêm `truncate` vào
|
|
102
|
+
* base cva của `Badge` là sàn tụt về 0 và badge méo, im lặng.
|
|
103
|
+
*/
|
|
104
|
+
/* @__PURE__ */ jsx(Badge, { variant: "destructive", size: "sm", className: "shrink-0", children: app.badge })
|
|
105
|
+
) : null
|
|
69
106
|
] }),
|
|
70
107
|
app.description ? /* @__PURE__ */ jsx(
|
|
71
108
|
"span",
|
|
@@ -128,7 +165,7 @@ function AppLauncherPanel({ variant = "grid", className, onSelected }) {
|
|
|
128
165
|
);
|
|
129
166
|
};
|
|
130
167
|
if (total === 0) return null;
|
|
131
|
-
const groupClass = variant === "grid" ? "grid grid-cols-3 gap-1" : "flex flex-col gap-0.5";
|
|
168
|
+
const groupClass = variant === "grid" ? "grid auto-rows-fr grid-cols-3 gap-1" : "flex flex-col gap-0.5";
|
|
132
169
|
return /* @__PURE__ */ jsxs("div", { ref: rootRef, className: cn("flex flex-col gap-3", className), children: [
|
|
133
170
|
entitled.length > 0 ? /* @__PURE__ */ jsxs("section", { children: [
|
|
134
171
|
/* @__PURE__ */ jsx("p", { className: "px-1 pb-1.5 text-[11px] font-semibold uppercase tracking-wide text-muted-foreground", children: labels.myApps }),
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"sources":["../../src/shell/app-launcher.tsx"],"sourcesContent":["\"use client\";\n\nimport { LayoutGrid, Lock } from \"lucide-react\";\nimport { type KeyboardEvent, useRef } from \"react\";\nimport { Badge } from \"../components/ui/badge.js\";\nimport { Popover, PopoverContent, PopoverTrigger } from \"../components/ui/popover.js\";\nimport { Tooltip, TooltipContent, TooltipTrigger } from \"../components/ui/tooltip.js\";\nimport { cn } from \"../lib/cn.js\";\nimport { useShell } from \"./shell-context.js\";\nimport { ShellNavLink } from \"./shell-link.js\";\nimport type { AppDescriptor } from \"./types.js\";\nimport { appAccentStyle, getEntitledApps, getLockedApps } from \"./utils.js\";\n\n/** Đánh dấu ô app để điều hướng bàn phím tìm được, kể cả ô render bằng LinkComponent của app. */\nconst TILE_ATTR = \"data-app-tile\";\n\nexport interface AppLauncherPanelProps {\n /** \"grid\" = lưới 3 cột kiểu Lark/Google Workspace; \"list\" = danh sách dọc (menu mobile). */\n variant?: \"grid\" | \"list\";\n className?: string;\n /** Gọi sau khi người dùng chọn một app — dùng để đóng popover/menu mobile. */\n onSelected?: () => void;\n}\n\n/**\n * Nội dung của AppLauncher, tách riêng để `MobileMenu` nhúng thẳng (không popover).\n *\n * Điều hướng bàn phím: ←/→/↑/↓ chạy giữa các ô (xuyên cả hai nhóm như một lưới liền),\n * Home/End nhảy đầu-cuối; Enter/Space để chính thẻ button/link xử lý.\n * Vị trí ô đọc từ DOM chứ không giữ mảng ref: ô \"đã mở khoá\" có thể là `LinkComponent`\n * do app cung cấp — không chắc component đó nhận `ref`.\n */\nexport function AppLauncherPanel({ variant = \"grid\", className, onSelected }: AppLauncherPanelProps) {\n const { apps, currentApp, onAppSelect, onUpsell, labels } = useShell();\n\n const entitled = getEntitledApps(apps);\n const locked = getLockedApps(apps);\n const total = entitled.length + locked.length;\n\n const rootRef = useRef<HTMLDivElement>(null);\n const columns = variant === \"grid\" ? 3 : 1;\n\n const handleKeyDown = (event: KeyboardEvent<HTMLElement>) => {\n const root = rootRef.current;\n if (!root) return;\n const tiles = Array.from(root.querySelectorAll<HTMLElement>(`[${TILE_ATTR}]`));\n const index = tiles.indexOf(event.currentTarget);\n if (index < 0) return;\n\n const moves: Record<string, number | undefined> = {\n ArrowRight: index + 1,\n ArrowLeft: index - 1,\n ArrowDown: index + columns,\n ArrowUp: index - columns,\n Home: 0,\n End: tiles.length - 1\n };\n const next = moves[event.key];\n if (next === undefined) return;\n\n event.preventDefault();\n tiles[Math.max(0, Math.min(tiles.length - 1, next))]?.focus();\n };\n\n const renderTile = (app: AppDescriptor) => {\n const isLocked = app.entitled === false;\n const isCurrent = app.id === currentApp?.id;\n const Icon = app.icon;\n\n // Ô KHOÁ không tô `opacity-60` nữa. Nó KHÔNG phải control vô hiệu — bấm được và gọi\n // `onUpsell(appId)` — nên không hưởng miễn trừ \"thành phần vô hiệu\" của WCAG 1.4.3/1.4.11.\n // Với 60% độ mờ thì mô tả app tụt còn 2,41:1 (light) / 2,74:1 (dark) ✗ và icon trong ô\n // `bg-muted` còn 2,31:1 / 2,60:1 ✗, chỉ phục hồi khi hover — tức người không rê chuột thì\n // không đọc được. Bỏ độ mờ: mô tả `text-muted-foreground` 5,23:1 (light) / 5,35:1 (dark) ✓,\n // icon trên `bg-muted` 4,89:1 / 4,86:1 ✓. Tín hiệu \"chưa mở khoá\" không mất gì: ô icon vẫn\n // xám (thay vì tô màu app), vẫn có huy hiệu ổ khoá, vẫn có `title`/`aria-label` nói rõ.\n const tileClass = cn(\n \"group relative flex rounded-md text-left transition-colors duration-150 ease-out\",\n variant === \"grid\" ? \"flex-col items-start gap-1.5 p-2.5\" : \"items-center gap-2.5 p-2\",\n isLocked ? \"hover:bg-muted\" : \"hover:bg-accent\",\n isCurrent && \"bg-accent\"\n );\n\n const body = (\n <>\n {/* Ô icon tô ĐẶC bằng `--app-accent` + chữ `--app-accent-foreground`, KHÔNG phải\n `bg-app-accent/15 text-app-accent`: accent mặc định là gold #E8B824, icon vàng trên\n nền vàng 15% (đã trộn ra #FCF4DE) chỉ được 1,69:1 — dưới xa mức 3:1 mà WCAG 1.4.11\n đòi cho icon. Nền đặc thì icon là mực trên vàng = 10,19:1 ✓ và ô app vẫn giữ đúng\n màu riêng của từng app. Cùng cách xử lý với `FilterChips` (chip đang bật). */}\n <span\n className={cn(\n \"relative flex shrink-0 items-center justify-center rounded-md\",\n variant === \"grid\" ? \"size-9\" : \"size-8\",\n isLocked ? \"bg-muted text-muted-foreground\" : \"bg-app-accent text-app-accent-foreground\"\n )}\n style={appAccentStyle(app)}\n >\n <Icon className=\"size-4\" />\n {isLocked ? (\n <span className=\"absolute -bottom-1 -right-1 flex size-4 items-center justify-center rounded-full bg-muted text-muted-foreground ring-2 ring-popover\">\n <Lock className=\"size-2.5\" />\n </span>\n ) : null}\n </span>\n\n <span className={cn(\"flex min-w-0 flex-col\", variant === \"list\" && \"flex-1\")}>\n <span className=\"flex items-center gap-1.5\">\n <span className=\"truncate text-sm font-medium text-foreground\">{app.name}</span>\n {app.badge ? (\n <Badge variant=\"destructive\" size=\"sm\">\n {app.badge}\n </Badge>\n ) : null}\n </span>\n {app.description ? (\n <span\n className={cn(\"text-[11px] leading-snug text-muted-foreground\", variant === \"grid\" && \"line-clamp-2\")}\n >\n {app.description}\n </span>\n ) : null}\n </span>\n </>\n );\n\n // Chưa mở khoá → không điều hướng, chỉ báo cho app biết để mời nâng gói.\n if (isLocked) {\n return (\n <button\n key={app.id}\n type=\"button\"\n data-app-tile=\"\"\n title={labels.lockedHint}\n aria-label={`${app.name} — ${labels.locked}`}\n className={tileClass}\n onKeyDown={handleKeyDown}\n onClick={() => {\n onUpsell?.(app.id);\n onSelected?.();\n }}\n >\n {body}\n </button>\n );\n }\n\n // Có `onAppSelect` → app tự lo điều hướng (đổi subdomain, nạp trước dữ liệu…);\n // không có → dùng link thật để giữ chuột-giữa / mở tab mới / trình đọc màn hình.\n if (onAppSelect) {\n return (\n <button\n key={app.id}\n type=\"button\"\n data-app-tile=\"\"\n aria-current={isCurrent ? \"page\" : undefined}\n className={tileClass}\n onKeyDown={handleKeyDown}\n onClick={() => {\n onAppSelect(app);\n onSelected?.();\n }}\n >\n {body}\n </button>\n );\n }\n\n return (\n <ShellNavLink\n key={app.id}\n href={app.href}\n data-app-tile=\"\"\n aria-current={isCurrent ? \"page\" : undefined}\n className={tileClass}\n onKeyDown={handleKeyDown}\n onClick={() => onSelected?.()}\n >\n {body}\n </ShellNavLink>\n );\n };\n\n if (total === 0) return null;\n\n const groupClass = variant === \"grid\" ? \"grid grid-cols-3 gap-1\" : \"flex flex-col gap-0.5\";\n\n return (\n <div ref={rootRef} className={cn(\"flex flex-col gap-3\", className)}>\n {entitled.length > 0 ? (\n <section>\n <p className=\"px-1 pb-1.5 text-[11px] font-semibold uppercase tracking-wide text-muted-foreground\">\n {labels.myApps}\n </p>\n <div className={groupClass}>{entitled.map(renderTile)}</div>\n </section>\n ) : null}\n\n {locked.length > 0 ? (\n <section>\n <p className=\"px-1 pb-1.5 text-[11px] font-semibold uppercase tracking-wide text-muted-foreground\">\n {labels.discoverApps}\n </p>\n <div className={groupClass}>{locked.map(renderTile)}</div>\n </section>\n ) : null}\n </div>\n );\n}\n\nexport interface AppLauncherProps extends AppLauncherPanelProps {\n /** Bỏ nút bấm + Popover, render thẳng panel (MobileMenu dùng cách này). */\n inline?: boolean;\n align?: \"start\" | \"center\" | \"end\";\n}\n\n/**\n * **Trục 2 — chuyển sản phẩm trong cùng workspace.**\n *\n * Nút lưới 3×3 ở header (kiểu Lark / Google Workspace) mở popover lưới ô app.\n * Trạng thái mở nằm ở `ShellProvider` để phím tắt `Cmd/Ctrl + Shift + K` bật được.\n * App chưa có trong gói hiện ô khoá và gọi `onUpsell(appId)` thay vì điều hướng.\n */\nexport function AppLauncher({\n className,\n variant = \"grid\",\n inline = false,\n align = \"end\",\n onSelected\n}: AppLauncherProps) {\n const { apps, appLauncherOpen, setAppLauncherOpen, labels } = useShell();\n\n if (apps.length === 0) return null;\n\n if (inline) {\n return <AppLauncherPanel variant={variant} className={className} onSelected={onSelected} />;\n }\n\n return (\n <Popover open={appLauncherOpen} onOpenChange={setAppLauncherOpen}>\n <Tooltip>\n <TooltipTrigger asChild>\n <PopoverTrigger asChild>\n <button\n type=\"button\"\n aria-label={labels.appLauncherLabel}\n className={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 appLauncherOpen && \"bg-muted text-foreground\",\n className\n )}\n >\n <LayoutGrid className=\"size-4\" />\n </button>\n </PopoverTrigger>\n </TooltipTrigger>\n <TooltipContent side=\"bottom\">{labels.apps}</TooltipContent>\n </Tooltip>\n\n <PopoverContent align={align} sideOffset={8} className=\"w-[22rem] p-2\">\n <AppLauncherPanel\n variant={variant}\n onSelected={() => {\n setAppLauncherOpen(false);\n onSelected?.();\n }}\n />\n </PopoverContent>\n </Popover>\n );\n}\n"],"mappings":"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","names":[]}
|
|
1
|
+
{"version":3,"sources":["../../src/shell/app-launcher.tsx"],"sourcesContent":["\"use client\";\n\nimport { LayoutGrid, Lock } from \"lucide-react\";\nimport { type KeyboardEvent, useRef } from \"react\";\nimport { Badge } from \"../components/ui/badge.js\";\nimport { Popover, PopoverContent, PopoverTrigger } from \"../components/ui/popover.js\";\nimport { Tooltip, TooltipContent, TooltipTrigger } from \"../components/ui/tooltip.js\";\nimport { cn } from \"../lib/cn.js\";\nimport { useShell } from \"./shell-context.js\";\nimport { ShellNavLink } from \"./shell-link.js\";\nimport type { AppDescriptor } from \"./types.js\";\nimport { appAccentStyle, getEntitledApps, getLockedApps } from \"./utils.js\";\n\n/** Đánh dấu ô app để điều hướng bàn phím tìm được, kể cả ô render bằng LinkComponent của app. */\nconst TILE_ATTR = \"data-app-tile\";\n\nexport interface AppLauncherPanelProps {\n /** \"grid\" = lưới 3 cột kiểu Lark/Google Workspace; \"list\" = danh sách dọc (menu mobile). */\n variant?: \"grid\" | \"list\";\n className?: string;\n /** Gọi sau khi người dùng chọn một app — dùng để đóng popover/menu mobile. */\n onSelected?: () => void;\n}\n\n/**\n * Nội dung của AppLauncher, tách riêng để `MobileMenu` nhúng thẳng (không popover).\n *\n * Điều hướng bàn phím: ←/→/↑/↓ chạy giữa các ô (xuyên cả hai nhóm như một lưới liền),\n * Home/End nhảy đầu-cuối; Enter/Space để chính thẻ button/link xử lý.\n * Vị trí ô đọc từ DOM chứ không giữ mảng ref: ô \"đã mở khoá\" có thể là `LinkComponent`\n * do app cung cấp — không chắc component đó nhận `ref`.\n */\nexport function AppLauncherPanel({ variant = \"grid\", className, onSelected }: AppLauncherPanelProps) {\n const { apps, currentApp, onAppSelect, onUpsell, labels } = useShell();\n\n const entitled = getEntitledApps(apps);\n const locked = getLockedApps(apps);\n const total = entitled.length + locked.length;\n\n const rootRef = useRef<HTMLDivElement>(null);\n const columns = variant === \"grid\" ? 3 : 1;\n\n const handleKeyDown = (event: KeyboardEvent<HTMLElement>) => {\n const root = rootRef.current;\n if (!root) return;\n const tiles = Array.from(root.querySelectorAll<HTMLElement>(`[${TILE_ATTR}]`));\n const index = tiles.indexOf(event.currentTarget);\n if (index < 0) return;\n\n const moves: Record<string, number | undefined> = {\n ArrowRight: index + 1,\n ArrowLeft: index - 1,\n ArrowDown: index + columns,\n ArrowUp: index - columns,\n Home: 0,\n End: tiles.length - 1\n };\n const next = moves[event.key];\n if (next === undefined) return;\n\n event.preventDefault();\n tiles[Math.max(0, Math.min(tiles.length - 1, next))]?.focus();\n };\n\n const renderTile = (app: AppDescriptor) => {\n const isLocked = app.entitled === false;\n const isCurrent = app.id === currentApp?.id;\n const Icon = app.icon;\n\n // Ô KHOÁ không tô `opacity-60` nữa. Nó KHÔNG phải control vô hiệu — bấm được và gọi\n // `onUpsell(appId)` — nên không hưởng miễn trừ \"thành phần vô hiệu\" của WCAG 1.4.3/1.4.11.\n // Với 60% độ mờ thì mô tả app tụt còn 2,41:1 (light) / 2,74:1 (dark) ✗ và icon trong ô\n // `bg-muted` còn 2,31:1 / 2,60:1 ✗, chỉ phục hồi khi hover — tức người không rê chuột thì\n // không đọc được. Bỏ độ mờ: mô tả `text-muted-foreground` 5,23:1 (light) / 5,35:1 (dark) ✓,\n // icon trên `bg-muted` 4,89:1 / 4,86:1 ✓. Tín hiệu \"chưa mở khoá\" không mất gì: ô icon vẫn\n // xám (thay vì tô màu app), vẫn có huy hiệu ổ khoá, vẫn có `title`/`aria-label` nói rõ.\n const tileClass = cn(\n \"group relative flex rounded-md text-left transition-colors duration-150 ease-out\",\n variant === \"grid\" ? \"flex-col items-start gap-1.5 p-2.5\" : \"items-center gap-2.5 p-2\",\n isLocked ? \"hover:bg-muted\" : \"hover:bg-accent\",\n isCurrent && \"bg-accent\"\n );\n\n const body = (\n <>\n {/* Ô icon tô ĐẶC bằng `--app-accent` + chữ `--app-accent-foreground`, KHÔNG phải\n `bg-app-accent/15 text-app-accent`: accent mặc định là gold #E8B824, icon vàng trên\n nền vàng 15% (đã trộn ra #FCF4DE) chỉ được 1,69:1 — dưới xa mức 3:1 mà WCAG 1.4.11\n đòi cho icon. Nền đặc thì icon là mực trên vàng = 10,19:1 ✓ và ô app vẫn giữ đúng\n màu riêng của từng app. Cùng cách xử lý với `FilterChips` (chip đang bật). */}\n <span\n className={cn(\n \"relative flex shrink-0 items-center justify-center rounded-md\",\n variant === \"grid\" ? \"size-9\" : \"size-8\",\n isLocked ? \"bg-muted text-muted-foreground\" : \"bg-app-accent text-app-accent-foreground\"\n )}\n style={appAccentStyle(app)}\n >\n <Icon className=\"size-4\" />\n {isLocked ? (\n <span className=\"absolute -bottom-1 -right-1 flex size-4 items-center justify-center rounded-full bg-muted text-muted-foreground ring-2 ring-popover\">\n <Lock className=\"size-2.5\" />\n </span>\n ) : null}\n </span>\n\n {/*\n * `w-full` ở ô lưới là thứ THẬT SỰ bó được chữ — không phải `min-w-0`.\n *\n * Ô lưới là `flex-col items-start` (xem `tileClass`), nên theo trục ngang cột này lấy\n * `fit-content`: nó RỘNG RA THEO TÊN thay vì bằng ô. Mọi phép cắt bên trong vì thế không\n * bao giờ có gì để bám vào. Đo ở app thật, ô 109px: tên một từ dài cho cột 272px và tràn\n * 174px, `scrollWidth === clientWidth` — `line-clamp` được đặt nhưng chưa từng chạy.\n * Thêm `min-w-0` vào hàng bên dưới KHÔNG đổi được số nào (đo: y hệt bản hỏng); `w-full`\n * đưa cột về đúng 89px và từ đó phép cắt mới có hiệu lực.\n *\n * Biến thể `list` không dính: ô đó là hàng ngang nên `flex-1` đã ghim bề rộng sẵn.\n */}\n <span className={cn(\"flex min-w-0 flex-col\", variant === \"grid\" ? \"w-full\" : \"flex-1\")}>\n {/*\n * `items-start` chứ không `items-center` khi tên có thể xuống hai dòng: căn giữa đẩy\n * badge xuống giữa khe hai dòng (đo được 10,6px), xa hẳn dòng chữ nó đang chú thích.\n * Tên một dòng thì hai cách lệch nhau 0,8px — không nhìn thấy.\n */}\n <span className={cn(\"flex min-w-0 gap-1.5\", variant === \"grid\" ? \"items-start\" : \"items-center\")}>\n <span\n className={cn(\n \"text-sm font-medium text-foreground\",\n /*\n * Lưới: tên ứng dụng dài hơn ô là chuyện thường (\"Tài khoản Comitor\" ở ô 109px),\n * nên cho hai dòng rồi mới cắt — `grid-cols-3` khai `items-stretch` mặc định nên\n * ô cao thêm một dòng không làm hàng bị so le.\n *\n * `break-words` đi KÈM `line-clamp-2`, không phải phòng xa: `-webkit-line-clamp`\n * chỉ đặt được dấu \"…\" khi chữ THẬT SỰ tràn sang dòng thứ ba. Một tên không có\n * khoảng trắng thì không xuống dòng được, nên nó nằm gọn một dòng và bị\n * `overflow: hidden` cắt PHẲNG giữa chữ — đo được: `soDong: 1`,\n * `scrollHeight === clientHeight`, không có dấu ba chấm nào. Cho phép ngắt trong\n * từ thì nó thành hai dòng và dấu \"…\" quay lại.\n *\n * Danh sách: mỗi mục là một hàng ngang trong popover hẹp; hai dòng ở đó làm chiều\n * cao các mục nhấp nhô. Giữ một dòng — `truncate` vốn đã chạy đúng ở đó vì `flex-1`\n * ghim sẵn bề rộng. Cùng lý do tác giả chỉ cho `description` xuống hai dòng ở lưới.\n */\n variant === \"grid\" ? \"line-clamp-2 break-words\" : \"truncate\"\n )}\n >\n {app.name}\n </span>\n {app.badge ? (\n /*\n * `shrink-0` KHÔNG phải để chống bóp méo — đo rồi: có hay không, badge đều 116,8px,\n * vì `whitespace-nowrap` trong `Badge` đã giữ sàn `min-width: auto` bằng trọn bề rộng\n * chữ, và phần co dồn hết sang ô tên (`overflow: hidden` → sàn 0).\n *\n * Nó ở đây vì hai lý do khác: khớp quy ước của mọi chỗ gọi `Badge` khác trong shell\n * (`workspace-switcher`, `sidebar-menu-item` — chỗ này là chỗ DUY NHẤT thiếu), và để\n * lá chắn thôi phụ thuộc vào một lớp ngầm trong `Badge`. Ai đó thêm `truncate` vào\n * base cva của `Badge` là sàn tụt về 0 và badge méo, im lặng.\n */\n <Badge variant=\"destructive\" size=\"sm\" className=\"shrink-0\">\n {app.badge}\n </Badge>\n ) : null}\n </span>\n {app.description ? (\n <span\n className={cn(\"text-[11px] leading-snug text-muted-foreground\", variant === \"grid\" && \"line-clamp-2\")}\n >\n {app.description}\n </span>\n ) : null}\n </span>\n </>\n );\n\n // Chưa mở khoá → không điều hướng, chỉ báo cho app biết để mời nâng gói.\n if (isLocked) {\n return (\n <button\n key={app.id}\n type=\"button\"\n data-app-tile=\"\"\n title={labels.lockedHint}\n aria-label={`${app.name} — ${labels.locked}`}\n className={tileClass}\n onKeyDown={handleKeyDown}\n onClick={() => {\n onUpsell?.(app.id);\n onSelected?.();\n }}\n >\n {body}\n </button>\n );\n }\n\n // Có `onAppSelect` → app tự lo điều hướng (đổi subdomain, nạp trước dữ liệu…);\n // không có → dùng link thật để giữ chuột-giữa / mở tab mới / trình đọc màn hình.\n if (onAppSelect) {\n return (\n <button\n key={app.id}\n type=\"button\"\n data-app-tile=\"\"\n aria-current={isCurrent ? \"page\" : undefined}\n className={tileClass}\n onKeyDown={handleKeyDown}\n onClick={() => {\n onAppSelect(app);\n onSelected?.();\n }}\n >\n {body}\n </button>\n );\n }\n\n return (\n <ShellNavLink\n key={app.id}\n href={app.href}\n data-app-tile=\"\"\n aria-current={isCurrent ? \"page\" : undefined}\n className={tileClass}\n onKeyDown={handleKeyDown}\n onClick={() => onSelected?.()}\n >\n {body}\n </ShellNavLink>\n );\n };\n\n if (total === 0) return null;\n\n /*\n * `auto-rows-fr` vá thứ duy nhất mà việc cho tên xuống hai dòng làm xấu đi: TRONG một hàng thì\n * các ô đã bằng nhau sẵn (`align-items: stretch` là mặc định của Grid), nhưng GIỮA các hàng thì\n * không — hàng có một tên hai dòng cao 116px trong khi hàng kia 97px. `fr` cho mọi hàng chung\n * một chiều cao.\n */\n const groupClass = variant === \"grid\" ? \"grid auto-rows-fr grid-cols-3 gap-1\" : \"flex flex-col gap-0.5\";\n\n return (\n <div ref={rootRef} className={cn(\"flex flex-col gap-3\", className)}>\n {entitled.length > 0 ? (\n <section>\n <p className=\"px-1 pb-1.5 text-[11px] font-semibold uppercase tracking-wide text-muted-foreground\">\n {labels.myApps}\n </p>\n <div className={groupClass}>{entitled.map(renderTile)}</div>\n </section>\n ) : null}\n\n {locked.length > 0 ? (\n <section>\n <p className=\"px-1 pb-1.5 text-[11px] font-semibold uppercase tracking-wide text-muted-foreground\">\n {labels.discoverApps}\n </p>\n <div className={groupClass}>{locked.map(renderTile)}</div>\n </section>\n ) : null}\n </div>\n );\n}\n\nexport interface AppLauncherProps extends AppLauncherPanelProps {\n /** Bỏ nút bấm + Popover, render thẳng panel (MobileMenu dùng cách này). */\n inline?: boolean;\n align?: \"start\" | \"center\" | \"end\";\n}\n\n/**\n * **Trục 2 — chuyển sản phẩm trong cùng workspace.**\n *\n * Nút lưới 3×3 ở header (kiểu Lark / Google Workspace) mở popover lưới ô app.\n * Trạng thái mở nằm ở `ShellProvider` để phím tắt `Cmd/Ctrl + Shift + K` bật được.\n * App chưa có trong gói hiện ô khoá và gọi `onUpsell(appId)` thay vì điều hướng.\n */\nexport function AppLauncher({\n className,\n variant = \"grid\",\n inline = false,\n align = \"end\",\n onSelected\n}: AppLauncherProps) {\n const { apps, appLauncherOpen, setAppLauncherOpen, labels } = useShell();\n\n if (apps.length === 0) return null;\n\n if (inline) {\n return <AppLauncherPanel variant={variant} className={className} onSelected={onSelected} />;\n }\n\n return (\n <Popover open={appLauncherOpen} onOpenChange={setAppLauncherOpen}>\n <Tooltip>\n <TooltipTrigger asChild>\n <PopoverTrigger asChild>\n <button\n type=\"button\"\n aria-label={labels.appLauncherLabel}\n className={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 appLauncherOpen && \"bg-muted text-foreground\",\n className\n )}\n >\n <LayoutGrid className=\"size-4\" />\n </button>\n </PopoverTrigger>\n </TooltipTrigger>\n <TooltipContent side=\"bottom\">{labels.apps}</TooltipContent>\n </Tooltip>\n\n <PopoverContent align={align} sideOffset={8} className=\"w-[22rem] p-2\">\n <AppLauncherPanel\n variant={variant}\n onSelected={() => {\n setAppLauncherOpen(false);\n onSelected?.();\n }}\n />\n </PopoverContent>\n </Popover>\n );\n}\n"],"mappings":"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","names":[]}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@comitor/ui",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.9.1",
|
|
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,19 +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 .",
|
|
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:a11y-gate && pnpm run lint:a11y"
|
|
72
|
-
},
|
|
73
60
|
"peerDependencies": {
|
|
74
61
|
"lucide-react": ">=0.540.0",
|
|
75
62
|
"next": ">=15",
|
|
@@ -151,5 +138,17 @@
|
|
|
151
138
|
"tailwindcss": "^4.2.0",
|
|
152
139
|
"tsup": "^8.3.5",
|
|
153
140
|
"typescript": "^5.7.0"
|
|
141
|
+
},
|
|
142
|
+
"scripts": {
|
|
143
|
+
"build": "tsup",
|
|
144
|
+
"typecheck": "tsc --noEmit",
|
|
145
|
+
"lint": "biome check .",
|
|
146
|
+
"lint:fix": "biome check --write .",
|
|
147
|
+
"lint:a11y": "node scripts/check-contrast.mjs",
|
|
148
|
+
"test:a11y-gate": "node scripts/a11y/self-test.mjs",
|
|
149
|
+
"test:nav-active": "node scripts/check-nav-active.mjs",
|
|
150
|
+
"verify:directives": "node scripts/verify-directives.mjs",
|
|
151
|
+
"smoke:entries": "node scripts/smoke-entries.mjs",
|
|
152
|
+
"check:exports": "publint --strict && attw --pack . --profile esm-only --exclude-entrypoints styles.css"
|
|
154
153
|
}
|
|
155
|
-
}
|
|
154
|
+
}
|