@comitor/ui 3.0.1 → 3.2.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 (89) hide show
  1. package/dist/components/composite/avatar-group.d.ts +10 -1
  2. package/dist/components/composite/avatar-group.js +37 -14
  3. package/dist/components/composite/avatar-group.js.map +1 -1
  4. package/dist/components/composite/confirm-dialog.js +56 -35
  5. package/dist/components/composite/confirm-dialog.js.map +1 -1
  6. package/dist/components/composite/search-filter-bar-constants.d.ts +3 -0
  7. package/dist/components/composite/search-filter-bar-constants.js.map +1 -1
  8. package/dist/components/composite/status-config.d.ts +41 -1
  9. package/dist/components/composite/status-config.js +6 -0
  10. package/dist/components/composite/status-config.js.map +1 -1
  11. package/dist/components/composite/status-pill.d.ts +4 -1
  12. package/dist/components/composite/status-pill.js +1 -1
  13. package/dist/components/composite/status-pill.js.map +1 -1
  14. package/dist/components/composite/table-pagination-constants.d.ts +3 -1
  15. package/dist/components/composite/table-pagination-constants.js.map +1 -1
  16. package/dist/components/composite/table-pagination.js +10 -2
  17. package/dist/components/composite/table-pagination.js.map +1 -1
  18. package/dist/components/composite/text-utils.d.ts +4 -23
  19. package/dist/components/composite/text-utils.js +1 -11
  20. package/dist/components/composite/text-utils.js.map +1 -1
  21. package/dist/components/ui/banner.d.ts +8 -0
  22. package/dist/components/ui/banner.js +149 -35
  23. package/dist/components/ui/banner.js.map +1 -1
  24. package/dist/components/ui/mask-input-constants.d.ts +34 -2
  25. package/dist/components/ui/mask-input-constants.js +56 -5
  26. package/dist/components/ui/mask-input-constants.js.map +1 -1
  27. package/dist/components/ui/mask-input.d.ts +6 -5
  28. package/dist/components/ui/mask-input.js +5 -1
  29. package/dist/components/ui/mask-input.js.map +1 -1
  30. package/dist/components/ui/mention.d.ts +14 -2
  31. package/dist/components/ui/mention.js +33 -6
  32. package/dist/components/ui/mention.js.map +1 -1
  33. package/dist/components/ui/phone-input.d.ts +18 -8
  34. package/dist/components/ui/phone-input.js +120 -33
  35. package/dist/components/ui/phone-input.js.map +1 -1
  36. package/dist/components/ui/scroll-spy.js +46 -31
  37. package/dist/components/ui/scroll-spy.js.map +1 -1
  38. package/dist/components/ui/stepper.js +26 -3
  39. package/dist/components/ui/stepper.js.map +1 -1
  40. package/dist/components/ui/time-picker.d.ts +24 -4
  41. package/dist/components/ui/time-picker.js +425 -162
  42. package/dist/components/ui/time-picker.js.map +1 -1
  43. package/dist/components/ui/timeline.js +45 -9
  44. package/dist/components/ui/timeline.js.map +1 -1
  45. package/dist/data-grid/data-grid-cell-variants.js +27 -9
  46. package/dist/data-grid/data-grid-cell-variants.js.map +1 -1
  47. package/dist/data-grid/data-grid-cell-wrapper.d.ts +1 -1
  48. package/dist/data-grid/data-grid-cell-wrapper.js +6 -0
  49. package/dist/data-grid/data-grid-cell-wrapper.js.map +1 -1
  50. package/dist/data-grid/data-grid-row.js +1 -1
  51. package/dist/data-grid/data-grid-row.js.map +1 -1
  52. package/dist/data-grid/data-grid-select-column.js +6 -2
  53. package/dist/data-grid/data-grid-select-column.js.map +1 -1
  54. package/dist/data-grid/data-grid-skeleton.d.ts +5 -0
  55. package/dist/data-grid/data-grid-skeleton.js +1 -4
  56. package/dist/data-grid/data-grid-skeleton.js.map +1 -1
  57. package/dist/data-grid/data-grid.d.ts +1 -1
  58. package/dist/data-grid/data-grid.js +3 -2
  59. package/dist/data-grid/data-grid.js.map +1 -1
  60. package/dist/data-grid/filters.js +3 -2
  61. package/dist/data-grid/filters.js.map +1 -1
  62. package/dist/data-grid/types.d.ts +23 -0
  63. package/dist/data-grid/use-data-grid.d.ts +1 -0
  64. package/dist/data-grid/use-data-grid.js +70 -24
  65. package/dist/data-grid/use-data-grid.js.map +1 -1
  66. package/dist/data-grid/utils.d.ts +7 -0
  67. package/dist/data-grid/utils.js +3 -1
  68. package/dist/data-grid/utils.js.map +1 -1
  69. package/dist/i18n/namespaces/search-filter-bar.js +8 -6
  70. package/dist/i18n/namespaces/search-filter-bar.js.map +1 -1
  71. package/dist/i18n/namespaces/table-pagination.js +6 -4
  72. package/dist/i18n/namespaces/table-pagination.js.map +1 -1
  73. package/dist/index.d.ts +2 -1
  74. package/dist/lib/collect-item-elements.d.ts +47 -0
  75. package/dist/lib/collect-item-elements.js +22 -0
  76. package/dist/lib/collect-item-elements.js.map +1 -0
  77. package/dist/lib/text-search.d.ts +32 -0
  78. package/dist/lib/text-search.js +17 -0
  79. package/dist/lib/text-search.js.map +1 -0
  80. package/dist/shell/app-shell.d.ts +1 -0
  81. package/dist/shell/display-axis-provider.d.ts +1 -1
  82. package/dist/shell/index.d.ts +2 -1
  83. package/dist/shell/shell-context.d.ts +1 -0
  84. package/dist/shell/utils.d.ts +2 -1
  85. package/dist/tokens.d.ts +43 -11
  86. package/dist/tokens.js +36 -9
  87. package/dist/tokens.js.map +1 -1
  88. package/package.json +1 -1
  89. package/styles.css +73 -13
@@ -49,6 +49,15 @@ interface AvatarGroupProps extends Omit<ComponentProps<"div">, "children"> {
49
49
  * Đặt nhóm lên bề mặt khác thì khai lại cả hai class, vd `"bg-sidebar ring-sidebar"`.
50
50
  */
51
51
  surfaceClassName?: string;
52
+ /**
53
+ * `false` (thêm ở `3.2.0`): vẽ TĨNH — không nút, không tooltip, ô "+k" không bấm được
54
+ * (`onOverflowClick` bị bỏ qua). Dùng khi nhóm nằm TRONG một phần tử tương tác khác: ô của
55
+ * `DataGrid` (ô đã là `role="button"`), thẻ bấm được, dòng là link. Nút lồng trong nút là
56
+ * HTML/ARIA sai (axe: nested-interactive) và chen điểm dừng Tab vào giữa các ô. Tên từng người
57
+ * vẫn tới trình đọc màn hình qua `sr-only` của `LetterAvatar`, và chuột thấy tên qua `title`
58
+ * gốc của nó. Mặc định `true`.
59
+ */
60
+ interactive?: boolean;
52
61
  }
53
62
  /**
54
63
  * Chồng avatar + ô "+k" — dùng cho người thực hiện (Tasks), người tham gia (Chat), người theo dõi (CRM).
@@ -59,6 +68,6 @@ interface AvatarGroupProps extends Omit<ComponentProps<"div">, "children"> {
59
68
  * Mỗi avatar là một nút focusable (Tab tới được) và mang tên thành viên cho trình đọc màn hình
60
69
  * — xem chú thích ở trigger bên dưới.
61
70
  */
62
- declare function AvatarGroup({ items, max, size, onOverflowClick, overflowLabel: overflowLabelProp, label: labelProp, surfaceClassName, className, ...props }: AvatarGroupProps): React.JSX.Element;
71
+ declare function AvatarGroup({ items, max, size, onOverflowClick, overflowLabel: overflowLabelProp, label: labelProp, surfaceClassName, interactive, className, ...props }: AvatarGroupProps): React.JSX.Element;
63
72
 
64
73
  export { AvatarGroup, type AvatarGroupItem, type AvatarGroupProps };
@@ -26,6 +26,7 @@ function AvatarGroup({
26
26
  overflowLabel: overflowLabelProp,
27
27
  label: labelProp,
28
28
  surfaceClassName = "bg-card ring-card",
29
+ interactive = true,
29
30
  className,
30
31
  ...props
31
32
  }) {
@@ -34,6 +35,41 @@ function AvatarGroup({
34
35
  const label = labelProp ?? t.label;
35
36
  const visible = items.slice(0, Math.max(0, max));
36
37
  const overflow = items.length - visible.length;
38
+ const staticOverflow = overflow > 0 ? /* @__PURE__ */ jsxs(
39
+ "span",
40
+ {
41
+ className: cn(
42
+ surfaceClassName,
43
+ "z-10 inline-flex shrink-0 items-center justify-center rounded-full bg-muted font-medium text-muted-foreground ring-2",
44
+ overflowSizeClasses[size]
45
+ ),
46
+ children: [
47
+ "+",
48
+ overflow
49
+ ]
50
+ }
51
+ ) : null;
52
+ if (!interactive) {
53
+ return /* @__PURE__ */ jsxs(
54
+ "div",
55
+ {
56
+ "data-slot": "avatar-group",
57
+ role: "group",
58
+ "aria-label": label,
59
+ className: cn("flex items-center", overlapClasses[size], className),
60
+ ...props,
61
+ children: [
62
+ visible.map((item) => (
63
+ // Cùng lớp nền đục + vòng như bản tương tác (xem `surfaceClassName`), chỉ bỏ nút và
64
+ // tooltip. Không truyền `title={undefined}`: `title={name}` của LetterAvatar ở lại để
65
+ // chuột vẫn đọc được tên khi không còn tooltip.
66
+ /* @__PURE__ */ jsx("span", { className: cn("inline-flex rounded-full ring-2", surfaceClassName), children: /* @__PURE__ */ jsx(LetterAvatar, { name: item.name, src: item.src, size }) }, item.id)
67
+ )),
68
+ staticOverflow
69
+ ]
70
+ }
71
+ );
72
+ }
37
73
  return /* @__PURE__ */ jsx(TooltipProvider, { delayDuration: 200, children: /* @__PURE__ */ jsxs(
38
74
  "div",
39
75
  {
@@ -78,20 +114,7 @@ function AvatarGroup({
78
114
  /* @__PURE__ */ jsx("span", { className: "sr-only", children: overflowLabel(items.length) })
79
115
  ]
80
116
  }
81
- ) : /* @__PURE__ */ jsxs(
82
- "span",
83
- {
84
- className: cn(
85
- surfaceClassName,
86
- "z-10 inline-flex shrink-0 items-center justify-center rounded-full bg-muted font-medium text-muted-foreground ring-2",
87
- overflowSizeClasses[size]
88
- ),
89
- children: [
90
- "+",
91
- overflow
92
- ]
93
- }
94
- ))
117
+ ) : staticOverflow)
95
118
  ]
96
119
  }
97
120
  ) });
