@comitor/ui 3.2.0 → 3.3.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,6 +1,6 @@
1
1
  "use client";
2
2
  import { jsx, jsxs } from "react/jsx-runtime";
3
- import { useRef, useState } from "react";
3
+ import { useState } from "react";
4
4
  import { useComitorMessages } from "../../i18n/locale-provider.js";
5
5
  import { cn } from "../../lib/cn.js";
6
6
  import {
@@ -38,7 +38,6 @@ 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);
42
41
  const isOpen = open ?? internalOpen;
43
42
  const setOpen = (next) => {
44
43
  if (pending && !next) return;
@@ -60,61 +59,41 @@ function ConfirmDialog({
60
59
  };
61
60
  return /* @__PURE__ */ jsxs(AlertDialog, { open: isOpen, onOpenChange: setOpen, children: [
62
61
  trigger && /* @__PURE__ */ jsx(AlertDialogTrigger, { asChild: true, children: trigger }),
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
- }
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"
93
71
  ),
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
- )
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
+ ] })
118
97
  ] });
119
98
  }
120
99
  export {
@@ -1 +1 @@
1
- {"version":3,"sources":["../../../src/components/composite/confirm-dialog.tsx"],"sourcesContent":["\"use client\";\n\nimport { type ReactNode, useRef, useState } from \"react\";\nimport { useComitorMessages } from \"../../i18n/locale-provider.js\";\nimport { cn } from \"../../lib/cn.js\";\nimport {\n AlertDialog,\n AlertDialogAction,\n AlertDialogCancel,\n AlertDialogContent,\n AlertDialogDescription,\n AlertDialogFooter,\n AlertDialogHeader,\n AlertDialogTitle,\n AlertDialogTrigger\n} from \"../ui/alert-dialog.js\";\nimport { buttonVariants } from \"../ui/button.js\";\nimport type { IconComponent } from \"./empty-state.js\";\n\nexport interface ConfirmDialogProps {\n /** Controlled — bỏ trống thì dialog tự quản trạng thái mở (dùng kèm `trigger`) */\n open?: boolean;\n onOpenChange?: (open: boolean) => void;\n /** Phần tử mở dialog (đặt trong `AlertDialogTrigger asChild`) */\n trigger?: ReactNode;\n\n title: ReactNode;\n description?: ReactNode;\n /** Nội dung phụ (danh sách bản ghi sẽ bị ảnh hưởng, ô nhập lý do…) */\n children?: ReactNode;\n icon?: IconComponent;\n\n /*\n * Ba nhãn nút dưới đây: không truyền thì lấy namespace `confirmDialog` của\n * `ComitorLocaleProvider` (mặc định tiếng Anh). Truyền prop là đè riêng chỗ này.\n */\n confirmLabel?: string;\n cancelLabel?: string;\n /**\n * Nhãn nút xác nhận trong lúc `onConfirm` bất đồng bộ đang chạy (khoá `pendingLabel`).\n *\n * Phải đè được chứ không được chôn cứng: tài liệu khuyến khích `onConfirm` trả về Promise, nên\n * đây KHÔNG phải nhánh hiếm — app đặt `confirmLabel=\"Xoá dự án\"` mà quên chuỗi này sẽ thấy nút\n * nhảy sang câu chung chung của bảng đúng vào giây người dùng đang nhìn nó.\n */\n pendingLabel?: string;\n /** \"destructive\" tô nút xác nhận màu đỏ — dùng cho xoá/thu hồi */\n variant?: \"default\" | \"destructive\";\n /**\n * Hành động khi xác nhận. Trả về Promise thì dialog giữ nguyên và bật trạng thái đang chạy\n * cho tới khi Promise xong — đóng ngay rồi mới gọi API là cách chắc chắn nhất để người dùng\n * bấm xoá hai lần.\n */\n onConfirm: () => void | Promise<void>;\n /** Khoá nút xác nhận (vd chưa gõ đúng tên bản ghi cần xoá) */\n confirmDisabled?: boolean;\n /** Nhận lỗi từ `onConfirm` để app hiện toast; không truyền thì lỗi được ghi ra console */\n onError?: (error: unknown) => void;\n className?: string;\n}\n\n/**\n * Hộp thoại xác nhận cho hành động không hoàn tác được (xoá việc, gỡ thành viên, huỷ gói).\n *\n * Bọc `AlertDialog` để mọi app có cùng một cách hỏi lại; nút xác nhận tự vào trạng thái đang\n * chạy (chữ theo `pendingLabel`) khi `onConfirm` bất đồng bộ, và lỗi ném ra sẽ giữ dialog mở để\n * người dùng thử lại.\n */\nexport function ConfirmDialog({\n open,\n onOpenChange,\n trigger,\n title,\n description,\n children,\n icon: Icon,\n confirmLabel: confirmLabelProp,\n cancelLabel: cancelLabelProp,\n pendingLabel: pendingLabelProp,\n variant = \"default\",\n onConfirm,\n confirmDisabled = false,\n onError,\n className\n}: ConfirmDialogProps) {\n const t = useComitorMessages(\"confirmDialog\");\n const confirmLabel = confirmLabelProp ?? t.confirmLabel;\n const cancelLabel = cancelLabelProp ?? t.cancelLabel;\n const pendingLabel = pendingLabelProp ?? t.pendingLabel;\n const [internalOpen, setInternalOpen] = useState(false);\n const [pending, setPending] = useState(false);\n /*\n * ⚠ Trả focus khi đóng (3.1.0). Radix chỉ trả focus về `AlertDialogTrigger` — thiếu trigger thì\n * `onCloseAutoFocus` của nó chặn mặc định rồi focus vào `null`. Mà dialog mở bằng `open` từ một\n * `ActionBar`, một menu hay phím tắt lưới là cách ConfirmDialog được dùng nhiều nhất: đóng xong\n * (Huỷ, Escape, xác nhận) là focus rơi xuống `<body>` (WCAG 2.4.3), dù nút đã mở nó vẫn còn đó.\n * Đo trên comitor-ds: Enter trên \"Refund\" của thanh thao tác, Escape → `document.activeElement`\n * là `<body>`. Nay ghi lại phần tử giữ focus ngay trước khi dialog lấy focus và trả về đó — chỉ\n * khi focus thật sự đã mất: app tự đưa focus đi thì app thắng; phần tử kia đã bị gỡ (dòng vừa\n * xoá, thanh đã đóng theo lựa chọn) thì gói không đoán. Có `trigger` thì để Radix lo như cũ.\n */\n const returnFocusRef = useRef<HTMLElement | null>(null);\n\n const isOpen = open ?? internalOpen;\n const setOpen = (next: boolean) => {\n if (pending && !next) return; // đang chạy thì không cho đóng ngang\n if (open === undefined) setInternalOpen(next);\n onOpenChange?.(next);\n };\n\n const handleConfirm = async () => {\n try {\n setPending(true);\n await onConfirm();\n setPending(false);\n if (open === undefined) setInternalOpen(false);\n onOpenChange?.(false);\n } catch (error) {\n // Giữ dialog mở để người dùng thấy toast lỗi của app và thử lại.\n // KHÔNG ném tiếp: lời gọi là `void handleConfirm()` trong handler nên ném ra chỉ tạo\n // unhandled rejection mà app không bắt được — lỗi đi ra ngoài qua `onError`.\n setPending(false);\n if (onError) onError(error);\n else console.error(\"[ConfirmDialog] onConfirm thất bại:\", error);\n }\n };\n\n return (\n <AlertDialog open={isOpen} onOpenChange={setOpen}>\n {trigger && <AlertDialogTrigger asChild>{trigger}</AlertDialogTrigger>}\n <AlertDialogContent\n className={className}\n onOpenAutoFocus={() => {\n // Chạy TRƯỚC khi Radix đưa focus vào dialog: phần tử đang focus vẫn là nơi người dùng đứng.\n const active = document.activeElement;\n returnFocusRef.current = active instanceof HTMLElement && active !== document.body ? active : null;\n }}\n onCloseAutoFocus={(event) => {\n const target = returnFocusRef.current;\n returnFocusRef.current = null;\n if (trigger) return;\n const active = document.activeElement;\n if (active && active !== document.body) return;\n if (!target?.isConnected) return;\n event.preventDefault();\n target.focus({ preventScroll: true });\n }}\n >\n <AlertDialogHeader>\n <div className=\"flex items-start gap-3\">\n {Icon && (\n <span\n aria-hidden=\"true\"\n // Cùng khuôn với `statCardIconVariants`: nền tint `--x` + icon bậc `--x-ink`.\n // `text-destructive` trên `bg-destructive/15` chỉ 4,06:1 (light) / 3,14:1 (dark) —\n // qua ngưỡng 3:1 của icon nhưng hết biên an toàn. Ink: 5,76:1 / 5,71:1 ✓.\n className={cn(\n \"flex size-9 shrink-0 items-center justify-center rounded-md\",\n variant === \"destructive\"\n ? \"bg-destructive/15 text-destructive-ink\"\n : \"bg-muted text-muted-foreground\"\n )}\n >\n <Icon className=\"size-5\" />\n </span>\n )}\n <div className=\"min-w-0 space-y-1.5\">\n <AlertDialogTitle>{title}</AlertDialogTitle>\n {description && <AlertDialogDescription>{description}</AlertDialogDescription>}\n </div>\n </div>\n </AlertDialogHeader>\n\n {children}\n\n <AlertDialogFooter>\n <AlertDialogCancel disabled={pending}>{cancelLabel}</AlertDialogCancel>\n <AlertDialogAction\n disabled={pending || confirmDisabled}\n onClick={(event) => {\n // Chặn hành vi tự đóng của Radix: dialog chỉ đóng sau khi onConfirm chạy xong.\n event.preventDefault();\n void handleConfirm();\n }}\n className={cn(variant === \"destructive\" && buttonVariants({ variant: \"destructive\" }))}\n >\n {pending ? pendingLabel : confirmLabel}\n </AlertDialogAction>\n </AlertDialogFooter>\n </AlertDialogContent>\n </AlertDialog>\n );\n}\n"],"mappings":"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","names":[]}
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 * ⚠ Trả focus khi đóng mà không có `trigger` — mở bằng `open` từ `ActionBar`, menu, phím tắt lưới,\n * tức cách ConfirmDialog được dùng nhiều nhất (đo trên comitor-ds, 3.1.0: Enter trên \"Refund\" của\n * thanh thao tác, Escape → `<body>`). Từ 3.3.1 việc này nằm ở `AlertDialogContent` (primitive),\n * không còn một bản riêng ở đây: bản riêng của 3.1.0 ghi nút mở ở `onOpenAutoFocus` nên hụt hai ca\n * — mở từ mục MENU (ghi phải mục menu, thứ bị gỡ cùng menu) và `children` có ô `autoFocus` (Radix\n * không gọi `onOpenAutoFocus`). Có `trigger` thì Radix lo như cũ.\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":";AA6HkB,cAoBN,YApBM;AA3HlB,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;AAS5C,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":[]}
@@ -27,7 +27,7 @@ interface ToastOptions {
27
27
  * const { resolvedTheme } = useTheme();
28
28
  * <Toaster theme={resolvedTheme as "light" | "dark"} />
29
29
  */
30
- declare function Toaster({ theme, position, ...props }: ToasterProps): React.JSX.Element;
30
+ declare function Toaster({ theme, position, toastOptions, ...props }: ToasterProps): React.JSX.Element;
31
31
  /**
32
32
  * Bắn toast theo biến thể của Comitor.
33
33
  *
@@ -2,15 +2,18 @@
2
2
  import { jsx } from "react/jsx-runtime";
3
3
  import { AlertTriangle, CheckCircle2, Info, XCircle } from "lucide-react";
4
4
  import { Toaster as SonnerToaster, toast as sonnerToast } from "sonner";
5
+ import { useComitorMessages } from "../../i18n/locale-provider.js";
5
6
  import { getActiveToastMessages, useToastLanguageBridge } from "../../lib/toast-language.js";
6
- function Toaster({ theme = "system", position = "bottom-right", ...props }) {
7
+ function Toaster({ theme = "system", position = "bottom-right", toastOptions, ...props }) {
7
8
  useToastLanguageBridge();
9
+ const t = useComitorMessages("toast");
8
10
  return /* @__PURE__ */ jsx(
9
11
  SonnerToaster,
10
12
  {
11
13
  theme,
12
14
  position,
13
15
  className: "toaster group",
16
+ containerAriaLabel: t.containerAriaLabel,
14
17
  icons: {
15
18
  success: /* @__PURE__ */ jsx(CheckCircle2, { className: "size-4 text-success-ink" }),
16
19
  warning: /* @__PURE__ */ jsx(AlertTriangle, { className: "size-4 text-warning-ink" }),
@@ -18,28 +21,32 @@ function Toaster({ theme = "system", position = "bottom-right", ...props }) {
18
21
  info: /* @__PURE__ */ jsx(Info, { className: "size-4 text-info-ink" })
19
22
  },
20
23
  toastOptions: {
21
- classNames: {
22
- toast: "group toast group-[.toaster]:bg-popover group-[.toaster]:text-popover-foreground group-[.toaster]:border-border group-[.toaster]:rounded-lg group-[.toaster]:shadow-card",
23
- title: "group-[.toast]:text-sm group-[.toast]:font-medium",
24
- description: "group-[.toast]:text-sm group-[.toast]:text-muted-foreground",
25
- // Hai nút này là CONTROL đứng trên nền `--popover`, nên ngoài tương phản CHỮ còn phải
26
- // nhận ra được RANH GIỚI (WCAG 1.4.11, ngưỡng 3:1):
27
- // actionButton chữ 10,19:1 (L) · 12,67:1 (D) ✓, nhưng ruột `bg-primary` (gold) chỉ
28
- // lệch nền popover 1,86:1 ở light. Giữ đúng cách variant `default` của
29
- // `Button` xử lý — viền trong suốt, để `shadow-control` làm tín hiệu
30
- // độ cao; đổi khác ở đây sẽ lệch với nút của chính gói.
31
- // cancelButton chữ 4,89:1 (L) · 4,86:1 (D) ✓, nhưng ruột `bg-muted` chỉ lệch nền
32
- // popover 1,07:1 (L) · 1,10:1 (D) → nút không có biên nào, đọc như chữ
33
- // trơn. `border-control-edge`: 3,83:1 (L) · 4,64:1 (D) so với popover,
34
- // 3,58:1 · 4,21:1 so với chính ruột `bg-muted` ✓ (hợp đồng §3).
35
- actionButton: "group-[.toast]:bg-primary group-[.toast]:text-primary-foreground group-[.toast]:shadow-control",
36
- cancelButton: "group-[.toast]:bg-muted group-[.toast]:text-muted-foreground group-[.toast]:border group-[.toast]:border-control-edge"
37
- }
24
+ closeButtonAriaLabel: t.closeButtonAriaLabel,
25
+ ...toastOptions ?? COMITOR_TOAST_OPTIONS
38
26
  },
39
27
  ...props
40
28
  }
41
29
  );
42
30
  }
31
+ const COMITOR_TOAST_OPTIONS = {
32
+ classNames: {
33
+ toast: "group toast group-[.toaster]:bg-popover group-[.toaster]:text-popover-foreground group-[.toaster]:border-border group-[.toaster]:rounded-lg group-[.toaster]:shadow-card",
34
+ title: "group-[.toast]:text-sm group-[.toast]:font-medium",
35
+ description: "group-[.toast]:text-sm group-[.toast]:text-muted-foreground",
36
+ // Hai nút này là CONTROL đứng trên nền `--popover`, nên ngoài tương phản CHỮ còn phải
37
+ // nhận ra được RANH GIỚI (WCAG 1.4.11, ngưỡng 3:1):
38
+ // actionButton chữ 10,19:1 (L) · 12,67:1 (D) ✓, nhưng ruột `bg-primary` (gold) chỉ
39
+ // lệch nền popover 1,86:1 ở light. Giữ đúng cách variant `default` của
40
+ // `Button` xử lý — viền trong suốt, để `shadow-control` làm tín hiệu
41
+ // độ cao; đổi khác ở đây sẽ lệch với nút của chính gói.
42
+ // cancelButton chữ 4,89:1 (L) · 4,86:1 (D) ✓, nhưng ruột `bg-muted` chỉ lệch nền
43
+ // popover 1,07:1 (L) · 1,10:1 (D) → nút không có biên nào, đọc như chữ
44
+ // trơn. `border-control-edge`: 3,83:1 (L) · 4,64:1 (D) so với popover,
45
+ // 3,58:1 · 4,21:1 so với chính ruột `bg-muted` ✓ (hợp đồng §3).
46
+ actionButton: "group-[.toast]:bg-primary group-[.toast]:text-primary-foreground group-[.toast]:shadow-control",
47
+ cancelButton: "group-[.toast]:bg-muted group-[.toast]:text-muted-foreground group-[.toast]:border group-[.toast]:border-control-edge"
48
+ }
49
+ };
43
50
  function toast(title, options = {}) {
44
51
  const { variant = "default", description, duration, action, id } = options;
45
52
  const config = { description, duration, action, id };
@@ -1 +1 @@
1
- {"version":3,"sources":["../../../src/components/composite/toast.tsx"],"sourcesContent":["\"use client\";\n\nimport { AlertTriangle, CheckCircle2, Info, XCircle } from \"lucide-react\";\nimport type { ComponentProps, ReactNode } from \"react\";\nimport { Toaster as SonnerToaster, toast as sonnerToast } from \"sonner\";\nimport { getActiveToastMessages, useToastLanguageBridge } from \"../../lib/toast-language.js\";\n\nexport type ToasterProps = ComponentProps<typeof SonnerToaster>;\n\nexport type ToastVariant = \"default\" | \"success\" | \"warning\" | \"destructive\" | \"info\";\n\nexport interface ToastOptions {\n description?: ReactNode;\n variant?: ToastVariant;\n /** ms; mặc định theo sonner (4000). `Number.POSITIVE_INFINITY` = giữ đến khi người dùng đóng. */\n duration?: number;\n /** Nút hành động, vd { label: \"Hoàn tác\", onClick } */\n action?: { label: string; onClick: () => void };\n /** id để cập nhật/đóng đúng toast đó (dùng với luồng \"đang lưu…\" → \"đã lưu\") */\n id?: string | number;\n}\n\n/**\n * Vùng hiển thị toast — đặt MỘT LẦN ở gốc app.\n *\n * `theme` mặc định \"system\" và KHÔNG đọc `next-themes`: entry \".\" của package phải chạy được ở\n * app không có next-themes. App dùng next-themes hãy truyền xuống, nếu không toast sẽ theo\n * `prefers-color-scheme` của hệ điều hành và lệch với theme người dùng tự chọn:\n *\n * const { resolvedTheme } = useTheme();\n * <Toaster theme={resolvedTheme as \"light\" | \"dark\"} />\n */\nexport function Toaster({ theme = \"system\", position = \"bottom-right\", ...props }: ToasterProps) {\n // Ghi ngôn ngữ của provider cho `toastPromise` đọc — xem `lib/toast-language.ts`.\n useToastLanguageBridge();\n\n return (\n <SonnerToaster\n theme={theme}\n position={position}\n className=\"toaster group\"\n // Icon tô bằng bậc `--x-ink`, KHÔNG phải token tô nền `--x`. Icon là thành phần phi văn\n // bản (ngưỡng 3:1) đứng trên nền `--popover`: `text-warning` (= `--yellow` #F2C94C) trên\n // nền trắng chỉ **1,59:1** ✗ — gần như vô hình ở light (dark thì 11,60:1, lại một chỗ mỗi\n // theme hỏng một nửa); `text-success` 3,13:1 và `text-info` 4,14:1 tuy qua ngưỡng nhưng\n // sát mép. Bậc ink (light / dark): success 6,52 / 10,19 · warning 6,51 / 11,60 ·\n // destructive 7,15 / 6,42 · info 6,68 / 9,54 ✓.\n icons={{\n success: <CheckCircle2 className=\"size-4 text-success-ink\" />,\n warning: <AlertTriangle className=\"size-4 text-warning-ink\" />,\n error: <XCircle className=\"size-4 text-destructive-ink\" />,\n info: <Info className=\"size-4 text-info-ink\" />\n }}\n toastOptions={{\n classNames: {\n toast:\n \"group toast group-[.toaster]:bg-popover group-[.toaster]:text-popover-foreground group-[.toaster]:border-border group-[.toaster]:rounded-lg group-[.toaster]:shadow-card\",\n title: \"group-[.toast]:text-sm group-[.toast]:font-medium\",\n description: \"group-[.toast]:text-sm group-[.toast]:text-muted-foreground\",\n // Hai nút này là CONTROL đứng trên nền `--popover`, nên ngoài tương phản CHỮ còn phải\n // nhận ra được RANH GIỚI (WCAG 1.4.11, ngưỡng 3:1):\n // actionButton chữ 10,19:1 (L) · 12,67:1 (D) ✓, nhưng ruột `bg-primary` (gold) chỉ\n // lệch nền popover 1,86:1 ở light. Giữ đúng cách variant `default` của\n // `Button` xử lý — viền trong suốt, để `shadow-control` làm tín hiệu\n // độ cao; đổi khác ở đây sẽ lệch với nút của chính gói.\n // cancelButton chữ 4,89:1 (L) · 4,86:1 (D) ✓, nhưng ruột `bg-muted` chỉ lệch nền\n // popover 1,07:1 (L) · 1,10:1 (D) → nút không có biên nào, đọc như chữ\n // trơn. `border-control-edge`: 3,83:1 (L) · 4,64:1 (D) so với popover,\n // 3,58:1 · 4,21:1 so với chính ruột `bg-muted` ✓ (hợp đồng §3).\n actionButton:\n \"group-[.toast]:bg-primary group-[.toast]:text-primary-foreground group-[.toast]:shadow-control\",\n cancelButton:\n \"group-[.toast]:bg-muted group-[.toast]:text-muted-foreground group-[.toast]:border group-[.toast]:border-control-edge\"\n }\n }}\n {...props}\n />\n );\n}\n\n/**\n * Bắn toast theo biến thể của Comitor.\n *\n * Vì sao bọc `sonner` thay vì để app gọi thẳng: tên biến thể ở đây khớp với ngôn ngữ của design\n * system (`destructive`, không phải `error`), và khi cần đổi thư viện toast thì chỉ sửa một file.\n */\nexport function toast(title: ReactNode, options: ToastOptions = {}): string | number {\n const { variant = \"default\", description, duration, action, id } = options;\n const config = { description, duration, action, id };\n\n switch (variant) {\n case \"success\":\n return sonnerToast.success(title, config);\n case \"warning\":\n return sonnerToast.warning(title, config);\n case \"destructive\":\n return sonnerToast.error(title, config);\n case \"info\":\n return sonnerToast.info(title, config);\n default:\n return sonnerToast(title, config);\n }\n}\n\n/** Đóng một toast theo id, hoặc đóng tất cả khi không truyền id. */\nexport function dismissToast(id?: string | number): void {\n sonnerToast.dismiss(id);\n}\n\n/**\n * Toast bám theo một Promise: \"đang lưu…\" → \"đã lưu\" / \"lưu thất bại\". `success` và `error` luôn\n * do app truyền; `loading` bỏ trống thì lấy khoá `toast.loading` theo ngôn ngữ của `<Toaster />`\n * (tức của `ComitorLocaleProvider` bọc nó — xem `lib/toast-language.ts`; từ `3.0.1` cả `SonnerToaster`\n * của `/shell` cũng ghi vào đó). Truyền\n * `loading` tường minh vẫn thắng.\n */\nexport function toastPromise<T>(\n promise: Promise<T>,\n messages: {\n loading?: ReactNode;\n success: ReactNode | ((data: T) => ReactNode);\n error: ReactNode | ((error: unknown) => ReactNode);\n }\n) {\n return sonnerToast.promise(promise, {\n loading: messages.loading ?? getActiveToastMessages().loading,\n success: messages.success,\n error: messages.error\n });\n}\n"],"mappings":";AAgDiB;AA9CjB,SAAS,eAAe,cAAc,MAAM,eAAe;AAE3D,SAAS,WAAW,eAAe,SAAS,mBAAmB;AAC/D,SAAS,wBAAwB,8BAA8B;AA2BxD,SAAS,QAAQ,EAAE,QAAQ,UAAU,WAAW,gBAAgB,GAAG,MAAM,GAAiB;AAE/F,yBAAuB;AAEvB,SACE;AAAA,IAAC;AAAA;AAAA,MACC;AAAA,MACA;AAAA,MACA,WAAU;AAAA,MAOV,OAAO;AAAA,QACL,SAAS,oBAAC,gBAAa,WAAU,2BAA0B;AAAA,QAC3D,SAAS,oBAAC,iBAAc,WAAU,2BAA0B;AAAA,QAC5D,OAAO,oBAAC,WAAQ,WAAU,+BAA8B;AAAA,QACxD,MAAM,oBAAC,QAAK,WAAU,wBAAuB;AAAA,MAC/C;AAAA,MACA,cAAc;AAAA,QACZ,YAAY;AAAA,UACV,OACE;AAAA,UACF,OAAO;AAAA,UACP,aAAa;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA,UAWb,cACE;AAAA,UACF,cACE;AAAA,QACJ;AAAA,MACF;AAAA,MACC,GAAG;AAAA;AAAA,EACN;AAEJ;AAQO,SAAS,MAAM,OAAkB,UAAwB,CAAC,GAAoB;AACnF,QAAM,EAAE,UAAU,WAAW,aAAa,UAAU,QAAQ,GAAG,IAAI;AACnE,QAAM,SAAS,EAAE,aAAa,UAAU,QAAQ,GAAG;AAEnD,UAAQ,SAAS;AAAA,IACf,KAAK;AACH,aAAO,YAAY,QAAQ,OAAO,MAAM;AAAA,IAC1C,KAAK;AACH,aAAO,YAAY,QAAQ,OAAO,MAAM;AAAA,IAC1C,KAAK;AACH,aAAO,YAAY,MAAM,OAAO,MAAM;AAAA,IACxC,KAAK;AACH,aAAO,YAAY,KAAK,OAAO,MAAM;AAAA,IACvC;AACE,aAAO,YAAY,OAAO,MAAM;AAAA,EACpC;AACF;AAGO,SAAS,aAAa,IAA4B;AACvD,cAAY,QAAQ,EAAE;AACxB;AASO,SAAS,aACd,SACA,UAKA;AACA,SAAO,YAAY,QAAQ,SAAS;AAAA,IAClC,SAAS,SAAS,WAAW,uBAAuB,EAAE;AAAA,IACtD,SAAS,SAAS;AAAA,IAClB,OAAO,SAAS;AAAA,EAClB,CAAC;AACH;","names":[]}
1
+ {"version":3,"sources":["../../../src/components/composite/toast.tsx"],"sourcesContent":["\"use client\";\n\nimport { AlertTriangle, CheckCircle2, Info, XCircle } from \"lucide-react\";\nimport type { ComponentProps, ReactNode } from \"react\";\nimport { Toaster as SonnerToaster, toast as sonnerToast } from \"sonner\";\nimport { useComitorMessages } from \"../../i18n/locale-provider.js\";\nimport { getActiveToastMessages, useToastLanguageBridge } from \"../../lib/toast-language.js\";\n\nexport type ToasterProps = ComponentProps<typeof SonnerToaster>;\n\nexport type ToastVariant = \"default\" | \"success\" | \"warning\" | \"destructive\" | \"info\";\n\nexport interface ToastOptions {\n description?: ReactNode;\n variant?: ToastVariant;\n /** ms; mặc định theo sonner (4000). `Number.POSITIVE_INFINITY` = giữ đến khi người dùng đóng. */\n duration?: number;\n /** Nút hành động, vd { label: \"Hoàn tác\", onClick } */\n action?: { label: string; onClick: () => void };\n /** id để cập nhật/đóng đúng toast đó (dùng với luồng \"đang lưu…\" → \"đã lưu\") */\n id?: string | number;\n}\n\n/**\n * Vùng hiển thị toast — đặt MỘT LẦN ở gốc app.\n *\n * `theme` mặc định \"system\" và KHÔNG đọc `next-themes`: entry \".\" của package phải chạy được ở\n * app không có next-themes. App dùng next-themes hãy truyền xuống, nếu không toast sẽ theo\n * `prefers-color-scheme` của hệ điều hành và lệch với theme người dùng tự chọn:\n *\n * const { resolvedTheme } = useTheme();\n * <Toaster theme={resolvedTheme as \"light\" | \"dark\"} />\n */\nexport function Toaster({ theme = \"system\", position = \"bottom-right\", toastOptions, ...props }: ToasterProps) {\n // Ghi ngôn ngữ của provider cho `toastPromise` đọc — xem `lib/toast-language.ts`.\n useToastLanguageBridge();\n const t = useComitorMessages(\"toast\");\n\n return (\n <SonnerToaster\n theme={theme}\n position={position}\n className=\"toaster group\"\n // Tên vùng toast theo provider — mặc định của sonner là \"Notifications\" CỨNG (xem\n // `namespaces/toast.ts`). Đứng TRƯỚC `{...props}` nên prop của app vẫn thắng.\n containerAriaLabel={t.containerAriaLabel}\n // Icon tô bằng bậc `--x-ink`, KHÔNG phải token tô nền `--x`. Icon là thành phần phi văn\n // bản (ngưỡng 3:1) đứng trên nền `--popover`: `text-warning` (= `--yellow` #F2C94C) trên\n // nền trắng chỉ **1,59:1** ✗ — gần như vô hình ở light (dark thì 11,60:1, lại một chỗ mỗi\n // theme hỏng một nửa); `text-success` 3,13:1 và `text-info` 4,14:1 tuy qua ngưỡng nhưng\n // sát mép. Bậc ink (light / dark): success 6,52 / 10,19 · warning 6,51 / 11,60 ·\n // destructive 7,15 / 6,42 · info 6,68 / 9,54 ✓.\n icons={{\n success: <CheckCircle2 className=\"size-4 text-success-ink\" />,\n warning: <AlertTriangle className=\"size-4 text-warning-ink\" />,\n error: <XCircle className=\"size-4 text-destructive-ink\" />,\n info: <Info className=\"size-4 text-info-ink\" />\n }}\n /*\n * ⚠ `toastOptions` của app THAY cả khối (kể cả `classNames` bên dưới) — hành vi có từ trước,\n * giữ nguyên. Riêng `closeButtonAriaLabel` thì luôn có mặc định theo provider, trừ khi app tự\n * khai: thiếu nó thì nút × (khi bật `closeButton`) đọc \"Close toast\" giữa giao diện tiếng Việt.\n */\n toastOptions={{\n closeButtonAriaLabel: t.closeButtonAriaLabel,\n ...(toastOptions ?? COMITOR_TOAST_OPTIONS)\n }}\n {...props}\n />\n );\n}\n\n/**\n * Lớp của từng toast — khối `toastOptions` mặc định của `Toaster`. Hằng module, không dựng lại mỗi\n * lượt render.\n */\nconst COMITOR_TOAST_OPTIONS: NonNullable<ToasterProps[\"toastOptions\"]> = {\n classNames: {\n toast:\n \"group toast group-[.toaster]:bg-popover group-[.toaster]:text-popover-foreground group-[.toaster]:border-border group-[.toaster]:rounded-lg group-[.toaster]:shadow-card\",\n title: \"group-[.toast]:text-sm group-[.toast]:font-medium\",\n description: \"group-[.toast]:text-sm group-[.toast]:text-muted-foreground\",\n // Hai nút này là CONTROL đứng trên nền `--popover`, nên ngoài tương phản CHỮ còn phải\n // nhận ra được RANH GIỚI (WCAG 1.4.11, ngưỡng 3:1):\n // actionButton chữ 10,19:1 (L) · 12,67:1 (D) ✓, nhưng ruột `bg-primary` (gold) chỉ\n // lệch nền popover 1,86:1 ở light. Giữ đúng cách variant `default` của\n // `Button` xử lý — viền trong suốt, để `shadow-control` làm tín hiệu\n // độ cao; đổi khác ở đây sẽ lệch với nút của chính gói.\n // cancelButton chữ 4,89:1 (L) · 4,86:1 (D) ✓, nhưng ruột `bg-muted` chỉ lệch nền\n // popover 1,07:1 (L) · 1,10:1 (D) → nút không có biên nào, đọc như chữ\n // trơn. `border-control-edge`: 3,83:1 (L) · 4,64:1 (D) so với popover,\n // 3,58:1 · 4,21:1 so với chính ruột `bg-muted` ✓ (hợp đồng §3).\n actionButton: \"group-[.toast]:bg-primary group-[.toast]:text-primary-foreground group-[.toast]:shadow-control\",\n cancelButton:\n \"group-[.toast]:bg-muted group-[.toast]:text-muted-foreground group-[.toast]:border group-[.toast]:border-control-edge\"\n }\n};\n\n/**\n * Bắn toast theo biến thể của Comitor.\n *\n * Vì sao bọc `sonner` thay vì để app gọi thẳng: tên biến thể ở đây khớp với ngôn ngữ của design\n * system (`destructive`, không phải `error`), và khi cần đổi thư viện toast thì chỉ sửa một file.\n */\nexport function toast(title: ReactNode, options: ToastOptions = {}): string | number {\n const { variant = \"default\", description, duration, action, id } = options;\n const config = { description, duration, action, id };\n\n switch (variant) {\n case \"success\":\n return sonnerToast.success(title, config);\n case \"warning\":\n return sonnerToast.warning(title, config);\n case \"destructive\":\n return sonnerToast.error(title, config);\n case \"info\":\n return sonnerToast.info(title, config);\n default:\n return sonnerToast(title, config);\n }\n}\n\n/** Đóng một toast theo id, hoặc đóng tất cả khi không truyền id. */\nexport function dismissToast(id?: string | number): void {\n sonnerToast.dismiss(id);\n}\n\n/**\n * Toast bám theo một Promise: \"đang lưu…\" → \"đã lưu\" / \"lưu thất bại\". `success` và `error` luôn\n * do app truyền; `loading` bỏ trống thì lấy khoá `toast.loading` theo ngôn ngữ của `<Toaster />`\n * (tức của `ComitorLocaleProvider` bọc nó — xem `lib/toast-language.ts`; từ `3.0.1` cả `SonnerToaster`\n * của `/shell` cũng ghi vào đó). Truyền\n * `loading` tường minh vẫn thắng.\n */\nexport function toastPromise<T>(\n promise: Promise<T>,\n messages: {\n loading?: ReactNode;\n success: ReactNode | ((data: T) => ReactNode);\n error: ReactNode | ((error: unknown) => ReactNode);\n }\n) {\n return sonnerToast.promise(promise, {\n loading: messages.loading ?? getActiveToastMessages().loading,\n success: messages.success,\n error: messages.error\n });\n}\n"],"mappings":";AAqDiB;AAnDjB,SAAS,eAAe,cAAc,MAAM,eAAe;AAE3D,SAAS,WAAW,eAAe,SAAS,mBAAmB;AAC/D,SAAS,0BAA0B;AACnC,SAAS,wBAAwB,8BAA8B;AA2BxD,SAAS,QAAQ,EAAE,QAAQ,UAAU,WAAW,gBAAgB,cAAc,GAAG,MAAM,GAAiB;AAE7G,yBAAuB;AACvB,QAAM,IAAI,mBAAmB,OAAO;AAEpC,SACE;AAAA,IAAC;AAAA;AAAA,MACC;AAAA,MACA;AAAA,MACA,WAAU;AAAA,MAGV,oBAAoB,EAAE;AAAA,MAOtB,OAAO;AAAA,QACL,SAAS,oBAAC,gBAAa,WAAU,2BAA0B;AAAA,QAC3D,SAAS,oBAAC,iBAAc,WAAU,2BAA0B;AAAA,QAC5D,OAAO,oBAAC,WAAQ,WAAU,+BAA8B;AAAA,QACxD,MAAM,oBAAC,QAAK,WAAU,wBAAuB;AAAA,MAC/C;AAAA,MAMA,cAAc;AAAA,QACZ,sBAAsB,EAAE;AAAA,QACxB,GAAI,gBAAgB;AAAA,MACtB;AAAA,MACC,GAAG;AAAA;AAAA,EACN;AAEJ;AAMA,MAAM,wBAAmE;AAAA,EACvE,YAAY;AAAA,IACV,OACE;AAAA,IACF,OAAO;AAAA,IACP,aAAa;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA,IAWb,cAAc;AAAA,IACd,cACE;AAAA,EACJ;AACF;AAQO,SAAS,MAAM,OAAkB,UAAwB,CAAC,GAAoB;AACnF,QAAM,EAAE,UAAU,WAAW,aAAa,UAAU,QAAQ,GAAG,IAAI;AACnE,QAAM,SAAS,EAAE,aAAa,UAAU,QAAQ,GAAG;AAEnD,UAAQ,SAAS;AAAA,IACf,KAAK;AACH,aAAO,YAAY,QAAQ,OAAO,MAAM;AAAA,IAC1C,KAAK;AACH,aAAO,YAAY,QAAQ,OAAO,MAAM;AAAA,IAC1C,KAAK;AACH,aAAO,YAAY,MAAM,OAAO,MAAM;AAAA,IACxC,KAAK;AACH,aAAO,YAAY,KAAK,OAAO,MAAM;AAAA,IACvC;AACE,aAAO,YAAY,OAAO,MAAM;AAAA,EACpC;AACF;AAGO,SAAS,aAAa,IAA4B;AACvD,cAAY,QAAQ,EAAE;AACxB;AASO,SAAS,aACd,SACA,UAKA;AACA,SAAO,YAAY,QAAQ,SAAS;AAAA,IAClC,SAAS,SAAS,WAAW,uBAAuB,EAAE;AAAA,IACtD,SAAS,SAAS;AAAA,IAClB,OAAO,SAAS;AAAA,EAClB,CAAC;AACH;","names":[]}
@@ -18,6 +18,26 @@ interface ActionBarProps extends DivProps {
18
18
  side?: "top" | "bottom";
19
19
  sideOffset?: number;
20
20
  portalContainer?: Element | DocumentFragment | null;
21
+ /**
22
+ * Căn NGANG theo đâu — `align`/`alignOffset` đo từ hộp nào. Chiều dọc (`side`/`sideOffset`) luôn
23
+ * theo khung nhìn: thanh vẫn nổi ở đáy (hay đỉnh) màn hình.
24
+ *
25
+ * - `"viewport"` (mặc định): khung nhìn — đúng như trước `3.3.0`.
26
+ * - `"container"`: hộp của `portalContainer` — giữa (hay mép) phần tử đó, và rộng tối đa bằng nó.
27
+ * Dành cho thanh phục vụ MỘT vùng (bảng, lưới) trong khung app có thanh bên: giữa khung nhìn lệch
28
+ * khỏi giữa vùng đúng nửa bề rộng thanh bên (120px với thanh bên 240px), và thanh trông như trôi
29
+ * lạc sang một phía. Đặt `portalContainer` là một khối CÙNG BỀ RỘNG với vùng — một `<div>` rỗng
30
+ * ngay sau bảng là đủ, và đó cũng là chỗ đúng cho thứ tự Tab.
31
+ *
32
+ * ⚠ Thêm so với thượng nguồn. Hai giới hạn, cả hai có lý do:
33
+ * - Đo bằng toạ độ KHUNG NHÌN, đo lại khi phần tử hoặc cửa sổ ĐỔI CỠ (thu/mở thanh bên, xoay máy).
34
+ * Phần tử DỜI chỗ mà không đổi cỡ thì không đo lại — `ResizeObserver` không báo chuyện đó.
35
+ * - Tổ tiên nào của `portalContainer` tạo khối chứa cho `position: fixed` (`transform`, `filter`,
36
+ * `contain: layout|paint`) thì toạ độ khung nhìn lệch đúng bằng vị trí khối đó. Ở đó dùng
37
+ * `"viewport"`: `left: 50%` khi ấy đã tự tính theo chính khối chứa.
38
+ * Không có `portalContainer` (thanh nằm ở `<body>`) thì hai chế độ như nhau.
39
+ */
40
+ anchor?: "viewport" | "container";
21
41
  dir?: Direction;
22
42
  orientation?: Orientation;
23
43
  loop?: boolean;
@@ -53,6 +53,17 @@ function useFocusContext(consumerName) {
53
53
  }
54
54
  return context;
55
55
  }
56
+ function horizontalPosition(align, alignOffset, box) {
57
+ if (!box) {
58
+ if (align === "center") return { left: "50%", translate: "-50% 0" };
59
+ if (align === "start") return { left: `${alignOffset}px` };
60
+ return { right: `${alignOffset}px` };
61
+ }
62
+ const maxWidth = `min(100vw - 1rem, ${box.width}px)`;
63
+ if (align === "center") return { left: `${box.left + box.width / 2}px`, translate: "-50% 0", maxWidth };
64
+ if (align === "start") return { left: `${box.left + alignOffset}px`, maxWidth };
65
+ return { right: `${box.viewportWidth - (box.left + box.width) + alignOffset}px`, maxWidth };
66
+ }
56
67
  function ActionBar(props) {
57
68
  const {
58
69
  open = false,
@@ -63,6 +74,7 @@ function ActionBar(props) {
63
74
  align = "center",
64
75
  sideOffset = 16,
65
76
  portalContainer: portalContainerProp,
77
+ anchor = "viewport",
66
78
  dir: dirProp,
67
79
  orientation = "horizontal",
68
80
  loop = true,
@@ -127,6 +139,28 @@ function ActionBar(props) {
127
139
  useIsomorphicLayoutEffect(() => {
128
140
  setMounted(true);
129
141
  }, []);
142
+ const anchorElement = anchor === "container" && portalContainerProp && "getBoundingClientRect" in portalContainerProp ? portalContainerProp : null;
143
+ const [anchorBox, setAnchorBox] = React.useState(null);
144
+ useIsomorphicLayoutEffect(() => {
145
+ if (!open || !anchorElement) return;
146
+ const view = anchorElement.ownerDocument.defaultView;
147
+ function measure() {
148
+ if (!anchorElement) return;
149
+ const rect = anchorElement.getBoundingClientRect();
150
+ const viewportWidth = anchorElement.ownerDocument.documentElement.clientWidth;
151
+ setAnchorBox(
152
+ (previous) => previous && previous.left === rect.left && previous.width === rect.width && previous.viewportWidth === viewportWidth ? previous : { left: rect.left, width: rect.width, viewportWidth }
153
+ );
154
+ }
155
+ measure();
156
+ const observer = typeof ResizeObserver === "undefined" ? null : new ResizeObserver(measure);
157
+ observer?.observe(anchorElement);
158
+ view?.addEventListener("resize", measure);
159
+ return () => {
160
+ observer?.disconnect();
161
+ view?.removeEventListener("resize", measure);
162
+ };
163
+ }, [open, anchorElement]);
130
164
  React.useEffect(() => {
131
165
  if (!open) return;
132
166
  const ownerDocument = rootRef.current?.ownerDocument ?? document;
@@ -175,6 +209,16 @@ function ActionBar(props) {
175
209
  */
176
210
  "fixed z-50 rounded-lg border bg-popover text-popover-foreground shadow-overlay outline-none",
177
211
  "animate-in duration-250 fade-in-0 zoom-in-95 [animation-timing-function:cubic-bezier(0.16,1,0.3,1)]",
212
+ /*
213
+ * ⚠ `transition-none` (3.3.0) — thanh không có transition nào CỐ Ý: hiệu ứng xuất hiện là
214
+ * keyframe (`animate-in`). Nhưng `duration-250` ở dòng trên, đặt cho keyframe đó, trong
215
+ * Tailwind v4 còn ghi luôn `transition-duration: 250ms`; mà `transition-property` mặc định
216
+ * của CSS là `all` — nên MỌI thay đổi thuộc tính của thanh trượt 250ms. Đo được với
217
+ * `anchor="container"`: lượt đo vị trí (trước lượt sơn) đổi `left` từ giữa khung nhìn sang
218
+ * giữa vùng, và thanh TRƯỢT NGANG 120px ở mỗi lần mở. Theo vùng khi thanh bên thu/mở thì
219
+ * vẫn mượt: `ResizeObserver` báo từng khung hình của chính hiệu ứng thu/mở đó.
220
+ */
221
+ "transition-none",
178
222
  "data-[side=bottom]:slide-in-from-bottom-4 data-[side=top]:slide-in-from-top-4",
179
223
  "motion-reduce:animate-none motion-reduce:transition-none",
180
224
  /*
@@ -182,19 +226,20 @@ function ActionBar(props) {
182
226
  * không có giới hạn nào — đo ở 375px: thanh rộng 413px, bắt đầu ở x = −19px, hai mép bị
183
227
  * cắt và "Đã chọn 3" vỡ thành 3 dòng. Xuống dòng chứ không cuộn ngang: vùng cuộn sẽ cắt
184
228
  * mất vòng focus của các nút ở mép.
229
+ *
230
+ * ⚠ Và `w-max` (3.3.0) — thiếu nó thì trần kia thành vô nghĩa. Phần tử `fixed` rộng "co
231
+ * theo nội dung" chỉ được dùng phần khung chứa nằm SAU mốc `left`; với `left: 50%` của
232
+ * `align="center"` là đúng NỬA khung nhìn. Đo trên app thật: khung 1129px, thanh rộng
233
+ * đúng 564,5px và gãy "3 selected ×" xuống hàng thứ hai dù nội dung chỉ cần 621px. `w-max`
234
+ * cho thanh rộng bằng nội dung, `max-w-*` vẫn chặn trần, nên chỉ màn hẹp mới xuống dòng.
185
235
  */
186
- "max-w-[calc(100vw-1rem)]",
236
+ "w-max max-w-[calc(100vw-1rem)]",
187
237
  orientation === "horizontal" ? "flex flex-row flex-wrap items-center gap-2 px-2 py-1.5" : "flex flex-col items-start gap-2 px-1.5 py-2",
188
238
  className
189
239
  ),
190
240
  style: {
191
241
  [side]: `${sideOffset}px`,
192
- ...align === "center" && {
193
- left: "50%",
194
- translate: "-50% 0"
195
- },
196
- ...align === "start" && { left: `${alignOffset}px` },
197
- ...align === "end" && { right: `${alignOffset}px` },
242
+ ...horizontalPosition(align, alignOffset, anchorElement ? anchorBox : null),
198
243
  ...style
199
244
  },
200
245
  onFocus,
@@ -346,7 +391,13 @@ function ActionBarGroup(props) {
346
391
  ref: composedRef,
347
392
  className: cn(
348
393
  "flex gap-2 outline-none",
349
- orientation === "horizontal" ? "items-center" : "w-full flex-col items-start",
394
+ /*
395
+ * ⚠ `flex-wrap` (3.3.0): thanh xuống dòng ở màn hẹp (2.1.0) nhưng NHÓM thì không — nó là một
396
+ * mục flex duy nhất của thanh, nên bốn nút nằm cứng trên một hàng. Đo ở 375px: nhóm 387px
397
+ * trong thanh 359px, nút cuối thò ra tới x = 404, ngoài màn hình. Xuống dòng chứ không cuộn
398
+ * ngang — cùng lý do với thanh: vùng cuộn cắt mất vòng focus của nút ở mép.
399
+ */
400
+ orientation === "horizontal" ? "flex-wrap items-center" : "w-full flex-col items-start",
350
401
  className
351
402
  ),
352
403
  onBlur,