@openway/ui 1.0.0 → 1.0.2

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 (58) hide show
  1. package/README.md +203 -181
  2. package/dist/chunk-X4LIYOS5.js +3 -0
  3. package/dist/chunk-X4LIYOS5.js.map +1 -0
  4. package/dist/index.cjs +5 -1
  5. package/dist/index.cjs.map +1 -1
  6. package/dist/index.d.cts +2740 -1473
  7. package/dist/index.d.ts +2740 -1473
  8. package/dist/index.js +5 -1
  9. package/dist/index.js.map +1 -1
  10. package/dist/query.cjs +3 -0
  11. package/dist/query.cjs.map +1 -0
  12. package/dist/query.d.cts +431 -0
  13. package/dist/query.d.ts +431 -0
  14. package/dist/query.js +3 -0
  15. package/dist/query.js.map +1 -0
  16. package/dist/useInfiniteScroll-D9AW6cQV.d.cts +598 -0
  17. package/dist/useInfiniteScroll-D9AW6cQV.d.ts +598 -0
  18. package/docs/AGENTS.md +144 -0
  19. package/docs/README.md +150 -0
  20. package/docs/SKILL.md +54 -0
  21. package/docs/components/alert.md +246 -0
  22. package/docs/components/badge.md +238 -0
  23. package/docs/components/button.md +262 -0
  24. package/docs/components/carousel.md +354 -0
  25. package/docs/components/checkbox.md +252 -0
  26. package/docs/components/collapse.md +318 -0
  27. package/docs/components/confirm.md +322 -0
  28. package/docs/components/datepicker.md +259 -0
  29. package/docs/components/daterangepicker.md +260 -0
  30. package/docs/components/datetimepicker.md +226 -0
  31. package/docs/components/datetimerangepicker.md +222 -0
  32. package/docs/components/dropdown.md +275 -0
  33. package/docs/components/empty.md +200 -0
  34. package/docs/components/file-preview.md +180 -0
  35. package/docs/components/input.md +559 -0
  36. package/docs/components/modal.md +293 -0
  37. package/docs/components/popover.md +255 -0
  38. package/docs/components/radio.md +245 -0
  39. package/docs/components/select.md +254 -0
  40. package/docs/components/skeleton.md +150 -0
  41. package/docs/components/slider.md +346 -0
  42. package/docs/components/table.md +316 -0
  43. package/docs/components/tabs.md +432 -0
  44. package/docs/components/textarea.md +193 -0
  45. package/docs/components/timepicker.md +242 -0
  46. package/docs/components/timerangepicker.md +210 -0
  47. package/docs/components/toast.md +282 -0
  48. package/docs/components/toggle.md +211 -0
  49. package/docs/components/tooltip.md +213 -0
  50. package/docs/components/upload-avatar.md +318 -0
  51. package/docs/components/upload-file.md +245 -0
  52. package/docs/components/upload-image.md +126 -0
  53. package/docs/hooks/useDebounce.md +92 -0
  54. package/docs/hooks/useInfiniteScroll.md +95 -0
  55. package/docs/hooks/useMutationApp.md +242 -0
  56. package/docs/hooks/useSelectInfiniteQuery.md +123 -0
  57. package/docs/hooks/useTableQuery.md +124 -0
  58. package/package.json +31 -12
