@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
@@ -1,11 +1,18 @@
1
1
  "use client";
2
2
  import { Fragment, jsx, jsxs } from "react/jsx-runtime";
3
3
  import { ChevronLeft, ChevronRight, ChevronsLeft, ChevronsRight } from "lucide-react";
4
- import { useComitorLabels } from "../../i18n/locale-provider.js";
4
+ import { useComitorLabels, useComitorLocale } from "../../i18n/locale-provider.js";
5
5
  import { cn } from "../../lib/cn.js";
6
6
  import { Button } from "../ui/button.js";
7
7
  import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "../ui/select.js";
8
8
  import { DEFAULT_PAGE_SIZE_OPTIONS } from "./table-pagination-constants.js";
9
+ function formatNumber(value, locale) {
10
+ try {
11
+ return value.toLocaleString(locale);
12
+ } catch {
13
+ return String(value);
14
+ }
15
+ }
9
16
  function TablePagination({
10
17
  page,
11
18
  pageSize,
@@ -20,6 +27,7 @@ function TablePagination({
20
27
  className
21
28
  }) {
22
29
  const labels = useComitorLabels("tablePagination", labelsProp);
30
+ const { locale } = useComitorLocale();
23
31
  const unitLabel = unitLabelProp ?? labels.unitLabel;
24
32
  const pageCount = Math.max(1, Math.ceil(total / Math.max(1, pageSize)));
25
33
  const current = Math.min(Math.max(1, page), pageCount);
@@ -39,7 +47,7 @@ function TablePagination({
39
47
  /* @__PURE__ */ jsx("span", { className: "hidden sm:inline", children: labels.pageSizePrefix }),
40
48
  /* @__PURE__ */ jsxs(Select, { value: String(pageSize), onValueChange: (value) => onPageSizeChange(Number(value)), children: [
41
49
  /* @__PURE__ */ jsx(SelectTrigger, { className: "h-8 w-[4.5rem]", "aria-label": labels.pageSizeSelect(unitLabel), children: /* @__PURE__ */ jsx(SelectValue, {}) }),
42
- /* @__PURE__ */ jsx(SelectContent, { children: pageSizeOptions.map((size) => /* @__PURE__ */ jsx(SelectItem, { value: String(size), children: size }, size)) })
50
+ /* @__PURE__ */ jsx(SelectContent, { children: pageSizeOptions.map((size) => /* @__PURE__ */ jsx(SelectItem, { value: String(size), children: formatNumber(size, locale) }, size)) })
43
51
  ] })
44
52
  ] }),
45
53
  /* @__PURE__ */ jsx("span", { role: "status", className: "tabular-nums", children: labels.range(start, end, total, unitLabel) })
@@ -1 +1 @@
1
- {"version":3,"sources":["../../../src/components/composite/table-pagination.tsx"],"sourcesContent":["\"use client\";\n\nimport { ChevronLeft, ChevronRight, ChevronsLeft, ChevronsRight } from \"lucide-react\";\nimport { useComitorLabels } from \"../../i18n/locale-provider.js\";\nimport { cn } from \"../../lib/cn.js\";\nimport { Button } from \"../ui/button.js\";\nimport { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from \"../ui/select.js\";\nimport { DEFAULT_PAGE_SIZE_OPTIONS, type TablePaginationLabels } from \"./table-pagination-constants.js\";\n\nexport interface TablePaginationProps {\n /** Trang hiện tại, đếm từ 1 */\n page: number;\n pageSize: number;\n /** Tổng số bản ghi (sau khi lọc) */\n total: number;\n pageSizeOptions?: readonly number[];\n onPageChange: (page: number) => void;\n onPageSizeChange: (pageSize: number) => void;\n /** Đơn vị đếm theo nội dung bảng, vd \"tasks\", \"contacts\" — mặc định `labels.unitLabel` (\"items\") */\n unitLabel?: string;\n /** Ẩn ô chọn số dòng/trang (khi cỡ trang do hệ thống quyết định) */\n hidePageSize?: boolean;\n /** Nhãn vùng phân trang — cần khi một trang có nhiều bảng; mặc định `labels.region` */\n label?: string;\n /**\n * Ghi đè chuỗi hiển thị/a11y — không truyền thì theo namespace `tablePagination` của\n * `ComitorLocaleProvider` (mặc định tiếng Anh). Xem `TablePaginationLabels`.\n */\n labels?: Partial<TablePaginationLabels>;\n className?: string;\n}\n\n/**\n * Thanh phân trang đi kèm `DataTable`.\n *\n * Tổng số trang tính từ `total`/`pageSize` ngay tại đây thay vì bắt app truyền vào — hai bên\n * tự tính rất dễ lệch nhau một trang ở bản ghi cuối, và người dùng sẽ thấy nút sang trang sau\n * còn bấm được trong khi bảng đã hết dữ liệu.\n */\nexport function TablePagination({\n page,\n pageSize,\n total,\n pageSizeOptions = DEFAULT_PAGE_SIZE_OPTIONS,\n onPageChange,\n onPageSizeChange,\n unitLabel: unitLabelProp,\n hidePageSize = false,\n label,\n labels: labelsProp,\n className\n}: TablePaginationProps) {\n const labels = useComitorLabels(\"tablePagination\", labelsProp);\n const unitLabel = unitLabelProp ?? labels.unitLabel;\n const pageCount = Math.max(1, Math.ceil(total / Math.max(1, pageSize)));\n const current = Math.min(Math.max(1, page), pageCount);\n const start = total === 0 ? 0 : (current - 1) * pageSize + 1;\n const end = Math.min(current * pageSize, total);\n const isFirst = current <= 1;\n const isLast = current >= pageCount;\n\n return (\n <nav\n aria-label={label ?? labels.region}\n data-slot=\"table-pagination\"\n className={cn(\"flex flex-wrap items-center justify-between gap-3 py-2 text-sm\", className)}\n >\n <div className=\"flex items-center gap-2 text-muted-foreground\">\n {!hidePageSize && (\n <>\n <span className=\"hidden sm:inline\">{labels.pageSizePrefix}</span>\n <Select value={String(pageSize)} onValueChange={(value) => onPageSizeChange(Number(value))}>\n <SelectTrigger className=\"h-8 w-[4.5rem]\" aria-label={labels.pageSizeSelect(unitLabel)}>\n <SelectValue />\n </SelectTrigger>\n <SelectContent>\n {pageSizeOptions.map((size) => (\n <SelectItem key={size} value={String(size)}>\n {size}\n </SelectItem>\n ))}\n </SelectContent>\n </Select>\n </>\n )}\n <span role=\"status\" className=\"tabular-nums\">\n {labels.range(start, end, total, unitLabel)}\n </span>\n </div>\n\n <div className=\"flex items-center gap-1\">\n <Button\n variant=\"outline\"\n size=\"icon-sm\"\n disabled={isFirst}\n onClick={() => onPageChange(1)}\n className=\"hidden sm:inline-flex\"\n >\n <ChevronsLeft aria-hidden=\"true\" className=\"size-4\" />\n <span className=\"sr-only\">{labels.firstPage}</span>\n </Button>\n <Button variant=\"outline\" size=\"icon-sm\" disabled={isFirst} onClick={() => onPageChange(current - 1)}>\n <ChevronLeft aria-hidden=\"true\" className=\"size-4\" />\n <span className=\"sr-only\">{labels.previousPage}</span>\n </Button>\n\n <span role=\"status\" className=\"px-2 tabular-nums text-muted-foreground\">\n {labels.pageStatus(current, pageCount)}\n </span>\n\n <Button variant=\"outline\" size=\"icon-sm\" disabled={isLast} onClick={() => onPageChange(current + 1)}>\n <ChevronRight aria-hidden=\"true\" className=\"size-4\" />\n <span className=\"sr-only\">{labels.nextPage}</span>\n </Button>\n <Button\n variant=\"outline\"\n size=\"icon-sm\"\n disabled={isLast}\n onClick={() => onPageChange(pageCount)}\n className=\"hidden sm:inline-flex\"\n >\n <ChevronsRight aria-hidden=\"true\" className=\"size-4\" />\n <span className=\"sr-only\">{labels.lastPage}</span>\n </Button>\n </div>\n </nav>\n );\n}\n"],"mappings":";AAqEU,mBACE,KACA,YAFF;AAnEV,SAAS,aAAa,cAAc,cAAc,qBAAqB;AACvE,SAAS,wBAAwB;AACjC,SAAS,UAAU;AACnB,SAAS,cAAc;AACvB,SAAS,QAAQ,eAAe,YAAY,eAAe,mBAAmB;AAC9E,SAAS,iCAA6D;AAgC/D,SAAS,gBAAgB;AAAA,EAC9B;AAAA,EACA;AAAA,EACA;AAAA,EACA,kBAAkB;AAAA,EAClB;AAAA,EACA;AAAA,EACA,WAAW;AAAA,EACX,eAAe;AAAA,EACf;AAAA,EACA,QAAQ;AAAA,EACR;AACF,GAAyB;AACvB,QAAM,SAAS,iBAAiB,mBAAmB,UAAU;AAC7D,QAAM,YAAY,iBAAiB,OAAO;AAC1C,QAAM,YAAY,KAAK,IAAI,GAAG,KAAK,KAAK,QAAQ,KAAK,IAAI,GAAG,QAAQ,CAAC,CAAC;AACtE,QAAM,UAAU,KAAK,IAAI,KAAK,IAAI,GAAG,IAAI,GAAG,SAAS;AACrD,QAAM,QAAQ,UAAU,IAAI,KAAK,UAAU,KAAK,WAAW;AAC3D,QAAM,MAAM,KAAK,IAAI,UAAU,UAAU,KAAK;AAC9C,QAAM,UAAU,WAAW;AAC3B,QAAM,SAAS,WAAW;AAE1B,SACE;AAAA,IAAC;AAAA;AAAA,MACC,cAAY,SAAS,OAAO;AAAA,MAC5B,aAAU;AAAA,MACV,WAAW,GAAG,kEAAkE,SAAS;AAAA,MAEzF;AAAA,6BAAC,SAAI,WAAU,iDACZ;AAAA,WAAC,gBACA,iCACE;AAAA,gCAAC,UAAK,WAAU,oBAAoB,iBAAO,gBAAe;AAAA,YAC1D,qBAAC,UAAO,OAAO,OAAO,QAAQ,GAAG,eAAe,CAAC,UAAU,iBAAiB,OAAO,KAAK,CAAC,GACvF;AAAA,kCAAC,iBAAc,WAAU,kBAAiB,cAAY,OAAO,eAAe,SAAS,GACnF,8BAAC,eAAY,GACf;AAAA,cACA,oBAAC,iBACE,0BAAgB,IAAI,CAAC,SACpB,oBAAC,cAAsB,OAAO,OAAO,IAAI,GACtC,kBADc,IAEjB,CACD,GACH;AAAA,eACF;AAAA,aACF;AAAA,UAEF,oBAAC,UAAK,MAAK,UAAS,WAAU,gBAC3B,iBAAO,MAAM,OAAO,KAAK,OAAO,SAAS,GAC5C;AAAA,WACF;AAAA,QAEA,qBAAC,SAAI,WAAU,2BACb;AAAA;AAAA,YAAC;AAAA;AAAA,cACC,SAAQ;AAAA,cACR,MAAK;AAAA,cACL,UAAU;AAAA,cACV,SAAS,MAAM,aAAa,CAAC;AAAA,cAC7B,WAAU;AAAA,cAEV;AAAA,oCAAC,gBAAa,eAAY,QAAO,WAAU,UAAS;AAAA,gBACpD,oBAAC,UAAK,WAAU,WAAW,iBAAO,WAAU;AAAA;AAAA;AAAA,UAC9C;AAAA,UACA,qBAAC,UAAO,SAAQ,WAAU,MAAK,WAAU,UAAU,SAAS,SAAS,MAAM,aAAa,UAAU,CAAC,GACjG;AAAA,gCAAC,eAAY,eAAY,QAAO,WAAU,UAAS;AAAA,YACnD,oBAAC,UAAK,WAAU,WAAW,iBAAO,cAAa;AAAA,aACjD;AAAA,UAEA,oBAAC,UAAK,MAAK,UAAS,WAAU,2CAC3B,iBAAO,WAAW,SAAS,SAAS,GACvC;AAAA,UAEA,qBAAC,UAAO,SAAQ,WAAU,MAAK,WAAU,UAAU,QAAQ,SAAS,MAAM,aAAa,UAAU,CAAC,GAChG;AAAA,gCAAC,gBAAa,eAAY,QAAO,WAAU,UAAS;AAAA,YACpD,oBAAC,UAAK,WAAU,WAAW,iBAAO,UAAS;AAAA,aAC7C;AAAA,UACA;AAAA,YAAC;AAAA;AAAA,cACC,SAAQ;AAAA,cACR,MAAK;AAAA,cACL,UAAU;AAAA,cACV,SAAS,MAAM,aAAa,SAAS;AAAA,cACrC,WAAU;AAAA,cAEV;AAAA,oCAAC,iBAAc,eAAY,QAAO,WAAU,UAAS;AAAA,gBACrD,oBAAC,UAAK,WAAU,WAAW,iBAAO,UAAS;AAAA;AAAA;AAAA,UAC7C;AAAA,WACF;AAAA;AAAA;AAAA,EACF;AAEJ;","names":[]}
1
+ {"version":3,"sources":["../../../src/components/composite/table-pagination.tsx"],"sourcesContent":["\"use client\";\n\nimport { ChevronLeft, ChevronRight, ChevronsLeft, ChevronsRight } from \"lucide-react\";\nimport { useComitorLabels, useComitorLocale } from \"../../i18n/locale-provider.js\";\nimport { cn } from \"../../lib/cn.js\";\nimport { Button } from \"../ui/button.js\";\nimport { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from \"../ui/select.js\";\nimport { DEFAULT_PAGE_SIZE_OPTIONS, type TablePaginationLabels } from \"./table-pagination-constants.js\";\n\n/**\n * Số theo `locale` của `ComitorLocaleProvider` — cho số in NGOÀI bảng chữ (các mức cỡ trang: `1,000` /\n * `1.000`). Mã locale hỏng (`\"vi_VN\"`, `\"\"`) làm `Intl` ném `RangeError`; `ComitorLocaleProvider` cố ý\n * nhận mã hỏng mà không ném (xem `getComitorLanguage`), nên ở đây rơi về số thô thay vì làm trắng cả\n * trang danh sách.\n */\nfunction formatNumber(value: number, locale: string): string {\n try {\n return value.toLocaleString(locale);\n } catch {\n return String(value);\n }\n}\n\nexport interface TablePaginationProps {\n /** Trang hiện tại, đếm từ 1 */\n page: number;\n pageSize: number;\n /** Tổng số bản ghi (sau khi lọc) */\n total: number;\n pageSizeOptions?: readonly number[];\n onPageChange: (page: number) => void;\n onPageSizeChange: (pageSize: number) => void;\n /** Đơn vị đếm theo nội dung bảng, vd \"tasks\", \"contacts\" — mặc định `labels.unitLabel` (\"items\") */\n unitLabel?: string;\n /** Ẩn ô chọn số dòng/trang (khi cỡ trang do hệ thống quyết định) */\n hidePageSize?: boolean;\n /** Nhãn vùng phân trang — cần khi một trang có nhiều bảng; mặc định `labels.region` */\n label?: string;\n /**\n * Ghi đè chuỗi hiển thị/a11y — không truyền thì theo namespace `tablePagination` của\n * `ComitorLocaleProvider` (mặc định tiếng Anh). Xem `TablePaginationLabels`.\n */\n labels?: Partial<TablePaginationLabels>;\n className?: string;\n}\n\n/**\n * Thanh phân trang đi kèm `DataTable`.\n *\n * Tổng số trang tính từ `total`/`pageSize` ngay tại đây thay vì bắt app truyền vào — hai bên\n * tự tính rất dễ lệch nhau một trang ở bản ghi cuối, và người dùng sẽ thấy nút sang trang sau\n * còn bấm được trong khi bảng đã hết dữ liệu.\n */\nexport function TablePagination({\n page,\n pageSize,\n total,\n pageSizeOptions = DEFAULT_PAGE_SIZE_OPTIONS,\n onPageChange,\n onPageSizeChange,\n unitLabel: unitLabelProp,\n hidePageSize = false,\n label,\n labels: labelsProp,\n className\n}: TablePaginationProps) {\n const labels = useComitorLabels(\"tablePagination\", labelsProp);\n const { locale } = useComitorLocale();\n const unitLabel = unitLabelProp ?? labels.unitLabel;\n const pageCount = Math.max(1, Math.ceil(total / Math.max(1, pageSize)));\n const current = Math.min(Math.max(1, page), pageCount);\n const start = total === 0 ? 0 : (current - 1) * pageSize + 1;\n const end = Math.min(current * pageSize, total);\n const isFirst = current <= 1;\n const isLast = current >= pageCount;\n\n return (\n <nav\n aria-label={label ?? labels.region}\n data-slot=\"table-pagination\"\n className={cn(\"flex flex-wrap items-center justify-between gap-3 py-2 text-sm\", className)}\n >\n <div className=\"flex items-center gap-2 text-muted-foreground\">\n {!hidePageSize && (\n <>\n <span className=\"hidden sm:inline\">{labels.pageSizePrefix}</span>\n <Select value={String(pageSize)} onValueChange={(value) => onPageSizeChange(Number(value))}>\n <SelectTrigger className=\"h-8 w-[4.5rem]\" aria-label={labels.pageSizeSelect(unitLabel)}>\n <SelectValue />\n </SelectTrigger>\n <SelectContent>\n {/* Giá trị giữ số thô (máy đọc); chữ hiện ra — kể cả ở `SelectValue` — theo locale. */}\n {pageSizeOptions.map((size) => (\n <SelectItem key={size} value={String(size)}>\n {formatNumber(size, locale)}\n </SelectItem>\n ))}\n </SelectContent>\n </Select>\n </>\n )}\n <span role=\"status\" className=\"tabular-nums\">\n {labels.range(start, end, total, unitLabel)}\n </span>\n </div>\n\n <div className=\"flex items-center gap-1\">\n <Button\n variant=\"outline\"\n size=\"icon-sm\"\n disabled={isFirst}\n onClick={() => onPageChange(1)}\n className=\"hidden sm:inline-flex\"\n >\n <ChevronsLeft aria-hidden=\"true\" className=\"size-4\" />\n <span className=\"sr-only\">{labels.firstPage}</span>\n </Button>\n <Button variant=\"outline\" size=\"icon-sm\" disabled={isFirst} onClick={() => onPageChange(current - 1)}>\n <ChevronLeft aria-hidden=\"true\" className=\"size-4\" />\n <span className=\"sr-only\">{labels.previousPage}</span>\n </Button>\n\n <span role=\"status\" className=\"px-2 tabular-nums text-muted-foreground\">\n {labels.pageStatus(current, pageCount)}\n </span>\n\n <Button variant=\"outline\" size=\"icon-sm\" disabled={isLast} onClick={() => onPageChange(current + 1)}>\n <ChevronRight aria-hidden=\"true\" className=\"size-4\" />\n <span className=\"sr-only\">{labels.nextPage}</span>\n </Button>\n <Button\n variant=\"outline\"\n size=\"icon-sm\"\n disabled={isLast}\n onClick={() => onPageChange(pageCount)}\n className=\"hidden sm:inline-flex\"\n >\n <ChevronsRight aria-hidden=\"true\" className=\"size-4\" />\n <span className=\"sr-only\">{labels.lastPage}</span>\n </Button>\n </div>\n </nav>\n );\n}\n"],"mappings":"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","names":[]}
@@ -1,31 +1,12 @@
1
+ export { matchesSearch, normalizeVi, removeVietnameseTones } from '../../lib/text-search.js';
2
+
1
3
  /**
2
4
  * Tiện ích chuỗi dùng chung cho tầng composite (không phụ thuộc React → dùng được cả 2 phía).
3
5
  *
4
6
  * Vì sao không nhét vào `lib/cn.ts`: những hàm này phục vụ riêng composite (tìm kiếm không dấu,
5
7
  * initials avatar). Tách file để `cn()` giữ đúng một việc.
6
8
  */
7
- /**
8
- * Bỏ dấu tiếng Việt để so khớp tìm kiếm ("Nguyễn" ⇄ "nguyen").
9
- *
10
- * Dùng NFD tách dấu thành ký tự tổ hợp rồi loại dải U+0300–U+036F. `đ/Đ` KHÔNG phải chữ có dấu
11
- * tổ hợp (là ký tự riêng trong Unicode) nên phải thay tay — thiếu bước này thì gõ "duc" sẽ không
12
- * ra "Đức".
13
- */
14
- declare function removeVietnameseTones(input: string): string;
15
- /**
16
- * Chuẩn hoá chuỗi để so khớp: bỏ dấu + hạ chữ thường + `trim`.
17
- *
18
- * Đây là phép chuẩn hoá DUY NHẤT của cả gói (tầng shell cũng gọi lại hàm này, không giữ bản
19
- * riêng) — mỗi nơi tự chuẩn hoá một kiểu là cùng một từ khoá ra hai kết quả tìm kiếm khác nhau.
20
- * `removeVietnameseTones` ở trên là bậc thấp hơn, chỉ bỏ dấu, dùng khi cần giữ nguyên hoa/thường
21
- * (vd sinh slug).
22
- */
23
- declare function normalizeVi(value: string): string;
24
- /**
25
- * So khớp không dấu, không phân biệt hoa thường: `needle` có nằm trong `haystack` không.
26
- * Cả hai vế đi qua `normalizeVi` — cùng một luật với ô tìm kiếm của shell.
27
- */
28
- declare function matchesSearch(haystack: string, needle: string): boolean;
9
+
29
10
  /**
30
11
  * Initials kiểu Việt Nam: chữ đầu của HỌ + chữ đầu của TÊN ("Nguyễn Đức Thành" → "NT").
31
12
  *
@@ -36,4 +17,4 @@ declare function getVietnameseInitials(name: string): string;
36
17
  /** Cắt chuỗi kèm dấu "…" — dùng cho tên workspace/task dài trong chip, tooltip. */
37
18
  declare function truncate(input: string, maxLength: number): string;
38
19
 
39
- export { getVietnameseInitials, matchesSearch, normalizeVi, removeVietnameseTones, truncate };
20
+ export { getVietnameseInitials, truncate };
@@ -1,14 +1,4 @@
1
- function removeVietnameseTones(input) {
2
- return input.normalize("NFD").replace(/[\u0300-\u036f]/g, "").replace(/đ/g, "d").replace(/Đ/g, "D");
3
- }
4
- function normalizeVi(value) {
5
- return removeVietnameseTones(value).toLowerCase().trim();
6
- }
7
- function matchesSearch(haystack, needle) {
8
- const query = normalizeVi(needle);
9
- if (!query) return true;
10
- return normalizeVi(haystack).includes(query);
11
- }
1
+ import { matchesSearch, normalizeVi, removeVietnameseTones } from "../../lib/text-search.js";
12
2
  function getVietnameseInitials(name) {
13
3
  const parts = name.trim().split(/\s+/).filter(Boolean);
14
4
  if (parts.length === 0) return "?";
@@ -1 +1 @@
1
- {"version":3,"sources":["../../../src/components/composite/text-utils.ts"],"sourcesContent":["/**\n * Tiện ích chuỗi dùng chung cho tầng composite (không phụ thuộc React → dùng được cả 2 phía).\n *\n * Vì sao không nhét vào `lib/cn.ts`: những hàm này phục vụ riêng composite (tìm kiếm không dấu,\n * initials avatar). Tách file để `cn()` giữ đúng một việc.\n */\n\n/**\n * Bỏ dấu tiếng Việt để so khớp tìm kiếm (\"Nguyễn\" ⇄ \"nguyen\").\n *\n * Dùng NFD tách dấu thành ký tự tổ hợp rồi loại dải U+0300–U+036F. `đ/Đ` KHÔNG phải chữ có dấu\n * tổ hợp (là ký tự riêng trong Unicode) nên phải thay tay — thiếu bước này thì gõ \"duc\" sẽ không\n * ra \"Đức\".\n */\nexport function removeVietnameseTones(input: string): string {\n return input\n .normalize(\"NFD\")\n .replace(/[\\u0300-\\u036f]/g, \"\")\n .replace(/đ/g, \"d\")\n .replace(/Đ/g, \"D\");\n}\n\n/**\n * Chuẩn hoá chuỗi để so khớp: bỏ dấu + hạ chữ thường + `trim`.\n *\n * Đây là phép chuẩn hoá DUY NHẤT của cả gói (tầng shell cũng gọi lại hàm này, không giữ bản\n * riêng) — mỗi nơi tự chuẩn hoá một kiểu là cùng một từ khoá ra hai kết quả tìm kiếm khác nhau.\n * `removeVietnameseTones` ở trên là bậc thấp hơn, chỉ bỏ dấu, dùng khi cần giữ nguyên hoa/thường\n * (vd sinh slug).\n */\nexport function normalizeVi(value: string): string {\n return removeVietnameseTones(value).toLowerCase().trim();\n}\n\n/**\n * So khớp không dấu, không phân biệt hoa thường: `needle` có nằm trong `haystack` không.\n * Cả hai vế đi qua `normalizeVi` — cùng một luật với ô tìm kiếm của shell.\n */\nexport function matchesSearch(haystack: string, needle: string): boolean {\n const query = normalizeVi(needle);\n if (!query) return true;\n return normalizeVi(haystack).includes(query);\n}\n\n/**\n * Initials kiểu Việt Nam: chữ đầu của HỌ + chữ đầu của TÊN (\"Nguyễn Đức Thành\" → \"NT\").\n *\n * Người Việt gọi nhau bằng TÊN (từ cuối), nên lấy 2 từ đầu như phương Tây (\"NĐ\") là sai thói quen.\n * Tên một từ → lấy 1 ký tự. Chuỗi rỗng → \"?\" (tránh ô avatar trống trơn).\n */\nexport function getVietnameseInitials(name: string): string {\n const parts = name.trim().split(/\\s+/).filter(Boolean);\n if (parts.length === 0) return \"?\";\n\n const first = parts[0] ?? \"\";\n const last = parts[parts.length - 1] ?? \"\";\n const initials = parts.length === 1 ? first.slice(0, 1) : `${first.slice(0, 1)}${last.slice(0, 1)}`;\n\n return initials.toLocaleUpperCase(\"vi-VN\");\n}\n\n/** Cắt chuỗi kèm dấu \"…\" — dùng cho tên workspace/task dài trong chip, tooltip. */\nexport function truncate(input: string, maxLength: number): string {\n if (input.length <= maxLength) return input;\n return `${input.slice(0, Math.max(0, maxLength - 1)).trimEnd()}…`;\n}\n"],"mappings":"AAcO,SAAS,sBAAsB,OAAuB;AAC3D,SAAO,MACJ,UAAU,KAAK,EACf,QAAQ,oBAAoB,EAAE,EAC9B,QAAQ,MAAM,GAAG,EACjB,QAAQ,MAAM,GAAG;AACtB;AAUO,SAAS,YAAY,OAAuB;AACjD,SAAO,sBAAsB,KAAK,EAAE,YAAY,EAAE,KAAK;AACzD;AAMO,SAAS,cAAc,UAAkB,QAAyB;AACvE,QAAM,QAAQ,YAAY,MAAM;AAChC,MAAI,CAAC,MAAO,QAAO;AACnB,SAAO,YAAY,QAAQ,EAAE,SAAS,KAAK;AAC7C;AAQO,SAAS,sBAAsB,MAAsB;AAC1D,QAAM,QAAQ,KAAK,KAAK,EAAE,MAAM,KAAK,EAAE,OAAO,OAAO;AACrD,MAAI,MAAM,WAAW,EAAG,QAAO;AAE/B,QAAM,QAAQ,MAAM,CAAC,KAAK;AAC1B,QAAM,OAAO,MAAM,MAAM,SAAS,CAAC,KAAK;AACxC,QAAM,WAAW,MAAM,WAAW,IAAI,MAAM,MAAM,GAAG,CAAC,IAAI,GAAG,MAAM,MAAM,GAAG,CAAC,CAAC,GAAG,KAAK,MAAM,GAAG,CAAC,CAAC;AAEjG,SAAO,SAAS,kBAAkB,OAAO;AAC3C;AAGO,SAAS,SAAS,OAAe,WAA2B;AACjE,MAAI,MAAM,UAAU,UAAW,QAAO;AACtC,SAAO,GAAG,MAAM,MAAM,GAAG,KAAK,IAAI,GAAG,YAAY,CAAC,CAAC,EAAE,QAAQ,CAAC;AAChE;","names":[]}
1
+ {"version":3,"sources":["../../../src/components/composite/text-utils.ts"],"sourcesContent":["/**\n * Tiện ích chuỗi dùng chung cho tầng composite (không phụ thuộc React → dùng được cả 2 phía).\n *\n * Vì sao không nhét vào `lib/cn.ts`: những hàm này phục vụ riêng composite (tìm kiếm không dấu,\n * initials avatar). Tách file để `cn()` giữ đúng một việc.\n */\n\n// ⚠ Từ `3.1.0` ba hàm tìm kiếm không dấu sống ở `lib/text-search.ts` (Tier 1 cũng dùng chúng); file\n// này xuất lại NGUYÊN tên và chữ ký — với app không có gì đổi.\nexport { matchesSearch, normalizeVi, removeVietnameseTones } from \"../../lib/text-search.js\";\n\n/**\n * Initials kiểu Việt Nam: chữ đầu của HỌ + chữ đầu của TÊN (\"Nguyễn Đức Thành\" → \"NT\").\n *\n * Người Việt gọi nhau bằng TÊN (từ cuối), nên lấy 2 từ đầu như phương Tây (\"NĐ\") là sai thói quen.\n * Tên một từ → lấy 1 ký tự. Chuỗi rỗng → \"?\" (tránh ô avatar trống trơn).\n */\nexport function getVietnameseInitials(name: string): string {\n const parts = name.trim().split(/\\s+/).filter(Boolean);\n if (parts.length === 0) return \"?\";\n\n const first = parts[0] ?? \"\";\n const last = parts[parts.length - 1] ?? \"\";\n const initials = parts.length === 1 ? first.slice(0, 1) : `${first.slice(0, 1)}${last.slice(0, 1)}`;\n\n return initials.toLocaleUpperCase(\"vi-VN\");\n}\n\n/** Cắt chuỗi kèm dấu \"…\" — dùng cho tên workspace/task dài trong chip, tooltip. */\nexport function truncate(input: string, maxLength: number): string {\n if (input.length <= maxLength) return input;\n return `${input.slice(0, Math.max(0, maxLength - 1)).trimEnd()}…`;\n}\n"],"mappings":"AASA,SAAS,eAAe,aAAa,6BAA6B;AAQ3D,SAAS,sBAAsB,MAAsB;AAC1D,QAAM,QAAQ,KAAK,KAAK,EAAE,MAAM,KAAK,EAAE,OAAO,OAAO;AACrD,MAAI,MAAM,WAAW,EAAG,QAAO;AAE/B,QAAM,QAAQ,MAAM,CAAC,KAAK;AAC1B,QAAM,OAAO,MAAM,MAAM,SAAS,CAAC,KAAK;AACxC,QAAM,WAAW,MAAM,WAAW,IAAI,MAAM,MAAM,GAAG,CAAC,IAAI,GAAG,MAAM,MAAM,GAAG,CAAC,CAAC,GAAG,KAAK,MAAM,GAAG,CAAC,CAAC;AAEjG,SAAO,SAAS,kBAAkB,OAAO;AAC3C;AAGO,SAAS,SAAS,OAAe,WAA2B;AACjE,MAAI,MAAM,UAAU,UAAW,QAAO;AACtC,SAAO,GAAG,MAAM,MAAM,GAAG,KAAK,IAAI,GAAG,YAAY,CAAC,CAAC,EAAE,QAAQ,CAAC;AAChE;","names":[]}
@@ -53,6 +53,14 @@ declare const bannerVariants: (props?: ({
53
53
  interface BannerProps extends DivProps, VariantProps<typeof bannerVariants> {
54
54
  open?: boolean;
55
55
  defaultOpen?: boolean;
56
+ /**
57
+ * Báo `false` khi banner đóng THẬT: `BannerClose`; trong `Banners` thêm hết `duration` hay hàng đợi
58
+ * gỡ nó. Không báo khi render lại, khi unmount, hay khi chính app đặt `open={false}`.
59
+ *
60
+ * Focus: nếu lúc banner rời DOM focus còn ở trong nó, gói trả focus về phần tử giữ focus ngay trước
61
+ * khi vào banner. Muốn đưa focus nơi khác (vd tiêu đề trang) thì làm ngay ở đây — app luôn thắng.
62
+ * Không có phần tử ấy (hoặc nó đã bị gỡ) thì gói không đoán, focus rơi về `<body>`.
63
+ */
56
64
  onOpenChange?: (open: boolean) => void;
57
65
  onDismiss?: () => void;
58
66
  priority?: number;
@@ -51,6 +51,28 @@ function useBanner() {
51
51
  };
52
52
  }, [id, variant, dismissible, onClose, storeContext]);
53
53
  }
54
+ function insertByPriority(banners, banner) {
55
+ const priority = banner.priority ?? DEFAULT_BANNER_PRIORITY;
56
+ const insertIndex = banners.findIndex((b) => (b.priority ?? DEFAULT_BANNER_PRIORITY) < priority);
57
+ if (insertIndex === -1) {
58
+ banners.push(banner);
59
+ } else {
60
+ banners.splice(insertIndex, 0, banner);
61
+ }
62
+ }
63
+ function scheduleAutoClose(timeouts, id, duration, onElapsed) {
64
+ const pendingTimeoutId = timeouts.get(id);
65
+ if (pendingTimeoutId !== void 0) {
66
+ clearTimeout(pendingTimeoutId);
67
+ timeouts.delete(id);
68
+ }
69
+ if (!duration || duration <= 0) return;
70
+ const timeoutId = setTimeout(() => {
71
+ onElapsed();
72
+ timeouts.delete(id);
73
+ }, duration);
74
+ timeouts.set(id, timeoutId);
75
+ }
54
76
  function Banners(props) {
55
77
  const { children, maxVisible = 1, side = "top", strategy = "fixed", container: containerProp } = props;
56
78
  const instanceId = React.useId();
@@ -78,25 +100,34 @@ function Banners(props) {
78
100
  idCounterRef.current += 1;
79
101
  const id = `${instanceId}-${idCounterRef.current}`;
80
102
  const newBanner = { ...banner, id };
81
- const priority = banner.priority ?? DEFAULT_BANNER_PRIORITY;
82
103
  const banners2 = [...stateRef.current.banners];
83
- const insertIndex = banners2.findIndex((b) => (b.priority ?? DEFAULT_BANNER_PRIORITY) < priority);
84
- if (insertIndex === -1) {
85
- banners2.push(newBanner);
86
- } else {
87
- banners2.splice(insertIndex, 0, newBanner);
88
- }
104
+ insertByPriority(banners2, newBanner);
89
105
  stateRef.current.banners = banners2;
90
106
  store.notify();
91
- if (banner.duration && banner.duration > 0) {
92
- const timeoutId = setTimeout(() => {
93
- store.onRemovingChange(id, true);
94
- timeoutsRef.current.delete(id);
95
- }, banner.duration);
96
- timeoutsRef.current.set(id, timeoutId);
97
- }
107
+ scheduleAutoClose(timeoutsRef.current, id, banner.duration, () => store.onRemovingChange(id, true));
98
108
  return id;
99
109
  },
110
+ onBannerUpdate: (id, patch) => {
111
+ const banners2 = stateRef.current.banners;
112
+ const index = banners2.findIndex((b) => b.id === id);
113
+ const current = banners2[index];
114
+ if (!current) return;
115
+ const keys = Object.keys(patch);
116
+ if (keys.every((key) => Object.is(current[key], patch[key]))) return;
117
+ const next = { ...current, ...patch };
118
+ const nextBanners = [...banners2];
119
+ if (Object.is(next.priority ?? DEFAULT_BANNER_PRIORITY, current.priority ?? DEFAULT_BANNER_PRIORITY)) {
120
+ nextBanners[index] = next;
121
+ } else {
122
+ nextBanners.splice(index, 1);
123
+ insertByPriority(nextBanners, next);
124
+ }
125
+ stateRef.current.banners = nextBanners;
126
+ if (!Object.is(next.duration, current.duration) && !stateRef.current.removing.has(id)) {
127
+ scheduleAutoClose(timeoutsRef.current, id, next.duration, () => store.onRemovingChange(id, true));
128
+ }
129
+ store.notify();
130
+ },
100
131
  onBannerRemove: (id) => {
101
132
  const banner = stateRef.current.banners.find((b) => b.id === id);
102
133
  if (!banner) return;
@@ -232,6 +263,49 @@ const bannerVariants = cva(
232
263
  }
233
264
  }
234
265
  );
266
+ function useFocusReturn(active, onFocusProp, onBlurProp) {
267
+ const returnFocusRef = React.useRef(null);
268
+ const focusInsideRef = React.useRef(false);
269
+ const onFocus = React.useCallback(
270
+ (event) => {
271
+ onFocusProp?.(event);
272
+ const from = event.relatedTarget;
273
+ if (!focusInsideRef.current && from instanceof HTMLElement && !event.currentTarget.contains(from)) {
274
+ returnFocusRef.current = from;
275
+ }
276
+ focusInsideRef.current = true;
277
+ },
278
+ [onFocusProp]
279
+ );
280
+ const onBlur = React.useCallback(
281
+ (event) => {
282
+ onBlurProp?.(event);
283
+ const to = event.relatedTarget;
284
+ if (!(to instanceof Node) || !event.currentTarget.contains(to)) {
285
+ focusInsideRef.current = false;
286
+ }
287
+ },
288
+ [onBlurProp]
289
+ );
290
+ useIsomorphicLayoutEffect(() => {
291
+ if (!active) return;
292
+ return () => {
293
+ const returnTarget = returnFocusRef.current;
294
+ const hadFocus = focusInsideRef.current;
295
+ returnFocusRef.current = null;
296
+ focusInsideRef.current = false;
297
+ if (!hadFocus || !returnTarget) return;
298
+ queueMicrotask(() => {
299
+ const ownerDocument = returnTarget.ownerDocument;
300
+ const activeElement = ownerDocument.activeElement;
301
+ if (activeElement && activeElement !== ownerDocument.body && activeElement.isConnected) return;
302
+ if (!returnTarget.isConnected) return;
303
+ returnTarget.focus({ preventScroll: true });
304
+ });
305
+ };
306
+ }, [active]);
307
+ return { onFocus, onBlur };
308
+ }
235
309
  function BannerImpl(props) {
236
310
  const { banner, side, index } = props;
237
311
  const store = useStoreContext("BannerImpl");
@@ -257,9 +331,15 @@ function BannerImpl(props) {
257
331
  return () => cancelAnimationFrame(frame);
258
332
  }, []);
259
333
  useIsomorphicLayoutEffect(() => {
260
- if (!bannerRef.current || removing) return;
261
- const height = bannerRef.current.getBoundingClientRect().height;
262
- store.onHeightChange(banner.id, height);
334
+ const node = bannerRef.current;
335
+ if (!node || removing) return;
336
+ store.onHeightChange(banner.id, node.getBoundingClientRect().height);
337
+ if (typeof ResizeObserver === "undefined") return;
338
+ const observer = new ResizeObserver(() => {
339
+ store.onHeightChange(banner.id, node.getBoundingClientRect().height);
340
+ });
341
+ observer.observe(node);
342
+ return () => observer.disconnect();
263
343
  }, [store, banner.id, removing]);
264
344
  React.useEffect(() => {
265
345
  if (!removing) return;
@@ -269,6 +349,7 @@ function BannerImpl(props) {
269
349
  }, [removing, store, banner.id]);
270
350
  const onClose = React.useCallback(() => store.onRemovingChange(banner.id, true), [store, banner.id]);
271
351
  const onRemove = React.useCallback(() => store.onBannerRemove(banner.id), [store, banner.id]);
352
+ const { onFocus, onBlur } = useFocusReturn(true);
272
353
  const dismissible = banner.dismissible ?? DEFAULT_BANNER_DISMISSIBLE;
273
354
  const contextValue = React.useMemo(
274
355
  () => ({ id: banner.id, variant: banner.variant, dismissible, onClose }),
@@ -319,10 +400,36 @@ function BannerImpl(props) {
319
400
  // Thời lượng qua `--banner-duration` (0 khi giảm chuyển động) — xem chú thích ở `Banners`.
320
401
  transition: `transform var(--banner-duration, ${BANNER_ANIMATION_DURATION}ms) cubic-bezier(0.32, 0.72, 0, 1), opacity var(--banner-duration, ${opacityDuration}ms) ease`
321
402
  },
403
+ onFocus,
404
+ onBlur,
322
405
  children: typeof banner.content === "function" ? banner.content(renderProps) : banner.content
323
406
  }
324
407
  ) });
325
408
  }
409
+ function createChildrenSource(initialChildren) {
410
+ let children = initialChildren;
411
+ const listeners = /* @__PURE__ */ new Set();
412
+ return {
413
+ get: () => children,
414
+ set: (nextChildren) => {
415
+ if (Object.is(children, nextChildren)) return;
416
+ children = nextChildren;
417
+ for (const listener of listeners) {
418
+ listener();
419
+ }
420
+ },
421
+ subscribe: (listener) => {
422
+ listeners.add(listener);
423
+ return () => {
424
+ listeners.delete(listener);
425
+ };
426
+ }
427
+ };
428
+ }
429
+ function DeclaredBannerChildren(props) {
430
+ const { source } = props;
431
+ return React.useSyncExternalStore(source.subscribe, source.get, source.get);
432
+ }
326
433
  function Banner(props) {
327
434
  const {
328
435
  className,
@@ -336,6 +443,8 @@ function Banner(props) {
336
443
  dismissible = DEFAULT_BANNER_DISMISSIBLE,
337
444
  children,
338
445
  asChild,
446
+ onFocus: onFocusProp,
447
+ onBlur: onBlurProp,
339
448
  ...rootProps
340
449
  } = props;
341
450
  const store = React.useContext(StoreContext);
@@ -346,6 +455,9 @@ function Banner(props) {
346
455
  const bannerIdRef = React.useRef(null);
347
456
  const onDismissRef = useAsRef(onDismiss);
348
457
  const onOpenChangeRef = useAsRef(onOpenChange);
458
+ const isControlledRef = useAsRef(isControlled);
459
+ const queuedDataRef = useAsRef({ variant: variant ?? void 0, priority, dismissible, duration });
460
+ const childrenSourceRef = useLazyRef(() => createChildrenSource(children));
349
461
  if (isControlled) {
350
462
  openRef.current = openProp;
351
463
  }
@@ -358,18 +470,23 @@ function Banner(props) {
358
470
  );
359
471
  const getSnapshot = React.useCallback(() => openRef.current, [openRef]);
360
472
  const open = React.useSyncExternalStore(subscribe, getSnapshot, getSnapshot);
473
+ useIsomorphicLayoutEffect(() => {
474
+ childrenSourceRef.current.set(children);
475
+ });
476
+ useIsomorphicLayoutEffect(() => {
477
+ const id = bannerIdRef.current;
478
+ if (!store || id === null) return;
479
+ store.onBannerUpdate(id, { variant: variant ?? void 0, priority, dismissible, duration });
480
+ }, [store, variant, priority, dismissible, duration]);
361
481
  React.useEffect(() => {
362
- if (!isInsideStore || !store || !open) return;
482
+ if (!store || !open) return;
363
483
  let isCleaningUp = false;
364
484
  const id = store.onBannerAdd({
365
- content: children,
366
- variant: variant ?? void 0,
367
- priority,
368
- dismissible,
369
- duration,
485
+ ...queuedDataRef.current,
486
+ content: /* @__PURE__ */ jsx(DeclaredBannerChildren, { source: childrenSourceRef.current }),
370
487
  onDismiss: () => {
371
488
  if (isCleaningUp) return;
372
- if (!isControlled) {
489
+ if (!isControlledRef.current) {
373
490
  openRef.current = false;
374
491
  for (const listener of listenersRef.current) {
375
492
  listener();
@@ -382,21 +499,15 @@ function Banner(props) {
382
499
  bannerIdRef.current = id;
383
500
  return () => {
384
501
  isCleaningUp = true;
385
- if (bannerIdRef.current) {
386
- store.onBannerRemove(bannerIdRef.current);
387
- bannerIdRef.current = null;
388
- }
502
+ bannerIdRef.current = null;
503
+ store.onBannerRemove(id);
389
504
  };
390
505
  }, [
391
- isInsideStore,
392
506
  store,
393
507
  open,
394
- isControlled,
395
- children,
396
- variant,
397
- priority,
398
- dismissible,
399
- duration,
508
+ queuedDataRef,
509
+ childrenSourceRef,
510
+ isControlledRef,
400
511
  openRef,
401
512
  listenersRef,
402
513
  onDismissRef,
@@ -411,6 +522,7 @@ function Banner(props) {
411
522
  }
412
523
  onOpenChangeRef.current?.(false);
413
524
  }, [isControlled, openRef, listenersRef, onOpenChangeRef]);
525
+ const { onFocus, onBlur } = useFocusReturn(open && !isInsideStore, onFocusProp, onBlurProp);
414
526
  const contextValue = React.useMemo(
415
527
  () => ({
416
528
  variant,
@@ -430,6 +542,8 @@ function Banner(props) {
430
542
  "data-state": "open",
431
543
  className: cn(bannerVariants({ variant, className })),
432
544
  ...rootProps,
545
+ onFocus,
546
+ onBlur,
433
547
  children
434
548
  }
435
549
  ) });