@comitor/ui 3.0.0 → 3.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (110) hide show
  1. package/README.md +5 -1
  2. package/dist/components/composite/confirm-dialog.js +56 -35
  3. package/dist/components/composite/confirm-dialog.js.map +1 -1
  4. package/dist/components/composite/search-filter-bar-constants.d.ts +3 -0
  5. package/dist/components/composite/search-filter-bar-constants.js.map +1 -1
  6. package/dist/components/composite/table-pagination-constants.d.ts +3 -1
  7. package/dist/components/composite/table-pagination-constants.js.map +1 -1
  8. package/dist/components/composite/table-pagination.js +10 -2
  9. package/dist/components/composite/table-pagination.js.map +1 -1
  10. package/dist/components/composite/text-utils.d.ts +4 -23
  11. package/dist/components/composite/text-utils.js +1 -11
  12. package/dist/components/composite/text-utils.js.map +1 -1
  13. package/dist/components/composite/toast.d.ts +2 -1
  14. package/dist/components/composite/toast.js +3 -9
  15. package/dist/components/composite/toast.js.map +1 -1
  16. package/dist/components/ui/action-bar.js +45 -1
  17. package/dist/components/ui/action-bar.js.map +1 -1
  18. package/dist/components/ui/banner.d.ts +8 -0
  19. package/dist/components/ui/banner.js +149 -35
  20. package/dist/components/ui/banner.js.map +1 -1
  21. package/dist/components/ui/circular-progress.js +10 -2
  22. package/dist/components/ui/circular-progress.js.map +1 -1
  23. package/dist/components/ui/kanban.js +9 -3
  24. package/dist/components/ui/kanban.js.map +1 -1
  25. package/dist/components/ui/key-value.js +70 -76
  26. package/dist/components/ui/key-value.js.map +1 -1
  27. package/dist/components/ui/mask-input-constants.d.ts +34 -2
  28. package/dist/components/ui/mask-input-constants.js +87 -76
  29. package/dist/components/ui/mask-input-constants.js.map +1 -1
  30. package/dist/components/ui/mask-input.d.ts +6 -5
  31. package/dist/components/ui/mask-input.js +23 -21
  32. package/dist/components/ui/mask-input.js.map +1 -1
  33. package/dist/components/ui/mention.d.ts +14 -2
  34. package/dist/components/ui/mention.js +33 -6
  35. package/dist/components/ui/mention.js.map +1 -1
  36. package/dist/components/ui/phone-input.d.ts +18 -8
  37. package/dist/components/ui/phone-input.js +125 -35
  38. package/dist/components/ui/phone-input.js.map +1 -1
  39. package/dist/components/ui/rating.js +4 -2
  40. package/dist/components/ui/rating.js.map +1 -1
  41. package/dist/components/ui/scroll-spy.js +46 -31
  42. package/dist/components/ui/scroll-spy.js.map +1 -1
  43. package/dist/components/ui/slider.d.ts +1 -1
  44. package/dist/components/ui/slider.js +15 -1
  45. package/dist/components/ui/slider.js.map +1 -1
  46. package/dist/components/ui/sonner.js +2 -0
  47. package/dist/components/ui/sonner.js.map +1 -1
  48. package/dist/components/ui/stepper.js +26 -3
  49. package/dist/components/ui/stepper.js.map +1 -1
  50. package/dist/components/ui/time-picker.d.ts +24 -4
  51. package/dist/components/ui/time-picker.js +425 -162
  52. package/dist/components/ui/time-picker.js.map +1 -1
  53. package/dist/components/ui/timeline.js +45 -9
  54. package/dist/components/ui/timeline.js.map +1 -1
  55. package/dist/components/ui/toggle-group.js +12 -1
  56. package/dist/components/ui/toggle-group.js.map +1 -1
  57. package/dist/components/ui/tour.js +3 -2
  58. package/dist/components/ui/tour.js.map +1 -1
  59. package/dist/data-grid/data-grid-cell-variants.js +4 -4
  60. package/dist/data-grid/data-grid-cell-variants.js.map +1 -1
  61. package/dist/data-grid/data-grid-cell-wrapper.d.ts +1 -1
  62. package/dist/data-grid/data-grid-cell-wrapper.js +6 -0
  63. package/dist/data-grid/data-grid-cell-wrapper.js.map +1 -1
  64. package/dist/data-grid/data-grid-row.js +1 -1
  65. package/dist/data-grid/data-grid-row.js.map +1 -1
  66. package/dist/data-grid/data-grid-select-column.js +6 -2
  67. package/dist/data-grid/data-grid-select-column.js.map +1 -1
  68. package/dist/data-grid/data-grid-skeleton.d.ts +5 -0
  69. package/dist/data-grid/data-grid-skeleton.js +1 -4
  70. package/dist/data-grid/data-grid-skeleton.js.map +1 -1
  71. package/dist/data-grid/data-grid.d.ts +1 -1
  72. package/dist/data-grid/data-grid.js +3 -2
  73. package/dist/data-grid/data-grid.js.map +1 -1
  74. package/dist/data-grid/filters.js +3 -2
  75. package/dist/data-grid/filters.js.map +1 -1
  76. package/dist/data-grid/types.d.ts +7 -0
  77. package/dist/data-grid/use-data-grid.d.ts +1 -0
  78. package/dist/data-grid/use-data-grid.js +70 -24
  79. package/dist/data-grid/use-data-grid.js.map +1 -1
  80. package/dist/data-grid/utils.d.ts +7 -0
  81. package/dist/data-grid/utils.js +3 -1
  82. package/dist/data-grid/utils.js.map +1 -1
  83. package/dist/i18n/locale-provider.d.ts +18 -0
  84. package/dist/i18n/locale-provider.js.map +1 -1
  85. package/dist/i18n/namespaces/rating.js +2 -1
  86. package/dist/i18n/namespaces/rating.js.map +1 -1
  87. package/dist/i18n/namespaces/search-filter-bar.js +8 -6
  88. package/dist/i18n/namespaces/search-filter-bar.js.map +1 -1
  89. package/dist/i18n/namespaces/shell.d.ts +4 -0
  90. package/dist/i18n/namespaces/shell.js +1 -1
  91. package/dist/i18n/namespaces/shell.js.map +1 -1
  92. package/dist/i18n/namespaces/table-pagination.js +6 -4
  93. package/dist/i18n/namespaces/table-pagination.js.map +1 -1
  94. package/dist/i18n/namespaces/toast.d.ts +3 -2
  95. package/dist/i18n/namespaces/toast.js.map +1 -1
  96. package/dist/index.d.ts +2 -1
  97. package/dist/lib/collect-item-elements.d.ts +47 -0
  98. package/dist/lib/collect-item-elements.js +22 -0
  99. package/dist/lib/collect-item-elements.js.map +1 -0
  100. package/dist/lib/text-search.d.ts +32 -0
  101. package/dist/lib/text-search.js +17 -0
  102. package/dist/lib/text-search.js.map +1 -0
  103. package/dist/lib/toast-language.d.ts +8 -0
  104. package/dist/lib/toast-language.js +19 -0
  105. package/dist/lib/toast-language.js.map +1 -0
  106. package/dist/shell/app-shell.d.ts +1 -0
  107. package/dist/shell/index.d.ts +2 -1
  108. package/dist/shell/shell-context.d.ts +1 -0
  109. package/dist/shell/utils.d.ts +2 -1
  110. package/package.json +1 -1
package/README.md CHANGED
@@ -195,7 +195,11 @@ import { ComitorLocaleProvider } from "@comitor/ui";
195
195
  còn lại → tiếng Anh) **và** quyết định định dạng: giờ, số, đồng hồ 12/24 giờ đi thẳng vào `Intl`.
196
196
  ⚠ Ngày định dạng bằng date-fns (`DatePicker`, `RelativeTime`, `Calendar`, `DataGrid`) chỉ tự ánh
197
197
  xạ `vi` → `vi`, mọi mã khác → `enUS` (`12/25/2024`); muốn kiểu Anh (`25/12/2024`) ở `en-GB` thì
198
- truyền thêm `dateFnsLocale={enGB}` cho provider.
198
+ truyền thêm `dateFnsLocale={enGB}` cho provider — ⚠ từ một component `"use client"` của app, KHÔNG
199
+ thẳng từ root layout: locale date-fns là object toàn HÀM, Server Component không truyền được sang
200
+ client, và trang lỗi 500 ("Functions cannot be passed directly to Client Components"). Cùng luật
201
+ cho `messages` có khoá là hàm (xem gạch đầu dòng về HÀM bên dưới). Mẫu dựng sẵn:
202
+ https://design-system.comitor.ai/get-started#language
199
203
  - Thứ tự ưu tiên cho MỌI chuỗi: prop của chính component (`labels`, `placeholder`, `clearLabel`…)
200
204
  → provider gần nhất → tiếng Anh. Kiểu của `labels` là `Partial<>` nên truyền một khoá không làm mất
201
205
  phần còn lại.
@@ -1,6 +1,6 @@
1
1
  "use client";
2
2
  import { jsx, jsxs } from "react/jsx-runtime";
3
- import { useState } from "react";
3
+ import { useRef, useState } from "react";
4
4
  import { useComitorMessages } from "../../i18n/locale-provider.js";
5
5
  import { cn } from "../../lib/cn.js";
