@comitor/ui 3.6.1 → 3.7.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.
- package/README.md +5 -5
- package/dist/components/composite/combobox.d.ts +9 -0
- package/dist/components/composite/combobox.js +16 -3
- package/dist/components/composite/combobox.js.map +1 -1
- package/dist/components/composite/inline-edit.d.ts +7 -1
- package/dist/components/composite/inline-edit.js +10 -1
- package/dist/components/composite/inline-edit.js.map +1 -1
- package/dist/components/composite/key-sequence.d.ts +44 -0
- package/dist/components/composite/key-sequence.js +32 -0
- package/dist/components/composite/key-sequence.js.map +1 -0
- package/dist/components/composite/keyboard-shortcuts-dialog.d.ts +39 -0
- package/dist/components/composite/keyboard-shortcuts-dialog.js +78 -0
- package/dist/components/composite/keyboard-shortcuts-dialog.js.map +1 -0
- package/dist/components/composite/use-key-sequences.d.ts +28 -0
- package/dist/components/composite/use-key-sequences.js +44 -0
- package/dist/components/composite/use-key-sequences.js.map +1 -0
- package/dist/components/ui/badge.d.ts +19 -2
- package/dist/components/ui/badge.js +44 -4
- package/dist/components/ui/badge.js.map +1 -1
- package/dist/components/ui/item.d.ts +1 -1
- package/dist/components/ui/item.js +13 -2
- package/dist/components/ui/item.js.map +1 -1
- package/dist/editor/bubble-toolbar.d.ts +2 -1
- package/dist/editor/editor-core.d.ts +2 -1
- package/dist/editor/editor-core.js +6 -4
- package/dist/editor/editor-core.js.map +1 -1
- package/dist/editor/editor-types.d.ts +3 -1
- package/dist/editor/fixed-toolbar.d.ts +2 -1
- package/dist/editor/image-upload.d.ts +3 -1
- package/dist/editor/index.d.ts +2 -1
- package/dist/editor/markdown-editor-classes.js +3 -1
- package/dist/editor/markdown-editor-classes.js.map +1 -1
- package/dist/editor/markdown-editor.d.ts +2 -1
- package/dist/editor/markdown-extensions.d.ts +9 -3
- package/dist/editor/markdown-extensions.js +28 -5
- package/dist/editor/markdown-extensions.js.map +1 -1
- package/dist/editor/markdown-syntax.d.ts +3 -1
- package/dist/editor/markdown-syntax.js +10 -4
- package/dist/editor/markdown-syntax.js.map +1 -1
- package/dist/editor/rich-text-editor.d.ts +2 -1
- package/dist/editor/slash-commands.d.ts +3 -1
- package/dist/editor/toolbar-actions.d.ts +3 -1
- package/dist/{editor-types-BMST7-VR.d.ts → editor-types-CSrMeuSw.d.ts} +20 -3
- package/dist/i18n/locale-provider.d.ts +1 -0
- package/dist/i18n/messages.d.ts +2 -0
- package/dist/i18n/messages.js +3 -0
- package/dist/i18n/messages.js.map +1 -1
- package/dist/i18n/namespaces/keyboard-shortcuts.d.ts +13 -0
- package/dist/i18n/namespaces/keyboard-shortcuts.js +15 -0
- package/dist/i18n/namespaces/keyboard-shortcuts.js.map +1 -0
- package/dist/i18n/namespaces/shell.js +4 -2
- package/dist/i18n/namespaces/shell.js.map +1 -1
- package/dist/index.d.ts +4 -0
- package/dist/index.js +3 -0
- package/dist/index.js.map +1 -1
- package/dist/shell/app-shell.d.ts +14 -1
- package/dist/shell/app-shell.js +24 -2
- package/dist/shell/app-shell.js.map +1 -1
- package/dist/shell/display-prefs-seed.d.ts +64 -0
- package/dist/shell/display-prefs-seed.js +58 -0
- package/dist/shell/display-prefs-seed.js.map +1 -0
- package/dist/shell/index.d.ts +1 -0
- package/dist/shell/index.js +1 -0
- package/dist/shell/index.js.map +1 -1
- package/dist/shell/types.d.ts +2 -0
- package/dist/shell/types.js.map +1 -1
- package/package.json +2 -2
package/README.md
CHANGED
|
@@ -129,7 +129,7 @@ Package có **8 entry**, tách theo peer optional mà mỗi tầng kéo theo:
|
|
|
129
129
|
| `@comitor/ui/chart` | Lớp shadcn bọc recharts + `Comitor*Chart` + `CHART_COLORS` | `recharts` (~100KB) |
|
|
130
130
|
| `@comitor/ui/data-grid` | `DataGrid` — lưới sửa trực tiếp kiểu bảng tính: ảo hoá dòng, chọn vùng bằng bàn phím, copy/paste với Excel, ghim cột, lọc/sắp xếp nhiều tiêu chí, hoàn tác/làm lại | `@tanstack/react-table` (peer); kèm `@tanstack/react-virtual` + `@dnd-kit/*` (dependency của gói) |
|
|
131
131
|
| `@comitor/ui/uploader` | `FileUpload` (tệp tài liệu) · `ImageUploadField` (ảnh đại diện, logo) · `useFileUpload` | `@uppy/core`, `@uppy/xhr-upload` (~13KB gzip) |
|
|
132
|
-
| `@comitor/ui/editor` | `MarkdownEditor` — trình soạn kiểu Linear, lưu bằng markdown · `RichTextEditor` — lưu HTML, thanh công cụ cố định kiểu CMS. Chung: gõ tắt, menu `/`, `@` nhắc người, ảnh, bảng, khối mã tô màu, `toolbar="bubble" \| "fixed"` | `@tiptap/*` + `lowlight` (~234KB gzip — nạp lười nếu trang không cần ngay) |
|
|
132
|
+
| `@comitor/ui/editor` | `MarkdownEditor` — trình soạn kiểu Linear, lưu bằng markdown · `RichTextEditor` — lưu HTML, thanh công cụ cố định kiểu CMS. Chung: gõ tắt, menu `/`, `@` nhắc người (và việc, dự án… qua `kind`), ảnh, bảng, khối mã tô màu, `toolbar="bubble" \| "fixed"` | `@tiptap/*` + `lowlight` (~234KB gzip — nạp lười nếu trang không cần ngay) |
|
|
133
133
|
| `@comitor/ui/form` | Binding shadcn ⇄ react-hook-form | `react-hook-form` |
|
|
134
134
|
| `@comitor/ui/tokens` | Token dạng object TS — cho nơi không có Tailwind: canvas, email HTML, PDF | không có |
|
|
135
135
|
|
|
@@ -176,18 +176,18 @@ https://design-system.comitor.ai/foundations/server-client
|
|
|
176
176
|
|
|
177
177
|
## 5. Bảng component
|
|
178
178
|
|
|
179
|
-
Số file nguồn hiện tại: **79 primitive ·
|
|
179
|
+
Số file nguồn hiện tại: **79 primitive · 36 composite · 26 shell · 4 chart · 32 data-grid · 5 uploader · 17 editor · 45 i18n**. Mỗi component có trang
|
|
180
180
|
riêng kèm demo, bảng prop và cạm bẫy của nó:
|
|
181
181
|
|
|
182
182
|
| Tầng | Là gì | Trang |
|
|
183
183
|
|---|---|---|
|
|
184
184
|
| **Tier 1 — primitive** · 79 file · `@comitor/ui` | shadcn/ui style `new-york`, đã tùy biến token theo brand Comitor — cộng 18 component port từ Dice UI ở `2.1.0`: `Kanban`, `Stepper`, `Timeline`, `TimePicker`, `TagsInput`, `Mention`, `ActionBar`, `PhoneInput`, `MaskInput`, `Rating`, `CircularProgress`, `Banner`, `ResponsiveDialog`, `Tour`, `KeyValue`, `Cropper`, `ScrollSpy`, `BadgeOverflow` | https://design-system.comitor.ai/components · vì sao chọn chúng, và mọi chỗ đã đổi so với thượng nguồn: [docs/diceui.md](docs/diceui.md) |
|
|
185
|
-
| **Tier 2 — composite** ·
|
|
186
|
-
| **Tier 3 — shell** ·
|
|
185
|
+
| **Tier 2 — composite** · 36 file · `@comitor/ui` | Giá trị thật của gói: pattern lặp ở mọi app, ghép từ nhiều primitive (`DataTable`, `PageHeader`, `Combobox`, `DatePicker`, `StatCard`…). Từ `3.7.0`: phím tắt chuỗi `useKeySequences` + bảng `?` `KeyboardShortcutsDialog` | https://design-system.comitor.ai/composites |
|
|
186
|
+
| **Tier 3 — shell** · 26 file · `@comitor/ui/shell` | Khung ứng dụng workspace × app: sidebar, header, theme, ba trục hiển thị (tương phản · mật độ · cỡ chữ). `AppShell` tự có skip link (WCAG 2.4.1) từ `3.7.0`; `DisplayPrefsSeedScript` gieo mặc định hiển thị của tài khoản trước lượt sơn đầu | https://design-system.comitor.ai/shell |
|
|
187
187
|
| **chart** · 4 file · `@comitor/ui/chart` | Lớp shadcn bọc recharts, cùng ba bản dựng sẵn theo brand. ⚠ Ba wrapper `Comitor*Chart` **mặc định TẮT** hiệu ứng vẽ dần (`isAnimationActive = false`) — bật lại là biểu đồ kẹt ở khung đầu: có trục, có lưới, KHÔNG có dữ liệu. Mặc định đó KHÔNG áp cho `<ChartContainer>` + recharts do app tự lắp | https://design-system.comitor.ai/components/chart |
|
|
188
188
|
| **data-grid** · 32 file · `@comitor/ui/data-grid` | `DataGrid` — lưới sửa trực tiếp kiểu bảng tính, cho màn nhập liệu hàng loạt. Port từ [tablecn](https://www.tablecn.com/data-grid) (MIT) sang primitive + token của gói. ⚠ Là tầng DUY NHẤT có peer **`@tanstack/react-table`**, có thêm prop `locale` riêng (xem dưới), và mọi thứ trong nó là client component | [docs/data-grid.md](docs/data-grid.md) |
|
|
189
189
|
| **uploader** · 5 file · `@comitor/ui/uploader` | `FileUpload` (tệp) · `ImageUploadField` (ảnh) · `useFileUpload` — kéo thả, xem trước, tiến độ. Peer optional: `@uppy/core` + `@uppy/xhr-upload` | https://design-system.comitor.ai/components |
|
|
190
|
-
| **editor** · 17 file · `@comitor/ui/editor` | `MarkdownEditor` — soạn trực tiếp trên bản đã định dạng, giá trị vào/ra là markdown (GFM); `RichTextEditor` — cùng lõi, giá trị là HTML, lược đồ có thêm gạch chân và ô bảng nhiều khối, mặc định thanh cố định. Dựng trên Tiptap 3 (ProseMirror). ⚠ Định dạng LƯU là hợp đồng: nhắc người là `[@Tên](mention:<id>)` / `<span data-type="mention">`, ảnh lưu `src` app trả về (không phải URL đã ký), markdown cố ý không có gạch chân. ⚠ HTML đã lưu hiển thị lại bằng `RichTextEditor editable={false}`, không `dangerouslySetInnerHTML`. Peer optional: bộ `@tiptap/*` + `lowlight` | [docs/editor.md](docs/editor.md) |
|
|
190
|
+
| **editor** · 17 file · `@comitor/ui/editor` | `MarkdownEditor` — soạn trực tiếp trên bản đã định dạng, giá trị vào/ra là markdown (GFM); `RichTextEditor` — cùng lõi, giá trị là HTML, lược đồ có thêm gạch chân và ô bảng nhiều khối, mặc định thanh cố định. Dựng trên Tiptap 3 (ProseMirror). ⚠ Định dạng LƯU là hợp đồng: nhắc người là `[@Tên](mention:<id>)` / `<span data-type="mention">`, nhắc thực thể khác (từ `3.7.0`, `kind`) là `[@KEY-1](mention:task/<id>)` / thêm `data-kind`, ảnh lưu `src` app trả về (không phải URL đã ký), markdown cố ý không có gạch chân. ⚠ HTML đã lưu hiển thị lại bằng `RichTextEditor editable={false}`, không `dangerouslySetInnerHTML`. Peer optional: bộ `@tiptap/*` + `lowlight` | [docs/editor.md](docs/editor.md) |
|
|
191
191
|
| **form** · `@comitor/ui/form` | Binding shadcn ⇄ react-hook-form | https://design-system.comitor.ai/components/form |
|
|
192
192
|
|
|
193
193
|
**Ngôn ngữ — mặc định TIẾNG ANH, tiếng Việt dựng sẵn (từ `3.0.0`).** Mọi chuỗi hiển thị của gói —
|
|
@@ -7,6 +7,15 @@ interface ComboboxOption {
|
|
|
7
7
|
/** Dòng phụ mờ dưới nhãn (vd email của người dùng, mã của khách hàng) */
|
|
8
8
|
description?: string;
|
|
9
9
|
icon?: IconComponent;
|
|
10
|
+
/**
|
|
11
|
+
* Mục là một NGƯỜI: có mặt khoá này (kể cả `null`) thì vẽ `LetterAvatar` theo `label` thay cho `icon` —
|
|
12
|
+
* ảnh nếu có URL, initials nếu `null`. Không có khoá = không avatar (mục thường).
|
|
13
|
+
*
|
|
14
|
+
* Vì sao `null` vẫn vẽ: trong một danh sách người, người KHÔNG có ảnh cũng phải có ô avatar (initials),
|
|
15
|
+
* không thì cột tên lệch lề theo việc ai có ảnh. Cùng hình dạng với `MarkdownEditorMentionItem.avatarUrl`.
|
|
16
|
+
* Hiện ở dòng trong panel và cạnh nhãn trên trigger của `Combobox`.
|
|
17
|
+
*/
|
|
18
|
+
avatarUrl?: string | null;
|
|
10
19
|
disabled?: boolean;
|
|
11
20
|
/** Nhãn nhóm — các mục cùng nhóm được gom lại và có tiêu đề nhóm */
|
|
12
21
|
group?: string;
|
|
@@ -8,6 +8,7 @@ import { Badge } from "../ui/badge.js";
|
|
|
8
8
|
import { Button } from "../ui/button.js";
|
|
9
9
|
import { Command, CommandEmpty, CommandGroup, CommandInput, CommandItem, CommandList } from "../ui/command.js";
|
|
10
10
|
import { Popover, PopoverContent, PopoverTrigger } from "../ui/popover.js";
|
|
11
|
+
import { LetterAvatar } from "./letter-avatar.js";
|
|
11
12
|
import { removeVietnameseTones } from "./text-utils.js";
|
|
12
13
|
function isAriaInvalid(value) {
|
|
13
14
|
return value === true || value === "true";
|
|
@@ -38,7 +39,7 @@ function OptionRow({ option, selected }) {
|
|
|
38
39
|
children: /* @__PURE__ */ jsx(Check, { className: "size-4" })
|
|
39
40
|
}
|
|
40
41
|
),
|
|
41
|
-
Icon && /* @__PURE__ */ jsx(Icon, { "aria-hidden": "true", className: "size-4 shrink-0 text-muted-foreground" }),
|
|
42
|
+
option.avatarUrl !== void 0 ? /* @__PURE__ */ jsx(LetterAvatar, { name: option.label, src: option.avatarUrl, size: "xs", "aria-hidden": "true", className: "shrink-0" }) : Icon && /* @__PURE__ */ jsx(Icon, { "aria-hidden": "true", className: "size-4 shrink-0 text-muted-foreground" }),
|
|
42
43
|
/* @__PURE__ */ jsxs("span", { className: "min-w-0 flex-1", children: [
|
|
43
44
|
/* @__PURE__ */ jsx("span", { className: "block truncate", children: option.label }),
|
|
44
45
|
option.description && /* @__PURE__ */ jsx("span", { className: "block truncate text-xs text-muted-foreground", children: option.description })
|
|
@@ -148,7 +149,7 @@ function Combobox({
|
|
|
148
149
|
const showClear = clearable && Boolean(selected) && !disabled;
|
|
149
150
|
return /* @__PURE__ */ jsxs("div", { className: "relative w-full", children: [
|
|
150
151
|
/* @__PURE__ */ jsxs(Popover, { open, onOpenChange: setOpen, children: [
|
|
151
|
-
/* @__PURE__ */ jsx(PopoverTrigger, { asChild: true, children: /* @__PURE__ */
|
|
152
|
+
/* @__PURE__ */ jsx(PopoverTrigger, { asChild: true, children: /* @__PURE__ */ jsxs(
|
|
152
153
|
Button,
|
|
153
154
|
{
|
|
154
155
|
type: "button",
|
|
@@ -166,7 +167,19 @@ function Combobox({
|
|
|
166
167
|
),
|
|
167
168
|
...aria,
|
|
168
169
|
"aria-invalid": invalid || void 0,
|
|
169
|
-
children:
|
|
170
|
+
children: [
|
|
171
|
+
selected && selected.avatarUrl !== void 0 ? /* @__PURE__ */ jsx(
|
|
172
|
+
LetterAvatar,
|
|
173
|
+
{
|
|
174
|
+
name: selected.label,
|
|
175
|
+
src: selected.avatarUrl,
|
|
176
|
+
size: "xs",
|
|
177
|
+
"aria-hidden": "true",
|
|
178
|
+
className: "-my-1 shrink-0"
|
|
179
|
+
}
|
|
180
|
+
) : null,
|
|
181
|
+
/* @__PURE__ */ jsx("span", { className: "truncate", children: selected ? selected.label : placeholder })
|
|
182
|
+
]
|
|
170
183
|
}
|
|
171
184
|
) }),
|
|
172
185
|
/* @__PURE__ */ jsx(PopoverContent, { align: "start", className: cn("w-(--radix-popover-trigger-width) p-0", contentClassName), children: /* @__PURE__ */ jsx(
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"sources":["../../../src/components/composite/combobox.tsx"],"sourcesContent":["\"use client\";\n\nimport { Check, ChevronsUpDown, Loader2, X } from \"lucide-react\";\nimport { type ReactNode, useMemo, useState } from \"react\";\nimport { useComitorMessages } from \"../../i18n/locale-provider.js\";\nimport { cn } from \"../../lib/cn.js\";\nimport { Badge } from \"../ui/badge.js\";\nimport { Button } from \"../ui/button.js\";\nimport { Command, CommandEmpty, CommandGroup, CommandInput, CommandItem, CommandList } from \"../ui/command.js\";\nimport { Popover, PopoverContent, PopoverTrigger } from \"../ui/popover.js\";\nimport type { IconComponent } from \"./empty-state.js\";\nimport { removeVietnameseTones } from \"./text-utils.js\";\n\nexport interface ComboboxOption {\n value: string;\n label: string;\n /** Dòng phụ mờ dưới nhãn (vd email của người dùng, mã của khách hàng) */\n description?: string;\n icon?: IconComponent;\n disabled?: boolean;\n /** Nhãn nhóm — các mục cùng nhóm được gom lại và có tiêu đề nhóm */\n group?: string;\n /** Từ khoá phụ để tìm ra mục này (vd mã số, tên viết tắt) */\n keywords?: string[];\n}\n\ninterface ComboboxSharedProps {\n options: ComboboxOption[];\n /*\n * Mọi chuỗi dưới đây: không truyền thì lấy namespace `combobox` của `ComitorLocaleProvider`\n * (mặc định tiếng Anh). Truyền prop là đè riêng chỗ này.\n */\n placeholder?: string;\n searchPlaceholder?: string;\n emptyText?: string;\n /**\n * Chữ hiện trong panel khi `loading`.\n * Là chữ THẤY ĐƯỢC (không phải sr-only) và cả `Combobox` lẫn `MultiCombobox` đều đi qua nó.\n */\n loadingText?: string;\n disabled?: boolean;\n /**\n * Có mặt = chế độ BẤT ĐỒNG BỘ: tắt lọc nội bộ của cmdk, app tự lọc `options` (thường là gọi API).\n * Không truyền = lọc tại chỗ, không dấu tiếng Việt.\n */\n onSearchChange?: (search: string) => void;\n /** Đang tải kết quả (chỉ có nghĩa ở chế độ bất đồng bộ) */\n loading?: boolean;\n /** Cho phép xoá lựa chọn bằng nút X trên trigger */\n clearable?: boolean;\n className?: string;\n /** className của panel popover (vd đặt bề rộng cố định) */\n contentClassName?: string;\n id?: string;\n name?: string;\n \"aria-label\"?: string;\n \"aria-labelledby\"?: string;\n \"aria-describedby\"?: string;\n /**\n * Ô đang lỗi. Ngoài `aria-invalid` cho trình đọc màn hình, trigger còn chuyển sang viền\n * `--destructive-ink` — cùng tín hiệu thị giác mà `Input` đã có (nhánh nằm ở `button.tsx`).\n *\n * Nhận cả `boolean` lẫn `\"true\"`/`\"false\"` để dùng thẳng được `aria-invalid={!!errors.x}`\n * của react-hook-form. Giá trị falsy KHÔNG phát thuộc tính ra DOM.\n */\n \"aria-invalid\"?: boolean | \"true\" | \"false\";\n \"aria-required\"?: boolean | \"true\" | \"false\";\n}\n\n/** `aria-invalid` nhận nhiều hình dạng — quy về một boolean trước khi dùng làm điều kiện. */\nfunction isAriaInvalid(value: ComboboxSharedProps[\"aria-invalid\"]): boolean {\n return value === true || value === \"true\";\n}\n\nexport interface ComboboxProps extends ComboboxSharedProps {\n /** Giá trị đang chọn; `null`/`undefined` = chưa chọn */\n value?: string | null;\n onValueChange: (value: string | null) => void;\n /**\n * Nhãn `sr-only` của nút xóa (khoá `clearLabel` của namespace `combobox`).\n *\n * Nút xóa chỉ có mỗi icon `X`, nên chuỗi này CHÍNH LÀ tên của nút với trình đọc màn hình. Sai\n * ngôn ngữ ở đây là lỗi không ảnh chụp nào thấy và cũng không có gì báo đỏ: giao diện nhìn thì\n * đúng, mà ĐỌC ra lại là thứ tiếng khác.\n *\n * Nằm ở đây chứ không ở `ComboboxSharedProps` vì `MultiCombobox` xóa cả danh sách nên mặc định\n * của nó là một câu khác.\n */\n clearLabel?: string;\n}\n\nexport interface MultiComboboxProps extends ComboboxSharedProps {\n value: string[];\n onValueChange: (value: string[]) => void;\n /** Số nhãn hiện trên trigger trước khi gộp thành \"+k\" (mặc định 2) */\n maxDisplay?: number;\n /** Giới hạn số lựa chọn; đạt ngưỡng thì các mục còn lại bị khoá */\n maxSelected?: number;\n /**\n * Dòng đếm ở đáy panel khi có `maxSelected`. HÀM vì nó chèn HAI số — nối tay ở component là\n * khoá cứng trật tự từ của một ngôn ngữ (\"Đã chọn 2/5\" ⇄ \"2/5 selected\") vào mọi bản dịch.\n */\n selectedCountLabel?: (selected: number, max: number) => string;\n /**\n * Nhãn `sr-only` của nút xóa (khoá `clearAllLabel` của namespace `combobox` — nút bỏ SẠCH danh\n * sách đang chọn, không phải bỏ một mục).\n *\n * Cùng lý do như `Combobox`, nhưng gấp hơn một bậc: ở đây `clearable` mặc định là `true`, nên\n * chuỗi này có mặt ở MỌI MultiCombobox mà app không hề bật gì.\n */\n clearLabel?: string;\n}\n\n/**\n * Lọc không dấu cho cmdk: so khớp cả value, label lẫn keywords.\n *\n * cmdk mặc định chấm điểm theo thuật toán riêng có phân biệt dấu — người Việt gõ \"nguyen\"\n * sẽ không ra \"Nguyễn\". Trả 1/0 là đủ vì thứ tự hiển thị đã theo thứ tự app truyền vào.\n */\nfunction vietnameseFilter(value: string, search: string, keywords?: string[]): number {\n if (!search) return 1;\n const haystack = removeVietnameseTones(`${value} ${keywords?.join(\" \") ?? \"\"}`).toLowerCase();\n return haystack.includes(removeVietnameseTones(search).toLowerCase()) ? 1 : 0;\n}\n\n/** Gom mục theo `group`, giữ nguyên thứ tự xuất hiện đầu tiên của mỗi nhóm. */\nfunction groupOptions(options: ComboboxOption[]): Array<{ label: string | undefined; items: ComboboxOption[] }> {\n const groups = new Map<string, ComboboxOption[]>();\n for (const option of options) {\n const key = option.group ?? \"\";\n const bucket = groups.get(key);\n if (bucket) bucket.push(option);\n else groups.set(key, [option]);\n }\n return [...groups.entries()].map(([label, items]) => ({ label: label || undefined, items }));\n}\n\nfunction OptionRow({ option, selected }: { option: ComboboxOption; selected: boolean }) {\n const Icon = option.icon;\n return (\n <>\n <span\n aria-hidden=\"true\"\n className={cn(\"flex size-4 shrink-0 items-center justify-center\", !selected && \"invisible\")}\n >\n <Check className=\"size-4\" />\n </span>\n {Icon && <Icon aria-hidden=\"true\" className=\"size-4 shrink-0 text-muted-foreground\" />}\n <span className=\"min-w-0 flex-1\">\n <span className=\"block truncate\">{option.label}</span>\n {option.description && (\n <span className=\"block truncate text-xs text-muted-foreground\">{option.description}</span>\n )}\n </span>\n </>\n );\n}\n\n/**\n * Hàng icon ĐÈ lên trigger: nút xóa bên trái, chevron neo cứng bên phải.\n *\n * Vì sao nút xóa phải là ANH EM của trigger chứ không nằm trong: `<button>` lồng `<button>` là\n * HTML không hợp lệ, và nếu hạ nó xuống `<span role=\"button\" tabIndex={-1}>` thì người dùng bàn\n * phím vĩnh viễn không tới được nút xóa.\n *\n * Vì sao CHEVRON cũng phải ra ngoài theo: nó là con cuối của trigger, nên chỗ trống dành cho nút\n * xóa chỉ có thể lấy từ `padding-right` của trigger — mà padding đó ĐẨY luôn chevron sang trái.\n * Kết quả là chọn xong một giá trị thì chevron nhảy chỗ, và hai ô cạnh nhau trong cùng một hàng\n * biểu mẫu (một ô đã chọn, một ô chưa) có chevron lệch nhau. Ra ngoài rồi thì chevron đứng yên ở\n * `right-4` như mọi control khác, nút xóa chèn vào BÊN TRÁI nó — đúng thứ tự của `DatePicker`.\n *\n * `pointer-events-none` ở hàng, `pointer-events-auto` ở nút xóa: chevron chỉ là trang trí, bấm\n * vào nó phải xuyên xuống trigger để mở popover (hồi nó còn là con của trigger thì miễn phí).\n *\n * Và hàng này phải TỰ mờ khi `disabled`: `disabled:opacity-50` của `Button` chỉ áp cho chính\n * trigger, không với tới anh em của nó.\n */\nfunction ComboboxAdornments({\n showClear,\n disabled,\n clearLabel,\n onClear\n}: {\n showClear: boolean;\n disabled: boolean;\n clearLabel: string;\n onClear: () => void;\n}) {\n return (\n <div\n className={cn(\n /* `border border-transparent pr-3` chứ không phải `right-3`: hàng này là ANH EM của\n trigger nên nó đo từ mép BORDER, trong khi chevron của `Select` nằm trong trigger nên\n đo từ mép CONTENT — lệch đúng 1px bề dày viền. Mượn lại mô hình hộp của trigger (viền\n 1px trong suốt + lề 12px) là hai bên rơi vào cùng một mốc, không phải bịa số lẻ. */\n \"pointer-events-none absolute inset-y-0 right-0 flex items-center border border-transparent pr-3\",\n disabled && \"opacity-50\"\n )}\n >\n {showClear && (\n <Button\n type=\"button\"\n variant=\"ghost\"\n size=\"icon-sm\"\n className=\"pointer-events-auto size-6 text-muted-foreground hover:text-foreground\"\n onClick={onClear}\n >\n <X aria-hidden=\"true\" className=\"size-3.5\" />\n <span className=\"sr-only\">{clearLabel}</span>\n </Button>\n )}\n {/* Chevron BÁO TRẠNG THÁI (aria-expanded) trên control còn bấm được → ngưỡng 3:1 của\n WCAG 1.4.11, `opacity-*` không miễn trừ. `opacity-50` chỉ được 2,03:1 (light) / 2,30:1\n (dark) khi trigger đang ở trạng thái placeholder (kế thừa `text-muted-foreground`) ✗.\n Nêu thẳng `text-muted-foreground` cho icon: 5,23:1 (light) / 5,70:1 (dark) ✓, và không\n đổi theo việc đã chọn hay chưa. */}\n <ChevronsUpDown aria-hidden=\"true\" className=\"size-4 shrink-0 text-muted-foreground\" />\n </div>\n );\n}\n\n/**\n * Lề của trigger. Trigger là `Button` nên nó mang sẵn `px-4` — mà Combobox nhìn là một Ô NHẬP chứ\n * không phải cái nút, và `Input`/`Select` (cả ở đây lẫn ở comitor-ds) đều `px-3`. Để nguyên `px-4`\n * là trong cùng một cột biểu mẫu, chữ của Combobox thụt vào 4px so với chữ của Input bên trên nó.\n * Nên `px-3`, và mọi icon phải neo ở 12px như các control còn lại.\n *\n * Chỗ trống bên phải phải chừa cho hàng icon tuyệt đối, vì trigger không còn tự đẩy chỗ nữa:\n * `pr-9` = 12 (lề) + 16 (chevron) + 8 (khoảng tới chữ); `pr-15` cộng thêm 24 cho nút xóa.\n */\nconst TRIGGER_PADDING = \"px-3 pr-9\";\nconst TRIGGER_PADDING_WITH_CLEAR = \"px-3 pr-15\";\n\nfunction useComboboxList(options: ComboboxOption[], isAsync: boolean) {\n const groups = useMemo(() => groupOptions(options), [options]);\n return { groups, shouldFilter: !isAsync };\n}\n\nfunction ComboboxPanel({\n options,\n isAsync,\n loading,\n search,\n onSearch,\n searchPlaceholder,\n emptyText,\n loadingText,\n renderItem\n}: {\n options: ComboboxOption[];\n isAsync: boolean;\n loading: boolean;\n search: string;\n onSearch: (value: string) => void;\n searchPlaceholder: string;\n emptyText: string;\n loadingText: string;\n renderItem: (option: ComboboxOption) => ReactNode;\n}) {\n const { groups, shouldFilter } = useComboboxList(options, isAsync);\n\n return (\n <Command shouldFilter={shouldFilter} filter={vietnameseFilter}>\n <CommandInput placeholder={searchPlaceholder} value={search} onValueChange={onSearch} />\n <CommandList>\n {loading ? (\n <div className=\"flex items-center justify-center gap-2 py-6 text-sm text-muted-foreground\">\n <Loader2 aria-hidden=\"true\" className=\"size-4 animate-spin\" />\n {loadingText}\n </div>\n ) : (\n <>\n <CommandEmpty>{emptyText}</CommandEmpty>\n {groups.map((group) => (\n <CommandGroup key={group.label ?? \"__default__\"} heading={group.label}>\n {group.items.map(renderItem)}\n </CommandGroup>\n ))}\n </>\n )}\n </CommandList>\n </Command>\n );\n}\n\n/**\n * Ô chọn một giá trị có tìm kiếm — cmdk trong Popover, rộng bằng trigger.\n *\n * Mặc định tìm KHÔNG DẤU tiếng Việt. Truyền `onSearchChange` để chuyển sang chế độ bất đồng bộ\n * (app tự gọi API và cập nhật `options`).\n */\nexport function Combobox({\n options,\n value,\n onValueChange,\n placeholder: placeholderProp,\n searchPlaceholder: searchPlaceholderProp,\n emptyText: emptyTextProp,\n loadingText: loadingTextProp,\n disabled = false,\n onSearchChange,\n loading = false,\n clearable = false,\n clearLabel: clearLabelProp,\n className,\n contentClassName,\n id,\n name,\n ...aria\n}: ComboboxProps) {\n const t = useComitorMessages(\"combobox\");\n const placeholder = placeholderProp ?? t.placeholder;\n const searchPlaceholder = searchPlaceholderProp ?? t.searchPlaceholder;\n const emptyText = emptyTextProp ?? t.emptyText;\n const loadingText = loadingTextProp ?? t.loadingText;\n const clearLabel = clearLabelProp ?? t.clearLabel;\n const [open, setOpen] = useState(false);\n const [search, setSearch] = useState(\"\");\n const isAsync = onSearchChange !== undefined;\n const selected = options.find((option) => option.value === value);\n const invalid = isAriaInvalid(aria[\"aria-invalid\"]);\n\n const handleSearch = (next: string) => {\n setSearch(next);\n onSearchChange?.(next);\n };\n\n const showClear = clearable && Boolean(selected) && !disabled;\n\n return (\n <div className=\"relative w-full\">\n <Popover open={open} onOpenChange={setOpen}>\n <PopoverTrigger asChild>\n <Button\n type=\"button\"\n variant=\"outline\"\n role=\"combobox\"\n aria-expanded={open}\n id={id}\n name={name}\n disabled={disabled}\n className={cn(\n \"w-full justify-start font-normal\",\n showClear ? TRIGGER_PADDING_WITH_CLEAR : TRIGGER_PADDING,\n !selected && \"text-muted-foreground\",\n className\n )}\n {...aria}\n // Sau `{...aria}`: chỉ phát thuộc tính khi THẬT SỰ lỗi, để `aria-invalid={false}`\n // không rơi ra DOM thành `aria-invalid=\"false\"` (hợp lệ nhưng thừa).\n aria-invalid={invalid || undefined}\n >\n <span className=\"truncate\">{selected ? selected.label : placeholder}</span>\n </Button>\n </PopoverTrigger>\n <PopoverContent align=\"start\" className={cn(\"w-(--radix-popover-trigger-width) p-0\", contentClassName)}>\n <ComboboxPanel\n options={options}\n isAsync={isAsync}\n loading={loading}\n search={search}\n onSearch={handleSearch}\n searchPlaceholder={searchPlaceholder}\n emptyText={emptyText}\n loadingText={loadingText}\n renderItem={(option) => (\n <CommandItem\n key={option.value}\n value={option.value}\n keywords={[option.label, option.description ?? \"\", ...(option.keywords ?? [])]}\n disabled={option.disabled}\n onSelect={() => {\n onValueChange(option.value === value ? null : option.value);\n setOpen(false);\n setSearch(\"\");\n if (isAsync) onSearchChange?.(\"\");\n }}\n >\n <OptionRow option={option} selected={option.value === value} />\n </CommandItem>\n )}\n />\n </PopoverContent>\n </Popover>\n\n <ComboboxAdornments\n showClear={showClear}\n disabled={disabled}\n clearLabel={clearLabel}\n onClear={() => onValueChange(null)}\n />\n </div>\n );\n}\n\n/**\n * Ô chọn NHIỀU giá trị — cùng hạ tầng với `Combobox`, popover không tự đóng sau mỗi lần chọn.\n *\n * Trigger hiện tối đa `maxDisplay` nhãn rồi gộp phần dư thành \"+k\": danh sách 12 người thực hiện\n * mà đổ hết ra trigger thì hàng form vỡ bố cục.\n */\nexport function MultiCombobox({\n options,\n value,\n onValueChange,\n placeholder: placeholderProp,\n searchPlaceholder: searchPlaceholderProp,\n emptyText: emptyTextProp,\n loadingText: loadingTextProp,\n disabled = false,\n onSearchChange,\n loading = false,\n clearable = true,\n clearLabel: clearLabelProp,\n selectedCountLabel: selectedCountLabelProp,\n maxDisplay = 2,\n maxSelected,\n className,\n contentClassName,\n id,\n name,\n ...aria\n}: MultiComboboxProps) {\n const t = useComitorMessages(\"combobox\");\n const placeholder = placeholderProp ?? t.placeholder;\n const searchPlaceholder = searchPlaceholderProp ?? t.searchPlaceholder;\n const emptyText = emptyTextProp ?? t.emptyText;\n const loadingText = loadingTextProp ?? t.loadingText;\n const clearLabel = clearLabelProp ?? t.clearAllLabel;\n const selectedCountLabel = selectedCountLabelProp ?? t.selectedCountLabel;\n const [open, setOpen] = useState(false);\n const [search, setSearch] = useState(\"\");\n const isAsync = onSearchChange !== undefined;\n const invalid = isAriaInvalid(aria[\"aria-invalid\"]);\n\n const selectedOptions = options.filter((option) => value.includes(option.value));\n const shown = selectedOptions.slice(0, Math.max(1, maxDisplay));\n const overflow = selectedOptions.length - shown.length;\n const limitReached = maxSelected !== undefined && value.length >= maxSelected;\n\n const toggle = (optionValue: string) => {\n if (value.includes(optionValue)) {\n onValueChange(value.filter((item) => item !== optionValue));\n return;\n }\n if (limitReached) return;\n onValueChange([...value, optionValue]);\n };\n\n const handleSearch = (next: string) => {\n setSearch(next);\n onSearchChange?.(next);\n };\n\n const showClear = clearable && value.length > 0 && !disabled;\n\n return (\n <div className=\"relative w-full\">\n <Popover open={open} onOpenChange={setOpen}>\n <PopoverTrigger asChild>\n <Button\n type=\"button\"\n variant=\"outline\"\n role=\"combobox\"\n aria-expanded={open}\n id={id}\n name={name}\n disabled={disabled}\n className={cn(\n \"h-auto min-h-9 w-full justify-start py-1.5 font-normal\",\n showClear ? TRIGGER_PADDING_WITH_CLEAR : TRIGGER_PADDING,\n value.length === 0 && \"text-muted-foreground\",\n className\n )}\n {...aria}\n // Cùng lý do như Combobox đơn.\n aria-invalid={invalid || undefined}\n >\n <span className=\"flex min-w-0 flex-wrap items-center gap-1\">\n {value.length === 0 ? (\n placeholder\n ) : (\n <>\n {shown.map((option) => (\n <Badge key={option.value} variant=\"secondary\" className=\"max-w-[10rem]\">\n <span className=\"truncate\">{option.label}</span>\n </Badge>\n ))}\n {overflow > 0 && <Badge variant=\"outline\">+{overflow}</Badge>}\n </>\n )}\n </span>\n </Button>\n </PopoverTrigger>\n <PopoverContent align=\"start\" className={cn(\"w-(--radix-popover-trigger-width) p-0\", contentClassName)}>\n <ComboboxPanel\n options={options}\n isAsync={isAsync}\n loading={loading}\n search={search}\n onSearch={handleSearch}\n searchPlaceholder={searchPlaceholder}\n emptyText={emptyText}\n loadingText={loadingText}\n renderItem={(option) => {\n const isSelected = value.includes(option.value);\n return (\n <CommandItem\n key={option.value}\n value={option.value}\n keywords={[option.label, option.description ?? \"\", ...(option.keywords ?? [])]}\n disabled={option.disabled || (limitReached && !isSelected)}\n onSelect={() => toggle(option.value)}\n >\n <OptionRow option={option} selected={isSelected} />\n </CommandItem>\n );\n }}\n />\n {maxSelected !== undefined && (\n <p className=\"border-t border-border px-3 py-2 text-xs text-muted-foreground\">\n {selectedCountLabel(value.length, maxSelected)}\n </p>\n )}\n </PopoverContent>\n </Popover>\n\n <ComboboxAdornments\n showClear={showClear}\n disabled={disabled}\n clearLabel={clearLabel}\n onClear={() => onValueChange([])}\n />\n </div>\n );\n}\n"],"mappings":"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","names":[]}
|
|
1
|
+
{"version":3,"sources":["../../../src/components/composite/combobox.tsx"],"sourcesContent":["\"use client\";\n\nimport { Check, ChevronsUpDown, Loader2, X } from \"lucide-react\";\nimport { type ReactNode, useMemo, useState } from \"react\";\nimport { useComitorMessages } from \"../../i18n/locale-provider.js\";\nimport { cn } from \"../../lib/cn.js\";\nimport { Badge } from \"../ui/badge.js\";\nimport { Button } from \"../ui/button.js\";\nimport { Command, CommandEmpty, CommandGroup, CommandInput, CommandItem, CommandList } from \"../ui/command.js\";\nimport { Popover, PopoverContent, PopoverTrigger } from \"../ui/popover.js\";\nimport type { IconComponent } from \"./empty-state.js\";\nimport { LetterAvatar } from \"./letter-avatar.js\";\nimport { removeVietnameseTones } from \"./text-utils.js\";\n\nexport interface ComboboxOption {\n value: string;\n label: string;\n /** Dòng phụ mờ dưới nhãn (vd email của người dùng, mã của khách hàng) */\n description?: string;\n icon?: IconComponent;\n /**\n * Mục là một NGƯỜI: có mặt khoá này (kể cả `null`) thì vẽ `LetterAvatar` theo `label` thay cho `icon` —\n * ảnh nếu có URL, initials nếu `null`. Không có khoá = không avatar (mục thường).\n *\n * Vì sao `null` vẫn vẽ: trong một danh sách người, người KHÔNG có ảnh cũng phải có ô avatar (initials),\n * không thì cột tên lệch lề theo việc ai có ảnh. Cùng hình dạng với `MarkdownEditorMentionItem.avatarUrl`.\n * Hiện ở dòng trong panel và cạnh nhãn trên trigger của `Combobox`.\n */\n avatarUrl?: string | null;\n disabled?: boolean;\n /** Nhãn nhóm — các mục cùng nhóm được gom lại và có tiêu đề nhóm */\n group?: string;\n /** Từ khoá phụ để tìm ra mục này (vd mã số, tên viết tắt) */\n keywords?: string[];\n}\n\ninterface ComboboxSharedProps {\n options: ComboboxOption[];\n /*\n * Mọi chuỗi dưới đây: không truyền thì lấy namespace `combobox` của `ComitorLocaleProvider`\n * (mặc định tiếng Anh). Truyền prop là đè riêng chỗ này.\n */\n placeholder?: string;\n searchPlaceholder?: string;\n emptyText?: string;\n /**\n * Chữ hiện trong panel khi `loading`.\n * Là chữ THẤY ĐƯỢC (không phải sr-only) và cả `Combobox` lẫn `MultiCombobox` đều đi qua nó.\n */\n loadingText?: string;\n disabled?: boolean;\n /**\n * Có mặt = chế độ BẤT ĐỒNG BỘ: tắt lọc nội bộ của cmdk, app tự lọc `options` (thường là gọi API).\n * Không truyền = lọc tại chỗ, không dấu tiếng Việt.\n */\n onSearchChange?: (search: string) => void;\n /** Đang tải kết quả (chỉ có nghĩa ở chế độ bất đồng bộ) */\n loading?: boolean;\n /** Cho phép xoá lựa chọn bằng nút X trên trigger */\n clearable?: boolean;\n className?: string;\n /** className của panel popover (vd đặt bề rộng cố định) */\n contentClassName?: string;\n id?: string;\n name?: string;\n \"aria-label\"?: string;\n \"aria-labelledby\"?: string;\n \"aria-describedby\"?: string;\n /**\n * Ô đang lỗi. Ngoài `aria-invalid` cho trình đọc màn hình, trigger còn chuyển sang viền\n * `--destructive-ink` — cùng tín hiệu thị giác mà `Input` đã có (nhánh nằm ở `button.tsx`).\n *\n * Nhận cả `boolean` lẫn `\"true\"`/`\"false\"` để dùng thẳng được `aria-invalid={!!errors.x}`\n * của react-hook-form. Giá trị falsy KHÔNG phát thuộc tính ra DOM.\n */\n \"aria-invalid\"?: boolean | \"true\" | \"false\";\n \"aria-required\"?: boolean | \"true\" | \"false\";\n}\n\n/** `aria-invalid` nhận nhiều hình dạng — quy về một boolean trước khi dùng làm điều kiện. */\nfunction isAriaInvalid(value: ComboboxSharedProps[\"aria-invalid\"]): boolean {\n return value === true || value === \"true\";\n}\n\nexport interface ComboboxProps extends ComboboxSharedProps {\n /** Giá trị đang chọn; `null`/`undefined` = chưa chọn */\n value?: string | null;\n onValueChange: (value: string | null) => void;\n /**\n * Nhãn `sr-only` của nút xóa (khoá `clearLabel` của namespace `combobox`).\n *\n * Nút xóa chỉ có mỗi icon `X`, nên chuỗi này CHÍNH LÀ tên của nút với trình đọc màn hình. Sai\n * ngôn ngữ ở đây là lỗi không ảnh chụp nào thấy và cũng không có gì báo đỏ: giao diện nhìn thì\n * đúng, mà ĐỌC ra lại là thứ tiếng khác.\n *\n * Nằm ở đây chứ không ở `ComboboxSharedProps` vì `MultiCombobox` xóa cả danh sách nên mặc định\n * của nó là một câu khác.\n */\n clearLabel?: string;\n}\n\nexport interface MultiComboboxProps extends ComboboxSharedProps {\n value: string[];\n onValueChange: (value: string[]) => void;\n /** Số nhãn hiện trên trigger trước khi gộp thành \"+k\" (mặc định 2) */\n maxDisplay?: number;\n /** Giới hạn số lựa chọn; đạt ngưỡng thì các mục còn lại bị khoá */\n maxSelected?: number;\n /**\n * Dòng đếm ở đáy panel khi có `maxSelected`. HÀM vì nó chèn HAI số — nối tay ở component là\n * khoá cứng trật tự từ của một ngôn ngữ (\"Đã chọn 2/5\" ⇄ \"2/5 selected\") vào mọi bản dịch.\n */\n selectedCountLabel?: (selected: number, max: number) => string;\n /**\n * Nhãn `sr-only` của nút xóa (khoá `clearAllLabel` của namespace `combobox` — nút bỏ SẠCH danh\n * sách đang chọn, không phải bỏ một mục).\n *\n * Cùng lý do như `Combobox`, nhưng gấp hơn một bậc: ở đây `clearable` mặc định là `true`, nên\n * chuỗi này có mặt ở MỌI MultiCombobox mà app không hề bật gì.\n */\n clearLabel?: string;\n}\n\n/**\n * Lọc không dấu cho cmdk: so khớp cả value, label lẫn keywords.\n *\n * cmdk mặc định chấm điểm theo thuật toán riêng có phân biệt dấu — người Việt gõ \"nguyen\"\n * sẽ không ra \"Nguyễn\". Trả 1/0 là đủ vì thứ tự hiển thị đã theo thứ tự app truyền vào.\n */\nfunction vietnameseFilter(value: string, search: string, keywords?: string[]): number {\n if (!search) return 1;\n const haystack = removeVietnameseTones(`${value} ${keywords?.join(\" \") ?? \"\"}`).toLowerCase();\n return haystack.includes(removeVietnameseTones(search).toLowerCase()) ? 1 : 0;\n}\n\n/** Gom mục theo `group`, giữ nguyên thứ tự xuất hiện đầu tiên của mỗi nhóm. */\nfunction groupOptions(options: ComboboxOption[]): Array<{ label: string | undefined; items: ComboboxOption[] }> {\n const groups = new Map<string, ComboboxOption[]>();\n for (const option of options) {\n const key = option.group ?? \"\";\n const bucket = groups.get(key);\n if (bucket) bucket.push(option);\n else groups.set(key, [option]);\n }\n return [...groups.entries()].map(([label, items]) => ({ label: label || undefined, items }));\n}\n\nfunction OptionRow({ option, selected }: { option: ComboboxOption; selected: boolean }) {\n const Icon = option.icon;\n return (\n <>\n <span\n aria-hidden=\"true\"\n className={cn(\"flex size-4 shrink-0 items-center justify-center\", !selected && \"invisible\")}\n >\n <Check className=\"size-4\" />\n </span>\n {option.avatarUrl !== undefined ? (\n <LetterAvatar name={option.label} src={option.avatarUrl} size=\"xs\" aria-hidden=\"true\" className=\"shrink-0\" />\n ) : (\n Icon && <Icon aria-hidden=\"true\" className=\"size-4 shrink-0 text-muted-foreground\" />\n )}\n <span className=\"min-w-0 flex-1\">\n <span className=\"block truncate\">{option.label}</span>\n {option.description && (\n <span className=\"block truncate text-xs text-muted-foreground\">{option.description}</span>\n )}\n </span>\n </>\n );\n}\n\n/**\n * Hàng icon ĐÈ lên trigger: nút xóa bên trái, chevron neo cứng bên phải.\n *\n * Vì sao nút xóa phải là ANH EM của trigger chứ không nằm trong: `<button>` lồng `<button>` là\n * HTML không hợp lệ, và nếu hạ nó xuống `<span role=\"button\" tabIndex={-1}>` thì người dùng bàn\n * phím vĩnh viễn không tới được nút xóa.\n *\n * Vì sao CHEVRON cũng phải ra ngoài theo: nó là con cuối của trigger, nên chỗ trống dành cho nút\n * xóa chỉ có thể lấy từ `padding-right` của trigger — mà padding đó ĐẨY luôn chevron sang trái.\n * Kết quả là chọn xong một giá trị thì chevron nhảy chỗ, và hai ô cạnh nhau trong cùng một hàng\n * biểu mẫu (một ô đã chọn, một ô chưa) có chevron lệch nhau. Ra ngoài rồi thì chevron đứng yên ở\n * `right-4` như mọi control khác, nút xóa chèn vào BÊN TRÁI nó — đúng thứ tự của `DatePicker`.\n *\n * `pointer-events-none` ở hàng, `pointer-events-auto` ở nút xóa: chevron chỉ là trang trí, bấm\n * vào nó phải xuyên xuống trigger để mở popover (hồi nó còn là con của trigger thì miễn phí).\n *\n * Và hàng này phải TỰ mờ khi `disabled`: `disabled:opacity-50` của `Button` chỉ áp cho chính\n * trigger, không với tới anh em của nó.\n */\nfunction ComboboxAdornments({\n showClear,\n disabled,\n clearLabel,\n onClear\n}: {\n showClear: boolean;\n disabled: boolean;\n clearLabel: string;\n onClear: () => void;\n}) {\n return (\n <div\n className={cn(\n /* `border border-transparent pr-3` chứ không phải `right-3`: hàng này là ANH EM của\n trigger nên nó đo từ mép BORDER, trong khi chevron của `Select` nằm trong trigger nên\n đo từ mép CONTENT — lệch đúng 1px bề dày viền. Mượn lại mô hình hộp của trigger (viền\n 1px trong suốt + lề 12px) là hai bên rơi vào cùng một mốc, không phải bịa số lẻ. */\n \"pointer-events-none absolute inset-y-0 right-0 flex items-center border border-transparent pr-3\",\n disabled && \"opacity-50\"\n )}\n >\n {showClear && (\n <Button\n type=\"button\"\n variant=\"ghost\"\n size=\"icon-sm\"\n className=\"pointer-events-auto size-6 text-muted-foreground hover:text-foreground\"\n onClick={onClear}\n >\n <X aria-hidden=\"true\" className=\"size-3.5\" />\n <span className=\"sr-only\">{clearLabel}</span>\n </Button>\n )}\n {/* Chevron BÁO TRẠNG THÁI (aria-expanded) trên control còn bấm được → ngưỡng 3:1 của\n WCAG 1.4.11, `opacity-*` không miễn trừ. `opacity-50` chỉ được 2,03:1 (light) / 2,30:1\n (dark) khi trigger đang ở trạng thái placeholder (kế thừa `text-muted-foreground`) ✗.\n Nêu thẳng `text-muted-foreground` cho icon: 5,23:1 (light) / 5,70:1 (dark) ✓, và không\n đổi theo việc đã chọn hay chưa. */}\n <ChevronsUpDown aria-hidden=\"true\" className=\"size-4 shrink-0 text-muted-foreground\" />\n </div>\n );\n}\n\n/**\n * Lề của trigger. Trigger là `Button` nên nó mang sẵn `px-4` — mà Combobox nhìn là một Ô NHẬP chứ\n * không phải cái nút, và `Input`/`Select` (cả ở đây lẫn ở comitor-ds) đều `px-3`. Để nguyên `px-4`\n * là trong cùng một cột biểu mẫu, chữ của Combobox thụt vào 4px so với chữ của Input bên trên nó.\n * Nên `px-3`, và mọi icon phải neo ở 12px như các control còn lại.\n *\n * Chỗ trống bên phải phải chừa cho hàng icon tuyệt đối, vì trigger không còn tự đẩy chỗ nữa:\n * `pr-9` = 12 (lề) + 16 (chevron) + 8 (khoảng tới chữ); `pr-15` cộng thêm 24 cho nút xóa.\n */\nconst TRIGGER_PADDING = \"px-3 pr-9\";\nconst TRIGGER_PADDING_WITH_CLEAR = \"px-3 pr-15\";\n\nfunction useComboboxList(options: ComboboxOption[], isAsync: boolean) {\n const groups = useMemo(() => groupOptions(options), [options]);\n return { groups, shouldFilter: !isAsync };\n}\n\nfunction ComboboxPanel({\n options,\n isAsync,\n loading,\n search,\n onSearch,\n searchPlaceholder,\n emptyText,\n loadingText,\n renderItem\n}: {\n options: ComboboxOption[];\n isAsync: boolean;\n loading: boolean;\n search: string;\n onSearch: (value: string) => void;\n searchPlaceholder: string;\n emptyText: string;\n loadingText: string;\n renderItem: (option: ComboboxOption) => ReactNode;\n}) {\n const { groups, shouldFilter } = useComboboxList(options, isAsync);\n\n return (\n <Command shouldFilter={shouldFilter} filter={vietnameseFilter}>\n <CommandInput placeholder={searchPlaceholder} value={search} onValueChange={onSearch} />\n <CommandList>\n {loading ? (\n <div className=\"flex items-center justify-center gap-2 py-6 text-sm text-muted-foreground\">\n <Loader2 aria-hidden=\"true\" className=\"size-4 animate-spin\" />\n {loadingText}\n </div>\n ) : (\n <>\n <CommandEmpty>{emptyText}</CommandEmpty>\n {groups.map((group) => (\n <CommandGroup key={group.label ?? \"__default__\"} heading={group.label}>\n {group.items.map(renderItem)}\n </CommandGroup>\n ))}\n </>\n )}\n </CommandList>\n </Command>\n );\n}\n\n/**\n * Ô chọn một giá trị có tìm kiếm — cmdk trong Popover, rộng bằng trigger.\n *\n * Mặc định tìm KHÔNG DẤU tiếng Việt. Truyền `onSearchChange` để chuyển sang chế độ bất đồng bộ\n * (app tự gọi API và cập nhật `options`).\n */\nexport function Combobox({\n options,\n value,\n onValueChange,\n placeholder: placeholderProp,\n searchPlaceholder: searchPlaceholderProp,\n emptyText: emptyTextProp,\n loadingText: loadingTextProp,\n disabled = false,\n onSearchChange,\n loading = false,\n clearable = false,\n clearLabel: clearLabelProp,\n className,\n contentClassName,\n id,\n name,\n ...aria\n}: ComboboxProps) {\n const t = useComitorMessages(\"combobox\");\n const placeholder = placeholderProp ?? t.placeholder;\n const searchPlaceholder = searchPlaceholderProp ?? t.searchPlaceholder;\n const emptyText = emptyTextProp ?? t.emptyText;\n const loadingText = loadingTextProp ?? t.loadingText;\n const clearLabel = clearLabelProp ?? t.clearLabel;\n const [open, setOpen] = useState(false);\n const [search, setSearch] = useState(\"\");\n const isAsync = onSearchChange !== undefined;\n const selected = options.find((option) => option.value === value);\n const invalid = isAriaInvalid(aria[\"aria-invalid\"]);\n\n const handleSearch = (next: string) => {\n setSearch(next);\n onSearchChange?.(next);\n };\n\n const showClear = clearable && Boolean(selected) && !disabled;\n\n return (\n <div className=\"relative w-full\">\n <Popover open={open} onOpenChange={setOpen}>\n <PopoverTrigger asChild>\n <Button\n type=\"button\"\n variant=\"outline\"\n role=\"combobox\"\n aria-expanded={open}\n id={id}\n name={name}\n disabled={disabled}\n className={cn(\n \"w-full justify-start font-normal\",\n showClear ? TRIGGER_PADDING_WITH_CLEAR : TRIGGER_PADDING,\n !selected && \"text-muted-foreground\",\n className\n )}\n {...aria}\n // Sau `{...aria}`: chỉ phát thuộc tính khi THẬT SỰ lỗi, để `aria-invalid={false}`\n // không rơi ra DOM thành `aria-invalid=\"false\"` (hợp lệ nhưng thừa).\n aria-invalid={invalid || undefined}\n >\n {selected && selected.avatarUrl !== undefined ? (\n <LetterAvatar\n name={selected.label}\n src={selected.avatarUrl}\n size=\"xs\"\n aria-hidden=\"true\"\n className=\"-my-1 shrink-0\"\n />\n ) : null}\n <span className=\"truncate\">{selected ? selected.label : placeholder}</span>\n </Button>\n </PopoverTrigger>\n <PopoverContent align=\"start\" className={cn(\"w-(--radix-popover-trigger-width) p-0\", contentClassName)}>\n <ComboboxPanel\n options={options}\n isAsync={isAsync}\n loading={loading}\n search={search}\n onSearch={handleSearch}\n searchPlaceholder={searchPlaceholder}\n emptyText={emptyText}\n loadingText={loadingText}\n renderItem={(option) => (\n <CommandItem\n key={option.value}\n value={option.value}\n keywords={[option.label, option.description ?? \"\", ...(option.keywords ?? [])]}\n disabled={option.disabled}\n onSelect={() => {\n onValueChange(option.value === value ? null : option.value);\n setOpen(false);\n setSearch(\"\");\n if (isAsync) onSearchChange?.(\"\");\n }}\n >\n <OptionRow option={option} selected={option.value === value} />\n </CommandItem>\n )}\n />\n </PopoverContent>\n </Popover>\n\n <ComboboxAdornments\n showClear={showClear}\n disabled={disabled}\n clearLabel={clearLabel}\n onClear={() => onValueChange(null)}\n />\n </div>\n );\n}\n\n/**\n * Ô chọn NHIỀU giá trị — cùng hạ tầng với `Combobox`, popover không tự đóng sau mỗi lần chọn.\n *\n * Trigger hiện tối đa `maxDisplay` nhãn rồi gộp phần dư thành \"+k\": danh sách 12 người thực hiện\n * mà đổ hết ra trigger thì hàng form vỡ bố cục.\n */\nexport function MultiCombobox({\n options,\n value,\n onValueChange,\n placeholder: placeholderProp,\n searchPlaceholder: searchPlaceholderProp,\n emptyText: emptyTextProp,\n loadingText: loadingTextProp,\n disabled = false,\n onSearchChange,\n loading = false,\n clearable = true,\n clearLabel: clearLabelProp,\n selectedCountLabel: selectedCountLabelProp,\n maxDisplay = 2,\n maxSelected,\n className,\n contentClassName,\n id,\n name,\n ...aria\n}: MultiComboboxProps) {\n const t = useComitorMessages(\"combobox\");\n const placeholder = placeholderProp ?? t.placeholder;\n const searchPlaceholder = searchPlaceholderProp ?? t.searchPlaceholder;\n const emptyText = emptyTextProp ?? t.emptyText;\n const loadingText = loadingTextProp ?? t.loadingText;\n const clearLabel = clearLabelProp ?? t.clearAllLabel;\n const selectedCountLabel = selectedCountLabelProp ?? t.selectedCountLabel;\n const [open, setOpen] = useState(false);\n const [search, setSearch] = useState(\"\");\n const isAsync = onSearchChange !== undefined;\n const invalid = isAriaInvalid(aria[\"aria-invalid\"]);\n\n const selectedOptions = options.filter((option) => value.includes(option.value));\n const shown = selectedOptions.slice(0, Math.max(1, maxDisplay));\n const overflow = selectedOptions.length - shown.length;\n const limitReached = maxSelected !== undefined && value.length >= maxSelected;\n\n const toggle = (optionValue: string) => {\n if (value.includes(optionValue)) {\n onValueChange(value.filter((item) => item !== optionValue));\n return;\n }\n if (limitReached) return;\n onValueChange([...value, optionValue]);\n };\n\n const handleSearch = (next: string) => {\n setSearch(next);\n onSearchChange?.(next);\n };\n\n const showClear = clearable && value.length > 0 && !disabled;\n\n return (\n <div className=\"relative w-full\">\n <Popover open={open} onOpenChange={setOpen}>\n <PopoverTrigger asChild>\n <Button\n type=\"button\"\n variant=\"outline\"\n role=\"combobox\"\n aria-expanded={open}\n id={id}\n name={name}\n disabled={disabled}\n className={cn(\n \"h-auto min-h-9 w-full justify-start py-1.5 font-normal\",\n showClear ? TRIGGER_PADDING_WITH_CLEAR : TRIGGER_PADDING,\n value.length === 0 && \"text-muted-foreground\",\n className\n )}\n {...aria}\n // Cùng lý do như Combobox đơn.\n aria-invalid={invalid || undefined}\n >\n <span className=\"flex min-w-0 flex-wrap items-center gap-1\">\n {value.length === 0 ? (\n placeholder\n ) : (\n <>\n {shown.map((option) => (\n <Badge key={option.value} variant=\"secondary\" className=\"max-w-[10rem]\">\n <span className=\"truncate\">{option.label}</span>\n </Badge>\n ))}\n {overflow > 0 && <Badge variant=\"outline\">+{overflow}</Badge>}\n </>\n )}\n </span>\n </Button>\n </PopoverTrigger>\n <PopoverContent align=\"start\" className={cn(\"w-(--radix-popover-trigger-width) p-0\", contentClassName)}>\n <ComboboxPanel\n options={options}\n isAsync={isAsync}\n loading={loading}\n search={search}\n onSearch={handleSearch}\n searchPlaceholder={searchPlaceholder}\n emptyText={emptyText}\n loadingText={loadingText}\n renderItem={(option) => {\n const isSelected = value.includes(option.value);\n return (\n <CommandItem\n key={option.value}\n value={option.value}\n keywords={[option.label, option.description ?? \"\", ...(option.keywords ?? [])]}\n disabled={option.disabled || (limitReached && !isSelected)}\n onSelect={() => toggle(option.value)}\n >\n <OptionRow option={option} selected={isSelected} />\n </CommandItem>\n );\n }}\n />\n {maxSelected !== undefined && (\n <p className=\"border-t border-border px-3 py-2 text-xs text-muted-foreground\">\n {selectedCountLabel(value.length, maxSelected)}\n </p>\n )}\n </PopoverContent>\n </Popover>\n\n <ComboboxAdornments\n showClear={showClear}\n disabled={disabled}\n clearLabel={clearLabel}\n onClear={() => onValueChange([])}\n />\n </div>\n );\n}\n"],"mappings":"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","names":[]}
|
|
@@ -15,6 +15,12 @@ interface InlineEditProps {
|
|
|
15
15
|
placeholder?: string;
|
|
16
16
|
/** Ô nhập nhiều dòng (mô tả task) — Enter xuống dòng, Cmd/Ctrl+Enter mới lưu */
|
|
17
17
|
multiline?: boolean;
|
|
18
|
+
/**
|
|
19
|
+
* Chỉ áp khi `multiline` tắt: phần HIỂN THỊ được xuống dòng thay vì cắt bằng "…" (mặc định false).
|
|
20
|
+
* Ô sửa vẫn là MỘT dòng — Enter lưu, giá trị không bao giờ mang `\n`. Dùng cho tiêu đề dài ở trang
|
|
21
|
+
* chi tiết: `multiline` thì Enter thành ký tự xuống dòng và dữ liệu lưu được `\n`.
|
|
22
|
+
*/
|
|
23
|
+
wrap?: boolean;
|
|
18
24
|
maxLength?: number;
|
|
19
25
|
disabled?: boolean;
|
|
20
26
|
/** Bắt buộc có nội dung: chuỗi rỗng bị coi là huỷ, không lưu (mặc định true) */
|
|
@@ -44,6 +50,6 @@ interface InlineEditProps {
|
|
|
44
50
|
* form khổng lồ và người dùng dễ sửa nhầm khi chỉ định bấm chọn. Hiển thị dạng chữ cho tới khi
|
|
45
51
|
* người dùng thật sự muốn sửa là ranh giới an toàn.
|
|
46
52
|
*/
|
|
47
|
-
declare function InlineEdit({ value, onSave, placeholder: placeholderProp, multiline, maxLength, disabled, required, saveOnBlur, showActions, ariaLabel: ariaLabelProp, onError, className, displayClassName }: InlineEditProps): React.JSX.Element;
|
|
53
|
+
declare function InlineEdit({ value, onSave, placeholder: placeholderProp, multiline, wrap, maxLength, disabled, required, saveOnBlur, showActions, ariaLabel: ariaLabelProp, onError, className, displayClassName }: InlineEditProps): React.JSX.Element;
|
|
48
54
|
|
|
49
55
|
export { InlineEdit, type InlineEditProps };
|
|
@@ -10,6 +10,7 @@ function InlineEdit({
|
|
|
10
10
|
onSave,
|
|
11
11
|
placeholder: placeholderProp,
|
|
12
12
|
multiline = false,
|
|
13
|
+
wrap = false,
|
|
13
14
|
maxLength,
|
|
14
15
|
disabled = false,
|
|
15
16
|
required = true,
|
|
@@ -84,7 +85,15 @@ function InlineEdit({
|
|
|
84
85
|
className,
|
|
85
86
|
displayClassName
|
|
86
87
|
),
|
|
87
|
-
children: /* @__PURE__ */ jsx(
|
|
88
|
+
children: /* @__PURE__ */ jsx(
|
|
89
|
+
"span",
|
|
90
|
+
{
|
|
91
|
+
className: cn(
|
|
92
|
+
multiline ? "whitespace-pre-wrap" : wrap ? "block whitespace-normal break-words" : "block truncate"
|
|
93
|
+
),
|
|
94
|
+
children: value || placeholder
|
|
95
|
+
}
|
|
96
|
+
)
|
|
88
97
|
}
|
|
89
98
|
);
|
|
90
99
|
}
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"sources":["../../../src/components/composite/inline-edit.tsx"],"sourcesContent":["\"use client\";\n\nimport { Check, X } from \"lucide-react\";\nimport { type KeyboardEvent, useEffect, useRef, useState } from \"react\";\nimport { useComitorMessages } from \"../../i18n/locale-provider.js\";\nimport { cn } from \"../../lib/cn.js\";\nimport { Button } from \"../ui/button.js\";\n\nexport interface InlineEditProps {\n /** Giá trị hiện tại (controlled từ phía dữ liệu) */\n value: string;\n /**\n * Lưu giá trị mới. Trả Promise thì component hiện trạng thái đang lưu và chỉ thoát chế độ\n * sửa khi lưu xong — thoát sớm sẽ nháy về giá trị cũ rồi mới nhảy sang giá trị mới.\n */\n onSave: (value: string) => void | Promise<void>;\n /**\n * Chữ hiện khi giá trị rỗng, kiêm placeholder của ô nhập (khoá `placeholder` của namespace\n * `inlineEdit` — `ComitorLocaleProvider`, mặc định tiếng Anh).\n */\n placeholder?: string;\n /** Ô nhập nhiều dòng (mô tả task) — Enter xuống dòng, Cmd/Ctrl+Enter mới lưu */\n multiline?: boolean;\n maxLength?: number;\n disabled?: boolean;\n /** Bắt buộc có nội dung: chuỗi rỗng bị coi là huỷ, không lưu (mặc định true) */\n required?: boolean;\n /** Rời ô là lưu (mặc định true) */\n saveOnBlur?: boolean;\n /**\n * Hiện cặp nút ✓/✕ khi đang sửa (mặc định false — thao tác bằng phím). Tên sr-only của hai nút\n * lấy khoá `save` / `cancel` của namespace `inlineEdit`; không có prop rời — đè qua `messages`\n * của `ComitorLocaleProvider`.\n */\n showActions?: boolean;\n /**\n * Nhãn cho trình đọc màn hình, vd \"Sửa tên công việc\" (không truyền thì lấy khoá `ariaLabel` của\n * namespace `inlineEdit`)\n */\n ariaLabel?: string;\n onError?: (error: unknown) => void;\n className?: string;\n /** className riêng cho phần hiển thị (khi chưa sửa) — vd chỉnh cỡ chữ tiêu đề */\n displayClassName?: string;\n}\n\n/**\n * Sửa tại chỗ: bấm vào chữ → thành ô nhập → Enter lưu, Esc huỷ.\n *\n * Vì sao không dùng `<input>` luôn cho gọn: ô nhập luôn hiện làm danh sách trông như một cái\n * form khổng lồ và người dùng dễ sửa nhầm khi chỉ định bấm chọn. Hiển thị dạng chữ cho tới khi\n * người dùng thật sự muốn sửa là ranh giới an toàn.\n */\nexport function InlineEdit({\n value,\n onSave,\n placeholder: placeholderProp,\n multiline = false,\n maxLength,\n disabled = false,\n required = true,\n saveOnBlur = true,\n showActions = false,\n ariaLabel: ariaLabelProp,\n onError,\n className,\n displayClassName\n}: InlineEditProps) {\n const t = useComitorMessages(\"inlineEdit\");\n const placeholder = placeholderProp ?? t.placeholder;\n const ariaLabel = ariaLabelProp ?? t.ariaLabel;\n const [editing, setEditing] = useState(false);\n const [draft, setDraft] = useState(value);\n const [saving, setSaving] = useState(false);\n const inputRef = useRef<HTMLInputElement | HTMLTextAreaElement | null>(null);\n\n // Giá trị đổi từ ngoài (người khác sửa qua realtime) — chỉ đồng bộ khi không sửa dở.\n useEffect(() => {\n if (!editing) setDraft(value);\n }, [value, editing]);\n\n useEffect(() => {\n if (!editing) return;\n const element = inputRef.current;\n if (!element) return;\n element.focus();\n element.setSelectionRange(element.value.length, element.value.length);\n }, [editing]);\n\n const cancel = () => {\n setDraft(value);\n setEditing(false);\n };\n\n const commit = async () => {\n const next = draft.trim();\n if (next === value.trim() || (required && next === \"\")) {\n cancel();\n return;\n }\n\n try {\n setSaving(true);\n await onSave(next);\n setSaving(false);\n setEditing(false);\n } catch (error) {\n setSaving(false);\n // Giữ nguyên chế độ sửa: người dùng không mất chữ vừa gõ khi mạng lỗi.\n if (onError) onError(error);\n else console.error(\"[InlineEdit] onSave thất bại:\", error);\n }\n };\n\n const handleKeyDown = (event: KeyboardEvent<HTMLInputElement | HTMLTextAreaElement>) => {\n if (event.key === \"Escape\") {\n event.preventDefault();\n cancel();\n return;\n }\n if (event.key === \"Enter\" && (!multiline || event.metaKey || event.ctrlKey)) {\n event.preventDefault();\n void commit();\n }\n };\n\n if (!editing) {\n return (\n <button\n type=\"button\"\n disabled={disabled}\n aria-label={ariaLabel}\n onClick={() => setEditing(true)}\n className={cn(\n \"-mx-1 w-full rounded-md px-1 py-0.5 text-left transition-colors duration-150 ease-out\",\n \"hover:bg-muted disabled:pointer-events-none disabled:opacity-60\",\n !value && \"text-muted-foreground\",\n className,\n displayClassName\n )}\n >\n <span className={cn(multiline ? \"whitespace-pre-wrap\" : \"block truncate\")}>{value || placeholder}</span>\n </button>\n );\n }\n\n const sharedProps = {\n value: draft,\n maxLength,\n placeholder,\n disabled: saving,\n \"aria-label\": ariaLabel,\n onKeyDown: handleKeyDown,\n onBlur: saveOnBlur ? () => void commit() : undefined,\n className: cn(\n \"w-full rounded-md border border-input bg-background px-1.5 py-0.5 text-sm text-foreground\",\n \"focus:border-primary-ink focus:outline-none focus:ring-2 focus:ring-ring/50\",\n \"disabled:opacity-60\",\n className\n )\n };\n\n return (\n <div className=\"flex items-start gap-1\">\n {multiline ? (\n <textarea\n {...sharedProps}\n ref={(element) => {\n inputRef.current = element;\n }}\n rows={3}\n onChange={(event) => setDraft(event.target.value)}\n />\n ) : (\n <input\n {...sharedProps}\n ref={(element) => {\n inputRef.current = element;\n }}\n type=\"text\"\n onChange={(event) => setDraft(event.target.value)}\n />\n )}\n\n {showActions && (\n <div className=\"flex shrink-0 items-center gap-0.5\">\n <Button\n type=\"button\"\n variant=\"ghost\"\n size=\"icon-sm\"\n disabled={saving}\n // onMouseDown: chạy TRƯỚC blur của ô nhập, nếu để onClick thì blur đã huỷ/lưu mất rồi.\n onMouseDown={(event) => {\n event.preventDefault();\n void commit();\n }}\n >\n <Check aria-hidden=\"true\" className=\"size-3.5\" />\n <span className=\"sr-only\">{t.save}</span>\n </Button>\n <Button\n type=\"button\"\n variant=\"ghost\"\n size=\"icon-sm\"\n disabled={saving}\n onMouseDown={(event) => {\n event.preventDefault();\n cancel();\n }}\n >\n <X aria-hidden=\"true\" className=\"size-3.5\" />\n <span className=\"sr-only\">{t.cancel}</span>\n </Button>\n </div>\n )}\n </div>\n );\n}\n"],"mappings":"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","names":[]}
|
|
1
|
+
{"version":3,"sources":["../../../src/components/composite/inline-edit.tsx"],"sourcesContent":["\"use client\";\n\nimport { Check, X } from \"lucide-react\";\nimport { type KeyboardEvent, useEffect, useRef, useState } from \"react\";\nimport { useComitorMessages } from \"../../i18n/locale-provider.js\";\nimport { cn } from \"../../lib/cn.js\";\nimport { Button } from \"../ui/button.js\";\n\nexport interface InlineEditProps {\n /** Giá trị hiện tại (controlled từ phía dữ liệu) */\n value: string;\n /**\n * Lưu giá trị mới. Trả Promise thì component hiện trạng thái đang lưu và chỉ thoát chế độ\n * sửa khi lưu xong — thoát sớm sẽ nháy về giá trị cũ rồi mới nhảy sang giá trị mới.\n */\n onSave: (value: string) => void | Promise<void>;\n /**\n * Chữ hiện khi giá trị rỗng, kiêm placeholder của ô nhập (khoá `placeholder` của namespace\n * `inlineEdit` — `ComitorLocaleProvider`, mặc định tiếng Anh).\n */\n placeholder?: string;\n /** Ô nhập nhiều dòng (mô tả task) — Enter xuống dòng, Cmd/Ctrl+Enter mới lưu */\n multiline?: boolean;\n /**\n * Chỉ áp khi `multiline` tắt: phần HIỂN THỊ được xuống dòng thay vì cắt bằng \"…\" (mặc định false).\n * Ô sửa vẫn là MỘT dòng — Enter lưu, giá trị không bao giờ mang `\\n`. Dùng cho tiêu đề dài ở trang\n * chi tiết: `multiline` thì Enter thành ký tự xuống dòng và dữ liệu lưu được `\\n`.\n */\n wrap?: boolean;\n maxLength?: number;\n disabled?: boolean;\n /** Bắt buộc có nội dung: chuỗi rỗng bị coi là huỷ, không lưu (mặc định true) */\n required?: boolean;\n /** Rời ô là lưu (mặc định true) */\n saveOnBlur?: boolean;\n /**\n * Hiện cặp nút ✓/✕ khi đang sửa (mặc định false — thao tác bằng phím). Tên sr-only của hai nút\n * lấy khoá `save` / `cancel` của namespace `inlineEdit`; không có prop rời — đè qua `messages`\n * của `ComitorLocaleProvider`.\n */\n showActions?: boolean;\n /**\n * Nhãn cho trình đọc màn hình, vd \"Sửa tên công việc\" (không truyền thì lấy khoá `ariaLabel` của\n * namespace `inlineEdit`)\n */\n ariaLabel?: string;\n onError?: (error: unknown) => void;\n className?: string;\n /** className riêng cho phần hiển thị (khi chưa sửa) — vd chỉnh cỡ chữ tiêu đề */\n displayClassName?: string;\n}\n\n/**\n * Sửa tại chỗ: bấm vào chữ → thành ô nhập → Enter lưu, Esc huỷ.\n *\n * Vì sao không dùng `<input>` luôn cho gọn: ô nhập luôn hiện làm danh sách trông như một cái\n * form khổng lồ và người dùng dễ sửa nhầm khi chỉ định bấm chọn. Hiển thị dạng chữ cho tới khi\n * người dùng thật sự muốn sửa là ranh giới an toàn.\n */\nexport function InlineEdit({\n value,\n onSave,\n placeholder: placeholderProp,\n multiline = false,\n wrap = false,\n maxLength,\n disabled = false,\n required = true,\n saveOnBlur = true,\n showActions = false,\n ariaLabel: ariaLabelProp,\n onError,\n className,\n displayClassName\n}: InlineEditProps) {\n const t = useComitorMessages(\"inlineEdit\");\n const placeholder = placeholderProp ?? t.placeholder;\n const ariaLabel = ariaLabelProp ?? t.ariaLabel;\n const [editing, setEditing] = useState(false);\n const [draft, setDraft] = useState(value);\n const [saving, setSaving] = useState(false);\n const inputRef = useRef<HTMLInputElement | HTMLTextAreaElement | null>(null);\n\n // Giá trị đổi từ ngoài (người khác sửa qua realtime) — chỉ đồng bộ khi không sửa dở.\n useEffect(() => {\n if (!editing) setDraft(value);\n }, [value, editing]);\n\n useEffect(() => {\n if (!editing) return;\n const element = inputRef.current;\n if (!element) return;\n element.focus();\n element.setSelectionRange(element.value.length, element.value.length);\n }, [editing]);\n\n const cancel = () => {\n setDraft(value);\n setEditing(false);\n };\n\n const commit = async () => {\n const next = draft.trim();\n if (next === value.trim() || (required && next === \"\")) {\n cancel();\n return;\n }\n\n try {\n setSaving(true);\n await onSave(next);\n setSaving(false);\n setEditing(false);\n } catch (error) {\n setSaving(false);\n // Giữ nguyên chế độ sửa: người dùng không mất chữ vừa gõ khi mạng lỗi.\n if (onError) onError(error);\n else console.error(\"[InlineEdit] onSave thất bại:\", error);\n }\n };\n\n const handleKeyDown = (event: KeyboardEvent<HTMLInputElement | HTMLTextAreaElement>) => {\n if (event.key === \"Escape\") {\n event.preventDefault();\n cancel();\n return;\n }\n if (event.key === \"Enter\" && (!multiline || event.metaKey || event.ctrlKey)) {\n event.preventDefault();\n void commit();\n }\n };\n\n if (!editing) {\n return (\n <button\n type=\"button\"\n disabled={disabled}\n aria-label={ariaLabel}\n onClick={() => setEditing(true)}\n className={cn(\n \"-mx-1 w-full rounded-md px-1 py-0.5 text-left transition-colors duration-150 ease-out\",\n \"hover:bg-muted disabled:pointer-events-none disabled:opacity-60\",\n !value && \"text-muted-foreground\",\n className,\n displayClassName\n )}\n >\n <span\n className={cn(\n multiline ? \"whitespace-pre-wrap\" : wrap ? \"block whitespace-normal break-words\" : \"block truncate\"\n )}\n >\n {value || placeholder}\n </span>\n </button>\n );\n }\n\n const sharedProps = {\n value: draft,\n maxLength,\n placeholder,\n disabled: saving,\n \"aria-label\": ariaLabel,\n onKeyDown: handleKeyDown,\n onBlur: saveOnBlur ? () => void commit() : undefined,\n className: cn(\n \"w-full rounded-md border border-input bg-background px-1.5 py-0.5 text-sm text-foreground\",\n \"focus:border-primary-ink focus:outline-none focus:ring-2 focus:ring-ring/50\",\n \"disabled:opacity-60\",\n className\n )\n };\n\n return (\n <div className=\"flex items-start gap-1\">\n {multiline ? (\n <textarea\n {...sharedProps}\n ref={(element) => {\n inputRef.current = element;\n }}\n rows={3}\n onChange={(event) => setDraft(event.target.value)}\n />\n ) : (\n <input\n {...sharedProps}\n ref={(element) => {\n inputRef.current = element;\n }}\n type=\"text\"\n onChange={(event) => setDraft(event.target.value)}\n />\n )}\n\n {showActions && (\n <div className=\"flex shrink-0 items-center gap-0.5\">\n <Button\n type=\"button\"\n variant=\"ghost\"\n size=\"icon-sm\"\n disabled={saving}\n // onMouseDown: chạy TRƯỚC blur của ô nhập, nếu để onClick thì blur đã huỷ/lưu mất rồi.\n onMouseDown={(event) => {\n event.preventDefault();\n void commit();\n }}\n >\n <Check aria-hidden=\"true\" className=\"size-3.5\" />\n <span className=\"sr-only\">{t.save}</span>\n </Button>\n <Button\n type=\"button\"\n variant=\"ghost\"\n size=\"icon-sm\"\n disabled={saving}\n onMouseDown={(event) => {\n event.preventDefault();\n cancel();\n }}\n >\n <X aria-hidden=\"true\" className=\"size-3.5\" />\n <span className=\"sr-only\">{t.cancel}</span>\n </Button>\n </div>\n )}\n </div>\n );\n}\n"],"mappings":"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","names":[]}
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Luật THUẦN của phím tắt dạng CHUỖI ("G" rồi "M") — không React. Bộ bắt phím `useKeySequences` dựng trên
|
|
3
|
+
* đây; app dùng thẳng được khi cần so khớp ở chỗ khác (vd phím của một danh sách phải nhường cho chuỗi toàn
|
|
4
|
+
* cục đang dở).
|
|
5
|
+
*
|
|
6
|
+
* Port từ module Tasks (T1.13, FR-KB-001) — nơi nó chạy thật trước khi lên gói. Không directive: Server
|
|
7
|
+
* Component nhập được hằng và kiểu từ đây.
|
|
8
|
+
*/
|
|
9
|
+
/** Hai phím của một chuỗi phải cách nhau không quá ngần này (ms) — quá thì chuỗi bỏ dở. */
|
|
10
|
+
declare const KEY_SEQUENCE_TIMEOUT_MS = 1200;
|
|
11
|
+
/** Một phím tắt chuỗi: `keys` là các phím TRẦN theo thứ tự (`["g", "m"]`, `["?"]`). */
|
|
12
|
+
interface KeySequence<Id extends string = string> {
|
|
13
|
+
id: Id;
|
|
14
|
+
keys: readonly string[];
|
|
15
|
+
}
|
|
16
|
+
/** Phần của `KeyboardEvent` mà phép chuẩn hoá đọc — để gọi được trong test không có DOM. */
|
|
17
|
+
interface KeySequenceInput {
|
|
18
|
+
key: string;
|
|
19
|
+
metaKey: boolean;
|
|
20
|
+
ctrlKey: boolean;
|
|
21
|
+
altKey: boolean;
|
|
22
|
+
repeat: boolean;
|
|
23
|
+
}
|
|
24
|
+
/**
|
|
25
|
+
* Phím → ký hiệu của chuỗi (`"c"`, `"/"`, `"?"`), hoặc `null` khi phím KHÔNG phải phím tắt trần: có
|
|
26
|
+
* ⌘/Ctrl/Alt (⌘K do khung app bắt riêng; ⌘C là sao chép), đang giữ-lặp, hoặc phím đặc biệt (Escape, mũi
|
|
27
|
+
* tên…). Shift được phép: `?` là Shift+/ trên phần lớn bàn phím, và Caps Lock biến `c` thành `C`.
|
|
28
|
+
*/
|
|
29
|
+
declare function normalizeSequenceKey(input: KeySequenceInput): string | null;
|
|
30
|
+
/**
|
|
31
|
+
* Thêm một phím vào chuỗi đang chờ. Trùng khớp trọn → `matched`; là TIỀN TỐ của một phím tắt → chờ tiếp
|
|
32
|
+
* (`pending`); không khớp gì → thử lại với riêng phím này (gõ "x" rồi "g" "m" vẫn ra "G M").
|
|
33
|
+
*/
|
|
34
|
+
declare function advanceKeySequence<Id extends string>(sequences: readonly KeySequence<Id>[], pending: readonly string[], key: string): {
|
|
35
|
+
matched: Id | null;
|
|
36
|
+
pending: string[];
|
|
37
|
+
};
|
|
38
|
+
/**
|
|
39
|
+
* Phím rơi vào chỗ soạn chữ, lớp nổi đang mở hay lưới (gõ chữ để sửa ô) — khi ấy phím là CHỮ, không là
|
|
40
|
+
* lệnh. Đọc DOM nhưng không React: chỉ gọi được ở trình duyệt.
|
|
41
|
+
*/
|
|
42
|
+
declare function isEditableTarget(target: EventTarget | null): boolean;
|
|
43
|
+
|
|
44
|
+
export { KEY_SEQUENCE_TIMEOUT_MS, type KeySequence, type KeySequenceInput, advanceKeySequence, isEditableTarget, normalizeSequenceKey };
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
const KEY_SEQUENCE_TIMEOUT_MS = 1200;
|
|
2
|
+
function normalizeSequenceKey(input) {
|
|
3
|
+
if (input.metaKey || input.ctrlKey || input.altKey || input.repeat) return null;
|
|
4
|
+
if (input.key.length !== 1) return null;
|
|
5
|
+
return input.key.toLowerCase();
|
|
6
|
+
}
|
|
7
|
+
function advanceKeySequence(sequences, pending, key) {
|
|
8
|
+
const attempt = (sequence) => {
|
|
9
|
+
const exact = sequences.find(
|
|
10
|
+
(spec) => spec.keys.length === sequence.length && spec.keys.every((part, index) => part === sequence[index])
|
|
11
|
+
);
|
|
12
|
+
if (exact) return { matched: exact.id, pending: [] };
|
|
13
|
+
const prefix = sequences.some(
|
|
14
|
+
(spec) => spec.keys.length > sequence.length && sequence.every((part, index) => spec.keys[index] === part)
|
|
15
|
+
);
|
|
16
|
+
return prefix ? { matched: null, pending: sequence } : null;
|
|
17
|
+
};
|
|
18
|
+
return attempt([...pending, key]) ?? (pending.length > 0 ? attempt([key]) : null) ?? { matched: null, pending: [] };
|
|
19
|
+
}
|
|
20
|
+
function isEditableTarget(target) {
|
|
21
|
+
if (typeof HTMLElement === "undefined" || !(target instanceof HTMLElement)) return false;
|
|
22
|
+
if (target.isContentEditable) return true;
|
|
23
|
+
if (["INPUT", "TEXTAREA", "SELECT"].includes(target.tagName)) return true;
|
|
24
|
+
return target.closest('[role="dialog"],[role="menu"],[role="listbox"],[role="combobox"],[role="grid"]') !== null;
|
|
25
|
+
}
|
|
26
|
+
export {
|
|
27
|
+
KEY_SEQUENCE_TIMEOUT_MS,
|
|
28
|
+
advanceKeySequence,
|
|
29
|
+
isEditableTarget,
|
|
30
|
+
normalizeSequenceKey
|
|
31
|
+
};
|
|
32
|
+
//# sourceMappingURL=key-sequence.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"sources":["../../../src/components/composite/key-sequence.ts"],"sourcesContent":["/**\n * Luật THUẦN của phím tắt dạng CHUỖI (\"G\" rồi \"M\") — không React. Bộ bắt phím `useKeySequences` dựng trên\n * đây; app dùng thẳng được khi cần so khớp ở chỗ khác (vd phím của một danh sách phải nhường cho chuỗi toàn\n * cục đang dở).\n *\n * Port từ module Tasks (T1.13, FR-KB-001) — nơi nó chạy thật trước khi lên gói. Không directive: Server\n * Component nhập được hằng và kiểu từ đây.\n */\n\n/** Hai phím của một chuỗi phải cách nhau không quá ngần này (ms) — quá thì chuỗi bỏ dở. */\nexport const KEY_SEQUENCE_TIMEOUT_MS = 1200;\n\n/** Một phím tắt chuỗi: `keys` là các phím TRẦN theo thứ tự (`[\"g\", \"m\"]`, `[\"?\"]`). */\nexport interface KeySequence<Id extends string = string> {\n id: Id;\n keys: readonly string[];\n}\n\n/** Phần của `KeyboardEvent` mà phép chuẩn hoá đọc — để gọi được trong test không có DOM. */\nexport interface KeySequenceInput {\n key: string;\n metaKey: boolean;\n ctrlKey: boolean;\n altKey: boolean;\n repeat: boolean;\n}\n\n/**\n * Phím → ký hiệu của chuỗi (`\"c\"`, `\"/\"`, `\"?\"`), hoặc `null` khi phím KHÔNG phải phím tắt trần: có\n * ⌘/Ctrl/Alt (⌘K do khung app bắt riêng; ⌘C là sao chép), đang giữ-lặp, hoặc phím đặc biệt (Escape, mũi\n * tên…). Shift được phép: `?` là Shift+/ trên phần lớn bàn phím, và Caps Lock biến `c` thành `C`.\n */\nexport function normalizeSequenceKey(input: KeySequenceInput): string | null {\n if (input.metaKey || input.ctrlKey || input.altKey || input.repeat) return null;\n if (input.key.length !== 1) return null;\n return input.key.toLowerCase();\n}\n\n/**\n * Thêm một phím vào chuỗi đang chờ. Trùng khớp trọn → `matched`; là TIỀN TỐ của một phím tắt → chờ tiếp\n * (`pending`); không khớp gì → thử lại với riêng phím này (gõ \"x\" rồi \"g\" \"m\" vẫn ra \"G M\").\n */\nexport function advanceKeySequence<Id extends string>(\n sequences: readonly KeySequence<Id>[],\n pending: readonly string[],\n key: string\n): { matched: Id | null; pending: string[] } {\n const attempt = (sequence: string[]) => {\n const exact = sequences.find(\n (spec) => spec.keys.length === sequence.length && spec.keys.every((part, index) => part === sequence[index])\n );\n if (exact) return { matched: exact.id, pending: [] };\n const prefix = sequences.some(\n (spec) => spec.keys.length > sequence.length && sequence.every((part, index) => spec.keys[index] === part)\n );\n return prefix ? { matched: null, pending: sequence } : null;\n };\n return attempt([...pending, key]) ?? (pending.length > 0 ? attempt([key]) : null) ?? { matched: null, pending: [] };\n}\n\n/**\n * Phím rơi vào chỗ soạn chữ, lớp nổi đang mở hay lưới (gõ chữ để sửa ô) — khi ấy phím là CHỮ, không là\n * lệnh. Đọc DOM nhưng không React: chỉ gọi được ở trình duyệt.\n */\nexport function isEditableTarget(target: EventTarget | null): boolean {\n if (typeof HTMLElement === \"undefined\" || !(target instanceof HTMLElement)) return false;\n if (target.isContentEditable) return true;\n if ([\"INPUT\", \"TEXTAREA\", \"SELECT\"].includes(target.tagName)) return true;\n return target.closest('[role=\"dialog\"],[role=\"menu\"],[role=\"listbox\"],[role=\"combobox\"],[role=\"grid\"]') !== null;\n}\n"],"mappings":"AAUO,MAAM,0BAA0B;AAsBhC,SAAS,qBAAqB,OAAwC;AAC3E,MAAI,MAAM,WAAW,MAAM,WAAW,MAAM,UAAU,MAAM,OAAQ,QAAO;AAC3E,MAAI,MAAM,IAAI,WAAW,EAAG,QAAO;AACnC,SAAO,MAAM,IAAI,YAAY;AAC/B;AAMO,SAAS,mBACd,WACA,SACA,KAC2C;AAC3C,QAAM,UAAU,CAAC,aAAuB;AACtC,UAAM,QAAQ,UAAU;AAAA,MACtB,CAAC,SAAS,KAAK,KAAK,WAAW,SAAS,UAAU,KAAK,KAAK,MAAM,CAAC,MAAM,UAAU,SAAS,SAAS,KAAK,CAAC;AAAA,IAC7G;AACA,QAAI,MAAO,QAAO,EAAE,SAAS,MAAM,IAAI,SAAS,CAAC,EAAE;AACnD,UAAM,SAAS,UAAU;AAAA,MACvB,CAAC,SAAS,KAAK,KAAK,SAAS,SAAS,UAAU,SAAS,MAAM,CAAC,MAAM,UAAU,KAAK,KAAK,KAAK,MAAM,IAAI;AAAA,IAC3G;AACA,WAAO,SAAS,EAAE,SAAS,MAAM,SAAS,SAAS,IAAI;AAAA,EACzD;AACA,SAAO,QAAQ,CAAC,GAAG,SAAS,GAAG,CAAC,MAAM,QAAQ,SAAS,IAAI,QAAQ,CAAC,GAAG,CAAC,IAAI,SAAS,EAAE,SAAS,MAAM,SAAS,CAAC,EAAE;AACpH;AAMO,SAAS,iBAAiB,QAAqC;AACpE,MAAI,OAAO,gBAAgB,eAAe,EAAE,kBAAkB,aAAc,QAAO;AACnF,MAAI,OAAO,kBAAmB,QAAO;AACrC,MAAI,CAAC,SAAS,YAAY,QAAQ,EAAE,SAAS,OAAO,OAAO,EAAG,QAAO;AACrE,SAAO,OAAO,QAAQ,gFAAgF,MAAM;AAC9G;","names":[]}
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
import * as React from 'react';
|
|
2
|
+
|
|
3
|
+
interface KeyboardShortcutItem {
|
|
4
|
+
id: string;
|
|
5
|
+
/** Việc phím làm — chữ nghiệp vụ của app ("Tạo việc", "Tới Dự án"). */
|
|
6
|
+
label: string;
|
|
7
|
+
/**
|
|
8
|
+
* Các BƯỚC bấm, theo thứ tự; mỗi bước là một tổ hợp theo cú pháp của `KeyboardHint` (`"g"`, `"shift+x"`,
|
|
9
|
+
* `"mod+k"`). `["g", "m"]` hiện thành "G rồi M". Chuỗi một bước thì truyền mảng một phần tử.
|
|
10
|
+
*/
|
|
11
|
+
keys: readonly string[];
|
|
12
|
+
}
|
|
13
|
+
interface KeyboardShortcutSection {
|
|
14
|
+
id: string;
|
|
15
|
+
/** Tiêu đề nhóm; bỏ trống ở nhóm đầu (phím toàn cục) là cách thường gặp. */
|
|
16
|
+
title?: string;
|
|
17
|
+
/** Dòng phụ dưới tiêu đề — vd "Khi đang ở danh sách việc". */
|
|
18
|
+
description?: string;
|
|
19
|
+
items: readonly KeyboardShortcutItem[];
|
|
20
|
+
}
|
|
21
|
+
interface KeyboardShortcutsDialogProps {
|
|
22
|
+
open: boolean;
|
|
23
|
+
onOpenChange: (open: boolean) => void;
|
|
24
|
+
sections: readonly KeyboardShortcutSection[];
|
|
25
|
+
/** Mặc định: khoá `title` / `description` của namespace `keyboardShortcuts`. */
|
|
26
|
+
title?: string;
|
|
27
|
+
description?: string;
|
|
28
|
+
className?: string;
|
|
29
|
+
}
|
|
30
|
+
/**
|
|
31
|
+
* Bảng `?` — liệt kê phím tắt theo nhóm. Cặp với `useKeySequences` (bắt phím); app giữ SỔ phím (id, phím,
|
|
32
|
+
* nhãn, quyền) ở một chỗ và đưa cùng sổ đó cho cả hai.
|
|
33
|
+
*
|
|
34
|
+
* Mỗi dòng: nhãn bên trái, các bước phím bên phải nối bằng chữ `sequenceJoiner`. Ô phím `aria-hidden` (ký hiệu ⌘⇧
|
|
35
|
+
* đọc lên vô nghĩa) nên dòng mang thêm một bản `sr-only` đọc được: "G rồi M".
|
|
36
|
+
*/
|
|
37
|
+
declare function KeyboardShortcutsDialog({ open, onOpenChange, sections, title, description, className }: KeyboardShortcutsDialogProps): React.JSX.Element;
|
|
38
|
+
|
|
39
|
+
export { type KeyboardShortcutItem, type KeyboardShortcutSection, KeyboardShortcutsDialog, type KeyboardShortcutsDialogProps };
|
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
import { jsx, jsxs } from "react/jsx-runtime";
|
|
3
|
+
import { Fragment } from "react";
|
|
4
|
+
import { useComitorMessages } from "../../i18n/locale-provider.js";
|
|
5
|
+
import { cn } from "../../lib/cn.js";
|
|
6
|
+
import { Dialog, DialogBody, DialogContent, DialogDescription, DialogHeader, DialogTitle } from "../ui/dialog.js";
|
|
7
|
+
import { KeyboardHint, useIsMac } from "./keyboard-hint.js";
|
|
8
|
+
const SPOKEN_KEYS = {
|
|
9
|
+
mod: { mac: "Command", other: "Ctrl" },
|
|
10
|
+
cmd: { mac: "Command", other: "Ctrl" },
|
|
11
|
+
meta: { mac: "Command", other: "Ctrl" },
|
|
12
|
+
ctrl: { mac: "Control", other: "Ctrl" },
|
|
13
|
+
control: { mac: "Control", other: "Ctrl" },
|
|
14
|
+
alt: { mac: "Option", other: "Alt" },
|
|
15
|
+
option: { mac: "Option", other: "Alt" },
|
|
16
|
+
shift: { mac: "Shift", other: "Shift" },
|
|
17
|
+
enter: { mac: "Return", other: "Enter" },
|
|
18
|
+
return: { mac: "Return", other: "Enter" },
|
|
19
|
+
backspace: { mac: "Delete", other: "Backspace" },
|
|
20
|
+
esc: { mac: "Escape", other: "Escape" },
|
|
21
|
+
escape: { mac: "Escape", other: "Escape" }
|
|
22
|
+
};
|
|
23
|
+
function spokenCombo(combo, isMac) {
|
|
24
|
+
return combo.split("+").map((token) => token.trim()).filter(Boolean).map((token) => {
|
|
25
|
+
const spoken = SPOKEN_KEYS[token.toLowerCase()];
|
|
26
|
+
if (spoken) return isMac ? spoken.mac : spoken.other;
|
|
27
|
+
return token.length === 1 ? token.toUpperCase() : token.charAt(0).toUpperCase() + token.slice(1);
|
|
28
|
+
}).join(" ");
|
|
29
|
+
}
|
|
30
|
+
function KeyboardShortcutsDialog({
|
|
31
|
+
open,
|
|
32
|
+
onOpenChange,
|
|
33
|
+
sections,
|
|
34
|
+
title,
|
|
35
|
+
description,
|
|
36
|
+
className
|
|
37
|
+
}) {
|
|
38
|
+
const t = useComitorMessages("keyboardShortcuts");
|
|
39
|
+
const isMac = useIsMac();
|
|
40
|
+
return /* @__PURE__ */ jsx(Dialog, { open, onOpenChange, children: /* @__PURE__ */ jsxs(DialogContent, { className, children: [
|
|
41
|
+
/* @__PURE__ */ jsxs(DialogHeader, { children: [
|
|
42
|
+
/* @__PURE__ */ jsx(DialogTitle, { children: title ?? t.title }),
|
|
43
|
+
/* @__PURE__ */ jsx(DialogDescription, { children: description ?? t.description })
|
|
44
|
+
] }),
|
|
45
|
+
/* @__PURE__ */ jsx(DialogBody, { children: sections.map((section, sectionIndex) => {
|
|
46
|
+
const headingId = `keyboard-shortcuts-${section.id}`;
|
|
47
|
+
return /* @__PURE__ */ jsxs(
|
|
48
|
+
"section",
|
|
49
|
+
{
|
|
50
|
+
"aria-labelledby": section.title ? headingId : void 0,
|
|
51
|
+
className: cn(sectionIndex > 0 && "mt-4"),
|
|
52
|
+
children: [
|
|
53
|
+
section.title ? /* @__PURE__ */ jsx("h3", { id: headingId, className: "text-sm font-medium", children: section.title }) : null,
|
|
54
|
+
section.description ? /* @__PURE__ */ jsx("p", { className: "text-xs text-muted-foreground", children: section.description }) : null,
|
|
55
|
+
/* @__PURE__ */ jsx("ul", { className: cn("flex flex-col divide-y divide-border", section.title && "mt-1"), children: section.items.map((item) => /* @__PURE__ */ jsxs("li", { className: "flex items-center justify-between gap-3 py-2 text-sm", children: [
|
|
56
|
+
/* @__PURE__ */ jsx("span", { children: item.label }),
|
|
57
|
+
/* @__PURE__ */ jsxs("span", { className: "flex shrink-0 items-center gap-1.5", children: [
|
|
58
|
+
item.keys.map((combo, index) => (
|
|
59
|
+
// biome-ignore lint/suspicious/noArrayIndexKey: bước phím có thể trùng nhau ("g g") và không có id riêng
|
|
60
|
+
/* @__PURE__ */ jsxs(Fragment, { children: [
|
|
61
|
+
index > 0 ? /* @__PURE__ */ jsx("span", { "aria-hidden": "true", className: "text-xs text-muted-foreground", children: t.sequenceJoiner }) : null,
|
|
62
|
+
/* @__PURE__ */ jsx(KeyboardHint, { keys: combo })
|
|
63
|
+
] }, `${index}-${combo}`)
|
|
64
|
+
)),
|
|
65
|
+
/* @__PURE__ */ jsx("span", { className: "sr-only", children: item.keys.map((combo) => spokenCombo(combo, isMac)).join(` ${t.sequenceJoiner} `) })
|
|
66
|
+
] })
|
|
67
|
+
] }, item.id)) })
|
|
68
|
+
]
|
|
69
|
+
},
|
|
70
|
+
section.id
|
|
71
|
+
);
|
|
72
|
+
}) })
|
|
73
|
+
] }) });
|
|
74
|
+
}
|
|
75
|
+
export {
|
|
76
|
+
KeyboardShortcutsDialog
|
|
77
|
+
};
|
|
78
|
+
//# sourceMappingURL=keyboard-shortcuts-dialog.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"sources":["../../../src/components/composite/keyboard-shortcuts-dialog.tsx"],"sourcesContent":["\"use client\";\n\nimport { Fragment } from \"react\";\nimport { useComitorMessages } from \"../../i18n/locale-provider.js\";\nimport { cn } from \"../../lib/cn.js\";\nimport { Dialog, DialogBody, DialogContent, DialogDescription, DialogHeader, DialogTitle } from \"../ui/dialog.js\";\nimport { KeyboardHint, useIsMac } from \"./keyboard-hint.js\";\n\nexport interface KeyboardShortcutItem {\n id: string;\n /** Việc phím làm — chữ nghiệp vụ của app (\"Tạo việc\", \"Tới Dự án\"). */\n label: string;\n /**\n * Các BƯỚC bấm, theo thứ tự; mỗi bước là một tổ hợp theo cú pháp của `KeyboardHint` (`\"g\"`, `\"shift+x\"`,\n * `\"mod+k\"`). `[\"g\", \"m\"]` hiện thành \"G rồi M\". Chuỗi một bước thì truyền mảng một phần tử.\n */\n keys: readonly string[];\n}\n\nexport interface KeyboardShortcutSection {\n id: string;\n /** Tiêu đề nhóm; bỏ trống ở nhóm đầu (phím toàn cục) là cách thường gặp. */\n title?: string;\n /** Dòng phụ dưới tiêu đề — vd \"Khi đang ở danh sách việc\". */\n description?: string;\n items: readonly KeyboardShortcutItem[];\n}\n\nexport interface KeyboardShortcutsDialogProps {\n open: boolean;\n onOpenChange: (open: boolean) => void;\n sections: readonly KeyboardShortcutSection[];\n /** Mặc định: khoá `title` / `description` của namespace `keyboardShortcuts`. */\n title?: string;\n description?: string;\n className?: string;\n}\n\n/** Tên ĐỌC của phím bổ trợ / phím đặc biệt — ký hiệu ⌘⇧↵ (thứ `KeyboardHint` vẽ) đọc lên vô nghĩa. */\nconst SPOKEN_KEYS: Record<string, { mac: string; other: string }> = {\n mod: { mac: \"Command\", other: \"Ctrl\" },\n cmd: { mac: \"Command\", other: \"Ctrl\" },\n meta: { mac: \"Command\", other: \"Ctrl\" },\n ctrl: { mac: \"Control\", other: \"Ctrl\" },\n control: { mac: \"Control\", other: \"Ctrl\" },\n alt: { mac: \"Option\", other: \"Alt\" },\n option: { mac: \"Option\", other: \"Alt\" },\n shift: { mac: \"Shift\", other: \"Shift\" },\n enter: { mac: \"Return\", other: \"Enter\" },\n return: { mac: \"Return\", other: \"Enter\" },\n backspace: { mac: \"Delete\", other: \"Backspace\" },\n esc: { mac: \"Escape\", other: \"Escape\" },\n escape: { mac: \"Escape\", other: \"Escape\" }\n};\n\n/** Tên đọc được của một tổ hợp cho trình đọc màn hình — `KeyboardHint` thì `aria-hidden`. Theo nền tảng như ô phím. */\nfunction spokenCombo(combo: string, isMac: boolean): string {\n return combo\n .split(\"+\")\n .map((token) => token.trim())\n .filter(Boolean)\n .map((token) => {\n const spoken = SPOKEN_KEYS[token.toLowerCase()];\n if (spoken) return isMac ? spoken.mac : spoken.other;\n return token.length === 1 ? token.toUpperCase() : token.charAt(0).toUpperCase() + token.slice(1);\n })\n .join(\" \");\n}\n\n/**\n * Bảng `?` — liệt kê phím tắt theo nhóm. Cặp với `useKeySequences` (bắt phím); app giữ SỔ phím (id, phím,\n * nhãn, quyền) ở một chỗ và đưa cùng sổ đó cho cả hai.\n *\n * Mỗi dòng: nhãn bên trái, các bước phím bên phải nối bằng chữ `sequenceJoiner`. Ô phím `aria-hidden` (ký hiệu ⌘⇧\n * đọc lên vô nghĩa) nên dòng mang thêm một bản `sr-only` đọc được: \"G rồi M\".\n */\nexport function KeyboardShortcutsDialog({\n open,\n onOpenChange,\n sections,\n title,\n description,\n className\n}: KeyboardShortcutsDialogProps) {\n const t = useComitorMessages(\"keyboardShortcuts\");\n const isMac = useIsMac();\n\n return (\n <Dialog open={open} onOpenChange={onOpenChange}>\n <DialogContent className={className}>\n <DialogHeader>\n <DialogTitle>{title ?? t.title}</DialogTitle>\n <DialogDescription>{description ?? t.description}</DialogDescription>\n </DialogHeader>\n <DialogBody>\n {sections.map((section, sectionIndex) => {\n const headingId = `keyboard-shortcuts-${section.id}`;\n return (\n <section\n key={section.id}\n aria-labelledby={section.title ? headingId : undefined}\n className={cn(sectionIndex > 0 && \"mt-4\")}\n >\n {section.title ? (\n <h3 id={headingId} className=\"text-sm font-medium\">\n {section.title}\n </h3>\n ) : null}\n {section.description ? <p className=\"text-xs text-muted-foreground\">{section.description}</p> : null}\n <ul className={cn(\"flex flex-col divide-y divide-border\", section.title && \"mt-1\")}>\n {section.items.map((item) => (\n <li key={item.id} className=\"flex items-center justify-between gap-3 py-2 text-sm\">\n <span>{item.label}</span>\n <span className=\"flex shrink-0 items-center gap-1.5\">\n {item.keys.map((combo, index) => (\n // biome-ignore lint/suspicious/noArrayIndexKey: bước phím có thể trùng nhau (\"g g\") và không có id riêng\n <Fragment key={`${index}-${combo}`}>\n {index > 0 ? (\n <span aria-hidden=\"true\" className=\"text-xs text-muted-foreground\">\n {t.sequenceJoiner}\n </span>\n ) : null}\n <KeyboardHint keys={combo} />\n </Fragment>\n ))}\n <span className=\"sr-only\">\n {item.keys.map((combo) => spokenCombo(combo, isMac)).join(` ${t.sequenceJoiner} `)}\n </span>\n </span>\n </li>\n ))}\n </ul>\n </section>\n );\n })}\n </DialogBody>\n </DialogContent>\n </Dialog>\n );\n}\n"],"mappings":"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","names":[]}
|