@@ -0,0 +1,245 @@
1
+ # 🔘 Radio & RadioGroup Component (`@owa/ui`)
2
+
3
+ Bộ đôi component **Radio** và **RadioGroup** chuyên nghiệp, thiết kế theo kiến trúc **Data-driven thuần túy (Pure Data-driven)**, không sử dụng React Context, loại bỏ hoàn toàn `useEffect` gây cascading render, tích hợp **Live Search (Client & Server modes)** thông qua component `Input` variant `outline`, hỗ trợ **bảo lưu mục đã chọn (Preserve Selected)** và tuân thủ chặt chẽ tiêu chuẩn **WAI-ARIA Accessibility**.
4
+
5
+ ---
6
+
7
+ ## 🌟 Điểm nổi bật
8
+
9
+ ### 1. Radio
10
+ - **Độc lập & Tối ưu**: Không phụ thuộc vào Context, nhận props trực tiếp, hỗ trợ chuyển tiếp `ref` chuẩn React 19 mà không cần wrapper trung gian.
11
+ - **5 Kích thước tiêu chuẩn (`size`)**: `xs`, `sm`, `md` (*mặc định*), `lg`, `xl` đồng bộ tỉ lệ ô tròn, dot indicator bên trong, nhãn và văn bản chú thích.
12
+ - **4 Biến thể giao diện (`variant`)**:
13
+ - `filled` (*mặc định*): Nền màu đặc tương phản cao khi được chọn.
14
+ - `outline`: Nền trong suốt, viền và dot mang màu chủ đề.
15
+ - `soft`: Nền pastel dịu mắt (`bg-{color}-100`).
16
+ - `other`: Tự do tùy biến style qua `boxClassName`.
17
+ - **7 Chủ đề màu sắc (`color`)**: `primary`, `secondary`, `error`, `success`, `warning`, `info`, `neutral`.
18
+ - **2 Vị trí đặt nhãn (`labelPlacement`)**: `right` (*mặc định*) và `left` (căn đều hai bên).
19
+ - **Safe Config Fallback**: Sử dụng `getSafeConfig` đảm bảo component luôn an toàn, không bao giờ bị lỗi hiển thị khi nhận giá trị không hợp lệ.
20
+
21
+ ### 2. RadioGroup
22
+ - **Kiến trúc Data-driven thuần túy**: Nhận danh sách lựa chọn qua prop `options: RadioOptionItem<TData>[]`. Không còn cấu trúc compound component cồng kềnh, không tốn chi phí Context Provider.
23
+ - **Tích hợp tìm kiếm trực quan (`searchable`)**:
24
+ - Sử dụng component `Input` variant `outline` với icon tìm kiếm (`SearchIcon`) và nút xóa nhanh (`isClearable`).
25
+ - **Không làm mất focus**: Trạng thái tải ngầm hiển thị spinner ở góc phải qua `rightIcon`, không disable input trong khi người dùng đang gõ phím.
26
+ - **2 Chế độ tìm kiếm linh hoạt (`searchMode`)**:
27
+ - `client` (*mặc định*): Tìm kiếm thông minh bằng thuật toán fuzzy ranking của `@tanstack/match-sorter-utils`. Hỗ trợ tìm theo đa trường dữ liệu (`searchField`) hoặc custom function (`filterFn`).
28
+ - `server`: Tìm kiếm qua API máy chủ với `onSearch` và bộ đệm thời gian `debounceMs`.
29
+ - **Bảo lưu mục đã chọn (`preserveSelected`)**: Tự động ghim lại mục đã chọn lên đầu danh sách khi chuyển đổi từ khóa tìm kiếm mới, quản lý bộ nhớ thông minh (chỉ lưu mục thực sự được chọn).
30
+ - **2 Bố cục sắp xếp (`orientation`)**: `vertical` (*mặc định*) và `horizontal`.
31
+
32
+ ---
33
+
34
+ ## 🚀 Cài đặt & Import
35
+
36
+ ```tsx
37
+ import { Radio, RadioGroup } from "@owa/ui";
38
+ import type {
39
+ RadioProps,
40
+ RadioGroupProps,
41
+ RadioOptionItem,
42
+ RadioSize,
43
+ RadioVariant,
44
+ RadioColor,
45
+ RadioLabelPlacement,
46
+ RadioSearchMode,
47
+ } from "@owa/ui";
48
+ ```
49
+
50
+ ---
51
+
52
+ ## 📖 Hướng dẫn sử dụng
53
+
54
+ ### 1. Sử dụng Radio đơn lẻ
55
+
56
+ ```tsx
57
+ import { useState } from "react";
58
+ import { Radio } from "@owa/ui";
59
+
60
+ export function SingleRadioExample() {
61
+ const [selected, setSelected] = useState(false);
62
+
63
+ return (
64
+ <Radio
65
+ checked={selected}
66
+ onChange={(e) => setSelected(e.target.checked)}
67
+ label="Nhận thông báo qua email"
68
+ helperText="Bạn có thể hủy đăng ký bất cứ lúc nào"
69
+ />
70
+ );
71
+ }
72
+ ```
73
+
74
+ ---
75
+
76
+ ### 2. RadioGroup cơ bản (Data-driven)
77
+
78
+ ```tsx
79
+ import { useState } from "react";
80
+ import { RadioGroup } from "@owa/ui";
81
+
82
+ export function BasicGroupExample() {
83
+ const [delivery, setDelivery] = useState<string | null>("standard");
84
+
85
+ return (
86
+ <RadioGroup
87
+ label="Phương thức giao hàng"
88
+ helperText="Chọn hình thức vận chuyển phù hợp"
89
+ value={delivery}
90
+ onChange={setDelivery}
91
+ color="primary"
92
+ orientation="vertical"
93
+ options={[
94
+ { value: "standard", label: "Giao hàng tiêu chuẩn (2-3 ngày)" },
95
+ { value: "express", label: "Giao hàng hỏa tốc (1 ngày)" },
96
+ { value: "same_day", label: "Giao hàng trong ngày" },
97
+ ]}
98
+ />
99
+ );
100
+ }
101
+ ```
102
+
103
+ ---
104
+
105
+ ### 3. Tìm kiếm Client Mode (Fuzzy Search & Đa trường)
106
+
107
+ ```tsx
108
+ import { useState } from "react";
109
+ import { RadioGroup } from "@owa/ui";
110
+
111
+ const frameworks = [
112
+ { value: "react", label: "React JS", code: "FE-01", description: "Facebook library" },
113
+ { value: "vue", label: "Vue JS", code: "FE-02", description: "Progressive framework" },
114
+ { value: "angular", label: "Angular", code: "FE-03", description: "Google platform" },
115
+ { value: "svelte", label: "Svelte", code: "FE-04", description: "Cybernetically enhanced" },
116
+ ];
117
+
118
+ export function ClientSearchExample() {
119
+ const [selected, setSelected] = useState<string | null>("react");
120
+
121
+ return (
122
+ <RadioGroup
123
+ label="Chọn Framework chính"
124
+ searchable
125
+ searchField={["label", "code", "description"]}
126
+ searchPlaceholder="Tìm theo tên, mã code hoặc mô tả..."
127
+ preserveSelected
128
+ value={selected}
129
+ onChange={setSelected}
130
+ options={frameworks}
131
+ />
132
+ );
133
+ }
134
+ ```
135
+
136
+ ---
137
+
138
+ ### 4. Tìm kiếm Server Mode (Gọi API với Debounce & Bảo lưu mục đã chọn)
139
+
140
+ ```tsx
141
+ import { useState } from "react";
142
+ import { RadioGroup } from "@owa/ui";
143
+
144
+ export function ServerSearchExample() {
145
+ const [selectedProduct, setSelectedProduct] = useState<string | null>(null);
146
+
147
+ const handleSearchProducts = async (query: string) => {
148
+ const res = await fetch(
149
+ `https://dummyjson.com/products/search?q=${encodeURIComponent(query)}&limit=5`
150
+ );
151
+ if (!res.ok) throw new Error("API request failed");
152
+ const data = await res.json();
153
+
154
+ return data.products.map((p: any) => ({
155
+ value: String(p.id),
156
+ label: p.title,
157
+ description: `$${p.price} - ${p.category}`,
158
+ }));
159
+ };
160
+
161
+ return (
162
+ <RadioGroup
163
+ label="Chọn sản phẩm yêu thích"
164
+ searchable
165
+ searchMode="server"
166
+ debounceMs={300}
167
+ onSearch={handleSearchProducts}
168
+ searchPlaceholder="Tìm kiếm sản phẩm từ máy chủ..."
169
+ preserveSelected
170
+ value={selectedProduct}
171
+ onChange={setSelectedProduct}
172
+ color="info"
173
+ />
174
+ );
175
+ }
176
+ ```
177
+
178
+ ---
179
+
180
+ ## 🎛️ Bảng Props & API Reference
181
+
182
+ ### `RadioProps`
183
+
184
+ Kế thừa `Omit<InputHTMLAttributes<HTMLInputElement>, "size" | "type">`:
185
+
186
+ | Thuộc tính | Kiểu dữ liệu | Mặc định | Mô tả |
187
+ | :--- | :--- | :--- | :--- |
188
+ | `size` | `'xs' \| 'sm' \| 'md' \| 'lg' \| 'xl'` | `'md'` | Kích cỡ ô chọn tròn, dot và nhãn |
189
+ | `variant` | `'filled' \| 'outline' \| 'soft' \| 'other'` | `'filled'` | Kiểu dáng hiển thị của box khi checked |
190
+ | `color` | `'primary' \| 'secondary' \| 'error' \| 'success' \| 'warning' \| 'info' \| 'neutral'` | `'primary'` | Chủ đề màu sắc |
191
+ | `disabled` | `boolean` | `false` | Vô hiệu hóa tương tác |
192
+ | `readOnly` | `boolean` | `false` | Chế độ chỉ đọc |
193
+ | `isLoading` | `boolean` | `false` | Trạng thái đang tải (spinner thay thế dot) |
194
+ | `isRequired` | `boolean` | `false` | Đánh dấu bắt buộc (dấu `*` đỏ) |
195
+ | `isInvalid` | `boolean` | `false` | Trạng thái báo lỗi viền đỏ |
196
+ | `labelPlacement` | `'right' \| 'left'` | `'right'` | Vị trí hiển thị nhãn |
197
+ | `label` | `ReactNode` | `undefined` | Nhãn văn bản cạnh ô radio |
198
+ | `helperText` | `ReactNode` | `undefined` | Chú thích bên dưới |
199
+ | `errorMessage` | `ReactNode` | `undefined` | Thông báo lỗi |
200
+ | `dotIcon` | `ReactNode` | `RadioDotIcon` | Icon tùy biến thay thế dot bên trong |
201
+
202
+ ---
203
+
204
+ ### `RadioGroupProps<TData = unknown>`
205
+
206
+ Kế thừa `Omit<HTMLAttributes<HTMLDivElement>, "onChange" | "defaultValue" | "children">`:
207
+
208
+ | Thuộc tính | Kiểu dữ liệu | Mặc định | Mô tả |
209
+ | :--- | :--- | :--- | :--- |
210
+ | `options` | `RadioOptionItem<TData>[]` | `[]` | Mảng dữ liệu các lựa chọn (Data-driven) |
211
+ | `value` | `string \| null` | `undefined` | Giá trị đang chọn (Controlled) |
212
+ | `defaultValue` | `string \| null` | `null` | Giá trị mặc định (Uncontrolled) |
213
+ | `onChange` | `(value: string \| null) => void` | `undefined` | Callback khi lựa chọn thay đổi |
214
+ | `orientation` | `'vertical' \| 'horizontal'` | `'vertical'` | Bố cục sắp xếp các mục |
215
+ | `searchable` | `boolean` | `false` | Bật/tắt thanh tìm kiếm |
216
+ | `searchMode` | `'client' \| 'server'` | `'client'` | Chế độ tìm kiếm phía client hoặc gọi API server |
217
+ | `searchField` | `string \| string[]` | `'label'` | Các trường dữ liệu dùng để tìm kiếm |
218
+ | `filterFn` | `(item: RadioOptionItem<TData>, query: string) => boolean` | `undefined` | Hàm lọc tùy biến phía client |
219
+ | `onSearch` | `(query: string) => Promise<RadioOptionItem<TData>[]>` | `undefined` | Hàm gọi API tìm kiếm phía server |
220
+ | `debounceMs` | `number` | `300` | Thời gian trễ gọi hàm `onSearch` (ms) |
221
+ | `preserveSelected` | `boolean` | `true` | Bảo lưu mục đã chọn khi từ khóa tìm kiếm thay đổi |
222
+ | `emptyText` | `ReactNode` | `'Không tìm thấy kết quả'` | Thông báo khi không có kết quả |
223
+ | `size` | `RadioSize` | `'md'` | Kích cỡ truyền xuống toàn bộ radio con |
224
+ | `color` | `RadioColor` | `'primary'` | Màu sắc truyền xuống toàn bộ radio con |
225
+ | `variant` | `RadioVariant` | `'filled'` | Biến thể truyền xuống toàn bộ radio con |
226
+ | `disabled` | `boolean` | `false` | Vô hiệu hóa toàn bộ nhóm |
227
+ | `isReadOnly` | `boolean` | `false` | Chế độ chỉ đọc cho toàn bộ nhóm |
228
+ | `isLoading` | `boolean` | `false` | Trạng thái đang tải của nhóm |
229
+ | `label` | `ReactNode` | `undefined` | Tiêu đề của nhóm |
230
+ | `helperText` | `ReactNode` | `undefined` | Chú thích của nhóm |
231
+ | `errorMessage` | `ReactNode` | `undefined` | Thông báo lỗi của nhóm |
232
+
233
+ ---
234
+
235
+ ### `RadioOptionItem<TData = unknown>`
236
+
237
+ | Trường | Kiểu dữ liệu | Mô tả |
238
+ | :--- | :--- | :--- |
239
+ | `value` | `string` | Giá trị định danh duy nhất của ô chọn |
240
+ | `label` | `ReactNode` | Nhãn hiển thị chính |
241
+ | `description` | `ReactNode` | Đoạn chú thích/mô tả phụ bên dưới nhãn |
242
+ | `disabled` | `boolean` | Vô hiệu hóa ô chọn này |
243
+ | `isReadOnly` | `boolean` | Chế độ chỉ đọc cho ô chọn này |
244
+ | `data` | `TData` | Đối tượng dữ liệu gốc đính kèm |
245
+ | `[key: string]` | `unknown` | Mở rộng các trường tùy ý (phục vụ lọc theo `searchField`) |
@@ -0,0 +1,254 @@
1
+ # 🔽 Select & MultiSelect Component (`@openway/ui`)
2
+
3
+ Bộ đôi component **Select** (Chọn đơn) và **MultiSelect** (Chọn nhiều) cao cấp, thiết kế theo chuẩn **Design System**, tuân thủ nghiêm ngặt **WAI-ARIA 1.2 Combobox/Listbox**, hỗ trợ **Zero `any`**, tìm kiếm **Client & Server**, phân trang vô tận với **TanStack Query v5** và hiệu ứng **Skeleton Loading**.
4
+
5
+ ---
6
+
7
+ ## 🌟 Điểm nổi bật
8
+
9
+ - **2 Component chuyên biệt**:
10
+ - `<Select>`: Chọn 1 giá trị duy nhất, hỗ trợ placeholder, clearable, render custom value/option.
11
+ - `<MultiSelect>`: Chọn nhiều giá trị dưới dạng tags/chips, hỗ trợ xóa nhanh từng tag, nút clear all, giới hạn số lượng tag hiển thị (`maxTags`).
12
+ - **2 Chế độ Tìm kiếm (`searchMode`)**:
13
+ - `client`: Tìm kiếm và sắp xếp kết quả ngay trên client với thuật toán xếp hạng từ khóa thông minh (`searchField`, `filterFn`).
14
+ - `server`: Tìm kiếm phía máy chủ. Tự động debounce từ khóa gõ theo `debounceMs` trước khi gọi `onSearch(query, filters)`.
15
+ - **Tích hợp TanStack Query v5 & Infinite Scroll (`@openway/ui/query`)**:
16
+ - Hook chuyên dụng `useSelectInfiniteQuery` kết hợp `useInfiniteQuery` với `useInfiniteScroll`.
17
+ - Tự động gộp options từ tất cả các trang, khử trùng lặp theo `value`.
18
+ - Hiệu ứng **Skeleton** nhấp nháy ở đáy danh sách khi đang tải thêm trang (`isFetchingNextPage`).
19
+ - Không bị double-debounce, tải mượt mà qua native `IntersectionObserver`.
20
+ - **Đầy đủ tính năng UI nâng cao**:
21
+ - `menuFilters`: Bộ lọc đa điều kiện tích hợp ngay trên thanh đầu của menu dropdown.
22
+ - `renderOption` & `renderValue`: Tùy biến hiển thị từng hàng option (avatar, description, badge...) và giá trị được chọn.
23
+ - `startContent` & `endContent`: Bổ sung icon hoặc nội dung phụ ở hai đầu trigger box.
24
+ - `portal={true}`: Render menu ra ngoài DOM qua `@floating-ui/react`, không bị tràn hoặc che khuất bởi `overflow: hidden`.
25
+ - **Trợ năng WAI-ARIA & Điều hướng bàn phím**:
26
+ - Hỗ trợ đầy đủ phím mũi tên `ArrowUp`, `ArrowDown`, `Home`, `End`, `Enter` để chọn, `Escape` để đóng dropdown.
27
+
28
+ ---
29
+
30
+ ## 🚀 Cài đặt & Import
31
+
32
+ ```tsx
33
+ // 1. Components & Types cơ bản
34
+ import { Select, MultiSelect } from "@openway/ui";
35
+ import type {
36
+ SelectProps,
37
+ MultiSelectProps,
38
+ SelectOptionItem,
39
+ SelectFilterField,
40
+ } from "@openway/ui";
41
+
42
+ // 2. Query Hook cho Server Infinite Scroll (Tùy chọn, cần @tanstack/react-query v5)
43
+ import { useSelectInfiniteQuery } from "@openway/ui/query";
44
+ ```
45
+
46
+ ---
47
+
48
+ ## 📖 Hướng dẫn sử dụng
49
+
50
+ ### 1. Select cơ bản (Client Mode)
51
+
52
+ ```tsx
53
+ import { useState } from "react";
54
+ import { Select, SelectOptionItem } from "@openway/ui";
55
+
56
+ const departments: SelectOptionItem<string>[] = [
57
+ { value: "hr", label: "Phòng Nhân sự" },
58
+ { value: "it", label: "Phòng Công nghệ Thông tin" },
59
+ { value: "sales", label: "Phòng Kinh doanh" },
60
+ { value: "mkt", label: "Phòng Marketing" },
61
+ ];
62
+
63
+ export function BasicSelectExample() {
64
+ const [value, setValue] = useState<string | number | null>("it");
65
+
66
+ return (
67
+ <div className="max-w-sm">
68
+ <Select
69
+ label="Phòng ban"
70
+ placeholder="Chọn phòng ban..."
71
+ options={departments}
72
+ value={value}
73
+ onChange={(val) => setValue(val)}
74
+ clearable
75
+ />
76
+ </div>
77
+ );
78
+ }
79
+ ```
80
+
81
+ ---
82
+
83
+ ### 2. MultiSelect với Tags
84
+
85
+ ```tsx
86
+ import { useState } from "react";
87
+ import { MultiSelect, SelectOptionItem } from "@openway/ui";
88
+
89
+ const roles: SelectOptionItem[] = [
90
+ { value: "admin", label: "Quản trị viên" },
91
+ { value: "editor", label: "Biên tập viên" },
92
+ { value: "moderator", label: "Điều hành viên" },
93
+ { value: "viewer", label: "Người xem" },
94
+ ];
95
+
96
+ export function MultiSelectExample() {
97
+ const [selectedRoles, setSelectedRoles] = useState<(string | number)[]>(["editor"]);
98
+
99
+ return (
100
+ <div className="max-w-md">
101
+ <MultiSelect
102
+ label="Vai trò người dùng"
103
+ placeholder="Chọn các vai trò..."
104
+ options={roles}
105
+ value={selectedRoles}
106
+ onChange={(vals) => setSelectedRoles(vals)}
107
+ searchable
108
+ clearable
109
+ />
110
+ </div>
111
+ );
112
+ }
113
+ ```
114
+
115
+ ---
116
+
117
+ ### 3. Server Pagination & Infinite Scroll (`useSelectInfiniteQuery`)
118
+
119
+ Đây là phương thức khuyên dùng khi dữ liệu từ máy chủ có số lượng lớn hoặc trả về theo từng trang:
120
+
121
+ ```tsx
122
+ import { Select } from "@openway/ui";
123
+ import { useSelectInfiniteQuery } from "@openway/ui/query";
124
+
125
+ interface UserItem {
126
+ id: number;
127
+ name: string;
128
+ email: string;
129
+ }
130
+
131
+ export function ServerInfiniteSelectExample() {
132
+ const { selectProps, query } = useSelectInfiniteQuery<
133
+ UserItem,
134
+ { users: UserItem[]; nextCursor?: number },
135
+ number
136
+ >({
137
+ queryKey: ["users-infinite"],
138
+ queryFn: async ({ pageParam, search }) => {
139
+ const res = await fetch(
140
+ `/api/users?cursor=${pageParam}&q=${encodeURIComponent(search)}`
141
+ );
142
+ return res.json();
143
+ },
144
+ initialPageParam: 1,
145
+ getNextPageParam: (lastPage) => lastPage.nextCursor,
146
+ // Biến đổi entity trả về thành định dạng SelectOptionItem chuẩn
147
+ mapOption: (item) => {
148
+ const user = item as UserItem;
149
+ return {
150
+ value: user.id,
151
+ label: user.name,
152
+ description: user.email,
153
+ data: user,
154
+ };
155
+ },
156
+ debounceMs: 300,
157
+ endMessage: "Đã hiển thị toàn bộ người dùng",
158
+ });
159
+
160
+ return (
161
+ <div className="max-w-md">
162
+ <Select
163
+ {...selectProps}
164
+ label="Chọn thành viên"
165
+ placeholder="Tìm kiếm thành viên..."
166
+ searchable
167
+ clearable
168
+ />
169
+ </div>
170
+ );
171
+ }
172
+ ```
173
+
174
+ ---
175
+
176
+ ### 4. Tùy biến Option (`renderOption`) & Rich Layout
177
+
178
+ ```tsx
179
+ <Select
180
+ label="Chọn người dùng"
181
+ options={userOptions}
182
+ renderOption={(option, { isSelected, isFocused }) => (
183
+ <div className="flex items-center gap-2.5 py-1">
184
+ <img
185
+ src={option.data?.avatarUrl}
186
+ alt={option.label}
187
+ className="w-7 h-7 rounded-full object-cover"
188
+ />
189
+ <div>
190
+ <div className="font-medium text-sm text-neutral-800 dark:text-neutral-100">
191
+ {option.label}
192
+ </div>
193
+ <div className="text-xs text-neutral-400 dark:text-neutral-500">
194
+ {option.description}
195
+ </div>
196
+ </div>
197
+ </div>
198
+ )}
199
+ />
200
+ ```
201
+
202
+ ---
203
+
204
+ ### 5. Bộ lọc tích hợp trong Menu (`menuFilters`)
205
+
206
+ ```tsx
207
+ const filterFields: SelectFilterField[] = [
208
+ {
209
+ name: "status",
210
+ label: "Trạng thái",
211
+ type: "select",
212
+ options: [
213
+ { value: "active", label: "Hoạt động" },
214
+ { value: "inactive", label: "Đã khóa" },
215
+ ],
216
+ },
217
+ ];
218
+
219
+ <Select
220
+ label="Khách hàng"
221
+ options={customerOptions}
222
+ menuFilters={filterFields}
223
+ onMenuFilterChange={(filters) => {
224
+ console.log("Filter áp dụng:", filters);
225
+ }}
226
+ />
227
+ ```
228
+
229
+ ---
230
+
231
+ ## 🎛️ Bảng Props Chi tiết
232
+
233
+ ### Props dùng chung (`BaseSelectProps`)
234
+
235
+ | Tên Prop | Kiểu dữ liệu | Mặc định | Mô tả |
236
+ | :--- | :--- | :--- | :--- |
237
+ | `options` | `SelectOptionItem<TData>[]` | `[]` | Mảng danh sách các lựa chọn. |
238
+ | `label` | `ReactNode` | `undefined` | Nhãn hiển thị phía trên trigger. |
239
+ | `placeholder` | `string` | `"Select..."` | Văn bản gợi ý khi chưa chọn giá trị. |
240
+ | `searchable` | `boolean` | `false` | Bật ô nhập tìm kiếm trong menu. |
241
+ | `searchMode` | `"client" \| "server"` | `"client"` | Chế độ tìm kiếm nội bộ hay gọi server. |
242
+ | `debounceMs` | `number` | `300` | Thời gian hoãn tìm kiếm server (ms). |
243
+ | `onSearch` | `(query, filters) => void` | `undefined` | Callback khi người dùng tìm kiếm ở chế độ server. |
244
+ | `listFooter` | `ReactNode` | `undefined` | Phần tử hiển thị dưới đáy danh sách (dùng cho Sentinel / Skeleton). |
245
+ | `clearable` | `boolean` | `false` | Hiển thị nút xóa nhanh giá trị đã chọn. |
246
+ | `disabled` | `boolean` | `false` | Khóa không cho phép tương tác. |
247
+ | `isLoading` | `boolean` | `false` | Hiển thị trạng thái đang tải dữ liệu. |
248
+ | `portal` | `boolean` | `true` | Render menu qua portal chống tràn khung nhìn. |
249
+ | `maxMenuHeight` | `number` | `280` | Chiều cao tối đa của khung menu cuộn (px). |
250
+ | `renderOption` | `(option, state) => ReactNode` | `undefined` | Hàm custom render từng option trong listbox. |
251
+ | `renderValue` | `(selected) => ReactNode` | `undefined` | Hàm custom hiển thị giá trị trên trigger. |
252
+ | `menuFilters` | `SelectFilterField[]` | `undefined` | Danh sách bộ lọc bổ sung gắn ở đầu dropdown. |
253
+ | `size` | `"sm" \| "md" \| "lg"` | `"md"` | Kích thước trigger box. |
254
+ | `variant` | `"outline" \| "filled" \| "soft"`| `"outline"` | Biến thể giao diện đường viền / nền. |
@@ -0,0 +1,150 @@
1
+ # 💀 Skeleton & LoadingImage Component (`@openway/ui`)
2
+
3
+ Bộ đôi component **Skeleton** và **LoadingImage** hiện đại, hiệu năng cao, thiết kế chuẩn **Design System**, hỗ trợ placeholder tải dữ liệu mượt mà, chống giật layout (Cumulative Layout Shift - CLS), tích hợp **Safe Config Fallback** (`getSafeConfig`) và tuân thủ tiêu chuẩn **WAI-ARIA Accessibility**.
4
+
5
+ ---
6
+
7
+ ## 🌟 Điểm nổi bật
8
+
9
+ - **Skeleton Placeholder**:
10
+ - **3 Biến thể animation (`variant`)**:
11
+ - `pulse` *(mặc định)*: Hiệu ứng nhịp thở mờ dần mượt mà.
12
+ - `wave`: Hiệu ứng shimmer ánh sáng lướt chéo từ góc trên-trái xuống góc dưới-phải (sử dụng GPU-accelerated CSS).
13
+ - `none`: Placeholder tĩnh không chuyển động.
14
+ - **2 Kiểu hình dạng (`shape`)**:
15
+ - `rectangle` *(mặc định)*: Hình chữ nhật hoặc vuông, kết hợp với `radius`.
16
+ - `circle`: Hình tròn hoàn hảo (`rounded-full`), tự động bỏ qua `radius`.
17
+ - **6 Mức bo góc (`radius`)**: `none`, `sm`, `md` *(mặc định)*, `lg`, `xl`, `full`.
18
+ - **Multi-line mode (`lines`)**: Tự động xếp chồng nhiều dòng văn bản theo dạng đoạn văn, với dòng cuối cùng tự động thu hẹp 60% chiều rộng để tạo cảm giác tự nhiên.
19
+ - **Linh hoạt kích thước (`width`, `height`, `gap`)**: Nhận cả số nguyên (`px`) lẫn chuỗi CSS (`rem`, `%`, `vh`...).
20
+
21
+ - **LoadingImage**:
22
+ - **Tích hợp Next.js Image**: Kế thừa toàn bộ tối ưu hóa của `next/image` (`fill`, `priority`, `sizes`, `quality`...).
23
+ - **Hỗ trợ đa dạng nguồn ảnh (`src`)**: Nhận đường dẫn ảnh URL/StaticImport, đồng thời hỗ trợ trực tiếp đối tượng **`File`** hoặc **`Blob`** (tự động tạo & giải phóng Object URL an toàn, không memory leak).
24
+ - **Tự động tối ưu `unoptimized`**: Tự động kích hoạt khi nhận `File` / `Blob` để tránh lỗi phân giải trên Image Optimization server của Next.js.
25
+ - **Tùy biến căn chỉnh (`objectFit`)**: Hỗ trợ 5 kiểu `cover` *(mặc định)*, `contain`, `fill`, `none`, `scale-down`.
26
+ - **Tự động reset trạng thái**: Reset `isLoaded` và `hasError` khi `src` thay đổi.
27
+ - **Fallback Error State**: Tự động hiển thị icon placeholder dự phòng khi ảnh tải lỗi (`onError`).
28
+
29
+ - **Safe Config Fallback**: Tích hợp hàm `getSafeConfig` từ `@/utils/function` đảm bảo an toàn tuyệt đối, không crash giao diện khi nhận giá trị `variant`, `radius`, `objectFit` không hợp lệ.
30
+ - **Trợ năng (Accessibility)**: Tích hợp đầy đủ `role="status"`, `aria-label="Loading..."`.
31
+
32
+ ---
33
+
34
+ ## 🚀 Cài đặt & Import
35
+
36
+ ```tsx
37
+ import { Skeleton, LoadingImage } from "@openway/ui";
38
+ import type {
39
+ SkeletonProps,
40
+ SkeletonVariant,
41
+ SkeletonShape,
42
+ SkeletonRadius,
43
+ SkeletonObjectFit,
44
+ LoadingImageProps,
45
+ } from "@openway/ui";
46
+ ```
47
+
48
+ ---
49
+
50
+ ## 📖 Hướng dẫn sử dụng
51
+
52
+ ### 1. Skeleton cơ bản
53
+
54
+ ```tsx
55
+ import { Skeleton } from "@openway/ui";
56
+
57
+ export function BasicSkeletonExample() {
58
+ return (
59
+ <div className="space-y-4">
60
+ {/* Khối đơn */}
61
+ <Skeleton width="100%" height="2rem" />
62
+
63
+ {/* Avatar tròn */}
64
+ <Skeleton shape="circle" width={48} height={48} />
65
+
66
+ {/* Hiệu ứng sóng wave */}
67
+ <Skeleton variant="wave" width="200px" height="1.5rem" />
68
+ </div>
69
+ );
70
+ }
71
+ ```
72
+
73
+ ### 2. Multi-line Skeleton (Đoạn văn bản)
74
+
75
+ ```tsx
76
+ <Skeleton lines={3} height="1rem" gap="0.75rem" />
77
+ ```
78
+
79
+ ### 3. LoadingImage với URL hoặc File / Blob
80
+
81
+ ```tsx
82
+ import { LoadingImage } from "@openway/ui";
83
+
84
+ export function ImageExamples({ file }: { file?: File }) {
85
+ return (
86
+ <div className="flex gap-4">
87
+ {/* Load ảnh qua URL với bo góc lg */}
88
+ <LoadingImage
89
+ src="https://images.unsplash.com/photo-1506905925346-21bda4d32df4?w=400"
90
+ alt="Mountain view"
91
+ width={300}
92
+ height={200}
93
+ radius="lg"
94
+ objectFit="cover"
95
+ />
96
+
97
+ {/* Load ảnh trực tiếp từ đối tượng File (Upload preview) */}
98
+ {file && (
99
+ <LoadingImage
100
+ src={file}
101
+ alt="Upload preview"
102
+ width={150}
103
+ height={150}
104
+ radius="full"
105
+ objectFit="contain"
106
+ />
107
+ )}
108
+ </div>
109
+ );
110
+ }
111
+ ```
112
+
113
+ ---
114
+
115
+ ## ⚙️ Props API Reference
116
+
117
+ ### `SkeletonProps`
118
+
119
+ | Prop | Type | Default | Mô tả |
120
+ | :--- | :--- | :--- | :--- |
121
+ | `variant` | `'pulse' \| 'wave' \| 'none'` | `'pulse'` | Kiểu hiệu ứng hoạt ảnh placeholder |
122
+ | `shape` | `'rectangle' \| 'circle'` | `'rectangle'` | Hình dạng hiển thị |
123
+ | `radius` | `'none' \| 'sm' \| 'md' \| 'lg' \| 'xl' \| 'full'` | `'md'` | Độ bo góc (chỉ áp dụng khi `shape === 'rectangle'`) |
124
+ | `width` | `string \| number` | — | Chiều rộng (px hoặc chuỗi CSS) |
125
+ | `height` | `string \| number` | `'1rem'` | Chiều cao (px hoặc chuỗi CSS) |
126
+ | `lines` | `number` | `1` | Số dòng hiển thị dạng cột (khi > 1) |
127
+ | `gap` | `string \| number` | `'0.5rem'` | Khoảng cách giữa các dòng khi `lines > 1` |
128
+ | `className` | `string` | `""` | Tùy biến class Tailwind bên ngoài |
129
+ | `ref` | `React.Ref<HTMLDivElement>` | — | Ref trỏ tới thẻ div gốc |
130
+
131
+ ### `LoadingImageProps`
132
+
133
+ | Prop | Type | Default | Mô tả |
134
+ | :--- | :--- | :--- | :--- |
135
+ | `src` | `ImageProps['src'] \| File \| Blob \| ServerFile` | — | Nguồn ảnh (URL, import tĩnh, File/Blob hoặc ServerFile) |
136
+ | `alt` | `string` | `""` | Văn bản thay thế cho ảnh |
137
+ | `width` | `number` | — | Chiều rộng hiển thị (nếu không dùng `fill`) |
138
+ | `height` | `number` | — | Chiều cao hiển thị (nếu không dùng `fill`) |
139
+ | `fill` | `boolean` | `false` | Co giãn ảnh lấp đầy wrapper cha |
140
+ | `skeletonVariant` | `'pulse' \| 'wave' \| 'none'` | `'pulse'` | Variant của skeleton khi đang tải |
141
+ | `radius` | `'none' \| 'sm' \| 'md' \| 'lg' \| 'xl' \| 'full'` | `'md'` | Bo góc áp dụng cho cả skeleton và ảnh |
142
+ | `objectFit` | `'cover' \| 'contain' \| 'fill' \| 'none' \| 'scale-down'` | `'cover'` | Kiểu căn chỉnh ảnh |
143
+ | `preview` | `boolean` | `true` | Bật xem trước ảnh phóng to trong modal khi click (qua FilePreview & FileContainer) |
144
+ | `wrapperClassName` | `string` | `""` | Class tùy biến cho wrapper div |
145
+ | `wrapperStyle` | `CSSProperties` | — | Style inline cho wrapper div |
146
+ | `onClick` | `(e) => void` | — | Callback khi click vào ảnh / wrapper |
147
+ | `onLoad` | `(e) => void` | — | Callback khi ảnh tải thành công |
148
+ | `onError` | `(e) => void` | — | Callback khi ảnh gặp lỗi tải |
149
+ | `ref` | `React.Ref<HTMLDivElement>` | — | Ref trỏ tới thẻ wrapper div |
150
+