6
6
  import {
@@ -38,6 +38,7 @@ function ConfirmDialog({
38
38
  const pendingLabel = pendingLabelProp ?? t.pendingLabel;
39
39
  const [internalOpen, setInternalOpen] = useState(false);
40
40
  const [pending, setPending] = useState(false);
41
+ const returnFocusRef = useRef(null);
41
42
  const isOpen = open ?? internalOpen;
42
43
  const setOpen = (next) => {
43
44
  if (pending && !next) return;
@@ -59,41 +60,61 @@ function ConfirmDialog({
59
60
  };
60
61
  return /* @__PURE__ */ jsxs(AlertDialog, { open: isOpen, onOpenChange: setOpen, children: [
61
62
  trigger && /* @__PURE__ */ jsx(AlertDialogTrigger, { asChild: true, children: trigger }),
62
- /* @__PURE__ */ jsxs(AlertDialogContent, { className, children: [
63
- /* @__PURE__ */ jsx(AlertDialogHeader, { children: /* @__PURE__ */ jsxs("div", { className: "flex items-start gap-3", children: [
64
- Icon && /* @__PURE__ */ jsx(
65
- "span",
66
- {
67
- "aria-hidden": "true",
68
- className: cn(
69
- "flex size-9 shrink-0 items-center justify-center rounded-md",
70
- variant === "destructive" ? "bg-destructive/15 text-destructive-ink" : "bg-muted text-muted-foreground"
63
+ /* @__PURE__ */ jsxs(
64
+ AlertDialogContent,
65
+ {
66
+ className,
67
+ onOpenAutoFocus: () => {
68
+ const active = document.activeElement;
69
+ returnFocusRef.current = active instanceof HTMLElement && active !== document.body ? active : null;
70
+ },
71
+ onCloseAutoFocus: (event) => {
72
+ const target = returnFocusRef.current;
73
+ returnFocusRef.current = null;
74
+ if (trigger) return;
75
+ const active = document.activeElement;
76
+ if (active && active !== document.body) return;
77
+ if (!target?.isConnected) return;
78
+ event.preventDefault();
79
+ target.focus({ preventScroll: true });
80
+ },
81
+ children: [
82
+ /* @__PURE__ */ jsx(AlertDialogHeader, { children: /* @__PURE__ */ jsxs("div", { className: "flex items-start gap-3", children: [
83
+ Icon && /* @__PURE__ */ jsx(
84
+ "span",
85
+ {
86
+ "aria-hidden": "true",
87
+ className: cn(
88
+ "flex size-9 shrink-0 items-center justify-center rounded-md",
89
+ variant === "destructive" ? "bg-destructive/15 text-destructive-ink" : "bg-muted text-muted-foreground"
90
+ ),
91
+ children: /* @__PURE__ */ jsx(Icon, { className: "size-5" })
92
+ }
71
93
  ),
72
- children: /* @__PURE__ */ jsx(Icon, { className: "size-5" })
73
- }
74
- ),
75
- /* @__PURE__ */ jsxs("div", { className: "min-w-0 space-y-1.5", children: [
76
- /* @__PURE__ */ jsx(AlertDialogTitle, { children: title }),
77
- description && /* @__PURE__ */ jsx(AlertDialogDescription, { children: description })
78
- ] })
79
- ] }) }),
80
- children,
81
- /* @__PURE__ */ jsxs(AlertDialogFooter, { children: [
82
- /* @__PURE__ */ jsx(AlertDialogCancel, { disabled: pending, children: cancelLabel }),
83
- /* @__PURE__ */ jsx(
84
- AlertDialogAction,
85
- {
86
- disabled: pending || confirmDisabled,
87
- onClick: (event) => {
88
- event.preventDefault();
89
- void handleConfirm();
90
- },
91
- className: cn(variant === "destructive" && buttonVariants({ variant: "destructive" })),
92
- children: pending ? pendingLabel : confirmLabel
93
- }
94
- )
95
- ] })
96
- ] })
94
+ /* @__PURE__ */ jsxs("div", { className: "min-w-0 space-y-1.5", children: [
95
+ /* @__PURE__ */ jsx(AlertDialogTitle, { children: title }),
96
+ description && /* @__PURE__ */ jsx(AlertDialogDescription, { children: description })
97
+ ] })
98
+ ] }) }),
99
+ children,
100
+ /* @__PURE__ */ jsxs(AlertDialogFooter, { children: [
101
+ /* @__PURE__ */ jsx(AlertDialogCancel, { disabled: pending, children: cancelLabel }),
102
+ /* @__PURE__ */ jsx(
103
+ AlertDialogAction,
104
+ {
105
+ disabled: pending || confirmDisabled,
106
+ onClick: (event) => {
107
+ event.preventDefault();
108
+ void handleConfirm();
109
+ },
110
+ className: cn(variant === "destructive" && buttonVariants({ variant: "destructive" })),
111
+ children: pending ? pendingLabel : confirmLabel
112
+ }
113
+ )
114
+ ] })
115
+ ]
116
+ }
117
+ )
97
118
  ] });
98
119
  }
99
120
  export {
@@ -1 +1 @@
1
- {"version":3,"sources":["../../../src/components/composite/confirm-dialog.tsx"],"sourcesContent":["\"use client\";\n\nimport { type ReactNode, useState } from \"react\";\nimport { useComitorMessages } from \"../../i18n/locale-provider.js\";\nimport { cn } from \"../../lib/cn.js\";\nimport {\n AlertDialog,\n AlertDialogAction,\n AlertDialogCancel,\n AlertDialogContent,\n AlertDialogDescription,\n AlertDialogFooter,\n AlertDialogHeader,\n AlertDialogTitle,\n AlertDialogTrigger\n} from \"../ui/alert-dialog.js\";\nimport { buttonVariants } from \"../ui/button.js\";\nimport type { IconComponent } from \"./empty-state.js\";\n\nexport interface ConfirmDialogProps {\n /** Controlled — bỏ trống thì dialog tự quản trạng thái mở (dùng kèm `trigger`) */\n open?: boolean;\n onOpenChange?: (open: boolean) => void;\n /** Phần tử mở dialog (đặt trong `AlertDialogTrigger asChild`) */\n trigger?: ReactNode;\n\n title: ReactNode;\n description?: ReactNode;\n /** Nội dung phụ (danh sách bản ghi sẽ bị ảnh hưởng, ô nhập lý do…) */\n children?: ReactNode;\n icon?: IconComponent;\n\n /*\n * Ba nhãn nút dưới đây: không truyền thì lấy namespace `confirmDialog` của\n * `ComitorLocaleProvider` (mặc định tiếng Anh). Truyền prop là đè riêng chỗ này.\n */\n confirmLabel?: string;\n cancelLabel?: string;\n /**\n * Nhãn nút xác nhận trong lúc `onConfirm` bất đồng bộ đang chạy (khoá `pendingLabel`).\n *\n * Phải đè được chứ không được chôn cứng: tài liệu khuyến khích `onConfirm` trả về Promise, nên\n * đây KHÔNG phải nhánh hiếm — app đặt `confirmLabel=\"Xoá dự án\"` mà quên chuỗi này sẽ thấy nút\n * nhảy sang câu chung chung của bảng đúng vào giây người dùng đang nhìn nó.\n */\n pendingLabel?: string;\n /** \"destructive\" tô nút xác nhận màu đỏ — dùng cho xoá/thu hồi */\n variant?: \"default\" | \"destructive\";\n /**\n * Hành động khi xác nhận. Trả về Promise thì dialog giữ nguyên và bật trạng thái đang chạy\n * cho tới khi Promise xong — đóng ngay rồi mới gọi API là cách chắc chắn nhất để người dùng\n * bấm xoá hai lần.\n */\n onConfirm: () => void | Promise<void>;\n /** Khoá nút xác nhận (vd chưa gõ đúng tên bản ghi cần xoá) */\n confirmDisabled?: boolean;\n /** Nhận lỗi từ `onConfirm` để app hiện toast; không truyền thì lỗi được ghi ra console */\n onError?: (error: unknown) => void;\n className?: string;\n}\n\n/**\n * Hộp thoại xác nhận cho hành động không hoàn tác được (xoá việc, gỡ thành viên, huỷ gói).\n *\n * Bọc `AlertDialog` để mọi app có cùng một cách hỏi lại; nút xác nhận tự vào trạng thái đang\n * chạy (chữ theo `pendingLabel`) khi `onConfirm` bất đồng bộ, và lỗi ném ra sẽ giữ dialog mở để\n * người dùng thử lại.\n */\nexport function ConfirmDialog({\n open,\n onOpenChange,\n trigger,\n title,\n description,\n children,\n icon: Icon,\n confirmLabel: confirmLabelProp,\n cancelLabel: cancelLabelProp,\n pendingLabel: pendingLabelProp,\n variant = \"default\",\n onConfirm,\n confirmDisabled = false,\n onError,\n className\n}: ConfirmDialogProps) {\n const t = useComitorMessages(\"confirmDialog\");\n const confirmLabel = confirmLabelProp ?? t.confirmLabel;\n const cancelLabel = cancelLabelProp ?? t.cancelLabel;\n const pendingLabel = pendingLabelProp ?? t.pendingLabel;\n const [internalOpen, setInternalOpen] = useState(false);\n const [pending, setPending] = useState(false);\n\n const isOpen = open ?? internalOpen;\n const setOpen = (next: boolean) => {\n if (pending && !next) return; // đang chạy thì không cho đóng ngang\n if (open === undefined) setInternalOpen(next);\n onOpenChange?.(next);\n };\n\n const handleConfirm = async () => {\n try {\n setPending(true);\n await onConfirm();\n setPending(false);\n if (open === undefined) setInternalOpen(false);\n onOpenChange?.(false);\n } catch (error) {\n // Giữ dialog mở để người dùng thấy toast lỗi của app và thử lại.\n // KHÔNG ném tiếp: lời gọi là `void handleConfirm()` trong handler nên ném ra chỉ tạo\n // unhandled rejection mà app không bắt được — lỗi đi ra ngoài qua `onError`.\n setPending(false);\n if (onError) onError(error);\n else console.error(\"[ConfirmDialog] onConfirm thất bại:\", error);\n }\n };\n\n return (\n <AlertDialog open={isOpen} onOpenChange={setOpen}>\n {trigger && <AlertDialogTrigger asChild>{trigger}</AlertDialogTrigger>}\n <AlertDialogContent className={className}>\n <AlertDialogHeader>\n <div className=\"flex items-start gap-3\">\n {Icon && (\n <span\n aria-hidden=\"true\"\n // Cùng khuôn với `statCardIconVariants`: nền tint `--x` + icon bậc `--x-ink`.\n // `text-destructive` trên `bg-destructive/15` chỉ 4,06:1 (light) / 3,14:1 (dark) —\n // qua ngưỡng 3:1 của icon nhưng hết biên an toàn. Ink: 5,76:1 / 5,71:1 ✓.\n className={cn(\n \"flex size-9 shrink-0 items-center justify-center rounded-md\",\n variant === \"destructive\"\n ? \"bg-destructive/15 text-destructive-ink\"\n : \"bg-muted text-muted-foreground\"\n )}\n >\n <Icon className=\"size-5\" />\n </span>\n )}\n <div className=\"min-w-0 space-y-1.5\">\n <AlertDialogTitle>{title}</AlertDialogTitle>\n {description && <AlertDialogDescription>{description}</AlertDialogDescription>}\n </div>\n </div>\n </AlertDialogHeader>\n\n {children}\n\n <AlertDialogFooter>\n <AlertDialogCancel disabled={pending}>{cancelLabel}</AlertDialogCancel>\n <AlertDialogAction\n disabled={pending || confirmDisabled}\n onClick={(event) => {\n // Chặn hành vi tự đóng của Radix: dialog chỉ đóng sau khi onConfirm chạy xong.\n event.preventDefault();\n void handleConfirm();\n }}\n className={cn(variant === \"destructive\" && buttonVariants({ variant: \"destructive\" }))}\n >\n {pending ? pendingLabel : confirmLabel}\n </AlertDialogAction>\n </AlertDialogFooter>\n </AlertDialogContent>\n </AlertDialog>\n );\n}\n"],"mappings":";AAsHkB,cAoBN,YApBM;AApHlB,SAAyB,gBAAgB;AACzC,SAAS,0BAA0B;AACnC,SAAS,UAAU;AACnB;AAAA,EACE;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,OACK;AACP,SAAS,sBAAsB;AAoDxB,SAAS,cAAc;AAAA,EAC5B;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,EACA,MAAM;AAAA,EACN,cAAc;AAAA,EACd,aAAa;AAAA,EACb,cAAc;AAAA,EACd,UAAU;AAAA,EACV;AAAA,EACA,kBAAkB;AAAA,EAClB;AAAA,EACA;AACF,GAAuB;AACrB,QAAM,IAAI,mBAAmB,eAAe;AAC5C,QAAM,eAAe,oBAAoB,EAAE;AAC3C,QAAM,cAAc,mBAAmB,EAAE;AACzC,QAAM,eAAe,oBAAoB,EAAE;AAC3C,QAAM,CAAC,cAAc,eAAe,IAAI,SAAS,KAAK;AACtD,QAAM,CAAC,SAAS,UAAU,IAAI,SAAS,KAAK;AAE5C,QAAM,SAAS,QAAQ;AACvB,QAAM,UAAU,CAAC,SAAkB;AACjC,QAAI,WAAW,CAAC,KAAM;AACtB,QAAI,SAAS,OAAW,iBAAgB,IAAI;AAC5C,mBAAe,IAAI;AAAA,EACrB;AAEA,QAAM,gBAAgB,YAAY;AAChC,QAAI;AACF,iBAAW,IAAI;AACf,YAAM,UAAU;AAChB,iBAAW,KAAK;AAChB,UAAI,SAAS,OAAW,iBAAgB,KAAK;AAC7C,qBAAe,KAAK;AAAA,IACtB,SAAS,OAAO;AAId,iBAAW,KAAK;AAChB,UAAI,QAAS,SAAQ,KAAK;AAAA,UACrB,SAAQ,MAAM,iDAAuC,KAAK;AAAA,IACjE;AAAA,EACF;AAEA,SACE,qBAAC,eAAY,MAAM,QAAQ,cAAc,SACtC;AAAA,eAAW,oBAAC,sBAAmB,SAAO,MAAE,mBAAQ;AAAA,IACjD,qBAAC,sBAAmB,WAClB;AAAA,0BAAC,qBACC,+BAAC,SAAI,WAAU,0BACZ;AAAA,gBACC;AAAA,UAAC;AAAA;AAAA,YACC,eAAY;AAAA,YAIZ,WAAW;AAAA,cACT;AAAA,cACA,YAAY,gBACR,2CACA;AAAA,YACN;AAAA,YAEA,8BAAC,QAAK,WAAU,UAAS;AAAA;AAAA,QAC3B;AAAA,QAEF,qBAAC,SAAI,WAAU,uBACb;AAAA,8BAAC,oBAAkB,iBAAM;AAAA,UACxB,eAAe,oBAAC,0BAAwB,uBAAY;AAAA,WACvD;AAAA,SACF,GACF;AAAA,MAEC;AAAA,MAED,qBAAC,qBACC;AAAA,4BAAC,qBAAkB,UAAU,SAAU,uBAAY;AAAA,QACnD;AAAA,UAAC;AAAA;AAAA,YACC,UAAU,WAAW;AAAA,YACrB,SAAS,CAAC,UAAU;AAElB,oBAAM,eAAe;AACrB,mBAAK,cAAc;AAAA,YACrB;AAAA,YACA,WAAW,GAAG,YAAY,iBAAiB,eAAe,EAAE,SAAS,cAAc,CAAC,CAAC;AAAA,YAEpF,oBAAU,eAAe;AAAA;AAAA,QAC5B;AAAA,SACF;AAAA,OACF;AAAA,KACF;AAEJ;","names":[]}
1
+ {"version":3,"sources":["../../../src/components/composite/confirm-dialog.tsx"],"sourcesContent":["\"use client\";\n\nimport { type ReactNode, useRef, useState } from \"react\";\nimport { useComitorMessages } from \"../../i18n/locale-provider.js\";\nimport { cn } from \"../../lib/cn.js\";\nimport {\n AlertDialog,\n AlertDialogAction,\n AlertDialogCancel,\n AlertDialogContent,\n AlertDialogDescription,\n AlertDialogFooter,\n AlertDialogHeader,\n AlertDialogTitle,\n AlertDialogTrigger\n} from \"../ui/alert-dialog.js\";\nimport { buttonVariants } from \"../ui/button.js\";\nimport type { IconComponent } from \"./empty-state.js\";\n\nexport interface ConfirmDialogProps {\n /** Controlled — bỏ trống thì dialog tự quản trạng thái mở (dùng kèm `trigger`) */\n open?: boolean;\n onOpenChange?: (open: boolean) => void;\n /** Phần tử mở dialog (đặt trong `AlertDialogTrigger asChild`) */\n trigger?: ReactNode;\n\n title: ReactNode;\n description?: ReactNode;\n /** Nội dung phụ (danh sách bản ghi sẽ bị ảnh hưởng, ô nhập lý do…) */\n children?: ReactNode;\n icon?: IconComponent;\n\n /*\n * Ba nhãn nút dưới đây: không truyền thì lấy namespace `confirmDialog` của\n * `ComitorLocaleProvider` (mặc định tiếng Anh). Truyền prop là đè riêng chỗ này.\n */\n confirmLabel?: string;\n cancelLabel?: string;\n /**\n * Nhãn nút xác nhận trong lúc `onConfirm` bất đồng bộ đang chạy (khoá `pendingLabel`).\n *\n * Phải đè được chứ không được chôn cứng: tài liệu khuyến khích `onConfirm` trả về Promise, nên\n * đây KHÔNG phải nhánh hiếm — app đặt `confirmLabel=\"Xoá dự án\"` mà quên chuỗi này sẽ thấy nút\n * nhảy sang câu chung chung của bảng đúng vào giây người dùng đang nhìn nó.\n */\n pendingLabel?: string;\n /** \"destructive\" tô nút xác nhận màu đỏ — dùng cho xoá/thu hồi */\n variant?: \"default\" | \"destructive\";\n /**\n * Hành động khi xác nhận. Trả về Promise thì dialog giữ nguyên và bật trạng thái đang chạy\n * cho tới khi Promise xong — đóng ngay rồi mới gọi API là cách chắc chắn nhất để người dùng\n * bấm xoá hai lần.\n */\n onConfirm: () => void | Promise<void>;\n /** Khoá nút xác nhận (vd chưa gõ đúng tên bản ghi cần xoá) */\n confirmDisabled?: boolean;\n /** Nhận lỗi từ `onConfirm` để app hiện toast; không truyền thì lỗi được ghi ra console */\n onError?: (error: unknown) => void;\n className?: string;\n}\n\n/**\n * Hộp thoại xác nhận cho hành động không hoàn tác được (xoá việc, gỡ thành viên, huỷ gói).\n *\n * Bọc `AlertDialog` để mọi app có cùng một cách hỏi lại; nút xác nhận tự vào trạng thái đang\n * chạy (chữ theo `pendingLabel`) khi `onConfirm` bất đồng bộ, và lỗi ném ra sẽ giữ dialog mở để\n * người dùng thử lại.\n */\nexport function ConfirmDialog({\n open,\n onOpenChange,\n trigger,\n title,\n description,\n children,\n icon: Icon,\n confirmLabel: confirmLabelProp,\n cancelLabel: cancelLabelProp,\n pendingLabel: pendingLabelProp,\n variant = \"default\",\n onConfirm,\n confirmDisabled = false,\n onError,\n className\n}: ConfirmDialogProps) {\n const t = useComitorMessages(\"confirmDialog\");\n const confirmLabel = confirmLabelProp ?? t.confirmLabel;\n const cancelLabel = cancelLabelProp ?? t.cancelLabel;\n const pendingLabel = pendingLabelProp ?? t.pendingLabel;\n const [internalOpen, setInternalOpen] = useState(false);\n const [pending, setPending] = useState(false);\n /*\n * ⚠ Trả focus khi đóng (3.1.0). Radix chỉ trả focus về `AlertDialogTrigger` — thiếu trigger thì\n * `onCloseAutoFocus` của nó chặn mặc định rồi focus vào `null`. Mà dialog mở bằng `open` từ một\n * `ActionBar`, một menu hay phím tắt lưới là cách ConfirmDialog được dùng nhiều nhất: đóng xong\n * (Huỷ, Escape, xác nhận) là focus rơi xuống `<body>` (WCAG 2.4.3), dù nút đã mở nó vẫn còn đó.\n * Đo trên comitor-ds: Enter trên \"Refund\" của thanh thao tác, Escape → `document.activeElement`\n * là `<body>`. Nay ghi lại phần tử giữ focus ngay trước khi dialog lấy focus và trả về đó — chỉ\n * khi focus thật sự đã mất: app tự đưa focus đi thì app thắng; phần tử kia đã bị gỡ (dòng vừa\n * xoá, thanh đã đóng theo lựa chọn) thì gói không đoán. Có `trigger` thì để Radix lo như cũ.\n */\n const returnFocusRef = useRef<HTMLElement | null>(null);\n\n const isOpen = open ?? internalOpen;\n const setOpen = (next: boolean) => {\n if (pending && !next) return; // đang chạy thì không cho đóng ngang\n if (open === undefined) setInternalOpen(next);\n onOpenChange?.(next);\n };\n\n const handleConfirm = async () => {\n try {\n setPending(true);\n await onConfirm();\n setPending(false);\n if (open === undefined) setInternalOpen(false);\n onOpenChange?.(false);\n } catch (error) {\n // Giữ dialog mở để người dùng thấy toast lỗi của app và thử lại.\n // KHÔNG ném tiếp: lời gọi là `void handleConfirm()` trong handler nên ném ra chỉ tạo\n // unhandled rejection mà app không bắt được — lỗi đi ra ngoài qua `onError`.\n setPending(false);\n if (onError) onError(error);\n else console.error(\"[ConfirmDialog] onConfirm thất bại:\", error);\n }\n };\n\n return (\n <AlertDialog open={isOpen} onOpenChange={setOpen}>\n {trigger && <AlertDialogTrigger asChild>{trigger}</AlertDialogTrigger>}\n <AlertDialogContent\n className={className}\n onOpenAutoFocus={() => {\n // Chạy TRƯỚC khi Radix đưa focus vào dialog: phần tử đang focus vẫn là nơi người dùng đứng.\n const active = document.activeElement;\n returnFocusRef.current = active instanceof HTMLElement && active !== document.body ? active : null;\n }}\n onCloseAutoFocus={(event) => {\n const target = returnFocusRef.current;\n returnFocusRef.current = null;\n if (trigger) return;\n const active = document.activeElement;\n if (active && active !== document.body) return;\n if (!target?.isConnected) return;\n event.preventDefault();\n target.focus({ preventScroll: true });\n }}\n >\n <AlertDialogHeader>\n <div className=\"flex items-start gap-3\">\n {Icon && (\n <span\n aria-hidden=\"true\"\n // Cùng khuôn với `statCardIconVariants`: nền tint `--x` + icon bậc `--x-ink`.\n // `text-destructive` trên `bg-destructive/15` chỉ 4,06:1 (light) / 3,14:1 (dark) —\n // qua ngưỡng 3:1 của icon nhưng hết biên an toàn. Ink: 5,76:1 / 5,71:1 ✓.\n className={cn(\n \"flex size-9 shrink-0 items-center justify-center rounded-md\",\n variant === \"destructive\"\n ? \"bg-destructive/15 text-destructive-ink\"\n : \"bg-muted text-muted-foreground\"\n )}\n >\n <Icon className=\"size-5\" />\n </span>\n )}\n <div className=\"min-w-0 space-y-1.5\">\n <AlertDialogTitle>{title}</AlertDialogTitle>\n {description && <AlertDialogDescription>{description}</AlertDialogDescription>}\n </div>\n </div>\n </AlertDialogHeader>\n\n {children}\n\n <AlertDialogFooter>\n <AlertDialogCancel disabled={pending}>{cancelLabel}</AlertDialogCancel>\n <AlertDialogAction\n disabled={pending || confirmDisabled}\n onClick={(event) => {\n // Chặn hành vi tự đóng của Radix: dialog chỉ đóng sau khi onConfirm chạy xong.\n event.preventDefault();\n void handleConfirm();\n }}\n className={cn(variant === \"destructive\" && buttonVariants({ variant: \"destructive\" }))}\n >\n {pending ? pendingLabel : confirmLabel}\n </AlertDialogAction>\n </AlertDialogFooter>\n </AlertDialogContent>\n </AlertDialog>\n );\n}\n"],"mappings":"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","names":[]}
@@ -25,6 +25,9 @@ import { ReactNode } from 'react';
25
25
  * `searchPlaceholder` và `unitLabel` vốn đã là prop rời và vẫn được giữ (prop thắng `labels`);
26
26
  * các khoá còn lại trước đây chôn cứng, trong đó có `sr-only` và nội dung nút mà KHÔNG prop nào
27
27
  * chạm tới — ghép thanh này với `DataTable` đã dịch sẽ ra giao diện nửa nọ nửa kia.
28
+ *
29
+ * Số tới `filterCount` / `resultSummary` ở dạng THÔ (`12345`); định dạng là việc của BẢNG — hai bảng
30
+ * dựng sẵn in `12,345` (Anh) / `12.345` (Việt) từ `3.1.0`, bảng app tự viết thì tự định dạng.
28
31
  */
29
32
  interface SearchFilterBarLabels {
30
33
  /** Placeholder + `aria-label` của ô tìm */
@@ -1 +1 @@
1
- {"version":3,"sources":["../../../src/components/composite/search-filter-bar-constants.tsx"],"sourcesContent":["/**\n * Nhãn mặc định của `SearchFilterBar`.\n *\n * Đuôi `.tsx` là di sản: JSX của `resultSummary` nay nằm ở bảng chữ\n * (`src/i18n/namespaces/search-filter-bar.tsx`). Giữ đuôi cũ vì đổi không mua được gì — đường dẫn\n * đã build (`.js`) và mọi `import` đều như nhau.\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. `search-filter-bar.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 type { ReactNode } from \"react\";\nimport { searchFilterBarEn } from \"../../i18n/namespaces/search-filter-bar.js\";\n\n/**\n * Chuỗi hiển thị + chuỗi a11y của `SearchFilterBar` — cùng khuôn `DataTableLabels`. Theo\n * `ComitorLocaleProvider` (namespace `searchFilterBar`, mặc định tiếng Anh); override từng khoá\n * qua prop `labels`.\n *\n * `searchPlaceholder` và `unitLabel` vốn đã là prop rời và vẫn được giữ (prop thắng `labels`);\n * các khoá còn lại trước đây chôn cứng, trong đó có `sr-only` và nội dung nút mà KHÔNG prop nào\n * chạm tới — ghép thanh này với `DataTable` đã dịch sẽ ra giao diện nửa nọ nửa kia.\n */\nexport interface SearchFilterBarLabels {\n /** Placeholder + `aria-label` của ô tìm */\n searchPlaceholder: string;\n /**\n * Đơn vị đếm mặc định (\"items\" / \"mục\") — prop `unitLabel` vẫn ghi đè được riêng.\n *\n * Cùng lý do như `TablePaginationLabels.unitLabel`: mặc định là chuỗi theo NGÔN NGỮ nên\n * phải override được qua `labels`, còn prop `unitLabel` là chuỗi theo NỘI DUNG của từng\n * bảng (\"tasks\", \"contacts\") nên vẫn là prop rời và vẫn thắng `labels`.\n */\n unitLabel: string;\n /** `sr-only` của nút xoá từ khoá trong ô tìm */\n clearSearch: string;\n /** Huy hiệu đếm bộ lọc đang bật: \"3 filters\" */\n filterCount: (count: number) => string;\n /** Nhãn nút xoá toàn bộ bộ lọc */\n clearFilters: string;\n /**\n * Dòng đếm kết quả sau lọc: \"**3** of 57 items\" (`total` bỏ trống thì chỉ \"**3** items\").\n *\n * Trả `ReactNode` chứ không phải chuỗi vì con số cần thẻ riêng (`tabular-nums` để số không\n * nhảy cột, `text-foreground` để nổi trên nền chữ mờ) — ép về chuỗi là mất phần định dạng đó.\n * Là hàm vì trật tự số/đơn vị không giống nhau giữa các ngôn ngữ.\n */\n resultSummary: (resultCount: number, total: number | undefined, unitLabel: string) => ReactNode;\n}\n\n/**\n * Nhãn TIẾNG ANH mặc định (từ `3.0.0`; trước đó là tiếng Việt). Bảng tiếng Việt:\n * `comitorMessagesVi.searchFilterBar`. Component không đọc thẳng hằng này — nó đọc bảng của\n * `ComitorLocaleProvider`; hằng ở đây để app trải ra khi tự dựng nhãn.\n */\nexport const DEFAULT_SEARCH_FILTER_BAR_LABELS: SearchFilterBarLabels = { ...searchFilterBarEn };\n"],"mappings":"AAiBA,SAAS,yBAAyB;AA2C3B,MAAM,mCAA0D,EAAE,GAAG,kBAAkB;","names":[]}
1
+ {"version":3,"sources":["../../../src/components/composite/search-filter-bar-constants.tsx"],"sourcesContent":["/**\n * Nhãn mặc định của `SearchFilterBar`.\n *\n * Đuôi `.tsx` là di sản: JSX của `resultSummary` nay nằm ở bảng chữ\n * (`src/i18n/namespaces/search-filter-bar.tsx`). Giữ đuôi cũ vì đổi không mua được gì — đường dẫn\n * đã build (`.js`) và mọi `import` đều như nhau.\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. `search-filter-bar.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 type { ReactNode } from \"react\";\nimport { searchFilterBarEn } from \"../../i18n/namespaces/search-filter-bar.js\";\n\n/**\n * Chuỗi hiển thị + chuỗi a11y của `SearchFilterBar` — cùng khuôn `DataTableLabels`. Theo\n * `ComitorLocaleProvider` (namespace `searchFilterBar`, mặc định tiếng Anh); override từng khoá\n * qua prop `labels`.\n *\n * `searchPlaceholder` và `unitLabel` vốn đã là prop rời và vẫn được giữ (prop thắng `labels`);\n * các khoá còn lại trước đây chôn cứng, trong đó có `sr-only` và nội dung nút mà KHÔNG prop nào\n * chạm tới — ghép thanh này với `DataTable` đã dịch sẽ ra giao diện nửa nọ nửa kia.\n *\n * Số tới `filterCount` / `resultSummary` ở dạng THÔ (`12345`); định dạng là việc của BẢNG — hai bảng\n * dựng sẵn in `12,345` (Anh) / `12.345` (Việt) từ `3.1.0`, bảng app tự viết thì tự định dạng.\n */\nexport interface SearchFilterBarLabels {\n /** Placeholder + `aria-label` của ô tìm */\n searchPlaceholder: string;\n /**\n * Đơn vị đếm mặc định (\"items\" / \"mục\") — prop `unitLabel` vẫn ghi đè được riêng.\n *\n * Cùng lý do như `TablePaginationLabels.unitLabel`: mặc định là chuỗi theo NGÔN NGỮ nên\n * phải override được qua `labels`, còn prop `unitLabel` là chuỗi theo NỘI DUNG của từng\n * bảng (\"tasks\", \"contacts\") nên vẫn là prop rời và vẫn thắng `labels`.\n */\n unitLabel: string;\n /** `sr-only` của nút xoá từ khoá trong ô tìm */\n clearSearch: string;\n /** Huy hiệu đếm bộ lọc đang bật: \"3 filters\" */\n filterCount: (count: number) => string;\n /** Nhãn nút xoá toàn bộ bộ lọc */\n clearFilters: string;\n /**\n * Dòng đếm kết quả sau lọc: \"**3** of 57 items\" (`total` bỏ trống thì chỉ \"**3** items\").\n *\n * Trả `ReactNode` chứ không phải chuỗi vì con số cần thẻ riêng (`tabular-nums` để số không\n * nhảy cột, `text-foreground` để nổi trên nền chữ mờ) — ép về chuỗi là mất phần định dạng đó.\n * Là hàm vì trật tự số/đơn vị không giống nhau giữa các ngôn ngữ.\n */\n resultSummary: (resultCount: number, total: number | undefined, unitLabel: string) => ReactNode;\n}\n\n/**\n * Nhãn TIẾNG ANH mặc định (từ `3.0.0`; trước đó là tiếng Việt). Bảng tiếng Việt:\n * `comitorMessagesVi.searchFilterBar`. Component không đọc thẳng hằng này — nó đọc bảng của\n * `ComitorLocaleProvider`; hằng ở đây để app trải ra khi tự dựng nhãn.\n */\nexport const DEFAULT_SEARCH_FILTER_BAR_LABELS: SearchFilterBarLabels = { ...searchFilterBarEn };\n"],"mappings":"AAiBA,SAAS,yBAAyB;AA8C3B,MAAM,mCAA0D,EAAE,GAAG,kBAAkB;","names":[]}
@@ -23,7 +23,9 @@ declare const DEFAULT_PAGE_SIZE_OPTIONS: readonly [20, 50, 100];
23
23
  * (đã dịch) kèm `TablePagination` (chưa) sẽ ra một cái bảng dịch NỬA VỜI.
24
24
  *
25
25
  * Khoá nào có số/đơn vị chèn vào thì là HÀM: nối chuỗi bằng dấu cộng ở phía app là cách chắc
26
- * chắn sai trật tự từ với các ngôn ngữ khác.
26
+ * chắn sai trật tự từ với các ngôn ngữ khác. Số tới hàm ở dạng THÔ (`12345`); định dạng là việc của
27
+ * BẢNG — hai bảng dựng sẵn in `12,345` (Anh) / `12.345` (Việt) từ `3.1.0`, bảng app tự viết thì tự
28
+ * định dạng (vd `n.toLocaleString("de-DE")`).
27
29
  */
28
30
  interface TablePaginationLabels {
29
31
  /** `aria-label` của vùng phân trang (`<nav>`) — prop `label` vẫn ghi đè được riêng */
@@ -1 +1 @@
1
- {"version":3,"sources":["../../../src/components/composite/table-pagination-constants.ts"],"sourcesContent":["/**\n * Cỡ trang và nhãn mặc định của `TablePagination`.\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. `table-pagination.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 */\n\nimport { tablePaginationEn } from \"../../i18n/namespaces/table-pagination.js\";\n\n/** Cỡ trang mặc định — chốt ở 3 mức để mọi bảng trong hệ sinh thái giống nhau. */\nexport const DEFAULT_PAGE_SIZE_OPTIONS = [20, 50, 100] as const;\n\n/**\n * Toàn bộ chuỗi hiển thị và chuỗi a11y của `TablePagination` — cùng khuôn `DataTableLabels`.\n * Theo `ComitorLocaleProvider` (namespace `tablePagination`, mặc định tiếng Anh); override từng\n * khoá qua prop `labels`.\n *\n * Vì sao phải có, dù component đã nhận `label`/`unitLabel` qua prop rời: 4 nhãn `sr-only` của\n * cụm nút (trang đầu/trước/sau/cuối), `aria-label` của ô chọn cỡ trang và hai dòng đếm\n * (`range`, `pageStatus`) KHÔNG có prop nào chạm tới được, nên một bảng dùng `DataTable`\n * (đã dịch) kèm `TablePagination` (chưa) sẽ ra một cái bảng dịch NỬA VỜI.\n *\n * Khoá nào có số/đơn vị chèn vào thì là HÀM: nối chuỗi bằng dấu cộng ở phía app là cách chắc\n * chắn sai trật tự từ với các ngôn ngữ khác.\n */\nexport interface TablePaginationLabels {\n /** `aria-label` của vùng phân trang (`<nav>`) — prop `label` vẫn ghi đè được riêng */\n region: string;\n /**\n * Đơn vị đếm mặc định (\"items\" / \"mục\") — prop `unitLabel` vẫn ghi đè được riêng.\n *\n * Nằm trong `labels` vì đây là chuỗi theo NGÔN NGỮ: app override cả bộ `labels` mà đơn vị vẫn\n * kẹt ở ngôn ngữ mặc định thì đúng là cái giao diện nửa vời mà cơ chế này sinh ra để chặn. Còn\n * prop `unitLabel` là chuỗi theo NỘI DUNG (\"tasks\", \"contacts\") — mỗi bảng một khác, nên vẫn\n * là prop rời và vẫn thắng `labels`, đúng cặp `label`/`labels.region` ở trên.\n */\n unitLabel: string;\n /** Chữ đứng trước ô chọn cỡ trang (ẩn ở màn hình hẹp) */\n pageSizePrefix: string;\n /** `aria-label` của ô chọn cỡ trang */\n pageSizeSelect: (unitLabel: string) => string;\n /** Dòng đếm bản ghi: \"1–20 of 57 items\" */\n range: (start: number, end: number, total: number, unitLabel: string) => string;\n /** Dòng đếm trang: \"Page 2 of 5\" */\n pageStatus: (page: number, pageCount: number) => string;\n /** `sr-only` của 4 nút điều hướng */\n firstPage: string;\n previousPage: string;\n nextPage: string;\n lastPage: string;\n}\n\n/**\n * Nhãn TIẾNG ANH mặc định (từ `3.0.0`; trước đó là tiếng Việt). Bảng tiếng Việt:\n * `comitorMessagesVi.tablePagination`. Component không đọc thẳng hằng này — nó đọc bảng của\n * `ComitorLocaleProvider`; hằng ở đây để app trải ra khi tự dựng nhãn.\n */\nexport const DEFAULT_TABLE_PAGINATION_LABELS: TablePaginationLabels = { ...tablePaginationEn };\n"],"mappings":"AAaA,SAAS,yBAAyB;AAG3B,MAAM,4BAA4B,CAAC,IAAI,IAAI,GAAG;AA+C9C,MAAM,kCAAyD,EAAE,GAAG,kBAAkB;","names":[]}
1
+ {"version":3,"sources":["../../../src/components/composite/table-pagination-constants.ts"],"sourcesContent":["/**\n * Cỡ trang và nhãn mặc định của `TablePagination`.\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. `table-pagination.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 */\n\nimport { tablePaginationEn } from \"../../i18n/namespaces/table-pagination.js\";\n\n/** Cỡ trang mặc định — chốt ở 3 mức để mọi bảng trong hệ sinh thái giống nhau. */\nexport const DEFAULT_PAGE_SIZE_OPTIONS = [20, 50, 100] as const;\n\n/**\n * Toàn bộ chuỗi hiển thị và chuỗi a11y của `TablePagination` — cùng khuôn `DataTableLabels`.\n * Theo `ComitorLocaleProvider` (namespace `tablePagination`, mặc định tiếng Anh); override từng\n * khoá qua prop `labels`.\n *\n * Vì sao phải có, dù component đã nhận `label`/`unitLabel` qua prop rời: 4 nhãn `sr-only` của\n * cụm nút (trang đầu/trước/sau/cuối), `aria-label` của ô chọn cỡ trang và hai dòng đếm\n * (`range`, `pageStatus`) KHÔNG có prop nào chạm tới được, nên một bảng dùng `DataTable`\n * (đã dịch) kèm `TablePagination` (chưa) sẽ ra một cái bảng dịch NỬA VỜI.\n *\n * Khoá nào có số/đơn vị chèn vào thì là HÀM: nối chuỗi bằng dấu cộng ở phía app là cách chắc\n * chắn sai trật tự từ với các ngôn ngữ khác. Số tới hàm ở dạng THÔ (`12345`); định dạng là việc của\n * BẢNG — hai bảng dựng sẵn in `12,345` (Anh) / `12.345` (Việt) từ `3.1.0`, bảng app tự viết thì tự\n * định dạng (vd `n.toLocaleString(\"de-DE\")`).\n */\nexport interface TablePaginationLabels {\n /** `aria-label` của vùng phân trang (`<nav>`) — prop `label` vẫn ghi đè được riêng */\n region: string;\n /**\n * Đơn vị đếm mặc định (\"items\" / \"mục\") — prop `unitLabel` vẫn ghi đè được riêng.\n *\n * Nằm trong `labels` vì đây là chuỗi theo NGÔN NGỮ: app override cả bộ `labels` mà đơn vị vẫn\n * kẹt ở ngôn ngữ mặc định thì đúng là cái giao diện nửa vời mà cơ chế này sinh ra để chặn. Còn\n * prop `unitLabel` là chuỗi theo NỘI DUNG (\"tasks\", \"contacts\") — mỗi bảng một khác, nên vẫn\n * là prop rời và vẫn thắng `labels`, đúng cặp `label`/`labels.region` ở trên.\n */\n unitLabel: string;\n /** Chữ đứng trước ô chọn cỡ trang (ẩn ở màn hình hẹp) */\n pageSizePrefix: string;\n /** `aria-label` của ô chọn cỡ trang */\n pageSizeSelect: (unitLabel: string) => string;\n /** Dòng đếm bản ghi: \"1–20 of 57 items\" */\n range: (start: number, end: number, total: number, unitLabel: string) => string;\n /** Dòng đếm trang: \"Page 2 of 5\" */\n pageStatus: (page: number, pageCount: number) => string;\n /** `sr-only` của 4 nút điều hướng */\n firstPage: string;\n previousPage: string;\n nextPage: string;\n lastPage: string;\n}\n\n/**\n * Nhãn TIẾNG ANH mặc định (từ `3.0.0`; trước đó là tiếng Việt). Bảng tiếng Việt:\n * `comitorMessagesVi.tablePagination`. Component không đọc thẳng hằng này — nó đọc bảng của\n * `ComitorLocaleProvider`; hằng ở đây để app trải ra khi tự dựng nhãn.\n */\nexport const DEFAULT_TABLE_PAGINATION_LABELS: TablePaginationLabels = { ...tablePaginationEn };\n"],"mappings":"AAaA,SAAS,yBAAyB;AAG3B,MAAM,4BAA4B,CAAC,IAAI,IAAI,GAAG;AAiD9C,MAAM,kCAAyD,EAAE,GAAG,kBAAkB;","names":[]}
@@ -1,11 +1,18 @@
1
1
  "use client";
2
2
  import { Fragment, jsx, jsxs } from "react/jsx-runtime";
3
3
  import { ChevronLeft, ChevronRight, ChevronsLeft, ChevronsRight } from "lucide-react";
4
- import { useComitorLabels } from "../../i18n/locale-provider.js";
4
+ import { useComitorLabels, useComitorLocale } from "../../i18n/locale-provider.js";
5
5
  import { cn } from "../../lib/cn.js";
6
6
  import { Button } from "../ui/button.js";
7
7
  import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "../ui/select.js";
8
8
  import { DEFAULT_PAGE_SIZE_OPTIONS } from "./table-pagination-constants.js";
9
+ function formatNumber(value, locale) {
10
+ try {
11
+ return value.toLocaleString(locale);
12
+ } catch {
13
+ return String(value);
14
+ }
15
+ }
9
16
  function TablePagination({
10
17
  page,
11
18
  pageSize,
@@ -20,6 +27,7 @@ function TablePagination({
20
27
  className
21
28
  }) {
22
29
  const labels = useComitorLabels("tablePagination", labelsProp);
30
+ const { locale } = useComitorLocale();
23
31
  const unitLabel = unitLabelProp ?? labels.unitLabel;
24
32
  const pageCount = Math.max(1, Math.ceil(total / Math.max(1, pageSize)));
25
33
  const current = Math.min(Math.max(1, page), pageCount);
@@ -39,7 +47,7 @@ function TablePagination({
39
47
  /* @__PURE__ */ jsx("span", { className: "hidden sm:inline", children: labels.pageSizePrefix }),
40
48
  /* @__PURE__ */ jsxs(Select, { value: String(pageSize), onValueChange: (value) => onPageSizeChange(Number(value)), children: [
41
49
  /* @__PURE__ */ jsx(SelectTrigger, { className: "h-8 w-[4.5rem]", "aria-label": labels.pageSizeSelect(unitLabel), children: /* @__PURE__ */ jsx(SelectValue, {}) }),
42
- /* @__PURE__ */ jsx(SelectContent, { children: pageSizeOptions.map((size) => /* @__PURE__ */ jsx(SelectItem, { value: String(size), children: size }, size)) })
50
+ /* @__PURE__ */ jsx(SelectContent, { children: pageSizeOptions.map((size) => /* @__PURE__ */ jsx(SelectItem, { value: String(size), children: formatNumber(size, locale) }, size)) })
43
51
  ] })
44
52
  ] }),
45
53
  /* @__PURE__ */ jsx("span", { role: "status", className: "tabular-nums", children: labels.range(start, end, total, unitLabel) })
@@ -1 +1 @@
1
- {"version":3,"sources":["../../../src/components/composite/table-pagination.tsx"],"sourcesContent":["\"use client\";\n\nimport { ChevronLeft, ChevronRight, ChevronsLeft, ChevronsRight } from \"lucide-react\";\nimport { useComitorLabels } from \"../../i18n/locale-provider.js\";\nimport { cn } from \"../../lib/cn.js\";\nimport { Button } from \"../ui/button.js\";\nimport { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from \"../ui/select.js\";\nimport { DEFAULT_PAGE_SIZE_OPTIONS, type TablePaginationLabels } from \"./table-pagination-constants.js\";\n\nexport interface TablePaginationProps {\n /** Trang hiện tại, đếm từ 1 */\n page: number;\n pageSize: number;\n /** Tổng số bản ghi (sau khi lọc) */\n total: number;\n pageSizeOptions?: readonly number[];\n onPageChange: (page: number) => void;\n onPageSizeChange: (pageSize: number) => void;\n /** Đơn vị đếm theo nội dung bảng, vd \"tasks\", \"contacts\" — mặc định `labels.unitLabel` (\"items\") */\n unitLabel?: string;\n /** Ẩn ô chọn số dòng/trang (khi cỡ trang do hệ thống quyết định) */\n hidePageSize?: boolean;\n /** Nhãn vùng phân trang — cần khi một trang có nhiều bảng; mặc định `labels.region` */\n label?: string;\n /**\n * Ghi đè chuỗi hiển thị/a11y — không truyền thì theo namespace `tablePagination` của\n * `ComitorLocaleProvider` (mặc định tiếng Anh). Xem `TablePaginationLabels`.\n */\n labels?: Partial<TablePaginationLabels>;\n className?: string;\n}\n\n/**\n * Thanh phân trang đi kèm `DataTable`.\n *\n * Tổng số trang tính từ `total`/`pageSize` ngay tại đây thay vì bắt app truyền vào — hai bên\n * tự tính rất dễ lệch nhau một trang ở bản ghi cuối, và người dùng sẽ thấy nút sang trang sau\n * còn bấm được trong khi bảng đã hết dữ liệu.\n */\nexport function TablePagination({\n page,\n pageSize,\n total,\n pageSizeOptions = DEFAULT_PAGE_SIZE_OPTIONS,\n onPageChange,\n onPageSizeChange,\n unitLabel: unitLabelProp,\n hidePageSize = false,\n label,\n labels: labelsProp,\n className\n}: TablePaginationProps) {\n const labels = useComitorLabels(\"tablePagination\", labelsProp);\n const unitLabel = unitLabelProp ?? labels.unitLabel;\n const pageCount = Math.max(1, Math.ceil(total / Math.max(1, pageSize)));\n const current = Math.min(Math.max(1, page), pageCount);\n const start = total === 0 ? 0 : (current - 1) * pageSize + 1;\n const end = Math.min(current * pageSize, total);\n const isFirst = current <= 1;\n const isLast = current >= pageCount;\n\n return (\n <nav\n aria-label={label ?? labels.region}\n data-slot=\"table-pagination\"\n className={cn(\"flex flex-wrap items-center justify-between gap-3 py-2 text-sm\", className)}\n >\n <div className=\"flex items-center gap-2 text-muted-foreground\">\n {!hidePageSize && (\n <>\n <span className=\"hidden sm:inline\">{labels.pageSizePrefix}</span>\n <Select value={String(pageSize)} onValueChange={(value) => onPageSizeChange(Number(value))}>\n <SelectTrigger className=\"h-8 w-[4.5rem]\" aria-label={labels.pageSizeSelect(unitLabel)}>\n <SelectValue />\n </SelectTrigger>\n <SelectContent>\n {pageSizeOptions.map((size) => (\n <SelectItem key={size} value={String(size)}>\n {size}\n </SelectItem>\n ))}\n </SelectContent>\n </Select>\n </>\n )}\n <span role=\"status\" className=\"tabular-nums\">\n {labels.range(start, end, total, unitLabel)}\n </span>\n </div>\n\n <div className=\"flex items-center gap-1\">\n <Button\n variant=\"outline\"\n size=\"icon-sm\"\n disabled={isFirst}\n onClick={() => onPageChange(1)}\n className=\"hidden sm:inline-flex\"\n >\n <ChevronsLeft aria-hidden=\"true\" className=\"size-4\" />\n <span className=\"sr-only\">{labels.firstPage}</span>\n </Button>\n <Button variant=\"outline\" size=\"icon-sm\" disabled={isFirst} onClick={() => onPageChange(current - 1)}>\n <ChevronLeft aria-hidden=\"true\" className=\"size-4\" />\n <span className=\"sr-only\">{labels.previousPage}</span>\n </Button>\n\n <span role=\"status\" className=\"px-2 tabular-nums text-muted-foreground\">\n {labels.pageStatus(current, pageCount)}\n </span>\n\n <Button variant=\"outline\" size=\"icon-sm\" disabled={isLast} onClick={() => onPageChange(current + 1)}>\n <ChevronRight aria-hidden=\"true\" className=\"size-4\" />\n <span className=\"sr-only\">{labels.nextPage}</span>\n </Button>\n <Button\n variant=\"outline\"\n size=\"icon-sm\"\n disabled={isLast}\n onClick={() => onPageChange(pageCount)}\n className=\"hidden sm:inline-flex\"\n >\n <ChevronsRight aria-hidden=\"true\" className=\"size-4\" />\n <span className=\"sr-only\">{labels.lastPage}</span>\n </Button>\n </div>\n </nav>\n );\n}\n"],"mappings":";AAqEU,mBACE,KACA,YAFF;AAnEV,SAAS,aAAa,cAAc,cAAc,qBAAqB;AACvE,SAAS,wBAAwB;AACjC,SAAS,UAAU;AACnB,SAAS,cAAc;AACvB,SAAS,QAAQ,eAAe,YAAY,eAAe,mBAAmB;AAC9E,SAAS,iCAA6D;AAgC/D,SAAS,gBAAgB;AAAA,EAC9B;AAAA,EACA;AAAA,EACA;AAAA,EACA,kBAAkB;AAAA,EAClB;AAAA,EACA;AAAA,EACA,WAAW;AAAA,EACX,eAAe;AAAA,EACf;AAAA,EACA,QAAQ;AAAA,EACR;AACF,GAAyB;AACvB,QAAM,SAAS,iBAAiB,mBAAmB,UAAU;AAC7D,QAAM,YAAY,iBAAiB,OAAO;AAC1C,QAAM,YAAY,KAAK,IAAI,GAAG,KAAK,KAAK,QAAQ,KAAK,IAAI,GAAG,QAAQ,CAAC,CAAC;AACtE,QAAM,UAAU,KAAK,IAAI,KAAK,IAAI,GAAG,IAAI,GAAG,SAAS;AACrD,QAAM,QAAQ,UAAU,IAAI,KAAK,UAAU,KAAK,WAAW;AAC3D,QAAM,MAAM,KAAK,IAAI,UAAU,UAAU,KAAK;AAC9C,QAAM,UAAU,WAAW;AAC3B,QAAM,SAAS,WAAW;AAE1B,SACE;AAAA,IAAC;AAAA;AAAA,MACC,cAAY,SAAS,OAAO;AAAA,MAC5B,aAAU;AAAA,MACV,WAAW,GAAG,kEAAkE,SAAS;AAAA,MAEzF;AAAA,6BAAC,SAAI,WAAU,iDACZ;AAAA,WAAC,gBACA,iCACE;AAAA,gCAAC,UAAK,WAAU,oBAAoB,iBAAO,gBAAe;AAAA,YAC1D,qBAAC,UAAO,OAAO,OAAO,QAAQ,GAAG,eAAe,CAAC,UAAU,iBAAiB,OAAO,KAAK,CAAC,GACvF;AAAA,kCAAC,iBAAc,WAAU,kBAAiB,cAAY,OAAO,eAAe,SAAS,GACnF,8BAAC,eAAY,GACf;AAAA,cACA,oBAAC,iBACE,0BAAgB,IAAI,CAAC,SACpB,oBAAC,cAAsB,OAAO,OAAO,IAAI,GACtC,kBADc,IAEjB,CACD,GACH;AAAA,eACF;AAAA,aACF;AAAA,UAEF,oBAAC,UAAK,MAAK,UAAS,WAAU,gBAC3B,iBAAO,MAAM,OAAO,KAAK,OAAO,SAAS,GAC5C;AAAA,WACF;AAAA,QAEA,qBAAC,SAAI,WAAU,2BACb;AAAA;AAAA,YAAC;AAAA;AAAA,cACC,SAAQ;AAAA,cACR,MAAK;AAAA,cACL,UAAU;AAAA,cACV,SAAS,MAAM,aAAa,CAAC;AAAA,cAC7B,WAAU;AAAA,cAEV;AAAA,oCAAC,gBAAa,eAAY,QAAO,WAAU,UAAS;AAAA,gBACpD,oBAAC,UAAK,WAAU,WAAW,iBAAO,WAAU;AAAA;AAAA;AAAA,UAC9C;AAAA,UACA,qBAAC,UAAO,SAAQ,WAAU,MAAK,WAAU,UAAU,SAAS,SAAS,MAAM,aAAa,UAAU,CAAC,GACjG;AAAA,gCAAC,eAAY,eAAY,QAAO,WAAU,UAAS;AAAA,YACnD,oBAAC,UAAK,WAAU,WAAW,iBAAO,cAAa;AAAA,aACjD;AAAA,UAEA,oBAAC,UAAK,MAAK,UAAS,WAAU,2CAC3B,iBAAO,WAAW,SAAS,SAAS,GACvC;AAAA,UAEA,qBAAC,UAAO,SAAQ,WAAU,MAAK,WAAU,UAAU,QAAQ,SAAS,MAAM,aAAa,UAAU,CAAC,GAChG;AAAA,gCAAC,gBAAa,eAAY,QAAO,WAAU,UAAS;AAAA,YACpD,oBAAC,UAAK,WAAU,WAAW,iBAAO,UAAS;AAAA,aAC7C;AAAA,UACA;AAAA,YAAC;AAAA;AAAA,cACC,SAAQ;AAAA,cACR,MAAK;AAAA,cACL,UAAU;AAAA,cACV,SAAS,MAAM,aAAa,SAAS;AAAA,cACrC,WAAU;AAAA,cAEV;AAAA,oCAAC,iBAAc,eAAY,QAAO,WAAU,UAAS;AAAA,gBACrD,oBAAC,UAAK,WAAU,WAAW,iBAAO,UAAS;AAAA;AAAA;AAAA,UAC7C;AAAA,WACF;AAAA;AAAA;AAAA,EACF;AAEJ;","names":[]}
1
+ {"version":3,"sources":["../../../src/components/composite/table-pagination.tsx"],"sourcesContent":["\"use client\";\n\nimport { ChevronLeft, ChevronRight, ChevronsLeft, ChevronsRight } from \"lucide-react\";\nimport { useComitorLabels, useComitorLocale } from \"../../i18n/locale-provider.js\";\nimport { cn } from \"../../lib/cn.js\";\nimport { Button } from \"../ui/button.js\";\nimport { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from \"../ui/select.js\";\nimport { DEFAULT_PAGE_SIZE_OPTIONS, type TablePaginationLabels } from \"./table-pagination-constants.js\";\n\n/**\n * Số theo `locale` của `ComitorLocaleProvider` — cho số in NGOÀI bảng chữ (các mức cỡ trang: `1,000` /\n * `1.000`). Mã locale hỏng (`\"vi_VN\"`, `\"\"`) làm `Intl` ném `RangeError`; `ComitorLocaleProvider` cố ý\n * nhận mã hỏng mà không ném (xem `getComitorLanguage`), nên ở đây rơi về số thô thay vì làm trắng cả\n * trang danh sách.\n */\nfunction formatNumber(value: number, locale: string): string {\n try {\n return value.toLocaleString(locale);\n } catch {\n return String(value);\n }\n}\n\nexport interface TablePaginationProps {\n /** Trang hiện tại, đếm từ 1 */\n page: number;\n pageSize: number;\n /** Tổng số bản ghi (sau khi lọc) */\n total: number;\n pageSizeOptions?: readonly number[];\n onPageChange: (page: number) => void;\n onPageSizeChange: (pageSize: number) => void;\n /** Đơn vị đếm theo nội dung bảng, vd \"tasks\", \"contacts\" — mặc định `labels.unitLabel` (\"items\") */\n unitLabel?: string;\n /** Ẩn ô chọn số dòng/trang (khi cỡ trang do hệ thống quyết định) */\n hidePageSize?: boolean;\n /** Nhãn vùng phân trang — cần khi một trang có nhiều bảng; mặc định `labels.region` */\n label?: string;\n /**\n * Ghi đè chuỗi hiển thị/a11y — không truyền thì theo namespace `tablePagination` của\n * `ComitorLocaleProvider` (mặc định tiếng Anh). Xem `TablePaginationLabels`.\n */\n labels?: Partial<TablePaginationLabels>;\n className?: string;\n}\n\n/**\n * Thanh phân trang đi kèm `DataTable`.\n *\n * Tổng số trang tính từ `total`/`pageSize` ngay tại đây thay vì bắt app truyền vào — hai bên\n * tự tính rất dễ lệch nhau một trang ở bản ghi cuối, và người dùng sẽ thấy nút sang trang sau\n * còn bấm được trong khi bảng đã hết dữ liệu.\n */\nexport function TablePagination({\n page,\n pageSize,\n total,\n pageSizeOptions = DEFAULT_PAGE_SIZE_OPTIONS,\n onPageChange,\n onPageSizeChange,\n unitLabel: unitLabelProp,\n hidePageSize = false,\n label,\n labels: labelsProp,\n className\n}: TablePaginationProps) {\n const labels = useComitorLabels(\"tablePagination\", labelsProp);\n const { locale } = useComitorLocale();\n const unitLabel = unitLabelProp ?? labels.unitLabel;\n const pageCount = Math.max(1, Math.ceil(total / Math.max(1, pageSize)));\n const current = Math.min(Math.max(1, page), pageCount);\n const start = total === 0 ? 0 : (current - 1) * pageSize + 1;\n const end = Math.min(current * pageSize, total);\n const isFirst = current <= 1;\n const isLast = current >= pageCount;\n\n return (\n <nav\n aria-label={label ?? labels.region}\n data-slot=\"table-pagination\"\n className={cn(\"flex flex-wrap items-center justify-between gap-3 py-2 text-sm\", className)}\n >\n <div className=\"flex items-center gap-2 text-muted-foreground\">\n {!hidePageSize && (\n <>\n <span className=\"hidden sm:inline\">{labels.pageSizePrefix}</span>\n <Select value={String(pageSize)} onValueChange={(value) => onPageSizeChange(Number(value))}>\n <SelectTrigger className=\"h-8 w-[4.5rem]\" aria-label={labels.pageSizeSelect(unitLabel)}>\n <SelectValue />\n </SelectTrigger>\n <SelectContent>\n {/* Giá trị giữ số thô (máy đọc); chữ hiện ra — kể cả ở `SelectValue` — theo locale. */}\n {pageSizeOptions.map((size) => (\n <SelectItem key={size} value={String(size)}>\n {formatNumber(size, locale)}\n </SelectItem>\n ))}\n </SelectContent>\n </Select>\n </>\n )}\n <span role=\"status\" className=\"tabular-nums\">\n {labels.range(start, end, total, unitLabel)}\n </span>\n </div>\n\n <div className=\"flex items-center gap-1\">\n <Button\n variant=\"outline\"\n size=\"icon-sm\"\n disabled={isFirst}\n onClick={() => onPageChange(1)}\n className=\"hidden sm:inline-flex\"\n >\n <ChevronsLeft aria-hidden=\"true\" className=\"size-4\" />\n <span className=\"sr-only\">{labels.firstPage}</span>\n </Button>\n <Button variant=\"outline\" size=\"icon-sm\" disabled={isFirst} onClick={() => onPageChange(current - 1)}>\n <ChevronLeft aria-hidden=\"true\" className=\"size-4\" />\n <span className=\"sr-only\">{labels.previousPage}</span>\n </Button>\n\n <span role=\"status\" className=\"px-2 tabular-nums text-muted-foreground\">\n {labels.pageStatus(current, pageCount)}\n </span>\n\n <Button variant=\"outline\" size=\"icon-sm\" disabled={isLast} onClick={() => onPageChange(current + 1)}>\n <ChevronRight aria-hidden=\"true\" className=\"size-4\" />\n <span className=\"sr-only\">{labels.nextPage}</span>\n </Button>\n <Button\n variant=\"outline\"\n size=\"icon-sm\"\n disabled={isLast}\n onClick={() => onPageChange(pageCount)}\n className=\"hidden sm:inline-flex\"\n >\n <ChevronsRight aria-hidden=\"true\" className=\"size-4\" />\n <span className=\"sr-only\">{labels.lastPage}</span>\n </Button>\n </div>\n </nav>\n );\n}\n"],"mappings":"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","names":[]}
@@ -1,31 +1,12 @@
1
+ export { matchesSearch, normalizeVi, removeVietnameseTones } from '../../lib/text-search.js';
2
+
1
3
  /**
2
4
  * Tiện ích chuỗi dùng chung cho tầng composite (không phụ thuộc React → dùng được cả 2 phía).
3
5
  *
4
6
  * Vì sao không nhét vào `lib/cn.ts`: những hàm này phục vụ riêng composite (tìm kiếm không dấu,
5
7
  * initials avatar). Tách file để `cn()` giữ đúng một việc.
6
8
  */
7
- /**
8
- * Bỏ dấu tiếng Việt để so khớp tìm kiếm ("Nguyễn" ⇄ "nguyen").
9
- *
10
- * Dùng NFD tách dấu thành ký tự tổ hợp rồi loại dải U+0300–U+036F. `đ/Đ` KHÔNG phải chữ có dấu
11
- * tổ hợp (là ký tự riêng trong Unicode) nên phải thay tay — thiếu bước này thì gõ "duc" sẽ không
12
- * ra "Đức".
13
- */
14
- declare function removeVietnameseTones(input: string): string;
15
- /**
16
- * Chuẩn hoá chuỗi để so khớp: bỏ dấu + hạ chữ thường + `trim`.
17
- *
18
- * Đây là phép chuẩn hoá DUY NHẤT của cả gói (tầng shell cũng gọi lại hàm này, không giữ bản
19
- * riêng) — mỗi nơi tự chuẩn hoá một kiểu là cùng một từ khoá ra hai kết quả tìm kiếm khác nhau.
20
- * `removeVietnameseTones` ở trên là bậc thấp hơn, chỉ bỏ dấu, dùng khi cần giữ nguyên hoa/thường
21
- * (vd sinh slug).
22
- */
23
- declare function normalizeVi(value: string): string;
24
- /**
25
- * So khớp không dấu, không phân biệt hoa thường: `needle` có nằm trong `haystack` không.
26
- * Cả hai vế đi qua `normalizeVi` — cùng một luật với ô tìm kiếm của shell.
27
- */
28
- declare function matchesSearch(haystack: string, needle: string): boolean;
9
+
29
10
  /**
30
11
  * Initials kiểu Việt Nam: chữ đầu của HỌ + chữ đầu của TÊN ("Nguyễn Đức Thành" → "NT").
31
12
  *
@@ -36,4 +17,4 @@ declare function getVietnameseInitials(name: string): string;
36
17
  /** Cắt chuỗi kèm dấu "…" — dùng cho tên workspace/task dài trong chip, tooltip. */
37
18
  declare function truncate(input: string, maxLength: number): string;
38
19
 
39
- export { getVietnameseInitials, matchesSearch, normalizeVi, removeVietnameseTones, truncate };
20
+ export { getVietnameseInitials, truncate };
@@ -1,14 +1,4 @@
1
- function removeVietnameseTones(input) {
2
- return input.normalize("NFD").replace(/[\u0300-\u036f]/g, "").replace(/đ/g, "d").replace(/Đ/g, "D");
3
- }
4
- function normalizeVi(value) {
5
- return removeVietnameseTones(value).toLowerCase().trim();
6
- }
7
- function matchesSearch(haystack, needle) {
8
- const query = normalizeVi(needle);
9
- if (!query) return true;
10
- return normalizeVi(haystack).includes(query);
11
- }
1
+ import { matchesSearch, normalizeVi, removeVietnameseTones } from "../../lib/text-search.js";
12
2
  function getVietnameseInitials(name) {
13
3
  const parts = name.trim().split(/\s+/).filter(Boolean);
14
4
  if (parts.length === 0) return "?";
@@ -1 +1 @@
1
- {"version":3,"sources":["../../../src/components/composite/text-utils.ts"],"sourcesContent":["/**\n * Tiện ích chuỗi dùng chung cho tầng composite (không phụ thuộc React → dùng được cả 2 phía).\n *\n * Vì sao không nhét vào `lib/cn.ts`: những hàm này phục vụ riêng composite (tìm kiếm không dấu,\n * initials avatar). Tách file để `cn()` giữ đúng một việc.\n */\n\n/**\n * Bỏ dấu tiếng Việt để so khớp tìm kiếm (\"Nguyễn\" ⇄ \"nguyen\").\n *\n * Dùng NFD tách dấu thành ký tự tổ hợp rồi loại dải U+0300–U+036F. `đ/Đ` KHÔNG phải chữ có dấu\n * tổ hợp (là ký tự riêng trong Unicode) nên phải thay tay — thiếu bước này thì gõ \"duc\" sẽ không\n * ra \"Đức\".\n */\nexport function removeVietnameseTones(input: string): string {\n return input\n .normalize(\"NFD\")\n .replace(/[\\u0300-\\u036f]/g, \"\")\n .replace(/đ/g, \"d\")\n .replace(/Đ/g, \"D\");\n}\n\n/**\n * Chuẩn hoá chuỗi để so khớp: bỏ dấu + hạ chữ thường + `trim`.\n *\n * Đây là phép chuẩn hoá DUY NHẤT của cả gói (tầng shell cũng gọi lại hàm này, không giữ bản\n * riêng) — mỗi nơi tự chuẩn hoá một kiểu là cùng một từ khoá ra hai kết quả tìm kiếm khác nhau.\n * `removeVietnameseTones` ở trên là bậc thấp hơn, chỉ bỏ dấu, dùng khi cần giữ nguyên hoa/thường\n * (vd sinh slug).\n */\nexport function normalizeVi(value: string): string {\n return removeVietnameseTones(value).toLowerCase().trim();\n}\n\n/**\n * So khớp không dấu, không phân biệt hoa thường: `needle` có nằm trong `haystack` không.\n * Cả hai vế đi qua `normalizeVi` — cùng một luật với ô tìm kiếm của shell.\n */\nexport function matchesSearch(haystack: string, needle: string): boolean {\n const query = normalizeVi(needle);\n if (!query) return true;\n return normalizeVi(haystack).includes(query);\n}\n\n/**\n * Initials kiểu Việt Nam: chữ đầu của HỌ + chữ đầu của TÊN (\"Nguyễn Đức Thành\" → \"NT\").\n *\n * Người Việt gọi nhau bằng TÊN (từ cuối), nên lấy 2 từ đầu như phương Tây (\"NĐ\") là sai thói quen.\n * Tên một từ → lấy 1 ký tự. Chuỗi rỗng → \"?\" (tránh ô avatar trống trơn).\n */\nexport function getVietnameseInitials(name: string): string {\n const parts = name.trim().split(/\\s+/).filter(Boolean);\n if (parts.length === 0) return \"?\";\n\n const first = parts[0] ?? \"\";\n const last = parts[parts.length - 1] ?? \"\";\n const initials = parts.length === 1 ? first.slice(0, 1) : `${first.slice(0, 1)}${last.slice(0, 1)}`;\n\n return initials.toLocaleUpperCase(\"vi-VN\");\n}\n\n/** Cắt chuỗi kèm dấu \"…\" — dùng cho tên workspace/task dài trong chip, tooltip. */\nexport function truncate(input: string, maxLength: number): string {\n if (input.length <= maxLength) return input;\n return `${input.slice(0, Math.max(0, maxLength - 1)).trimEnd()}…`;\n}\n"],"mappings":"AAcO,SAAS,sBAAsB,OAAuB;AAC3D,SAAO,MACJ,UAAU,KAAK,EACf,QAAQ,oBAAoB,EAAE,EAC9B,QAAQ,MAAM,GAAG,EACjB,QAAQ,MAAM,GAAG;AACtB;AAUO,SAAS,YAAY,OAAuB;AACjD,SAAO,sBAAsB,KAAK,EAAE,YAAY,EAAE,KAAK;AACzD;AAMO,SAAS,cAAc,UAAkB,QAAyB;AACvE,QAAM,QAAQ,YAAY,MAAM;AAChC,MAAI,CAAC,MAAO,QAAO;AACnB,SAAO,YAAY,QAAQ,EAAE,SAAS,KAAK;AAC7C;AAQO,SAAS,sBAAsB,MAAsB;AAC1D,QAAM,QAAQ,KAAK,KAAK,EAAE,MAAM,KAAK,EAAE,OAAO,OAAO;AACrD,MAAI,MAAM,WAAW,EAAG,QAAO;AAE/B,QAAM,QAAQ,MAAM,CAAC,KAAK;AAC1B,QAAM,OAAO,MAAM,MAAM,SAAS,CAAC,KAAK;AACxC,QAAM,WAAW,MAAM,WAAW,IAAI,MAAM,MAAM,GAAG,CAAC,IAAI,GAAG,MAAM,MAAM,GAAG,CAAC,CAAC,GAAG,KAAK,MAAM,GAAG,CAAC,CAAC;AAEjG,SAAO,SAAS,kBAAkB,OAAO;AAC3C;AAGO,SAAS,SAAS,OAAe,WAA2B;AACjE,MAAI,MAAM,UAAU,UAAW,QAAO;AACtC,SAAO,GAAG,MAAM,MAAM,GAAG,KAAK,IAAI,GAAG,YAAY,CAAC,CAAC,EAAE,QAAQ,CAAC;AAChE;","names":[]}
1
+ {"version":3,"sources":["../../../src/components/composite/text-utils.ts"],"sourcesContent":["/**\n * Tiện ích chuỗi dùng chung cho tầng composite (không phụ thuộc React → dùng được cả 2 phía).\n *\n * Vì sao không nhét vào `lib/cn.ts`: những hàm này phục vụ riêng composite (tìm kiếm không dấu,\n * initials avatar). Tách file để `cn()` giữ đúng một việc.\n */\n\n// ⚠ Từ `3.1.0` ba hàm tìm kiếm không dấu sống ở `lib/text-search.ts` (Tier 1 cũng dùng chúng); file\n// này xuất lại NGUYÊN tên và chữ ký — với app không có gì đổi.\nexport { matchesSearch, normalizeVi, removeVietnameseTones } from \"../../lib/text-search.js\";\n\n/**\n * Initials kiểu Việt Nam: chữ đầu của HỌ + chữ đầu của TÊN (\"Nguyễn Đức Thành\" → \"NT\").\n *\n * Người Việt gọi nhau bằng TÊN (từ cuối), nên lấy 2 từ đầu như phương Tây (\"NĐ\") là sai thói quen.\n * Tên một từ → lấy 1 ký tự. Chuỗi rỗng → \"?\" (tránh ô avatar trống trơn).\n */\nexport function getVietnameseInitials(name: string): string {\n const parts = name.trim().split(/\\s+/).filter(Boolean);\n if (parts.length === 0) return \"?\";\n\n const first = parts[0] ?? \"\";\n const last = parts[parts.length - 1] ?? \"\";\n const initials = parts.length === 1 ? first.slice(0, 1) : `${first.slice(0, 1)}${last.slice(0, 1)}`;\n\n return initials.toLocaleUpperCase(\"vi-VN\");\n}\n\n/** Cắt chuỗi kèm dấu \"…\" — dùng cho tên workspace/task dài trong chip, tooltip. */\nexport function truncate(input: string, maxLength: number): string {\n if (input.length <= maxLength) return input;\n return `${input.slice(0, Math.max(0, maxLength - 1)).trimEnd()}…`;\n}\n"],"mappings":"AASA,SAAS,eAAe,aAAa,6BAA6B;AAQ3D,SAAS,sBAAsB,MAAsB;AAC1D,QAAM,QAAQ,KAAK,KAAK,EAAE,MAAM,KAAK,EAAE,OAAO,OAAO;AACrD,MAAI,MAAM,WAAW,EAAG,QAAO;AAE/B,QAAM,QAAQ,MAAM,CAAC,KAAK;AAC1B,QAAM,OAAO,MAAM,MAAM,SAAS,CAAC,KAAK;AACxC,QAAM,WAAW,MAAM,WAAW,IAAI,MAAM,MAAM,GAAG,CAAC,IAAI,GAAG,MAAM,MAAM,GAAG,CAAC,CAAC,GAAG,KAAK,MAAM,GAAG,CAAC,CAAC;AAEjG,SAAO,SAAS,kBAAkB,OAAO;AAC3C;AAGO,SAAS,SAAS,OAAe,WAA2B;AACjE,MAAI,MAAM,UAAU,UAAW,QAAO;AACtC,SAAO,GAAG,MAAM,MAAM,GAAG,KAAK,IAAI,GAAG,YAAY,CAAC,CAAC,EAAE,QAAQ,CAAC;AAChE;","names":[]}
@@ -40,7 +40,8 @@ declare function dismissToast(id?: string | number): void;
40
40
  /**
41
41
  * Toast bám theo một Promise: "đang lưu…" → "đã lưu" / "lưu thất bại". `success` và `error` luôn
42
42
  * do app truyền; `loading` bỏ trống thì lấy khoá `toast.loading` theo ngôn ngữ của `<Toaster />`
43
- * (tức của `ComitorLocaleProvider` bọc nó — xem `activeToastMessages` ở đầu file). Truyền
43
+ * (tức của `ComitorLocaleProvider` bọc nó — xem `lib/toast-language.ts`; từ `3.0.1` cả `SonnerToaster`
44
+ * của `/shell` cũng ghi vào đó). Truyền
44
45
  * `loading` tường minh vẫn thắng.
45
46
  */
46
47
  declare function toastPromise<T>(promise: Promise<T>, messages: {
@@ -2,15 +2,9 @@
2
2
  import { jsx } from "react/jsx-runtime";
3
3
  import { AlertTriangle, CheckCircle2, Info, XCircle } from "lucide-react";
4
4
  import { Toaster as SonnerToaster, toast as sonnerToast } from "sonner";
5
- import { useComitorMessages } from "../../i18n/locale-provider.js";
6
- import { toastEn } from "../../i18n/namespaces/toast.js";
7
- import { useIsomorphicLayoutEffect } from "../../lib/use-isomorphic-layout-effect.js";
8
- let activeToastMessages = toastEn;
5
+ import { getActiveToastMessages, useToastLanguageBridge } from "../../lib/toast-language.js";
9
6
  function Toaster({ theme = "system", position = "bottom-right", ...props }) {
10
- const toastMessages = useComitorMessages("toast");
11
- useIsomorphicLayoutEffect(() => {
12
- activeToastMessages = toastMessages;
13
- }, [toastMessages]);
7
+ useToastLanguageBridge();
14
8
  return /* @__PURE__ */ jsx(
15
9
  SonnerToaster,
16
10
  {
@@ -67,7 +61,7 @@ function dismissToast(id) {
67
61
  }
68
62
  function toastPromise(promise, messages) {
69
63
  return sonnerToast.promise(promise, {
70
- loading: messages.loading ?? activeToastMessages.loading,
64
+ loading: messages.loading ?? getActiveToastMessages().loading,
71
65
  success: messages.success,
72
66
  error: messages.error
73
67
  });
@@ -1 +1 @@
1
- {"version":3,"sources":["../../../src/components/composite/toast.tsx"],"sourcesContent":["\"use client\";\n\nimport { AlertTriangle, CheckCircle2, Info, XCircle } from \"lucide-react\";\nimport type { ComponentProps, ReactNode } from \"react\";\nimport { Toaster as SonnerToaster, toast as sonnerToast } from \"sonner\";\nimport { useComitorMessages } from \"../../i18n/locale-provider.js\";\nimport { toastEn } from \"../../i18n/namespaces/toast.js\";\nimport { useIsomorphicLayoutEffect } from \"../../lib/use-isomorphic-layout-effect.js\";\n\nexport type ToasterProps = ComponentProps<typeof SonnerToaster>;\n\n/*\n * Bảng chữ `toast` mà `toastPromise` dùng khi app không truyền `loading`.\n *\n * `toastPromise` là hàm THƯỜNG gọi từ event handler — ngoài lượt render, không gọi được hook, nên\n * không đọc được `ComitorLocaleProvider`. Nhưng `<Toaster />` thì LUÔN nằm trong cây React (đặt một\n * lần ở gốc, bên trong provider): nó ghi bảng chữ hiện hành vào đây mỗi khi ngôn ngữ đổi, và\n * `toastPromise` đọc từ đây. Không có `<Toaster />` thì cũng không có toast nào hiện ra, nên thiếu\n * nó thì chữ rơi về tiếng Anh cũng không ai thấy.\n */\nlet activeToastMessages = toastEn;\n\nexport type ToastVariant = \"default\" | \"success\" | \"warning\" | \"destructive\" | \"info\";\n\nexport interface ToastOptions {\n description?: ReactNode;\n variant?: ToastVariant;\n /** ms; mặc định theo sonner (4000). `Number.POSITIVE_INFINITY` = giữ đến khi người dùng đóng. */\n duration?: number;\n /** Nút hành động, vd { label: \"Hoàn tác\", onClick } */\n action?: { label: string; onClick: () => void };\n /** id để cập nhật/đóng đúng toast đó (dùng với luồng \"đang lưu…\" → \"đã lưu\") */\n id?: string | number;\n}\n\n/**\n * Vùng hiển thị toast — đặt MỘT LẦN ở gốc app.\n *\n * `theme` mặc định \"system\" và KHÔNG đọc `next-themes`: entry \".\" của package phải chạy được ở\n * app không có next-themes. App dùng next-themes hãy truyền xuống, nếu không toast sẽ theo\n * `prefers-color-scheme` của hệ điều hành và lệch với theme người dùng tự chọn:\n *\n * const { resolvedTheme } = useTheme();\n * <Toaster theme={resolvedTheme as \"light\" | \"dark\"} />\n */\nexport function Toaster({ theme = \"system\", position = \"bottom-right\", ...props }: ToasterProps) {\n const toastMessages = useComitorMessages(\"toast\");\n useIsomorphicLayoutEffect(() => {\n activeToastMessages = toastMessages;\n }, [toastMessages]);\n\n return (\n <SonnerToaster\n theme={theme}\n position={position}\n className=\"toaster group\"\n // Icon tô bằng bậc `--x-ink`, KHÔNG phải token tô nền `--x`. Icon là thành phần phi văn\n // bản (ngưỡng 3:1) đứng trên nền `--popover`: `text-warning` (= `--yellow` #F2C94C) trên\n // nền trắng chỉ **1,59:1** ✗ — gần như vô hình ở light (dark thì 11,60:1, lại một chỗ mỗi\n // theme hỏng một nửa); `text-success` 3,13:1 và `text-info` 4,14:1 tuy qua ngưỡng nhưng\n // sát mép. Bậc ink (light / dark): success 6,52 / 10,19 · warning 6,51 / 11,60 ·\n // destructive 7,15 / 6,42 · info 6,68 / 9,54 ✓.\n icons={{\n success: <CheckCircle2 className=\"size-4 text-success-ink\" />,\n warning: <AlertTriangle className=\"size-4 text-warning-ink\" />,\n error: <XCircle className=\"size-4 text-destructive-ink\" />,\n info: <Info className=\"size-4 text-info-ink\" />\n }}\n toastOptions={{\n classNames: {\n toast:\n \"group toast group-[.toaster]:bg-popover group-[.toaster]:text-popover-foreground group-[.toaster]:border-border group-[.toaster]:rounded-lg group-[.toaster]:shadow-card\",\n title: \"group-[.toast]:text-sm group-[.toast]:font-medium\",\n description: \"group-[.toast]:text-sm group-[.toast]:text-muted-foreground\",\n // Hai nút này là CONTROL đứng trên nền `--popover`, nên ngoài tương phản CHỮ còn phải\n // nhận ra được RANH GIỚI (WCAG 1.4.11, ngưỡng 3:1):\n // actionButton chữ 10,19:1 (L) · 12,67:1 (D) ✓, nhưng ruột `bg-primary` (gold) chỉ\n // lệch nền popover 1,86:1 ở light. Giữ đúng cách variant `default` của\n // `Button` xử lý — viền trong suốt, để `shadow-control` làm tín hiệu\n // độ cao; đổi khác ở đây sẽ lệch với nút của chính gói.\n // cancelButton chữ 4,89:1 (L) · 4,86:1 (D) ✓, nhưng ruột `bg-muted` chỉ lệch nền\n // popover 1,07:1 (L) · 1,10:1 (D) → nút không có biên nào, đọc như chữ\n // trơn. `border-control-edge`: 3,83:1 (L) · 4,64:1 (D) so với popover,\n // 3,58:1 · 4,21:1 so với chính ruột `bg-muted` ✓ (hợp đồng §3).\n actionButton:\n \"group-[.toast]:bg-primary group-[.toast]:text-primary-foreground group-[.toast]:shadow-control\",\n cancelButton:\n \"group-[.toast]:bg-muted group-[.toast]:text-muted-foreground group-[.toast]:border group-[.toast]:border-control-edge\"\n }\n }}\n {...props}\n />\n );\n}\n\n/**\n * Bắn toast theo biến thể của Comitor.\n *\n * Vì sao bọc `sonner` thay vì để app gọi thẳng: tên biến thể ở đây khớp với ngôn ngữ của design\n * system (`destructive`, không phải `error`), và khi cần đổi thư viện toast thì chỉ sửa một file.\n */\nexport function toast(title: ReactNode, options: ToastOptions = {}): string | number {\n const { variant = \"default\", description, duration, action, id } = options;\n const config = { description, duration, action, id };\n\n switch (variant) {\n case \"success\":\n return sonnerToast.success(title, config);\n case \"warning\":\n return sonnerToast.warning(title, config);\n case \"destructive\":\n return sonnerToast.error(title, config);\n case \"info\":\n return sonnerToast.info(title, config);\n default:\n return sonnerToast(title, config);\n }\n}\n\n/** Đóng một toast theo id, hoặc đóng tất cả khi không truyền id. */\nexport function dismissToast(id?: string | number): void {\n sonnerToast.dismiss(id);\n}\n\n/**\n * Toast bám theo một Promise: \"đang lưu…\" → \"đã lưu\" / \"lưu thất bại\". `success` và `error` luôn\n * do app truyền; `loading` bỏ trống thì lấy khoá `toast.loading` theo ngôn ngữ của `<Toaster />`\n * (tức của `ComitorLocaleProvider` bọc nó — xem `activeToastMessages` ở đầu file). Truyền\n * `loading` tường minh vẫn thắng.\n */\nexport function toastPromise<T>(\n promise: Promise<T>,\n messages: {\n loading?: ReactNode;\n success: ReactNode | ((data: T) => ReactNode);\n error: ReactNode | ((error: unknown) => ReactNode);\n }\n) {\n return sonnerToast.promise(promise, {\n loading: messages.loading ?? activeToastMessages.loading,\n success: messages.success,\n error: messages.error\n });\n}\n"],"mappings":";AA+DiB;AA7DjB,SAAS,eAAe,cAAc,MAAM,eAAe;AAE3D,SAAS,WAAW,eAAe,SAAS,mBAAmB;AAC/D,SAAS,0BAA0B;AACnC,SAAS,eAAe;AACxB,SAAS,iCAAiC;AAa1C,IAAI,sBAAsB;AAyBnB,SAAS,QAAQ,EAAE,QAAQ,UAAU,WAAW,gBAAgB,GAAG,MAAM,GAAiB;AAC/F,QAAM,gBAAgB,mBAAmB,OAAO;AAChD,4BAA0B,MAAM;AAC9B,0BAAsB;AAAA,EACxB,GAAG,CAAC,aAAa,CAAC;AAElB,SACE;AAAA,IAAC;AAAA;AAAA,MACC;AAAA,MACA;AAAA,MACA,WAAU;AAAA,MAOV,OAAO;AAAA,QACL,SAAS,oBAAC,gBAAa,WAAU,2BAA0B;AAAA,QAC3D,SAAS,oBAAC,iBAAc,WAAU,2BAA0B;AAAA,QAC5D,OAAO,oBAAC,WAAQ,WAAU,+BAA8B;AAAA,QACxD,MAAM,oBAAC,QAAK,WAAU,wBAAuB;AAAA,MAC/C;AAAA,MACA,cAAc;AAAA,QACZ,YAAY;AAAA,UACV,OACE;AAAA,UACF,OAAO;AAAA,UACP,aAAa;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA,UAWb,cACE;AAAA,UACF,cACE;AAAA,QACJ;AAAA,MACF;AAAA,MACC,GAAG;AAAA;AAAA,EACN;AAEJ;AAQO,SAAS,MAAM,OAAkB,UAAwB,CAAC,GAAoB;AACnF,QAAM,EAAE,UAAU,WAAW,aAAa,UAAU,QAAQ,GAAG,IAAI;AACnE,QAAM,SAAS,EAAE,aAAa,UAAU,QAAQ,GAAG;AAEnD,UAAQ,SAAS;AAAA,IACf,KAAK;AACH,aAAO,YAAY,QAAQ,OAAO,MAAM;AAAA,IAC1C,KAAK;AACH,aAAO,YAAY,QAAQ,OAAO,MAAM;AAAA,IAC1C,KAAK;AACH,aAAO,YAAY,MAAM,OAAO,MAAM;AAAA,IACxC,KAAK;AACH,aAAO,YAAY,KAAK,OAAO,MAAM;AAAA,IACvC;AACE,aAAO,YAAY,OAAO,MAAM;AAAA,EACpC;AACF;AAGO,SAAS,aAAa,IAA4B;AACvD,cAAY,QAAQ,EAAE;AACxB;AAQO,SAAS,aACd,SACA,UAKA;AACA,SAAO,YAAY,QAAQ,SAAS;AAAA,IAClC,SAAS,SAAS,WAAW,oBAAoB;AAAA,IACjD,SAAS,SAAS;AAAA,IAClB,OAAO,SAAS;AAAA,EAClB,CAAC;AACH;","names":[]}
1
+ {"version":3,"sources":["../../../src/components/composite/toast.tsx"],"sourcesContent":["\"use client\";\n\nimport { AlertTriangle, CheckCircle2, Info, XCircle } from \"lucide-react\";\nimport type { ComponentProps, ReactNode } from \"react\";\nimport { Toaster as SonnerToaster, toast as sonnerToast } from \"sonner\";\nimport { getActiveToastMessages, useToastLanguageBridge } from \"../../lib/toast-language.js\";\n\nexport type ToasterProps = ComponentProps<typeof SonnerToaster>;\n\nexport type ToastVariant = \"default\" | \"success\" | \"warning\" | \"destructive\" | \"info\";\n\nexport interface ToastOptions {\n description?: ReactNode;\n variant?: ToastVariant;\n /** ms; mặc định theo sonner (4000). `Number.POSITIVE_INFINITY` = giữ đến khi người dùng đóng. */\n duration?: number;\n /** Nút hành động, vd { label: \"Hoàn tác\", onClick } */\n action?: { label: string; onClick: () => void };\n /** id để cập nhật/đóng đúng toast đó (dùng với luồng \"đang lưu…\" → \"đã lưu\") */\n id?: string | number;\n}\n\n/**\n * Vùng hiển thị toast — đặt MỘT LẦN ở gốc app.\n *\n * `theme` mặc định \"system\" và KHÔNG đọc `next-themes`: entry \".\" của package phải chạy được ở\n * app không có next-themes. App dùng next-themes hãy truyền xuống, nếu không toast sẽ theo\n * `prefers-color-scheme` của hệ điều hành và lệch với theme người dùng tự chọn:\n *\n * const { resolvedTheme } = useTheme();\n * <Toaster theme={resolvedTheme as \"light\" | \"dark\"} />\n */\nexport function Toaster({ theme = \"system\", position = \"bottom-right\", ...props }: ToasterProps) {\n // Ghi ngôn ngữ của provider cho `toastPromise` đọc — xem `lib/toast-language.ts`.\n useToastLanguageBridge();\n\n return (\n <SonnerToaster\n theme={theme}\n position={position}\n className=\"toaster group\"\n // Icon tô bằng bậc `--x-ink`, KHÔNG phải token tô nền `--x`. Icon là thành phần phi văn\n // bản (ngưỡng 3:1) đứng trên nền `--popover`: `text-warning` (= `--yellow` #F2C94C) trên\n // nền trắng chỉ **1,59:1** ✗ — gần như vô hình ở light (dark thì 11,60:1, lại một chỗ mỗi\n // theme hỏng một nửa); `text-success` 3,13:1 và `text-info` 4,14:1 tuy qua ngưỡng nhưng\n // sát mép. Bậc ink (light / dark): success 6,52 / 10,19 · warning 6,51 / 11,60 ·\n // destructive 7,15 / 6,42 · info 6,68 / 9,54 ✓.\n icons={{\n success: <CheckCircle2 className=\"size-4 text-success-ink\" />,\n warning: <AlertTriangle className=\"size-4 text-warning-ink\" />,\n error: <XCircle className=\"size-4 text-destructive-ink\" />,\n info: <Info className=\"size-4 text-info-ink\" />\n }}\n toastOptions={{\n classNames: {\n toast:\n \"group toast group-[.toaster]:bg-popover group-[.toaster]:text-popover-foreground group-[.toaster]:border-border group-[.toaster]:rounded-lg group-[.toaster]:shadow-card\",\n title: \"group-[.toast]:text-sm group-[.toast]:font-medium\",\n description: \"group-[.toast]:text-sm group-[.toast]:text-muted-foreground\",\n // Hai nút này là CONTROL đứng trên nền `--popover`, nên ngoài tương phản CHỮ còn phải\n // nhận ra được RANH GIỚI (WCAG 1.4.11, ngưỡng 3:1):\n // actionButton chữ 10,19:1 (L) · 12,67:1 (D) ✓, nhưng ruột `bg-primary` (gold) chỉ\n // lệch nền popover 1,86:1 ở light. Giữ đúng cách variant `default` của\n // `Button` xử lý — viền trong suốt, để `shadow-control` làm tín hiệu\n // độ cao; đổi khác ở đây sẽ lệch với nút của chính gói.\n // cancelButton chữ 4,89:1 (L) · 4,86:1 (D) ✓, nhưng ruột `bg-muted` chỉ lệch nền\n // popover 1,07:1 (L) · 1,10:1 (D) → nút không có biên nào, đọc như chữ\n // trơn. `border-control-edge`: 3,83:1 (L) · 4,64:1 (D) so với popover,\n // 3,58:1 · 4,21:1 so với chính ruột `bg-muted` ✓ (hợp đồng §3).\n actionButton:\n \"group-[.toast]:bg-primary group-[.toast]:text-primary-foreground group-[.toast]:shadow-control\",\n cancelButton:\n \"group-[.toast]:bg-muted group-[.toast]:text-muted-foreground group-[.toast]:border group-[.toast]:border-control-edge\"\n }\n }}\n {...props}\n />\n );\n}\n\n/**\n * Bắn toast theo biến thể của Comitor.\n *\n * Vì sao bọc `sonner` thay vì để app gọi thẳng: tên biến thể ở đây khớp với ngôn ngữ của design\n * system (`destructive`, không phải `error`), và khi cần đổi thư viện toast thì chỉ sửa một file.\n */\nexport function toast(title: ReactNode, options: ToastOptions = {}): string | number {\n const { variant = \"default\", description, duration, action, id } = options;\n const config = { description, duration, action, id };\n\n switch (variant) {\n case \"success\":\n return sonnerToast.success(title, config);\n case \"warning\":\n return sonnerToast.warning(title, config);\n case \"destructive\":\n return sonnerToast.error(title, config);\n case \"info\":\n return sonnerToast.info(title, config);\n default:\n return sonnerToast(title, config);\n }\n}\n\n/** Đóng một toast theo id, hoặc đóng tất cả khi không truyền id. */\nexport function dismissToast(id?: string | number): void {\n sonnerToast.dismiss(id);\n}\n\n/**\n * Toast bám theo một Promise: \"đang lưu…\" → \"đã lưu\" / \"lưu thất bại\". `success` và `error` luôn\n * do app truyền; `loading` bỏ trống thì lấy khoá `toast.loading` theo ngôn ngữ của `<Toaster />`\n * (tức của `ComitorLocaleProvider` bọc nó — xem `lib/toast-language.ts`; từ `3.0.1` cả `SonnerToaster`\n * của `/shell` cũng ghi vào đó). Truyền\n * `loading` tường minh vẫn thắng.\n */\nexport function toastPromise<T>(\n promise: Promise<T>,\n messages: {\n loading?: ReactNode;\n success: ReactNode | ((data: T) => ReactNode);\n error: ReactNode | ((error: unknown) => ReactNode);\n }\n) {\n return sonnerToast.promise(promise, {\n loading: messages.loading ?? getActiveToastMessages().loading,\n success: messages.success,\n error: messages.error\n });\n}\n"],"mappings":";AAgDiB;AA9CjB,SAAS,eAAe,cAAc,MAAM,eAAe;AAE3D,SAAS,WAAW,eAAe,SAAS,mBAAmB;AAC/D,SAAS,wBAAwB,8BAA8B;AA2BxD,SAAS,QAAQ,EAAE,QAAQ,UAAU,WAAW,gBAAgB,GAAG,MAAM,GAAiB;AAE/F,yBAAuB;AAEvB,SACE;AAAA,IAAC;AAAA;AAAA,MACC;AAAA,MACA;AAAA,MACA,WAAU;AAAA,MAOV,OAAO;AAAA,QACL,SAAS,oBAAC,gBAAa,WAAU,2BAA0B;AAAA,QAC3D,SAAS,oBAAC,iBAAc,WAAU,2BAA0B;AAAA,QAC5D,OAAO,oBAAC,WAAQ,WAAU,+BAA8B;AAAA,QACxD,MAAM,oBAAC,QAAK,WAAU,wBAAuB;AAAA,MAC/C;AAAA,MACA,cAAc;AAAA,QACZ,YAAY;AAAA,UACV,OACE;AAAA,UACF,OAAO;AAAA,UACP,aAAa;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA,UAWb,cACE;AAAA,UACF,cACE;AAAA,QACJ;AAAA,MACF;AAAA,MACC,GAAG;AAAA;AAAA,EACN;AAEJ;AAQO,SAAS,MAAM,OAAkB,UAAwB,CAAC,GAAoB;AACnF,QAAM,EAAE,UAAU,WAAW,aAAa,UAAU,QAAQ,GAAG,IAAI;AACnE,QAAM,SAAS,EAAE,aAAa,UAAU,QAAQ,GAAG;AAEnD,UAAQ,SAAS;AAAA,IACf,KAAK;AACH,aAAO,YAAY,QAAQ,OAAO,MAAM;AAAA,IAC1C,KAAK;AACH,aAAO,YAAY,QAAQ,OAAO,MAAM;AAAA,IAC1C,KAAK;AACH,aAAO,YAAY,MAAM,OAAO,MAAM;AAAA,IACxC,KAAK;AACH,aAAO,YAAY,KAAK,OAAO,MAAM;AAAA,IACvC;AACE,aAAO,YAAY,OAAO,MAAM;AAAA,EACpC;AACF;AAGO,SAAS,aAAa,IAA4B;AACvD,cAAY,QAAQ,EAAE;AACxB;AASO,SAAS,aACd,SACA,UAKA;AACA,SAAO,YAAY,QAAQ,SAAS;AAAA,IAClC,SAAS,SAAS,WAAW,uBAAuB,EAAE;AAAA,IACtD,SAAS,SAAS;AAAA,IAClB,OAAO,SAAS;AAAA,EAClB,CAAC;AACH;","names":[]}
@@ -67,6 +67,8 @@ function ActionBar(props) {
67
67
  orientation = "horizontal",
68
68
  loop = true,
69
69
  labels: labelsProp,
70
+ onFocus: onFocusProp,
71
+ onBlur: onBlurProp,
70
72
  className,
71
73
  style,
72
74
  ref,
@@ -80,6 +82,46 @@ function ActionBar(props) {
80
82
  onEscapeKeyDown,
81
83
  onOpenChange
82
84
  });
85
+ const returnFocusRef = React.useRef(null);
86
+ const focusInsideRef = React.useRef(false);
87
+ const onFocus = React.useCallback(
88
+ (event) => {
89
+ onFocusProp?.(event);
90
+ const from = event.relatedTarget;
91
+ if (!focusInsideRef.current && from instanceof HTMLElement && !event.currentTarget.contains(from)) {
92
+ returnFocusRef.current = from;
93
+ }
94
+ focusInsideRef.current = true;
95
+ },
96
+ [onFocusProp]
97
+ );
98
+ const onBlur = React.useCallback(
99
+ (event) => {
100
+ onBlurProp?.(event);
101
+ const to = event.relatedTarget;
102
+ if (!(to instanceof Node) || !event.currentTarget.contains(to)) {
103
+ focusInsideRef.current = false;
104
+ }
105
+ },
106
+ [onBlurProp]
107
+ );
108
+ useIsomorphicLayoutEffect(() => {
109
+ if (!open) return;
110
+ return () => {
111
+ const returnTarget = returnFocusRef.current;
112
+ const hadFocus = focusInsideRef.current;
113
+ returnFocusRef.current = null;
114
+ focusInsideRef.current = false;
115
+ if (!hadFocus || !returnTarget) return;
116
+ queueMicrotask(() => {
117
+ const ownerDocument = returnTarget.ownerDocument;
118
+ const active = ownerDocument.activeElement;
119
+ if (active && active !== ownerDocument.body && active.isConnected) return;
120
+ if (!returnTarget.isConnected) return;
121
+ returnTarget.focus({ preventScroll: true });
122
+ });
123
+ };
124
+ }, [open]);
83
125
  const dir = useDirection(dirProp);
84
126
  const labels = useComitorLabels("actionBar", labelsProp);
85
127
  useIsomorphicLayoutEffect(() => {
@@ -154,7 +196,9 @@ function ActionBar(props) {
154
196
  ...align === "start" && { left: `${alignOffset}px` },
155
197
  ...align === "end" && { right: `${alignOffset}px` },
156
198
  ...style
157
- }
199
+ },
200
+ onFocus,
201
+ onBlur
158
202
  }
159
203
  ),
160
204
  portalContainer