@@ -1 +1 @@
1
- {"version":3,"sources":["../../../src/components/composite/avatar-group.tsx"],"sourcesContent":["\"use client\";\n\nimport type { ComponentProps } from \"react\";\nimport { useComitorMessages } from \"../../i18n/locale-provider.js\";\nimport { cn } from \"../../lib/cn.js\";\nimport { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from \"../ui/tooltip.js\";\nimport { LetterAvatar, type LetterAvatarSize } from \"./letter-avatar.js\";\n\nexport interface AvatarGroupItem {\n id: string;\n name: string;\n src?: string | null;\n /** Dòng phụ trong tooltip (vd email, vai trò) */\n caption?: string;\n}\n\nexport interface AvatarGroupProps extends Omit<ComponentProps<\"div\">, \"children\"> {\n items: AvatarGroupItem[];\n /** Số avatar hiện tối đa; phần dư gộp vào ô \"+k\" (mặc định 4) */\n max?: number;\n size?: LetterAvatarSize;\n /** Bấm vào ô \"+k\" — vd mở danh sách đầy đủ */\n onOverflowClick?: () => void;\n /**\n * Nhãn `sr-only` của ô \"+k\" khi ô đó bấm được. Không truyền thì lấy khoá `overflowLabel` của\n * namespace `avatarGroup` (`ComitorLocaleProvider`, mặc định tiếng Anh: \"View all 5 members\").\n *\n * Là HÀM chứ không phải chuỗi mẫu vì có số chèn vào giữa câu: trật tự từ quanh con số khác nhau\n * theo ngôn ngữ, nên mọi cách để gói tự ghép chuỗi đều là chỗ chắc chắn dịch sai.\n *\n * Nhận TỔNG số thành viên (`items.length`), không phải phần dư đang bị giấu: mặt nút đã in sẵn\n * \"+k\" là phần dư rồi, còn câu này nói ra HÀNH ĐỘNG — bấm vào là xem hết cả nhóm.\n *\n * Khác `label` ở tầm phủ: `label` đặt tên cho CẢ NHÓM (`aria-label` của `role=\"group\"`), đọc lên\n * khi con trỏ bước vào nhóm; `overflowLabel` đặt tên cho MỘT nút bên trong, đọc lên khi Tab tới\n * đúng ô \"+k\". Đè bằng prop thì đè cả hai — đè mỗi `label` thì nhóm mang tên mới mà nút cuối\n * vẫn xưng theo bảng của provider.\n */\n overflowLabel?: (count: number) => string;\n /**\n * Nhãn của cả nhóm cho trình đọc màn hình (khoá `label` của namespace `avatarGroup`) — xem\n * `overflowLabel` để phân biệt\n */\n label?: string;\n /**\n * BỀ MẶT mà nhóm đang đứng lên — cho cả lớp nền đục dưới mỗi avatar lẫn màu vòng ngăn cách.\n *\n * Vì sao phải khai: tone avatar là các tint CÓ ALPHA (`bg-teal/15`, `bg-yellow/25`…), mà nhóm\n * thì cố tình cho chúng chồng lên nhau `-space-x-2`. Không có lớp đục ở dưới thì tint của avatar\n * trên hợp thành với MÀU CỦA AVATAR DƯỚI chứ không phải với nền trang — ba khuôn mặt lẫn vào\n * nhau đúng ở chỗ chồng. Đây cũng chính là bề mặt mà `getAvatarToneColors` giả định khi tính hex\n * đục cho canvas/email (`tintOver(…, p.card, …)`), nên mặc định là `--card`.\n *\n * Đặt nhóm lên bề mặt khác thì khai lại cả hai class, vd `\"bg-sidebar ring-sidebar\"`.\n */\n surfaceClassName?: string;\n}\n\n/**\n * Độ chồng theo CỠ, không phải một hằng số cho mọi cỡ.\n *\n * Mẫu Avatar stack ở comitor-ds là `-space-x-2` trên avatar 40px — tức chồng **20%**. Áp thẳng\n * `-space-x-2` cho mọi cỡ thì ở `xs` (24px) thành chồng 33%, và cộng thêm vòng 2px nữa là phần bị\n * che ăn vào đúng chữ cái đầu của initials: \"ĐH\" ở 10px bắt đầu cách mép ~5,5px, trong khi mép che\n * đã tới 10px. Giữ tỉ lệ ~20% thì mọi cỡ đọc được mà mật độ vẫn y như bản duyệt.\n *\n * xs 24px → 4px (17%) · sm 28px → 6px (21%) · md 36px → 8px (22%)\n * lg 44px → 10px (23%) · xl 56px → 12px (21%)\n */\nconst overlapClasses: Record<LetterAvatarSize, string> = {\n xs: \"-space-x-1\",\n sm: \"-space-x-1.5\",\n md: \"-space-x-2\",\n lg: \"-space-x-2.5\",\n xl: \"-space-x-3\"\n};\n\nconst overflowSizeClasses: Record<LetterAvatarSize, string> = {\n xs: \"size-6 text-[10px]\",\n sm: \"size-7 text-xs\",\n md: \"size-9 text-xs\",\n lg: \"size-11 text-sm\",\n xl: \"size-14 text-base\"\n};\n\n/**\n * Chồng avatar + ô \"+k\" — dùng cho người thực hiện (Tasks), người tham gia (Chat), người theo dõi (CRM).\n *\n * Tooltip hiện tên đầy đủ vì avatar chồng nhau thì initials bị che một phần; không có tooltip\n * thì nhóm avatar chỉ là trang trí, người dùng không biết ai đang ở trong việc.\n *\n * Mỗi avatar là một nút focusable (Tab tới được) và mang tên thành viên cho trình đọc màn hình\n * — xem chú thích ở trigger bên dưới.\n */\nexport function AvatarGroup({\n items,\n max = 4,\n size = \"sm\",\n onOverflowClick,\n overflowLabel: overflowLabelProp,\n label: labelProp,\n surfaceClassName = \"bg-card ring-card\",\n className,\n ...props\n}: AvatarGroupProps) {\n const t = useComitorMessages(\"avatarGroup\");\n const overflowLabel = overflowLabelProp ?? t.overflowLabel;\n const label = labelProp ?? t.label;\n const visible = items.slice(0, Math.max(0, max));\n const overflow = items.length - visible.length;\n\n return (\n <TooltipProvider delayDuration={200}>\n <div\n data-slot=\"avatar-group\"\n role=\"group\"\n aria-label={label}\n className={cn(\"flex items-center\", overlapClasses[size], className)}\n {...props}\n >\n {visible.map((item) => (\n <Tooltip key={item.id}>\n <TooltipTrigger asChild>\n {/* Trigger phải FOCUSABLE: `<span>` thường chỉ nhận sự kiện chuột nên tooltip\n (và cả tên thành viên) biến mất với người dùng bàn phím. Nút không có\n onClick là chủ ý — nó chỉ mở tooltip; tên accessible lấy từ `sr-only`\n bên trong LetterAvatar nên không cần aria-label lặp lại. */}\n {/* Viền focus dùng `foreground` chứ không `ring`: `--ring` là gold — #E8B824 chỉ\n 1,86:1 trên nền trang ở light, dưới 3:1 mà WCAG 1.4.11/2.4.11 đòi cho chỉ báo\n focus (ở dark #F5D060 mới đạt 12,67:1, tức hỏng đúng một nửa số người dùng).\n `outline-foreground` đạt 18,91:1 (light) / 17,66:1 (dark) — cùng lối với viền\n focus của dòng trong `DataTable`. */}\n {/* Nền đục nằm DƯỚI tint có alpha của avatar — xem `surfaceClassName`. Vòng\n `ring-2` cùng màu bề mặt chỉ tách hai avatar ra, nó KHÔNG che được phần tint\n chồng lên nhau bên trong vòng. */}\n {/* ⚠ `inline-flex` KHÔNG phải để bố trí — nó vá một khoảng trắng có thật.\n `LetterAvatar` là `inline-flex`, nên trong một nút KHỐI nó thành nội dung\n inline và bị canh theo BASELINE: line box chừa thêm khoảng chân chữ BÊN DƯỚI.\n Đã đo ở `xs` trên `/`: nút cao **30,5px** ôm một avatar **24px** — dư 6,5px,\n toàn bộ ở đáy. Nút lại có `bg-card` ĐỤC và `rounded-full`, nên chỗ dư ấy là\n một mảng trắng hình lưỡi liềm dưới mỗi khuôn mặt. Lúc nghỉ nó lẫn vào nền thẻ;\n `hover:z-10` + `hover:-translate-y-0.5` kéo nút lên trước và phơi nó ra.\n ⚠ `leading-none` KHÔNG đủ — đã đo: còn dư 2,5px. Phải bỏ hẳn line box.\n Ô \"+k\" bên dưới vốn đã `inline-flex`; chỗ này chỉ là sót. */}\n <button\n type=\"button\"\n className={cn(\n \"inline-flex rounded-full ring-2 transition-transform hover:z-10 hover:-translate-y-0.5 focus-visible:z-10 focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-foreground\",\n surfaceClassName\n )}\n >\n <LetterAvatar name={item.name} src={item.src} size={size} title={undefined} />\n </button>\n </TooltipTrigger>\n <TooltipContent>\n <span className=\"font-medium\">{item.name}</span>\n {/* `opacity-80` ở đây ĐẠT chuẩn và đã đo, không phải chỗ sót: Tooltip là\n `bg-foreground text-background`, nên chữ mờ 80% ra 12,15:1 (light) / 9,85:1\n (dark) — trên xa ngưỡng 4,5:1. Tooltip cũng không tương tác được. */}\n {item.caption && <span className=\"block text-xs opacity-80\">{item.caption}</span>}\n </TooltipContent>\n </Tooltip>\n ))}\n\n {overflow > 0 &&\n (onOverflowClick ? (\n <button\n type=\"button\"\n onClick={onOverflowClick}\n className={cn(\n // `surfaceClassName` đứng TRƯỚC để `bg-muted` ngay sau đè lại phần nền của nó:\n // ô \"+k\" vốn đã đục, ở đây chỉ mượn màu VÒNG của bề mặt.\n surfaceClassName,\n \"z-10 inline-flex shrink-0 items-center justify-center rounded-full bg-muted font-medium text-muted-foreground ring-2 transition-colors hover:bg-accent hover:text-accent-foreground\",\n overflowSizeClasses[size]\n )}\n >\n +{overflow}\n <span className=\"sr-only\">{overflowLabel(items.length)}</span>\n </button>\n ) : (\n <span\n className={cn(\n surfaceClassName,\n \"z-10 inline-flex shrink-0 items-center justify-center rounded-full bg-muted font-medium text-muted-foreground ring-2\",\n overflowSizeClasses[size]\n )}\n >\n +{overflow}\n </span>\n ))}\n </div>\n </TooltipProvider>\n );\n}\n"],"mappings":";AAuJgB,cAGJ,YAHI;AApJhB,SAAS,0BAA0B;AACnC,SAAS,UAAU;AACnB,SAAS,SAAS,gBAAgB,iBAAiB,sBAAsB;AACzE,SAAS,oBAA2C;AA+DpD,MAAM,iBAAmD;AAAA,EACvD,IAAI;AAAA,EACJ,IAAI;AAAA,EACJ,IAAI;AAAA,EACJ,IAAI;AAAA,EACJ,IAAI;AACN;AAEA,MAAM,sBAAwD;AAAA,EAC5D,IAAI;AAAA,EACJ,IAAI;AAAA,EACJ,IAAI;AAAA,EACJ,IAAI;AAAA,EACJ,IAAI;AACN;AAWO,SAAS,YAAY;AAAA,EAC1B;AAAA,EACA,MAAM;AAAA,EACN,OAAO;AAAA,EACP;AAAA,EACA,eAAe;AAAA,EACf,OAAO;AAAA,EACP,mBAAmB;AAAA,EACnB;AAAA,EACA,GAAG;AACL,GAAqB;AACnB,QAAM,IAAI,mBAAmB,aAAa;AAC1C,QAAM,gBAAgB,qBAAqB,EAAE;AAC7C,QAAM,QAAQ,aAAa,EAAE;AAC7B,QAAM,UAAU,MAAM,MAAM,GAAG,KAAK,IAAI,GAAG,GAAG,CAAC;AAC/C,QAAM,WAAW,MAAM,SAAS,QAAQ;AAExC,SACE,oBAAC,mBAAgB,eAAe,KAC9B;AAAA,IAAC;AAAA;AAAA,MACC,aAAU;AAAA,MACV,MAAK;AAAA,MACL,cAAY;AAAA,MACZ,WAAW,GAAG,qBAAqB,eAAe,IAAI,GAAG,SAAS;AAAA,MACjE,GAAG;AAAA,MAEH;AAAA,gBAAQ,IAAI,CAAC,SACZ,qBAAC,WACC;AAAA,8BAAC,kBAAe,SAAO,MAsBrB;AAAA,YAAC;AAAA;AAAA,cACC,MAAK;AAAA,cACL,WAAW;AAAA,gBACT;AAAA,gBACA;AAAA,cACF;AAAA,cAEA,8BAAC,gBAAa,MAAM,KAAK,MAAM,KAAK,KAAK,KAAK,MAAY,OAAO,QAAW;AAAA;AAAA,UAC9E,GACF;AAAA,UACA,qBAAC,kBACC;AAAA,gCAAC,UAAK,WAAU,eAAe,eAAK,MAAK;AAAA,YAIxC,KAAK,WAAW,oBAAC,UAAK,WAAU,4BAA4B,eAAK,SAAQ;AAAA,aAC5E;AAAA,aAvCY,KAAK,EAwCnB,CACD;AAAA,QAEA,WAAW,MACT,kBACC;AAAA,UAAC;AAAA;AAAA,YACC,MAAK;AAAA,YACL,SAAS;AAAA,YACT,WAAW;AAAA;AAAA;AAAA,cAGT;AAAA,cACA;AAAA,cACA,oBAAoB,IAAI;AAAA,YAC1B;AAAA,YACD;AAAA;AAAA,cACG;AAAA,cACF,oBAAC,UAAK,WAAU,WAAW,wBAAc,MAAM,MAAM,GAAE;AAAA;AAAA;AAAA,QACzD,IAEA;AAAA,UAAC;AAAA;AAAA,YACC,WAAW;AAAA,cACT;AAAA,cACA;AAAA,cACA,oBAAoB,IAAI;AAAA,YAC1B;AAAA,YACD;AAAA;AAAA,cACG;AAAA;AAAA;AAAA,QACJ;AAAA;AAAA;AAAA,EAEN,GACF;AAEJ;","names":[]}
1
+ {"version":3,"sources":["../../../src/components/composite/avatar-group.tsx"],"sourcesContent":["\"use client\";\n\nimport type { ComponentProps } from \"react\";\nimport { useComitorMessages } from \"../../i18n/locale-provider.js\";\nimport { cn } from \"../../lib/cn.js\";\nimport { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from \"../ui/tooltip.js\";\nimport { LetterAvatar, type LetterAvatarSize } from \"./letter-avatar.js\";\n\nexport interface AvatarGroupItem {\n id: string;\n name: string;\n src?: string | null;\n /** Dòng phụ trong tooltip (vd email, vai trò) */\n caption?: string;\n}\n\nexport interface AvatarGroupProps extends Omit<ComponentProps<\"div\">, \"children\"> {\n items: AvatarGroupItem[];\n /** Số avatar hiện tối đa; phần dư gộp vào ô \"+k\" (mặc định 4) */\n max?: number;\n size?: LetterAvatarSize;\n /** Bấm vào ô \"+k\" — vd mở danh sách đầy đủ */\n onOverflowClick?: () => void;\n /**\n * Nhãn `sr-only` của ô \"+k\" khi ô đó bấm được. Không truyền thì lấy khoá `overflowLabel` của\n * namespace `avatarGroup` (`ComitorLocaleProvider`, mặc định tiếng Anh: \"View all 5 members\").\n *\n * Là HÀM chứ không phải chuỗi mẫu vì có số chèn vào giữa câu: trật tự từ quanh con số khác nhau\n * theo ngôn ngữ, nên mọi cách để gói tự ghép chuỗi đều là chỗ chắc chắn dịch sai.\n *\n * Nhận TỔNG số thành viên (`items.length`), không phải phần dư đang bị giấu: mặt nút đã in sẵn\n * \"+k\" là phần dư rồi, còn câu này nói ra HÀNH ĐỘNG — bấm vào là xem hết cả nhóm.\n *\n * Khác `label` ở tầm phủ: `label` đặt tên cho CẢ NHÓM (`aria-label` của `role=\"group\"`), đọc lên\n * khi con trỏ bước vào nhóm; `overflowLabel` đặt tên cho MỘT nút bên trong, đọc lên khi Tab tới\n * đúng ô \"+k\". Đè bằng prop thì đè cả hai — đè mỗi `label` thì nhóm mang tên mới mà nút cuối\n * vẫn xưng theo bảng của provider.\n */\n overflowLabel?: (count: number) => string;\n /**\n * Nhãn của cả nhóm cho trình đọc màn hình (khoá `label` của namespace `avatarGroup`) — xem\n * `overflowLabel` để phân biệt\n */\n label?: string;\n /**\n * BỀ MẶT mà nhóm đang đứng lên — cho cả lớp nền đục dưới mỗi avatar lẫn màu vòng ngăn cách.\n *\n * Vì sao phải khai: tone avatar là các tint CÓ ALPHA (`bg-teal/15`, `bg-yellow/25`…), mà nhóm\n * thì cố tình cho chúng chồng lên nhau `-space-x-2`. Không có lớp đục ở dưới thì tint của avatar\n * trên hợp thành với MÀU CỦA AVATAR DƯỚI chứ không phải với nền trang — ba khuôn mặt lẫn vào\n * nhau đúng ở chỗ chồng. Đây cũng chính là bề mặt mà `getAvatarToneColors` giả định khi tính hex\n * đục cho canvas/email (`tintOver(…, p.card, …)`), nên mặc định là `--card`.\n *\n * Đặt nhóm lên bề mặt khác thì khai lại cả hai class, vd `\"bg-sidebar ring-sidebar\"`.\n */\n surfaceClassName?: string;\n /**\n * `false` (thêm ở `3.2.0`): vẽ TĨNH — không nút, không tooltip, ô \"+k\" không bấm được\n * (`onOverflowClick` bị bỏ qua). Dùng khi nhóm nằm TRONG một phần tử tương tác khác: ô của\n * `DataGrid` (ô đã là `role=\"button\"`), thẻ bấm được, dòng là link. Nút lồng trong nút là\n * HTML/ARIA sai (axe: nested-interactive) và chen điểm dừng Tab vào giữa các ô. Tên từng người\n * vẫn tới trình đọc màn hình qua `sr-only` của `LetterAvatar`, và chuột thấy tên qua `title`\n * gốc của nó. Mặc định `true`.\n */\n interactive?: boolean;\n}\n\n/**\n * Độ chồng theo CỠ, không phải một hằng số cho mọi cỡ.\n *\n * Mẫu Avatar stack ở comitor-ds là `-space-x-2` trên avatar 40px — tức chồng **20%**. Áp thẳng\n * `-space-x-2` cho mọi cỡ thì ở `xs` (24px) thành chồng 33%, và cộng thêm vòng 2px nữa là phần bị\n * che ăn vào đúng chữ cái đầu của initials: \"ĐH\" ở 10px bắt đầu cách mép ~5,5px, trong khi mép che\n * đã tới 10px. Giữ tỉ lệ ~20% thì mọi cỡ đọc được mà mật độ vẫn y như bản duyệt.\n *\n * xs 24px → 4px (17%) · sm 28px → 6px (21%) · md 36px → 8px (22%)\n * lg 44px → 10px (23%) · xl 56px → 12px (21%)\n */\nconst overlapClasses: Record<LetterAvatarSize, string> = {\n xs: \"-space-x-1\",\n sm: \"-space-x-1.5\",\n md: \"-space-x-2\",\n lg: \"-space-x-2.5\",\n xl: \"-space-x-3\"\n};\n\nconst overflowSizeClasses: Record<LetterAvatarSize, string> = {\n xs: \"size-6 text-[10px]\",\n sm: \"size-7 text-xs\",\n md: \"size-9 text-xs\",\n lg: \"size-11 text-sm\",\n xl: \"size-14 text-base\"\n};\n\n/**\n * Chồng avatar + ô \"+k\" — dùng cho người thực hiện (Tasks), người tham gia (Chat), người theo dõi (CRM).\n *\n * Tooltip hiện tên đầy đủ vì avatar chồng nhau thì initials bị che một phần; không có tooltip\n * thì nhóm avatar chỉ là trang trí, người dùng không biết ai đang ở trong việc.\n *\n * Mỗi avatar là một nút focusable (Tab tới được) và mang tên thành viên cho trình đọc màn hình\n * — xem chú thích ở trigger bên dưới.\n */\nexport function AvatarGroup({\n items,\n max = 4,\n size = \"sm\",\n onOverflowClick,\n overflowLabel: overflowLabelProp,\n label: labelProp,\n surfaceClassName = \"bg-card ring-card\",\n interactive = true,\n className,\n ...props\n}: AvatarGroupProps) {\n const t = useComitorMessages(\"avatarGroup\");\n const overflowLabel = overflowLabelProp ?? t.overflowLabel;\n const label = labelProp ?? t.label;\n const visible = items.slice(0, Math.max(0, max));\n const overflow = items.length - visible.length;\n\n const staticOverflow =\n overflow > 0 ? (\n <span\n className={cn(\n surfaceClassName,\n \"z-10 inline-flex shrink-0 items-center justify-center rounded-full bg-muted font-medium text-muted-foreground ring-2\",\n overflowSizeClasses[size]\n )}\n >\n +{overflow}\n </span>\n ) : null;\n\n if (!interactive) {\n return (\n <div\n data-slot=\"avatar-group\"\n role=\"group\"\n aria-label={label}\n className={cn(\"flex items-center\", overlapClasses[size], className)}\n {...props}\n >\n {visible.map((item) => (\n // Cùng lớp nền đục + vòng như bản tương tác (xem `surfaceClassName`), chỉ bỏ nút và\n // tooltip. Không truyền `title={undefined}`: `title={name}` của LetterAvatar ở lại để\n // chuột vẫn đọc được tên khi không còn tooltip.\n <span key={item.id} className={cn(\"inline-flex rounded-full ring-2\", surfaceClassName)}>\n <LetterAvatar name={item.name} src={item.src} size={size} />\n </span>\n ))}\n {staticOverflow}\n </div>\n );\n }\n\n return (\n <TooltipProvider delayDuration={200}>\n <div\n data-slot=\"avatar-group\"\n role=\"group\"\n aria-label={label}\n className={cn(\"flex items-center\", overlapClasses[size], className)}\n {...props}\n >\n {visible.map((item) => (\n <Tooltip key={item.id}>\n <TooltipTrigger asChild>\n {/* Trigger phải FOCUSABLE: `<span>` thường chỉ nhận sự kiện chuột nên tooltip\n (và cả tên thành viên) biến mất với người dùng bàn phím. Nút không có\n onClick là chủ ý — nó chỉ mở tooltip; tên accessible lấy từ `sr-only`\n bên trong LetterAvatar nên không cần aria-label lặp lại. */}\n {/* Viền focus dùng `foreground` chứ không `ring`: `--ring` là gold — #E8B824 chỉ\n 1,86:1 trên nền trang ở light, dưới 3:1 mà WCAG 1.4.11/2.4.11 đòi cho chỉ báo\n focus (ở dark #F5D060 mới đạt 12,67:1, tức hỏng đúng một nửa số người dùng).\n `outline-foreground` đạt 18,91:1 (light) / 17,66:1 (dark) — cùng lối với viền\n focus của dòng trong `DataTable`. */}\n {/* Nền đục nằm DƯỚI tint có alpha của avatar — xem `surfaceClassName`. Vòng\n `ring-2` cùng màu bề mặt chỉ tách hai avatar ra, nó KHÔNG che được phần tint\n chồng lên nhau bên trong vòng. */}\n {/* ⚠ `inline-flex` KHÔNG phải để bố trí — nó vá một khoảng trắng có thật.\n `LetterAvatar` là `inline-flex`, nên trong một nút KHỐI nó thành nội dung\n inline và bị canh theo BASELINE: line box chừa thêm khoảng chân chữ BÊN DƯỚI.\n Đã đo ở `xs` trên `/`: nút cao **30,5px** ôm một avatar **24px** — dư 6,5px,\n toàn bộ ở đáy. Nút lại có `bg-card` ĐỤC và `rounded-full`, nên chỗ dư ấy là\n một mảng trắng hình lưỡi liềm dưới mỗi khuôn mặt. Lúc nghỉ nó lẫn vào nền thẻ;\n `hover:z-10` + `hover:-translate-y-0.5` kéo nút lên trước và phơi nó ra.\n ⚠ `leading-none` KHÔNG đủ — đã đo: còn dư 2,5px. Phải bỏ hẳn line box.\n Ô \"+k\" bên dưới vốn đã `inline-flex`; chỗ này chỉ là sót. */}\n <button\n type=\"button\"\n className={cn(\n \"inline-flex rounded-full ring-2 transition-transform hover:z-10 hover:-translate-y-0.5 focus-visible:z-10 focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-foreground\",\n surfaceClassName\n )}\n >\n <LetterAvatar name={item.name} src={item.src} size={size} title={undefined} />\n </button>\n </TooltipTrigger>\n <TooltipContent>\n <span className=\"font-medium\">{item.name}</span>\n {/* `opacity-80` ở đây ĐẠT chuẩn và đã đo, không phải chỗ sót: Tooltip là\n `bg-foreground text-background`, nên chữ mờ 80% ra 12,15:1 (light) / 9,85:1\n (dark) — trên xa ngưỡng 4,5:1. Tooltip cũng không tương tác được. */}\n {item.caption && <span className=\"block text-xs opacity-80\">{item.caption}</span>}\n </TooltipContent>\n </Tooltip>\n ))}\n\n {overflow > 0 &&\n (onOverflowClick ? (\n <button\n type=\"button\"\n onClick={onOverflowClick}\n className={cn(\n // `surfaceClassName` đứng TRƯỚC để `bg-muted` ngay sau đè lại phần nền của nó:\n // ô \"+k\" vốn đã đục, ở đây chỉ mượn màu VÒNG của bề mặt.\n surfaceClassName,\n \"z-10 inline-flex shrink-0 items-center justify-center rounded-full bg-muted font-medium text-muted-foreground ring-2 transition-colors hover:bg-accent hover:text-accent-foreground\",\n overflowSizeClasses[size]\n )}\n >\n +{overflow}\n <span className=\"sr-only\">{overflowLabel(items.length)}</span>\n </button>\n ) : (\n staticOverflow\n ))}\n </div>\n </TooltipProvider>\n );\n}\n"],"mappings":";AA2HM,SAyBM,KAzBN;AAxHN,SAAS,0BAA0B;AACnC,SAAS,UAAU;AACnB,SAAS,SAAS,gBAAgB,iBAAiB,sBAAsB;AACzE,SAAS,oBAA2C;AAwEpD,MAAM,iBAAmD;AAAA,EACvD,IAAI;AAAA,EACJ,IAAI;AAAA,EACJ,IAAI;AAAA,EACJ,IAAI;AAAA,EACJ,IAAI;AACN;AAEA,MAAM,sBAAwD;AAAA,EAC5D,IAAI;AAAA,EACJ,IAAI;AAAA,EACJ,IAAI;AAAA,EACJ,IAAI;AAAA,EACJ,IAAI;AACN;AAWO,SAAS,YAAY;AAAA,EAC1B;AAAA,EACA,MAAM;AAAA,EACN,OAAO;AAAA,EACP;AAAA,EACA,eAAe;AAAA,EACf,OAAO;AAAA,EACP,mBAAmB;AAAA,EACnB,cAAc;AAAA,EACd;AAAA,EACA,GAAG;AACL,GAAqB;AACnB,QAAM,IAAI,mBAAmB,aAAa;AAC1C,QAAM,gBAAgB,qBAAqB,EAAE;AAC7C,QAAM,QAAQ,aAAa,EAAE;AAC7B,QAAM,UAAU,MAAM,MAAM,GAAG,KAAK,IAAI,GAAG,GAAG,CAAC;AAC/C,QAAM,WAAW,MAAM,SAAS,QAAQ;AAExC,QAAM,iBACJ,WAAW,IACT;AAAA,IAAC;AAAA;AAAA,MACC,WAAW;AAAA,QACT;AAAA,QACA;AAAA,QACA,oBAAoB,IAAI;AAAA,MAC1B;AAAA,MACD;AAAA;AAAA,QACG;AAAA;AAAA;AAAA,EACJ,IACE;AAEN,MAAI,CAAC,aAAa;AAChB,WACE;AAAA,MAAC;AAAA;AAAA,QACC,aAAU;AAAA,QACV,MAAK;AAAA,QACL,cAAY;AAAA,QACZ,WAAW,GAAG,qBAAqB,eAAe,IAAI,GAAG,SAAS;AAAA,QACjE,GAAG;AAAA,QAEH;AAAA,kBAAQ,IAAI,CAAC;AAAA;AAAA;AAAA;AAAA,YAIZ,oBAAC,UAAmB,WAAW,GAAG,mCAAmC,gBAAgB,GACnF,8BAAC,gBAAa,MAAM,KAAK,MAAM,KAAK,KAAK,KAAK,MAAY,KADjD,KAAK,EAEhB;AAAA,WACD;AAAA,UACA;AAAA;AAAA;AAAA,IACH;AAAA,EAEJ;AAEA,SACE,oBAAC,mBAAgB,eAAe,KAC9B;AAAA,IAAC;AAAA;AAAA,MACC,aAAU;AAAA,MACV,MAAK;AAAA,MACL,cAAY;AAAA,MACZ,WAAW,GAAG,qBAAqB,eAAe,IAAI,GAAG,SAAS;AAAA,MACjE,GAAG;AAAA,MAEH;AAAA,gBAAQ,IAAI,CAAC,SACZ,qBAAC,WACC;AAAA,8BAAC,kBAAe,SAAO,MAsBrB;AAAA,YAAC;AAAA;AAAA,cACC,MAAK;AAAA,cACL,WAAW;AAAA,gBACT;AAAA,gBACA;AAAA,cACF;AAAA,cAEA,8BAAC,gBAAa,MAAM,KAAK,MAAM,KAAK,KAAK,KAAK,MAAY,OAAO,QAAW;AAAA;AAAA,UAC9E,GACF;AAAA,UACA,qBAAC,kBACC;AAAA,gCAAC,UAAK,WAAU,eAAe,eAAK,MAAK;AAAA,YAIxC,KAAK,WAAW,oBAAC,UAAK,WAAU,4BAA4B,eAAK,SAAQ;AAAA,aAC5E;AAAA,aAvCY,KAAK,EAwCnB,CACD;AAAA,QAEA,WAAW,MACT,kBACC;AAAA,UAAC;AAAA;AAAA,YACC,MAAK;AAAA,YACL,SAAS;AAAA,YACT,WAAW;AAAA;AAAA;AAAA,cAGT;AAAA,cACA;AAAA,cACA,oBAAoB,IAAI;AAAA,YAC1B;AAAA,YACD;AAAA;AAAA,cACG;AAAA,cACF,oBAAC,UAAK,WAAU,WAAW,wBAAc,MAAM,MAAM,GAAE;AAAA;AAAA;AAAA,QACzD,IAEA;AAAA;AAAA;AAAA,EAEN,GACF;AAEJ;","names":[]}
@@ -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":";AAiIkB,cAqCN,YArCM;AA/HlB,SAAyB,QAAQ,gBAAgB;AACjD,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;AAW5C,QAAM,iBAAiB,OAA2B,IAAI;AAEtD,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;AAAA,MAAC;AAAA;AAAA,QACC;AAAA,QACA,iBAAiB,MAAM;AAErB,gBAAM,SAAS,SAAS;AACxB,yBAAe,UAAU,kBAAkB,eAAe,WAAW,SAAS,OAAO,SAAS;AAAA,QAChG;AAAA,QACA,kBAAkB,CAAC,UAAU;AAC3B,gBAAM,SAAS,eAAe;AAC9B,yBAAe,UAAU;AACzB,cAAI,QAAS;AACb,gBAAM,SAAS,SAAS;AACxB,cAAI,UAAU,WAAW,SAAS,KAAM;AACxC,cAAI,CAAC,QAAQ,YAAa;AAC1B,gBAAM,eAAe;AACrB,iBAAO,MAAM,EAAE,eAAe,KAAK,CAAC;AAAA,QACtC;AAAA,QAEA;AAAA,8BAAC,qBACC,+BAAC,SAAI,WAAU,0BACZ;AAAA,oBACC;AAAA,cAAC;AAAA;AAAA,gBACC,eAAY;AAAA,gBAIZ,WAAW;AAAA,kBACT;AAAA,kBACA,YAAY,gBACR,2CACA;AAAA,gBACN;AAAA,gBAEA,8BAAC,QAAK,WAAU,UAAS;AAAA;AAAA,YAC3B;AAAA,YAEF,qBAAC,SAAI,WAAU,uBACb;AAAA,kCAAC,oBAAkB,iBAAM;AAAA,cACxB,eAAe,oBAAC,0BAAwB,uBAAY;AAAA,eACvD;AAAA,aACF,GACF;AAAA,UAEC;AAAA,UAED,qBAAC,qBACC;AAAA,gCAAC,qBAAkB,UAAU,SAAU,uBAAY;AAAA,YACnD;AAAA,cAAC;AAAA;AAAA,gBACC,UAAU,WAAW;AAAA,gBACrB,SAAS,CAAC,UAAU;AAElB,wBAAM,eAAe;AACrB,uBAAK,cAAc;AAAA,gBACrB;AAAA,gBACA,WAAW,GAAG,YAAY,iBAAiB,eAAe,EAAE,SAAS,cAAc,CAAC,CAAC;AAAA,gBAEpF,oBAAU,eAAe;AAAA;AAAA,YAC5B;AAAA,aACF;AAAA;AAAA;AAAA,IACF;AAAA,KACF;AAEJ;","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":[]}
@@ -28,6 +28,23 @@ interface StatusTone {
28
28
  * `textColor` (`border-success-ink`), đừng hạ alpha xuống `/40`.
29
29
  */
30
30
  borderColor?: string;
31
+ /**
32
+ * Lớp tô CHẤM của `StatusPill variant="dot"` (tuỳ chọn) — một token TÔ NỀN viết thành lớp nền
33
+ * TĨNH (`"bg-severe"`), sơn làm `background-color` của chấm 6px. Không khai thì chấm giữ cách
34
+ * cũ: lấy `currentColor` của bậc ink trong `textColor`.
35
+ *
36
+ * Vì sao cần: bậc ink được chọn để đủ tối cho CHỮ, và ở cỡ chấm một bậc tối đọc ra một đốm xỉn
37
+ * — `--warning-ink` #7A5800 là "ô-liu" chứ không phải "gấp". Chấm là hình, không phải chữ, nên
38
+ * nó chỉ chịu ngưỡng 3:1 của WCAG 1.4.11 và dùng được chính bậc tô nền nếu bậc đó đủ đậm.
39
+ *
40
+ * ⚠ Token phải đạt **3:1 với nền trang ở CẢ hai theme** (--background/--card, và nên cả
41
+ * --muted vì chấm hay nằm trong dòng bảng). Bậc tô nền của các tone khác KHÔNG đạt: `--warning`
42
+ * 1,59 · `--primary` 1,86:1 trên nền trắng ✗; `--success` qua nền trắng (3,13) nhưng tụt trên
43
+ * --muted (2,92:1 ✗) — nên đừng thêm trường này cho tone nào chưa đo. Cổng tương phản KHÔNG
44
+ * thấy được cặp này: chuỗi lớp nằm ở bảng tone, còn phần tử chấm nằm ở `status-pill.tsx` — số đo
45
+ * ghi tay ở bảng dưới là bằng chứng duy nhất. Như mọi trường ở đây: lớp TĨNH, không `bg-${tone}`.
46
+ */
47
+ dotColor?: string;
31
48
  }
32
49
  interface StatusConfig<T extends string = string> extends StatusTone {
33
50
  value: T;
@@ -64,16 +81,33 @@ interface StatusConfig<T extends string = string> extends StatusTone {
64
81
  * text-success / text-info trên nền TRANG (outline, dot) = 3,13:1 / 4,14:1 ✗
65
82
  *
66
83
  * Đi qua bậc ink thì đạt ở CẢ hai theme (`--x-ink` ở `.dark` vốn trỏ về chính `--x`, nên không
67
- * cần biến thể `dark:` nào):
84
+ * cần biến thể `dark:` nào). Số của bảng TƯƠNG PHẢN CAO — bảng mà cổng chặn, như mọi số trong
85
+ * docs/hop-dong-mau.md:
68
86
  *
69
87
  * soft (trên tint của chính nó) outline · dot (trên nền trang)
70
88
  * success 5,60:1 (L) · 7,44:1 (D) 6,52:1 (L) · 10,85:1 (D)
71
89
  * info 5,54:1 (L) · 7,12:1 (D) 6,68:1 (L) · 10,16:1 (D)
72
90
  * destructive 5,76:1 (L) · 5,71:1 (D) 7,15:1 (L) · 6,84:1 (D)
73
91
  * warning 5,93:1 (L) · 7,19:1 (D) 6,51:1 (L) · 12,35:1 (D)
92
+ * severe 5,55:1 (L) · 6,09:1 (D) 6,66:1 (L) · 8,36:1 (D) ← chỉ outline; chấm: dưới
74
93
  * neutral 4,89:1 (L) · 4,86:1 (D) 5,23:1 (L) · 5,70:1 (D)
75
94
  * primary 5,77:1 (L) · 7,26:1 (D) 6,51:1 (L) · 12,67:1 (D)
76
95
  *
96
+ * ─ TONE `severe` (cam) — nấc giữa `warning` và `destructive` ─
97
+ * Ưu tiên cao, SLA sắp trễ — nấc mà GitHub Primer gọi là "severe". Có vì `warning` tô chấm bằng
98
+ * `--warning-ink` #7A5800: đạt chuẩn (6,51:1) nhưng ở cỡ chấm đọc ra ô-liu xỉn, không ra "gấp".
99
+ * Nó là tone DUY NHẤT có `dotColor`: chấm tô bằng chính bậc tô nền `--severe` (ngưỡng 3:1, vì chấm
100
+ * là hình) thay cho bậc ink tối. Đo ở CẢ HAI bảng màu — ở tone này bảng mặc định cũng đạt hết:
101
+ *
102
+ * soft (ink trên /15) outline (ink trên nền trang) dot (FILL trên nền trang)
103
+ * mặc định L #AC440F 4,87:1 5,85:1 (--muted 5,47) #EA580C 3,56:1 (--muted 3,32)
104
+ * tương phản L #9D3F10 5,55:1 6,66:1 (--muted 6,22) #EA580C 3,56:1 (--muted 3,32)
105
+ * cả hai D #FB923C 6,09:1 8,36:1 (--card 7,85) #FB923C 8,36:1 (--card 7,85)
106
+ *
107
+ * `/15` như `success`/`info`/`destructive` (không cần nấc `/20` của `warning`/`primary` — đó là
108
+ * cho vàng): tint cam đã nổi 1,20:1 so với nền trắng, hơn cả `success/15` (1,16:1). Trần tint của
109
+ * `text-severe-ink` là `/21` ở bảng mặc định (light) — vẫn giữ trần chung `/20` của hợp đồng.
110
+ *
77
111
  * `warning` không còn cần cặp `text-gold-500 dark:text-warning`: `--warning-ink` ĐÚNG BẰNG
78
112
  * `--gold-500` ở light và `--yellow` ở dark — cùng một màu, ít hơn một class và một nơi để lệch.
79
113
  *
@@ -120,6 +154,12 @@ declare const STATUS_TONES: {
120
154
  readonly textColor: "text-warning-ink";
121
155
  readonly borderColor: "border-warning-ink";
122
156
  };
157
+ readonly severe: {
158
+ readonly bgColor: "bg-severe/15";
159
+ readonly textColor: "text-severe-ink";
160
+ readonly borderColor: "border-severe-ink";
161
+ readonly dotColor: "bg-severe";
162
+ };
123
163
  readonly destructive: {
124
164
  readonly bgColor: "bg-destructive/15";
125
165
  readonly textColor: "text-destructive-ink";
@@ -7,6 +7,12 @@ const STATUS_TONES = {
7
7
  primary: { bgColor: "bg-primary/20", textColor: "text-primary-ink", borderColor: "border-primary-ink" },
8
8
  success: { bgColor: "bg-success/15", textColor: "text-success-ink", borderColor: "border-success-ink" },
9
9
  warning: { bgColor: "bg-warning/20", textColor: "text-warning-ink", borderColor: "border-warning-ink" },
10
+ severe: {
11
+ bgColor: "bg-severe/15",
12
+ textColor: "text-severe-ink",
13
+ borderColor: "border-severe-ink",
14
+ dotColor: "bg-severe"
15
+ },
10
16
  destructive: {
11
17
  bgColor: "bg-destructive/15",
12
18
  textColor: "text-destructive-ink",
@@ -1 +1 @@
1
- {"version":3,"sources":["../../../src/components/composite/status-config.ts"],"sourcesContent":["/**\n * Cơ chế StatusConfig generic — app khai map \"trạng thái → lớp màu TOKEN\".\n *\n * Vì sao generic thay vì liệt kê sẵn trạng thái: mỗi app Comitor có bộ trạng thái riêng\n * (Tasks: todo/doing/done; CRM: lead/qualified/won/lost; HR: nộp/duyệt/từ chối). Package\n * chỉ cấp KIỂU và bảng tone, còn nhãn tiếng Việt là chuyện của app.\n *\n * Bắt buộc dùng class token (`bg-success/15 text-success-ink`), KHÔNG hex — hex sẽ chết ở dark mode.\n *\n * @example\n * const TASK_STATUSES: StatusConfig<TaskStatus>[] = [\n * { value: \"todo\", label: \"Cần làm\", shortLabel: \"CL\", ...STATUS_TONES.neutral },\n * { value: \"doing\", label: \"Đang làm\", shortLabel: \"ĐL\", ...STATUS_TONES.info },\n * { value: \"done\", label: \"Hoàn thành\", shortLabel: \"HT\", ...STATUS_TONES.success }\n * ];\n */\n\nexport interface StatusTone {\n /** Lớp nền tint — token TÔ NỀN, vd \"bg-success/15\" */\n bgColor: string;\n /**\n * Lớp chữ — phải là bậc **ink** (`text-success-ink`), KHÔNG phải token tô nền (`text-success`).\n * Chữ chip đứng trên tint của chính màu đó nên đo theo AA 4,5:1; bậc tô nền không đạt ở light.\n */\n textColor: string;\n /**\n * Lớp viền (tuỳ chọn) — dùng cho biến thể `outline`. Ở biến thể đó nền trong suốt nên viền là\n * thứ DUY NHẤT khoanh vùng chip → chịu ngưỡng 3:1 của WCAG 1.4.11. Dùng cùng bậc ink với\n * `textColor` (`border-success-ink`), đừng hạ alpha xuống `/40`.\n */\n borderColor?: string;\n}\n\nexport interface StatusConfig<T extends string = string> extends StatusTone {\n value: T;\n label: string;\n /** Nhãn rút gọn cho bảng/chip hẹp (vd \"HT\" thay \"Hoàn thành\") */\n shortLabel?: string;\n /** Ưu tiên sắp xếp (nhỏ = lên trước) */\n priority?: number;\n /**\n * Class thêm cho DÒNG bảng khi trạng thái cần gây chú ý (vd quá hạn).\n *\n * ⚠ ĐỪNG để đây một lớp nền có alpha (`bg-destructive/5`…) nếu bảng có cột GHIM hoặc có\n * `striped`. Ô cột ghim lấy nền bằng `bg-inherit` từ hàng nên nó thừa hưởng luôn alpha và\n * trở nên trong mờ; còn một class nền trần thì thua sọc của `striped` về độ đặc tả nên mất\n * hẳn ở hàng chẵn. Dùng `outline` (như `isRowActive` của `DataTable` đang làm) hoặc nói trạng\n * thái bằng chip + màu chữ.\n */\n rowClassName?: string;\n}\n\n/**\n * Bảng tone dựng sẵn theo token semantic — trải (`...STATUS_TONES.success`) vào StatusConfig.\n *\n * Mọi chuỗi ở đây là class TĨNH để Tailwind quét được từ `dist`; tuyệt đối không sinh động\n * kiểu `bg-${tone}/15` (class động bị loại khỏi bundle của app).\n *\n * ─ VÌ SAO CHỮ VÀ VIỀN ĐỀU LÀ BẬC `--x-ink`, KHÔNG PHẢI `--x` ─\n *\n * `--success/--warning/--info/--destructive` là token TÔ NỀN. Đặt chúng làm màu CHỮ thì bản light\n * hỏng ở cả ba biến thể của `StatusPill` (đo theo WCAG, nền tint đã trộn với `--card` trắng):\n *\n * text-success trên bg-success/15 (#E0F2E9) = 2,69:1 ✗ (dưới cả ngưỡng 3:1)\n * text-info trên bg-info/15 (#DDEDEE) = 3,44:1 ✗\n * text-destructive trên bg-destructive/15 = 4,06:1 ✗\n * text-success / text-info trên nền TRANG (outline, dot) = 3,13:1 / 4,14:1 ✗\n *\n * Đi qua bậc ink thì đạt ở CẢ hai theme (`--x-ink` ở `.dark` vốn trỏ về chính `--x`, nên không\n * cần biến thể `dark:` nào):\n *\n * soft (trên tint của chính nó) outline · dot (trên nền trang)\n * success 5,60:1 (L) · 7,44:1 (D) 6,52:1 (L) · 10,85:1 (D)\n * info 5,54:1 (L) · 7,12:1 (D) 6,68:1 (L) · 10,16:1 (D)\n * destructive 5,76:1 (L) · 5,71:1 (D) 7,15:1 (L) · 6,84:1 (D)\n * warning 5,93:1 (L) · 7,19:1 (D) 6,51:1 (L) · 12,35:1 (D)\n * neutral 4,89:1 (L) · 4,86:1 (D) 5,23:1 (L) · 5,70:1 (D)\n * primary 5,77:1 (L) · 7,26:1 (D) 6,51:1 (L) · 12,67:1 (D)\n *\n * `warning` không còn cần cặp `text-gold-500 dark:text-warning`: `--warning-ink` ĐÚNG BẰNG\n * `--gold-500` ở light và `--yellow` ở dark — cùng một màu, ít hơn một class và một nơi để lệch.\n *\n * ─ TONE `primary` ĐI THEO `--primary`, KHÔNG PHẢI THANG `gold-*` ─\n * Trước đây tone này chôn cứng `bg-gold-100 text-gold-600 dark:bg-gold-500/25 dark:text-gold-100`.\n * Tương phản thì ĐẠT (7,77:1 L · 11,85:1 D soft; 9,58:1 L · 15,33:1 D outline — bảng cũ ghi\n * 11,90:1 cho ô dark, đó là số của `--primary-ink` trên `--card`, chép nhầm sang), nhưng SAI VAI\n * TRÒ: thang `gold-*` không có bản dark nên phải viết hai lớp `dark:`, và app đổi `--primary` cho\n * sản phẩm của mình thì chip \"primary\" vẫn cứ vàng. `bg-primary/20` + `text-primary-ink` đi theo\n * token ở cả hai theme bằng MỘT lớp — hợp đồng §5. Alpha `/20` (không phải `/15` như các tone\n * khác) để giữ đúng độ nổi của `bg-gold-100` cũ: nền chip ⇄ nền trang 1,13:1 thay vì 1,10:1,\n * cùng mức với `warning` — vàng luôn cần thêm một nấc mới thấy được.\n *\n * ─ VIỀN: VÌ SAO BỎ HẲN ALPHA ─\n * Ở `variant=\"outline\"` nền trong suốt nên viền là ranh giới DUY NHẤT của chip → ngưỡng 3:1 của\n * WCAG 1.4.11. Không cách hạ alpha nào qua được; đo trên nền trang, cả hai cơ sở đều trượt:\n *\n * border-<tone>/40 (token NỀN) light 1,21–1,84:1 · dark 1,51–2,86:1 ✗\n * border-<tone>/70 (token NỀN) light 1,38–3,09:1 · dark 2,38–6,45:1 ✗\n * border-<tone>-ink/40 (bậc ink) light 1,88–1,99:1 · dark 2,02–2,86:1 ✗\n * border-<tone>-ink/70 (bậc ink) light 3,34–3,69:1 · dark 3,87–6,45:1 — hết biên an toàn\n *\n * (Bảng cũ trộn hai cơ sở: dải `/40` là của token NỀN, còn con số `/70 = 3,34:1` là của bậc ink.)\n * Viền full-strength bằng đúng màu chữ: 6,51–7,15:1 (light) · 6,84–12,67:1 (dark) ✓.\n */\nexport const STATUS_TONES = {\n neutral: {\n bgColor: \"bg-muted\",\n textColor: \"text-muted-foreground\",\n borderColor: \"border-muted-foreground\"\n },\n primary: { bgColor: \"bg-primary/20\", textColor: \"text-primary-ink\", borderColor: \"border-primary-ink\" },\n success: { bgColor: \"bg-success/15\", textColor: \"text-success-ink\", borderColor: \"border-success-ink\" },\n warning: { bgColor: \"bg-warning/20\", textColor: \"text-warning-ink\", borderColor: \"border-warning-ink\" },\n destructive: {\n bgColor: \"bg-destructive/15\",\n textColor: \"text-destructive-ink\",\n borderColor: \"border-destructive-ink\"\n },\n info: { bgColor: \"bg-info/15\", textColor: \"text-info-ink\", borderColor: \"border-info-ink\" }\n} as const satisfies Record<string, StatusTone>;\n\nexport type StatusToneName = keyof typeof STATUS_TONES;\n\n/** Tìm config theo value trong danh sách app cung cấp. */\nexport function getStatusConfig<T extends string>(\n configs: readonly StatusConfig<T>[],\n value: T | null | undefined\n): StatusConfig<T> | undefined {\n if (value == null) return undefined;\n return configs.find((config) => config.value === value);\n}\n\n/** Lớp màu (nền tint + chữ) của một trạng thái; không tìm thấy → chuỗi rỗng. */\nexport function getStatusClasses<T extends string>(\n configs: readonly StatusConfig<T>[],\n value: T | null | undefined\n): string {\n const config = getStatusConfig(configs, value);\n if (!config) return \"\";\n return `${config.bgColor} ${config.textColor}`;\n}\n\n/** Nhãn hiển thị của trạng thái; không tìm thấy → trả về chính `value` để không mất dữ liệu. */\nexport function getStatusLabel<T extends string>(\n configs: readonly StatusConfig<T>[],\n value: T | null | undefined,\n options: { short?: boolean } = {}\n): string {\n const config = getStatusConfig(configs, value);\n if (!config) return value ?? \"\";\n return options.short ? (config.shortLabel ?? config.label) : config.label;\n}\n\n/** Sắp xếp theo `priority` (thiếu priority thì xuống cuối, giữ thứ tự khai báo). */\nexport function sortByStatusPriority<T extends string>(configs: readonly StatusConfig<T>[]): StatusConfig<T>[] {\n return [...configs].sort((a, b) => (a.priority ?? Number.MAX_SAFE_INTEGER) - (b.priority ?? Number.MAX_SAFE_INTEGER));\n}\n"],"mappings":"AAwGO,MAAM,eAAe;AAAA,EAC1B,SAAS;AAAA,IACP,SAAS;AAAA,IACT,WAAW;AAAA,IACX,aAAa;AAAA,EACf;AAAA,EACA,SAAS,EAAE,SAAS,iBAAiB,WAAW,oBAAoB,aAAa,qBAAqB;AAAA,EACtG,SAAS,EAAE,SAAS,iBAAiB,WAAW,oBAAoB,aAAa,qBAAqB;AAAA,EACtG,SAAS,EAAE,SAAS,iBAAiB,WAAW,oBAAoB,aAAa,qBAAqB;AAAA,EACtG,aAAa;AAAA,IACX,SAAS;AAAA,IACT,WAAW;AAAA,IACX,aAAa;AAAA,EACf;AAAA,EACA,MAAM,EAAE,SAAS,cAAc,WAAW,iBAAiB,aAAa,kBAAkB;AAC5F;AAKO,SAAS,gBACd,SACA,OAC6B;AAC7B,MAAI,SAAS,KAAM,QAAO;AAC1B,SAAO,QAAQ,KAAK,CAAC,WAAW,OAAO,UAAU,KAAK;AACxD;AAGO,SAAS,iBACd,SACA,OACQ;AACR,QAAM,SAAS,gBAAgB,SAAS,KAAK;AAC7C,MAAI,CAAC,OAAQ,QAAO;AACpB,SAAO,GAAG,OAAO,OAAO,IAAI,OAAO,SAAS;AAC9C;AAGO,SAAS,eACd,SACA,OACA,UAA+B,CAAC,GACxB;AACR,QAAM,SAAS,gBAAgB,SAAS,KAAK;AAC7C,MAAI,CAAC,OAAQ,QAAO,SAAS;AAC7B,SAAO,QAAQ,QAAS,OAAO,cAAc,OAAO,QAAS,OAAO;AACtE;AAGO,SAAS,qBAAuC,SAAwD;AAC7G,SAAO,CAAC,GAAG,OAAO,EAAE,KAAK,CAAC,GAAG,OAAO,EAAE,YAAY,OAAO,qBAAqB,EAAE,YAAY,OAAO,iBAAiB;AACtH;","names":[]}
1
+ {"version":3,"sources":["../../../src/components/composite/status-config.ts"],"sourcesContent":["/**\n * Cơ chế StatusConfig generic — app khai map \"trạng thái → lớp màu TOKEN\".\n *\n * Vì sao generic thay vì liệt kê sẵn trạng thái: mỗi app Comitor có bộ trạng thái riêng\n * (Tasks: todo/doing/done; CRM: lead/qualified/won/lost; HR: nộp/duyệt/từ chối). Package\n * chỉ cấp KIỂU và bảng tone, còn nhãn tiếng Việt là chuyện của app.\n *\n * Bắt buộc dùng class token (`bg-success/15 text-success-ink`), KHÔNG hex — hex sẽ chết ở dark mode.\n *\n * @example\n * const TASK_STATUSES: StatusConfig<TaskStatus>[] = [\n * { value: \"todo\", label: \"Cần làm\", shortLabel: \"CL\", ...STATUS_TONES.neutral },\n * { value: \"doing\", label: \"Đang làm\", shortLabel: \"ĐL\", ...STATUS_TONES.info },\n * { value: \"done\", label: \"Hoàn thành\", shortLabel: \"HT\", ...STATUS_TONES.success }\n * ];\n */\n\nexport interface StatusTone {\n /** Lớp nền tint — token TÔ NỀN, vd \"bg-success/15\" */\n bgColor: string;\n /**\n * Lớp chữ — phải là bậc **ink** (`text-success-ink`), KHÔNG phải token tô nền (`text-success`).\n * Chữ chip đứng trên tint của chính màu đó nên đo theo AA 4,5:1; bậc tô nền không đạt ở light.\n */\n textColor: string;\n /**\n * Lớp viền (tuỳ chọn) — dùng cho biến thể `outline`. Ở biến thể đó nền trong suốt nên viền là\n * thứ DUY NHẤT khoanh vùng chip → chịu ngưỡng 3:1 của WCAG 1.4.11. Dùng cùng bậc ink với\n * `textColor` (`border-success-ink`), đừng hạ alpha xuống `/40`.\n */\n borderColor?: string;\n /**\n * Lớp tô CHẤM của `StatusPill variant=\"dot\"` (tuỳ chọn) — một token TÔ NỀN viết thành lớp nền\n * TĨNH (`\"bg-severe\"`), sơn làm `background-color` của chấm 6px. Không khai thì chấm giữ cách\n * cũ: lấy `currentColor` của bậc ink trong `textColor`.\n *\n * Vì sao cần: bậc ink được chọn để đủ tối cho CHỮ, và ở cỡ chấm một bậc tối đọc ra một đốm xỉn\n * — `--warning-ink` #7A5800 là \"ô-liu\" chứ không phải \"gấp\". Chấm là hình, không phải chữ, nên\n * nó chỉ chịu ngưỡng 3:1 của WCAG 1.4.11 và dùng được chính bậc tô nền nếu bậc đó đủ đậm.\n *\n * ⚠ Token phải đạt **3:1 với nền trang ở CẢ hai theme** (--background/--card, và nên cả\n * --muted vì chấm hay nằm trong dòng bảng). Bậc tô nền của các tone khác KHÔNG đạt: `--warning`\n * 1,59 · `--primary` 1,86:1 trên nền trắng ✗; `--success` qua nền trắng (3,13) nhưng tụt trên\n * --muted (2,92:1 ✗) — nên đừng thêm trường này cho tone nào chưa đo. Cổng tương phản KHÔNG\n * thấy được cặp này: chuỗi lớp nằm ở bảng tone, còn phần tử chấm nằm ở `status-pill.tsx` — số đo\n * ghi tay ở bảng dưới là bằng chứng duy nhất. Như mọi trường ở đây: lớp TĨNH, không `bg-${tone}`.\n */\n dotColor?: string;\n}\n\nexport interface StatusConfig<T extends string = string> extends StatusTone {\n value: T;\n label: string;\n /** Nhãn rút gọn cho bảng/chip hẹp (vd \"HT\" thay \"Hoàn thành\") */\n shortLabel?: string;\n /** Ưu tiên sắp xếp (nhỏ = lên trước) */\n priority?: number;\n /**\n * Class thêm cho DÒNG bảng khi trạng thái cần gây chú ý (vd quá hạn).\n *\n * ⚠ ĐỪNG để đây một lớp nền có alpha (`bg-destructive/5`…) nếu bảng có cột GHIM hoặc có\n * `striped`. Ô cột ghim lấy nền bằng `bg-inherit` từ hàng nên nó thừa hưởng luôn alpha và\n * trở nên trong mờ; còn một class nền trần thì thua sọc của `striped` về độ đặc tả nên mất\n * hẳn ở hàng chẵn. Dùng `outline` (như `isRowActive` của `DataTable` đang làm) hoặc nói trạng\n * thái bằng chip + màu chữ.\n */\n rowClassName?: string;\n}\n\n/**\n * Bảng tone dựng sẵn theo token semantic — trải (`...STATUS_TONES.success`) vào StatusConfig.\n *\n * Mọi chuỗi ở đây là class TĨNH để Tailwind quét được từ `dist`; tuyệt đối không sinh động\n * kiểu `bg-${tone}/15` (class động bị loại khỏi bundle của app).\n *\n * ─ VÌ SAO CHỮ VÀ VIỀN ĐỀU LÀ BẬC `--x-ink`, KHÔNG PHẢI `--x` ─\n *\n * `--success/--warning/--info/--destructive` là token TÔ NỀN. Đặt chúng làm màu CHỮ thì bản light\n * hỏng ở cả ba biến thể của `StatusPill` (đo theo WCAG, nền tint đã trộn với `--card` trắng):\n *\n * text-success trên bg-success/15 (#E0F2E9) = 2,69:1 ✗ (dưới cả ngưỡng 3:1)\n * text-info trên bg-info/15 (#DDEDEE) = 3,44:1 ✗\n * text-destructive trên bg-destructive/15 = 4,06:1 ✗\n * text-success / text-info trên nền TRANG (outline, dot) = 3,13:1 / 4,14:1 ✗\n *\n * Đi qua bậc ink thì đạt ở CẢ hai theme (`--x-ink` ở `.dark` vốn trỏ về chính `--x`, nên không\n * cần biến thể `dark:` nào). Số của bảng TƯƠNG PHẢN CAO — bảng mà cổng chặn, như mọi số trong\n * docs/hop-dong-mau.md:\n *\n * soft (trên tint của chính nó) outline · dot (trên nền trang)\n * success 5,60:1 (L) · 7,44:1 (D) 6,52:1 (L) · 10,85:1 (D)\n * info 5,54:1 (L) · 7,12:1 (D) 6,68:1 (L) · 10,16:1 (D)\n * destructive 5,76:1 (L) · 5,71:1 (D) 7,15:1 (L) · 6,84:1 (D)\n * warning 5,93:1 (L) · 7,19:1 (D) 6,51:1 (L) · 12,35:1 (D)\n * severe 5,55:1 (L) · 6,09:1 (D) 6,66:1 (L) · 8,36:1 (D) ← chỉ outline; chấm: dưới\n * neutral 4,89:1 (L) · 4,86:1 (D) 5,23:1 (L) · 5,70:1 (D)\n * primary 5,77:1 (L) · 7,26:1 (D) 6,51:1 (L) · 12,67:1 (D)\n *\n * ─ TONE `severe` (cam) — nấc giữa `warning` và `destructive` ─\n * Ưu tiên cao, SLA sắp trễ — nấc mà GitHub Primer gọi là \"severe\". Có vì `warning` tô chấm bằng\n * `--warning-ink` #7A5800: đạt chuẩn (6,51:1) nhưng ở cỡ chấm đọc ra ô-liu xỉn, không ra \"gấp\".\n * Nó là tone DUY NHẤT có `dotColor`: chấm tô bằng chính bậc tô nền `--severe` (ngưỡng 3:1, vì chấm\n * là hình) thay cho bậc ink tối. Đo ở CẢ HAI bảng màu — ở tone này bảng mặc định cũng đạt hết:\n *\n * soft (ink trên /15) outline (ink trên nền trang) dot (FILL trên nền trang)\n * mặc định L #AC440F 4,87:1 5,85:1 (--muted 5,47) #EA580C 3,56:1 (--muted 3,32)\n * tương phản L #9D3F10 5,55:1 6,66:1 (--muted 6,22) #EA580C 3,56:1 (--muted 3,32)\n * cả hai D #FB923C 6,09:1 8,36:1 (--card 7,85) #FB923C 8,36:1 (--card 7,85)\n *\n * `/15` như `success`/`info`/`destructive` (không cần nấc `/20` của `warning`/`primary` — đó là\n * cho vàng): tint cam đã nổi 1,20:1 so với nền trắng, hơn cả `success/15` (1,16:1). Trần tint của\n * `text-severe-ink` là `/21` ở bảng mặc định (light) — vẫn giữ trần chung `/20` của hợp đồng.\n *\n * `warning` không còn cần cặp `text-gold-500 dark:text-warning`: `--warning-ink` ĐÚNG BẰNG\n * `--gold-500` ở light và `--yellow` ở dark — cùng một màu, ít hơn một class và một nơi để lệch.\n *\n * ─ TONE `primary` ĐI THEO `--primary`, KHÔNG PHẢI THANG `gold-*` ─\n * Trước đây tone này chôn cứng `bg-gold-100 text-gold-600 dark:bg-gold-500/25 dark:text-gold-100`.\n * Tương phản thì ĐẠT (7,77:1 L · 11,85:1 D soft; 9,58:1 L · 15,33:1 D outline — bảng cũ ghi\n * 11,90:1 cho ô dark, đó là số của `--primary-ink` trên `--card`, chép nhầm sang), nhưng SAI VAI\n * TRÒ: thang `gold-*` không có bản dark nên phải viết hai lớp `dark:`, và app đổi `--primary` cho\n * sản phẩm của mình thì chip \"primary\" vẫn cứ vàng. `bg-primary/20` + `text-primary-ink` đi theo\n * token ở cả hai theme bằng MỘT lớp — hợp đồng §5. Alpha `/20` (không phải `/15` như các tone\n * khác) để giữ đúng độ nổi của `bg-gold-100` cũ: nền chip ⇄ nền trang 1,13:1 thay vì 1,10:1,\n * cùng mức với `warning` — vàng luôn cần thêm một nấc mới thấy được.\n *\n * ─ VIỀN: VÌ SAO BỎ HẲN ALPHA ─\n * Ở `variant=\"outline\"` nền trong suốt nên viền là ranh giới DUY NHẤT của chip → ngưỡng 3:1 của\n * WCAG 1.4.11. Không cách hạ alpha nào qua được; đo trên nền trang, cả hai cơ sở đều trượt:\n *\n * border-<tone>/40 (token NỀN) light 1,21–1,84:1 · dark 1,51–2,86:1 ✗\n * border-<tone>/70 (token NỀN) light 1,38–3,09:1 · dark 2,38–6,45:1 ✗\n * border-<tone>-ink/40 (bậc ink) light 1,88–1,99:1 · dark 2,02–2,86:1 ✗\n * border-<tone>-ink/70 (bậc ink) light 3,34–3,69:1 · dark 3,87–6,45:1 — hết biên an toàn\n *\n * (Bảng cũ trộn hai cơ sở: dải `/40` là của token NỀN, còn con số `/70 = 3,34:1` là của bậc ink.)\n * Viền full-strength bằng đúng màu chữ: 6,51–7,15:1 (light) · 6,84–12,67:1 (dark) ✓.\n */\nexport const STATUS_TONES = {\n neutral: {\n bgColor: \"bg-muted\",\n textColor: \"text-muted-foreground\",\n borderColor: \"border-muted-foreground\"\n },\n primary: { bgColor: \"bg-primary/20\", textColor: \"text-primary-ink\", borderColor: \"border-primary-ink\" },\n success: { bgColor: \"bg-success/15\", textColor: \"text-success-ink\", borderColor: \"border-success-ink\" },\n warning: { bgColor: \"bg-warning/20\", textColor: \"text-warning-ink\", borderColor: \"border-warning-ink\" },\n severe: {\n bgColor: \"bg-severe/15\",\n textColor: \"text-severe-ink\",\n borderColor: \"border-severe-ink\",\n dotColor: \"bg-severe\"\n },\n destructive: {\n bgColor: \"bg-destructive/15\",\n textColor: \"text-destructive-ink\",\n borderColor: \"border-destructive-ink\"\n },\n info: { bgColor: \"bg-info/15\", textColor: \"text-info-ink\", borderColor: \"border-info-ink\" }\n} as const satisfies Record<string, StatusTone>;\n\nexport type StatusToneName = keyof typeof STATUS_TONES;\n\n/** Tìm config theo value trong danh sách app cung cấp. */\nexport function getStatusConfig<T extends string>(\n configs: readonly StatusConfig<T>[],\n value: T | null | undefined\n): StatusConfig<T> | undefined {\n if (value == null) return undefined;\n return configs.find((config) => config.value === value);\n}\n\n/** Lớp màu (nền tint + chữ) của một trạng thái; không tìm thấy → chuỗi rỗng. */\nexport function getStatusClasses<T extends string>(\n configs: readonly StatusConfig<T>[],\n value: T | null | undefined\n): string {\n const config = getStatusConfig(configs, value);\n if (!config) return \"\";\n return `${config.bgColor} ${config.textColor}`;\n}\n\n/** Nhãn hiển thị của trạng thái; không tìm thấy → trả về chính `value` để không mất dữ liệu. */\nexport function getStatusLabel<T extends string>(\n configs: readonly StatusConfig<T>[],\n value: T | null | undefined,\n options: { short?: boolean } = {}\n): string {\n const config = getStatusConfig(configs, value);\n if (!config) return value ?? \"\";\n return options.short ? (config.shortLabel ?? config.label) : config.label;\n}\n\n/** Sắp xếp theo `priority` (thiếu priority thì xuống cuối, giữ thứ tự khai báo). */\nexport function sortByStatusPriority<T extends string>(configs: readonly StatusConfig<T>[]): StatusConfig<T>[] {\n return [...configs].sort((a, b) => (a.priority ?? Number.MAX_SAFE_INTEGER) - (b.priority ?? Number.MAX_SAFE_INTEGER));\n}\n"],"mappings":"AA0IO,MAAM,eAAe;AAAA,EAC1B,SAAS;AAAA,IACP,SAAS;AAAA,IACT,WAAW;AAAA,IACX,aAAa;AAAA,EACf;AAAA,EACA,SAAS,EAAE,SAAS,iBAAiB,WAAW,oBAAoB,aAAa,qBAAqB;AAAA,EACtG,SAAS,EAAE,SAAS,iBAAiB,WAAW,oBAAoB,aAAa,qBAAqB;AAAA,EACtG,SAAS,EAAE,SAAS,iBAAiB,WAAW,oBAAoB,aAAa,qBAAqB;AAAA,EACtG,QAAQ;AAAA,IACN,SAAS;AAAA,IACT,WAAW;AAAA,IACX,aAAa;AAAA,IACb,UAAU;AAAA,EACZ;AAAA,EACA,aAAa;AAAA,IACX,SAAS;AAAA,IACT,WAAW;AAAA,IACX,aAAa;AAAA,EACf;AAAA,EACA,MAAM,EAAE,SAAS,cAAc,WAAW,iBAAiB,aAAa,kBAAkB;AAC5F;AAKO,SAAS,gBACd,SACA,OAC6B;AAC7B,MAAI,SAAS,KAAM,QAAO;AAC1B,SAAO,QAAQ,KAAK,CAAC,WAAW,OAAO,UAAU,KAAK;AACxD;AAGO,SAAS,iBACd,SACA,OACQ;AACR,QAAM,SAAS,gBAAgB,SAAS,KAAK;AAC7C,MAAI,CAAC,OAAQ,QAAO;AACpB,SAAO,GAAG,OAAO,OAAO,IAAI,OAAO,SAAS;AAC9C;AAGO,SAAS,eACd,SACA,OACA,UAA+B,CAAC,GACxB;AACR,QAAM,SAAS,gBAAgB,SAAS,KAAK;AAC7C,MAAI,CAAC,OAAQ,QAAO,SAAS;AAC7B,SAAO,QAAQ,QAAS,OAAO,cAAc,OAAO,QAAS,OAAO;AACtE;AAGO,SAAS,qBAAuC,SAAwD;AAC7G,SAAO,CAAC,GAAG,OAAO,EAAE,KAAK,CAAC,GAAG,OAAO,EAAE,YAAY,OAAO,qBAAqB,EAAE,YAAY,OAAO,iBAAiB;AACtH;","names":[]}
@@ -9,7 +9,10 @@ interface StatusPillProps<T extends string = string> extends Omit<ComponentProps
9
9
  value: T | null | undefined;
10
10
  /** Dùng shortLabel nếu có (bảng hẹp) */
11
11
  short?: boolean;
12
- /** "soft" = nền tint (mặc định), "outline" = chỉ viền + chữ, "dot" = chấm màu + chữ thường */
12
+ /**
13
+ * "soft" = nền tint (mặc định), "outline" = chỉ viền + chữ, "dot" = chấm màu + chữ thường (chấm
14
+ * tô bằng `dotColor` của tone nếu có, không thì bằng màu chữ của tone)
15
+ */
13
16
  variant?: StatusPillVariant;
14
17
  /** Nhãn thay thế khi `value` không có trong `configs` */
15
18
  fallbackLabel?: string;
@@ -15,7 +15,7 @@ function StatusPill({
15
15
  const base = "inline-flex items-center gap-1.5 whitespace-nowrap rounded-md px-2 py-0.5 text-xs font-medium";
16
16
  if (variant === "dot") {
17
17
  return /* @__PURE__ */ jsxs("span", { "data-slot": "status-pill", className: cn(base, "px-0 text-foreground", className), ...props, children: [
18
- /* @__PURE__ */ jsx(
18
+ config?.dotColor ? /* @__PURE__ */ jsx("span", { "aria-hidden": "true", className: cn("size-1.5 shrink-0 rounded-full", config.dotColor) }) : /* @__PURE__ */ jsx(
19
19
  "span",
20
20
  {
21
21
  "aria-hidden": "true",
@@ -1 +1 @@
1
- {"version":3,"sources":["../../../src/components/composite/status-pill.tsx"],"sourcesContent":["import type { ComponentProps } from \"react\";\nimport { cn } from \"../../lib/cn.js\";\nimport type { StatusConfig } from \"./status-config.js\";\nimport { getStatusConfig } from \"./status-config.js\";\n\nexport interface StatusPillProps<T extends string = string> extends Omit<ComponentProps<\"span\">, \"children\"> {\n /** Toàn bộ bảng trạng thái của app */\n configs: readonly StatusConfig<T>[];\n /** Trạng thái cần hiển thị */\n value: T | null | undefined;\n /** Dùng shortLabel nếu có (bảng hẹp) */\n short?: boolean;\n /** \"soft\" = nền tint (mặc định), \"outline\" = chỉ viền + chữ, \"dot\" = chấm màu + chữ thường */\n variant?: StatusPillVariant;\n /** Nhãn thay thế khi `value` không có trong `configs` */\n fallbackLabel?: string;\n}\n\nexport type StatusPillVariant = \"soft\" | \"outline\" | \"dot\";\n\n/**\n * Chip trạng thái tô theo StatusConfig của app (màu lấy từ token, không hex).\n *\n * Tĩnh hoàn toàn → dùng được trong React Server Component (bảng danh sách render phía server).\n * Trạng thái lạ (dữ liệu cũ, enum mới ở backend) KHÔNG bị nuốt: vẫn hiện nhãn ở tone neutral.\n */\nexport function StatusPill<T extends string = string>({\n configs,\n value,\n short = false,\n variant = \"soft\",\n fallbackLabel,\n className,\n ...props\n}: StatusPillProps<T>) {\n const config = getStatusConfig(configs, value);\n const label = config ? (short ? (config.shortLabel ?? config.label) : config.label) : (fallbackLabel ?? value ?? \"—\");\n\n const base = \"inline-flex items-center gap-1.5 whitespace-nowrap rounded-md px-2 py-0.5 text-xs font-medium\";\n\n if (variant === \"dot\") {\n return (\n <span data-slot=\"status-pill\" className={cn(base, \"px-0 text-foreground\", className)} {...props}>\n <span\n aria-hidden=\"true\"\n className={cn(\"size-1.5 shrink-0 rounded-full\", config ? config.textColor : \"text-muted-foreground\")}\n style={{ backgroundColor: \"currentColor\" }}\n />\n {label}\n </span>\n );\n }\n\n if (variant === \"outline\") {\n return (\n <span\n data-slot=\"status-pill\"\n className={cn(\n base,\n \"border bg-transparent\",\n // Lui về `border-muted-foreground` chứ KHÔNG phải `border-border`: ở biến thể này nền\n // trong suốt nên viền là thứ DUY NHẤT khoanh vùng chip → ngưỡng 3:1. `--border` là bậc\n // TRANG TRÍ, chỉ 1,16:1 (L) · 1,17:1 (D) so với nền trang ✗. `--muted-foreground` đạt\n // 5,23:1 (L) · 5,70:1 (D) và khớp đúng màu chữ lui bên dưới.\n config?.borderColor ?? \"border-muted-foreground\",\n config?.textColor ?? \"text-muted-foreground\",\n className\n )}\n {...props}\n >\n {label}\n </span>\n );\n }\n\n return (\n <span\n data-slot=\"status-pill\"\n className={cn(base, config?.bgColor ?? \"bg-muted\", config?.textColor ?? \"text-muted-foreground\", className)}\n {...props}\n >\n {label}\n </span>\n );\n}\n"],"mappings":"AA0CM,SACE,KADF;AAzCN,SAAS,UAAU;AAEnB,SAAS,uBAAuB;AAuBzB,SAAS,WAAsC;AAAA,EACpD;AAAA,EACA;AAAA,EACA,QAAQ;AAAA,EACR,UAAU;AAAA,EACV;AAAA,EACA;AAAA,EACA,GAAG;AACL,GAAuB;AACrB,QAAM,SAAS,gBAAgB,SAAS,KAAK;AAC7C,QAAM,QAAQ,SAAU,QAAS,OAAO,cAAc,OAAO,QAAS,OAAO,QAAU,iBAAiB,SAAS;AAEjH,QAAM,OAAO;AAEb,MAAI,YAAY,OAAO;AACrB,WACE,qBAAC,UAAK,aAAU,eAAc,WAAW,GAAG,MAAM,wBAAwB,SAAS,GAAI,GAAG,OACxF;AAAA;AAAA,QAAC;AAAA;AAAA,UACC,eAAY;AAAA,UACZ,WAAW,GAAG,kCAAkC,SAAS,OAAO,YAAY,uBAAuB;AAAA,UACnG,OAAO,EAAE,iBAAiB,eAAe;AAAA;AAAA,MAC3C;AAAA,MACC;AAAA,OACH;AAAA,EAEJ;AAEA,MAAI,YAAY,WAAW;AACzB,WACE;AAAA,MAAC;AAAA;AAAA,QACC,aAAU;AAAA,QACV,WAAW;AAAA,UACT;AAAA,UACA;AAAA;AAAA;AAAA;AAAA;AAAA,UAKA,QAAQ,eAAe;AAAA,UACvB,QAAQ,aAAa;AAAA,UACrB;AAAA,QACF;AAAA,QACC,GAAG;AAAA,QAEH;AAAA;AAAA,IACH;AAAA,EAEJ;AAEA,SACE;AAAA,IAAC;AAAA;AAAA,MACC,aAAU;AAAA,MACV,WAAW,GAAG,MAAM,QAAQ,WAAW,YAAY,QAAQ,aAAa,yBAAyB,SAAS;AAAA,MACzG,GAAG;AAAA,MAEH;AAAA;AAAA,EACH;AAEJ;","names":[]}
1
+ {"version":3,"sources":["../../../src/components/composite/status-pill.tsx"],"sourcesContent":["import type { ComponentProps } from \"react\";\nimport { cn } from \"../../lib/cn.js\";\nimport type { StatusConfig } from \"./status-config.js\";\nimport { getStatusConfig } from \"./status-config.js\";\n\nexport interface StatusPillProps<T extends string = string> extends Omit<ComponentProps<\"span\">, \"children\"> {\n /** Toàn bộ bảng trạng thái của app */\n configs: readonly StatusConfig<T>[];\n /** Trạng thái cần hiển thị */\n value: T | null | undefined;\n /** Dùng shortLabel nếu có (bảng hẹp) */\n short?: boolean;\n /**\n * \"soft\" = nền tint (mặc định), \"outline\" = chỉ viền + chữ, \"dot\" = chấm màu + chữ thường (chấm\n * tô bằng `dotColor` của tone nếu có, không thì bằng màu chữ của tone)\n */\n variant?: StatusPillVariant;\n /** Nhãn thay thế khi `value` không có trong `configs` */\n fallbackLabel?: string;\n}\n\nexport type StatusPillVariant = \"soft\" | \"outline\" | \"dot\";\n\n/**\n * Chip trạng thái tô theo StatusConfig của app (màu lấy từ token, không hex).\n *\n * Tĩnh hoàn toàn → dùng được trong React Server Component (bảng danh sách render phía server).\n * Trạng thái lạ (dữ liệu cũ, enum mới ở backend) KHÔNG bị nuốt: vẫn hiện nhãn ở tone neutral.\n */\nexport function StatusPill<T extends string = string>({\n configs,\n value,\n short = false,\n variant = \"soft\",\n fallbackLabel,\n className,\n ...props\n}: StatusPillProps<T>) {\n const config = getStatusConfig(configs, value);\n const label = config ? (short ? (config.shortLabel ?? config.label) : config.label) : (fallbackLabel ?? value ?? \"—\");\n\n const base = \"inline-flex items-center gap-1.5 whitespace-nowrap rounded-md px-2 py-0.5 text-xs font-medium\";\n\n if (variant === \"dot\") {\n // Tone có `dotColor` (hiện chỉ `severe`): chấm tô bằng token TÔ NỀN đó — đã đo 3:1 với nền trang,\n // vì ở cỡ 6px bậc ink (đủ tối cho CHỮ) đọc ra một đốm xỉn. Tone không khai thì giữ NGUYÊN phần tử\n // cũ: chấm lấy `currentColor` của bậc ink trong `textColor`.\n // Hai nhánh JSX tách hẳn chứ không gắn thuộc tính style theo điều kiện: style không phải object\n // literal là chỗ im lặng duy nhất của cổng tương phản (docs/hop-dong-mau.md §8, \"Còn lại\" mục 1).\n return (\n <span data-slot=\"status-pill\" className={cn(base, \"px-0 text-foreground\", className)} {...props}>\n {config?.dotColor ? (\n <span aria-hidden=\"true\" className={cn(\"size-1.5 shrink-0 rounded-full\", config.dotColor)} />\n ) : (\n <span\n aria-hidden=\"true\"\n className={cn(\"size-1.5 shrink-0 rounded-full\", config ? config.textColor : \"text-muted-foreground\")}\n style={{ backgroundColor: \"currentColor\" }}\n />\n )}\n {label}\n </span>\n );\n }\n\n if (variant === \"outline\") {\n return (\n <span\n data-slot=\"status-pill\"\n className={cn(\n base,\n \"border bg-transparent\",\n // Lui về `border-muted-foreground` chứ KHÔNG phải `border-border`: ở biến thể này nền\n // trong suốt nên viền là thứ DUY NHẤT khoanh vùng chip → ngưỡng 3:1. `--border` là bậc\n // TRANG TRÍ, chỉ 1,16:1 (L) · 1,17:1 (D) so với nền trang ✗. `--muted-foreground` đạt\n // 5,23:1 (L) · 5,70:1 (D) và khớp đúng màu chữ lui bên dưới.\n config?.borderColor ?? \"border-muted-foreground\",\n config?.textColor ?? \"text-muted-foreground\",\n className\n )}\n {...props}\n >\n {label}\n </span>\n );\n }\n\n return (\n <span\n data-slot=\"status-pill\"\n className={cn(base, config?.bgColor ?? \"bg-muted\", config?.textColor ?? \"text-muted-foreground\", className)}\n {...props}\n >\n {label}\n </span>\n );\n}\n"],"mappings":"AAkDM,SAEI,KAFJ;AAjDN,SAAS,UAAU;AAEnB,SAAS,uBAAuB;AA0BzB,SAAS,WAAsC;AAAA,EACpD;AAAA,EACA;AAAA,EACA,QAAQ;AAAA,EACR,UAAU;AAAA,EACV;AAAA,EACA;AAAA,EACA,GAAG;AACL,GAAuB;AACrB,QAAM,SAAS,gBAAgB,SAAS,KAAK;AAC7C,QAAM,QAAQ,SAAU,QAAS,OAAO,cAAc,OAAO,QAAS,OAAO,QAAU,iBAAiB,SAAS;AAEjH,QAAM,OAAO;AAEb,MAAI,YAAY,OAAO;AAMrB,WACE,qBAAC,UAAK,aAAU,eAAc,WAAW,GAAG,MAAM,wBAAwB,SAAS,GAAI,GAAG,OACvF;AAAA,cAAQ,WACP,oBAAC,UAAK,eAAY,QAAO,WAAW,GAAG,kCAAkC,OAAO,QAAQ,GAAG,IAE3F;AAAA,QAAC;AAAA;AAAA,UACC,eAAY;AAAA,UACZ,WAAW,GAAG,kCAAkC,SAAS,OAAO,YAAY,uBAAuB;AAAA,UACnG,OAAO,EAAE,iBAAiB,eAAe;AAAA;AAAA,MAC3C;AAAA,MAED;AAAA,OACH;AAAA,EAEJ;AAEA,MAAI,YAAY,WAAW;AACzB,WACE;AAAA,MAAC;AAAA;AAAA,QACC,aAAU;AAAA,QACV,WAAW;AAAA,UACT;AAAA,UACA;AAAA;AAAA;AAAA;AAAA;AAAA,UAKA,QAAQ,eAAe;AAAA,UACvB,QAAQ,aAAa;AAAA,UACrB;AAAA,QACF;AAAA,QACC,GAAG;AAAA,QAEH;AAAA;AAAA,IACH;AAAA,EAEJ;AAEA,SACE;AAAA,IAAC;AAAA;AAAA,MACC,aAAU;AAAA,MACV,WAAW,GAAG,MAAM,QAAQ,WAAW,YAAY,QAAQ,aAAa,yBAAyB,SAAS;AAAA,MACzG,GAAG;AAAA,MAEH;AAAA;AAAA,EACH;AAEJ;","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":[]}