@openway/ui 1.0.1 → 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.
@@ -0,0 +1,242 @@
1
+ # ⚡ Hook `useMutationApp` (`@openway/ui/query`)
2
+
3
+ Hook adapter chuyên dụng bọc quanh `useMutation` của **TanStack Query v5**, được thiết kế tối ưu cho các ứng dụng sử dụng hệ sinh thái **`@openway/ui`**. Hook giúp loại bỏ boilerplate code khi thao tác tạo, sửa, xóa (CUD), tự động hóa toàn diện quy trình hiển thị Toast thông báo trạng thái và làm mới cache dữ liệu (Query Invalidation).
4
+
5
+ ---
6
+
7
+ ## 🌟 Điểm nổi bật
8
+
9
+ - **Tự động hóa Toast Thông minh**:
10
+ - Tự động hiển thị `toast.loading` khi bắt đầu thực thi mutation.
11
+ - Tự động chuyển đổi mượt mà sang `toast.success` hoặc `toast.error` khi hoàn tất mà không bị nhảy popup thừa.
12
+ - **Trích xuất Lỗi Tự động (`extractErrorMessage`)**:
13
+ - Tự động bóc tách thông điệp lỗi từ cấu trúc `error.response?.data?.message`, `error.response?.data?.error`, NestJS/Laravel validation array, HTTP status codes hoặc standard `Error.message`.
14
+ - Không cần phải thủ công `catch (err) { toast.error(err.response.data.message) }` ở từng component.
15
+ - **Tự động Invalidate Cache Query**:
16
+ - Hỗ trợ option `invalidateQueries` nhận vào một hoặc nhiều `QueryKey` (ví dụ `["teachers"]`, `["classes"]`) hoặc hàm tính toán động theo `(data, variables)`.
17
+ - Khi mutation thành công, tự động gọi `queryClient.invalidateQueries` để các bảng `<Table />` (`useTableQuery`) hoặc `<Select />` (`useSelectInfiniteQuery`) lập tức hiển thị dữ liệu mới nhất.
18
+ - **Bổ sung `isLoading` (alias `isPending`)**:
19
+ - Cung cấp `isLoading: boolean` tương thích với thói quen sử dụng của TanStack Query v4 và code giao diện thân thuộc.
20
+ - **Zero `any` & Chuẩn Generic Type**:
21
+ - Hỗ trợ đầy đủ 4 tham số generic type chuẩn của TanStack Query: `<TData, TError, TVariables, TContext>`.
22
+ - Giữ nguyên toàn bộ options và callback lifecycle (`onMutate`, `onSuccess`, `onError`, `onSettled`).
23
+
24
+ ---
25
+
26
+ ## 🚀 Import
27
+
28
+ ```tsx
29
+ import { useMutationApp, extractErrorMessage } from "@openway/ui/query";
30
+ import type {
31
+ UseMutationAppOptions,
32
+ UseMutationAppReturn,
33
+ UseMutationAppToastOptions,
34
+ InvalidateQueryTarget,
35
+ } from "@openway/ui/query";
36
+ ```
37
+
38
+ ---
39
+
40
+ ## 📖 Hướng dẫn sử dụng
41
+
42
+ ### 1. Thêm mới bản ghi (Create) với Shortcut Message
43
+
44
+ Cách đơn giản nhất để tạo một mutation có thông báo thành công và tự động refresh dữ liệu bảng:
45
+
46
+ ```tsx
47
+ import { Button, Input, Modal } from "@openway/ui";
48
+ import { useMutationApp } from "@openway/ui/query";
49
+ import { useState } from "react";
50
+
51
+ interface CreateTeacherDto {
52
+ name: string;
53
+ email: string;
54
+ subjectId: string;
55
+ }
56
+
57
+ export function CreateTeacherModal({ open, onClose }: { open: boolean; onClose: () => void }) {
58
+ const [name, setName] = useState("");
59
+ const [email, setEmail] = useState("");
60
+
61
+ const { mutate, isLoading } = useMutationApp({
62
+ mutationFn: async (dto: CreateTeacherDto) => {
63
+ const res = await fetch("/api/teachers", {
64
+ method: "POST",
65
+ headers: { "Content-Type": "application/json" },
66
+ body: JSON.stringify(dto),
67
+ });
68
+ if (!res.ok) throw await res.json();
69
+ return res.json();
70
+ },
71
+ // Hiển thị toast thành công & tự động báo lỗi nếu server trả về mã lỗi
72
+ loadingMessage: "Đang lưu thông tin giáo viên...",
73
+ successMessage: "Thêm mới giáo viên thành công!",
74
+ // Tự động làm mới cache của bảng danh sách giáo viên
75
+ invalidateQueries: [["teachers"]],
76
+ onSuccess: () => {
77
+ onClose();
78
+ setName("");
79
+ setEmail("");
80
+ },
81
+ });
82
+
83
+ const handleSubmit = (e: React.FormEvent) => {
84
+ e.preventDefault();
85
+ mutate({ name, email, subjectId: "math" });
86
+ };
87
+
88
+ return (
89
+ <Modal open={open} onClose={onClose} title="Thêm mới Giáo viên">
90
+ <form onSubmit={handleSubmit} className="space-y-4">
91
+ <Input label="Họ và tên" value={name} onChange={(e) => setName(e.target.value)} required />
92
+ <Input label="Email" type="email" value={email} onChange={(e) => setEmail(e.target.value)} required />
93
+ <div className="flex justify-end gap-2 pt-4">
94
+ <Button variant="outline" onClick={onClose} disabled={isLoading}>
95
+ Hủy
96
+ </Button>
97
+ <Button type="submit" loading={isLoading}>
98
+ Lưu giáo viên
99
+ </Button>
100
+ </div>
101
+ </form>
102
+ </Modal>
103
+ );
104
+ }
105
+ ```
106
+
107
+ ---
108
+
109
+ ### 2. Cập nhật bản ghi với Toast động (`(data, variables)`)
110
+
111
+ Có thể truyền function để tạo thông điệp Toast chứa tên hoặc thông tin động từ dữ liệu:
112
+
113
+ ```tsx
114
+ import { useMutationApp } from "@openway/ui/query";
115
+
116
+ interface UpdateUserDto {
117
+ id: string;
118
+ name: string;
119
+ }
120
+
121
+ export function useUpdateUser() {
122
+ return useMutationApp({
123
+ mutationFn: async ({ id, name }: UpdateUserDto) => {
124
+ const res = await fetch(`/api/users/${id}`, {
125
+ method: "PUT",
126
+ body: JSON.stringify({ name }),
127
+ });
128
+ return res.json();
129
+ },
130
+ toast: {
131
+ loading: (vars) => `Đang cập nhật thông tin người dùng #${vars.id}...`,
132
+ success: (data, vars) => `Cập nhật người dùng "${vars.name}" thành công!`,
133
+ error: (err) => `Không thể cập nhật: ${extractErrorMessage(err)}`,
134
+ },
135
+ // Làm mới cả danh sách chung và chi tiết user
136
+ invalidateQueries: (data, vars) => [
137
+ ["users"],
138
+ ["user-detail", vars.id],
139
+ ],
140
+ });
141
+ }
142
+ ```
143
+
144
+ ---
145
+
146
+ ### 3. Xóa dữ liệu (Delete) & Invalidate nhiều Query
147
+
148
+ ```tsx
149
+ import { Button } from "@openway/ui";
150
+ import { useMutationApp } from "@openway/ui/query";
151
+
152
+ export function DeleteClassButton({ classId, className }: { classId: string; className: string }) {
153
+ const { mutate, isLoading } = useMutationApp({
154
+ mutationFn: async (id: string) => {
155
+ await fetch(`/api/classes/${id}`, { method: "DELETE" });
156
+ },
157
+ successMessage: `Đã xóa lớp ${className} khỏi hệ thống!`,
158
+ // Invalidate cả bảng lớp học và số liệu thống kê ở dashboard
159
+ invalidateQueries: [
160
+ ["classes"],
161
+ ["dashboard-stats"],
162
+ ],
163
+ });
164
+
165
+ return (
166
+ <Button
167
+ variant="soft"
168
+ color="error"
169
+ loading={isLoading}
170
+ onClick={() => {
171
+ if (confirm(`Bạn có chắc chắn muốn xóa lớp ${className}?`)) {
172
+ mutate(classId);
173
+ }
174
+ }}
175
+ >
176
+ Xóa lớp
177
+ </Button>
178
+ );
179
+ }
180
+ ```
181
+
182
+ ---
183
+
184
+ ### 4. Tắt Toast hoặc Tùy biến Giao diện Toast
185
+
186
+ ```tsx
187
+ // Tắt hoàn toàn toast (nếu muốn tự xử lý UI riêng)
188
+ const mutation1 = useMutationApp({
189
+ mutationFn: trackUserActivity,
190
+ toast: false,
191
+ });
192
+
193
+ // Tùy biến vị trí và kiểu hiển thị của Toast
194
+ const mutation2 = useMutationApp({
195
+ mutationFn: updateSettings,
196
+ toast: {
197
+ variant: "solid",
198
+ success: "Đã lưu cài đặt!",
199
+ options: {
200
+ position: "bottom-center",
201
+ duration: 3000,
202
+ },
203
+ },
204
+ });
205
+ ```
206
+
207
+ ---
208
+
209
+ ## 🎛️ Bảng Options (`UseMutationAppOptions`)
210
+
211
+ Kế thừa toàn bộ options chuẩn của `UseMutationOptions` từ TanStack Query v5, bổ sung thêm:
212
+
213
+ | Tên Option | Kiểu dữ liệu | Mặc định | Mô tả |
214
+ | :--- | :--- | :--- | :--- |
215
+ | `mutationFn` | `(variables: TVariables) => Promise<TData>` | `undefined` | Hàm bất đồng bộ gọi API thực thi tác vụ mutation. |
216
+ | `invalidateQueries` | `QueryKey \| QueryKey[] \| InvalidateQueryFilters \| InvalidateQueryFilters[] \| ((data, vars) => ...)` | `undefined` | Khóa truy vấn hoặc danh sách khóa truy vấn cần tự động làm mới khi mutation thành công. |
217
+ | `invalidateOptions` | `InvalidateOptions` | `undefined` | Tùy chọn nâng cao khi invalidate (ví dụ: `throwOnError`, `cancelRefetch`). |
218
+ | `toast` | `boolean \| UseMutationAppToastOptions` | `true` | Cấu hình Toast thông báo. Truyền `false` để tắt toàn bộ toast. |
219
+ | `loadingMessage` | `ReactNode \| ((vars) => ReactNode)` | `undefined` | Shortcut đặt thông báo loading khi đang chạy. |
220
+ | `successMessage` | `ReactNode \| ((data, vars) => ReactNode)` | `undefined` | Shortcut đặt thông báo khi thành công. |
221
+ | `errorMessage` | `ReactNode \| ((err, vars) => ReactNode)` | `undefined` | Shortcut đặt thông báo lỗi tùy biến (mặc định tự bóc tách lỗi qua `extractErrorMessage`). |
222
+ | `onSuccess` | `(data, variables, context) => Promise<unknown> \| unknown` | `undefined` | Callback chạy sau khi mutation thành công và sau khi đã refresh cache. |
223
+ | `onError` | `(error, variables, context) => Promise<unknown> \| unknown` | `undefined` | Callback chạy khi mutation gặp lỗi. |
224
+ | `onSettled` | `(data, error, variables, context) => Promise<unknown> \| unknown` | `undefined` | Callback chạy khi mutation kết thúc (dù thành công hay thất bại). |
225
+
226
+ ---
227
+
228
+ ## 📦 Bảng Return (`UseMutationAppReturn`)
229
+
230
+ Kế thừa toàn bộ kết quả trả về của `UseMutationResult` từ TanStack Query v5:
231
+
232
+ | Thuộc tính | Kiểu dữ liệu | Mô tả |
233
+ | :--- | :--- | :--- |
234
+ | `mutate` | `(variables: TVariables, options?) => void` | Kích hoạt mutation theo cơ chế fire-and-forget. |
235
+ | `mutateAsync` | `(variables: TVariables, options?) => Promise<TData>` | Kích hoạt mutation và trả về Promise để có thể `await`. |
236
+ | `isLoading` | `boolean` | **Alias tiện ích của `isPending`**, là `true` khi mutation đang chạy. |
237
+ | `isPending` | `boolean` | Trạng thái đang chạy của TanStack Query v5. |
238
+ | `isSuccess` | `boolean` | Là `true` khi mutation đã hoàn tất thành công. |
239
+ | `isError` | `boolean` | Là `true` khi mutation thất bại. |
240
+ | `data` | `TData \| undefined` | Dữ liệu trả về từ `mutationFn` khi thành công. |
241
+ | `error` | `TError \| null` | Đối tượng lỗi trả về từ `mutationFn` khi thất bại. |
242
+ | `reset` | `() => void` | Đặt lại trạng thái mutation về ban đầu (`idle`). |
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@openway/ui",
3
- "version": "1.0.1",
3
+ "version": "1.0.2",
4
4
  "private": false,
5
5
  "license": "MIT",
6
6
  "publishConfig": {
@@ -1,3 +0,0 @@
1
- "use client";
2
- import{rankItem as I,compareItems as E}from"@tanstack/match-sorter-utils";function S(e,l,i){return e!=null&&Object.prototype.hasOwnProperty.call(l,e)?l[e]:l[i]}function L(e,l,i){let c=l.trim();if(!c||!e.length)return e;let u=i?Array.isArray(i)?i:[i]:[],s=[];if(u.length>0)for(let t of u)typeof t=="function"?s.push(n=>{let o=t(n);return o!=null?String(o):""}):s.push(n=>{let o=n,d=o[t];if(d!=null)return String(d);if(o.data&&typeof o.data=="object"){let a=o.data[t];if(a!=null)return String(a)}return""});else s.push(t=>{if(t&&typeof t=="object"){let n=t;return n.label!=null?String(n.label):n.value!=null?String(n.value):""}return t!=null?String(t):""});let r=[];for(let t of e){let n=I(t,c,{accessors:s});n.passed&&r.push({item:t,info:n})}return r.sort((t,n)=>E(t.info,n.info)),r.map(t=>t.item)}import{useCallback as v,useEffect as k,useRef as O,useState as R}from"react";function U({onLoadMore:e,hasMore:l=!0,isLoading:i=!1,disabled:c=!1,rootMargin:u="100px",threshold:s=0,root:r=null}){let[t,n]=R(!1),[o,d]=R(null),a=O(!1),b=l&&!i&&!c,g=v(()=>{if(!(!b||a.current)){a.current=!0;try{let f=e();f&&typeof f.then=="function"?f.finally(()=>{a.current=!1}):a.current=!1}catch{a.current=!1}}},[b,e]),x=v(f=>{d(f)},[]);return k(()=>{if(!o||c||!l||typeof window>"u"||!("IntersectionObserver"in window))return;let f=r&&typeof r=="object"&&"current"in r?r.current:r,m=new IntersectionObserver(y=>{let[h]=y;if(!h)return;let w=h.isIntersecting;n(w),w&&g()},{root:f,rootMargin:u,threshold:s});return m.observe(o),()=>{m.disconnect(),n(!1)}},[o,r,u,s,c,l,g]),k(()=>{b&&t&&g()},[b,t,g]),{sentinelRef:x,isIntersecting:t}}var j={pulse:"animate-pulse",wave:"skeleton-wave",none:""},C={none:"rounded-none",sm:"rounded-sm",md:"rounded-md",lg:"rounded-lg",xl:"rounded-xl",full:"rounded-full"},_={cover:"object-cover",contain:"object-contain",fill:"object-fill",none:"object-none","scale-down":"object-scale-down"};var p=e=>e,B=e=>e?typeof e=="string"?e:typeof File<"u"&&e instanceof File?`${e.name}-${e.size}-${e.lastModified}`:typeof Blob<"u"&&e instanceof Blob?`${e.size}-${e.type}`:typeof e=="object"&&"src"in e&&typeof e.src=="string"?e.src:e:"";import{jsx as T}from"react/jsx-runtime";function A({ref:e,variant:l="pulse",shape:i="rectangle",radius:c="md",width:u,height:s="1rem",lines:r=1,gap:t="0.5rem",className:n="",style:o,...d}){let a=S(l,j,"pulse"),b=i==="circle"?"rounded-full":S(c,C,"md"),g={width:p(u),height:p(s),...o},x=["bg-neutral-200",a,b].filter(Boolean).join(" ");return r>1?T("div",{ref:e,role:"status","aria-label":"Loading...",className:["flex flex-col",n].filter(Boolean).join(" "),style:{gap:p(t),...o},...d,children:Array.from({length:r}).map((f,m)=>{let y=m===r-1;return T("div",{className:x,style:{width:y?"60%":p(u)??"100%",height:p(s)}},m)})}):T("div",{ref:e,role:"status","aria-label":"Loading...",className:[x,n].filter(Boolean).join(" "),style:g,...d})}var Z={sm:{head:"px-3 py-2 text-xs font-semibold",cell:"px-3 py-2 text-xs",text:"text-xs",icon:"w-3.5 h-3.5"},md:{head:"px-4 py-3 text-sm font-semibold",cell:"px-4 py-3 text-sm",text:"text-sm",icon:"w-4 h-4"},lg:{head:"px-5 py-4 text-base font-semibold",cell:"px-5 py-4 text-base",text:"text-base",icon:"w-5 h-5"}},q={default:{container:"border border-neutral-200 shadow-xs",table:"border-collapse",head:"bg-neutral-50/80 border-b border-neutral-200 text-neutral-600",row:"border-b border-neutral-200/80 hover:bg-neutral-50/50 transition-colors",cell:"text-neutral-700"},striped:{container:"border border-neutral-200 shadow-xs",table:"border-collapse",head:"bg-neutral-100/80 border-b border-neutral-200 text-neutral-700",row:"border-b border-neutral-200/80 even:bg-neutral-50/60 hover:bg-neutral-100/40 transition-colors",cell:"text-neutral-700"},bordered:{container:"border border-neutral-200 shadow-xs",table:"border-collapse border border-neutral-200",head:"bg-neutral-50 border border-neutral-200 text-neutral-700",row:"border-b border-neutral-200 hover:bg-neutral-50/50 transition-colors",cell:"border border-neutral-200 text-neutral-700"}},J={left:"text-left justify-start",center:"text-center justify-center",right:"text-right justify-end"},Q=[10,20,50,100],W=10;export{S as a,L as b,U as c,C as d,_ as e,B as f,A as g,Z as h,q as i,J as j,Q as k,W as l};
3
- //# sourceMappingURL=chunk-CJQNI3QX.js.map
@@ -1 +0,0 @@
1
- {"version":3,"sources":["../src/utils/function.ts","../src/hooks/useInfiniteScroll.ts","../src/components/skeleton/constants.ts","../src/components/skeleton/utils.ts","../src/components/skeleton/Skeleton.tsx","../src/components/table/constants.ts"],"sourcesContent":["import { rankItem, compareItems } from \"@tanstack/match-sorter-utils\";\n\n/**\n * Lấy cấu hình tương ứng theo `key` từ đối tượng `configMap` (sizeConfig, radiusConfig, variantConfig, colorConfig...),\n * nếu `key` không hợp lệ hoặc không tồn tại trong `configMap`, sẽ trả về giá trị an toàn của `fallbackKey`.\n *\n * @param key - Khóa được truyền vào (size, radius, variant... có thể undefined, null hoặc chuỗi bất kỳ)\n * @param configMap - Đối tượng ánh xạ cấu hình\n * @param fallbackKey - Khóa dự phòng khi không tìm thấy (fallback an toàn)\n * @returns Cấu hình tương ứng của khóa đó\n */\nexport function getSafeConfig<TConfig, TKey extends PropertyKey = string>(\n key: TKey | undefined | null,\n configMap: Record<TKey, TConfig>,\n fallbackKey: TKey\n): TConfig {\n if (key != null && Object.prototype.hasOwnProperty.call(configMap, key)) {\n return configMap[key];\n }\n return configMap[fallbackKey];\n}\n\n/**\n * Lọc và xếp hạng danh sách phần tử theo từ khóa tìm kiếm (Fuzzy Search qua @tanstack/match-sorter-utils).\n *\n * @param items - Danh sách các phần tử cần lọc\n * @param query - Từ khóa tìm kiếm\n * @param fields - Một trường hoặc danh sách các trường (hoặc hàm accessor) để so khớp\n * @returns Danh sách phần tử khớp, đã được sắp xếp theo độ tương quan cao nhất\n */\nexport function rankAndFilterItems<T>(\n items: T[],\n query: string,\n fields?: (keyof T | string | ((item: T) => string | undefined | null))[] | (keyof T | string)\n): T[] {\n const keyword = query.trim();\n if (!keyword || !items.length) {\n return items;\n }\n\n const fieldsArray = fields ? (Array.isArray(fields) ? fields : [fields]) : [];\n const accessors: ((item: T) => string)[] = [];\n\n if (fieldsArray.length > 0) {\n for (const field of fieldsArray) {\n if (typeof field === \"function\") {\n accessors.push((item: T) => {\n const res = field(item);\n return res != null ? String(res) : \"\";\n });\n } else {\n accessors.push((item: T) => {\n const record = item as Record<string, unknown>;\n const val = record[field as string];\n if (val != null) return String(val);\n if (record.data && typeof record.data === \"object\") {\n const dataVal = (record.data as Record<string, unknown>)[field as string];\n if (dataVal != null) return String(dataVal);\n }\n return \"\";\n });\n }\n }\n } else {\n // Mặc định: kiểm tra trường 'label', 'value' nếu là object, hoặc ép kiểu string\n accessors.push((item: T) => {\n if (item && typeof item === \"object\") {\n const record = item as Record<string, unknown>;\n return record.label != null\n ? String(record.label)\n : record.value != null\n ? String(record.value)\n : \"\";\n }\n return item != null ? String(item) : \"\";\n });\n }\n\n const ranked: { item: T; info: ReturnType<typeof rankItem> }[] = [];\n for (const item of items) {\n const info = rankItem(item, keyword, { accessors });\n if (info.passed) {\n ranked.push({ item, info });\n }\n }\n\n ranked.sort((a, b) => compareItems(a.info, b.info));\n return ranked.map((r) => r.item);\n}\n","import { useCallback, useEffect, useRef, useState, type RefObject } from \"react\";\r\n\r\n/**\r\n * Các tùy chọn cấu hình cho hook `useInfiniteScroll`.\r\n */\r\nexport interface UseInfiniteScrollOptions {\r\n /**\r\n * Callback được gọi khi người dùng cuộn đến cuối hoặc phần tử sentinel xuất hiện trong tầm nhìn.\r\n */\r\n onLoadMore: () => void | Promise<void>;\r\n\r\n /**\r\n * Cho biết còn dữ liệu để tải tiếp hay không.\r\n * Nếu là `false`, `onLoadMore` sẽ không được kích hoạt.\r\n * @default true\r\n */\r\n hasMore?: boolean;\r\n\r\n /**\r\n * Trạng thái đang tải dữ liệu của trang hiện tại / tiếp theo.\r\n * Nếu là `true`, `onLoadMore` sẽ tạm hoãn cho đến khi lượt tải trước hoàn tất.\r\n * @default false\r\n */\r\n isLoading?: boolean;\r\n\r\n /**\r\n * Vô hiệu hóa toàn bộ cơ chế theo dõi cuộn vô hạn.\r\n * @default false\r\n */\r\n disabled?: boolean;\r\n\r\n /**\r\n * Khoảng cách biên rootMargin của IntersectionObserver (vd: \"100px\", \"0px 0px 100px 0px\").\r\n * Giúp kích hoạt tải trước khi người dùng chạm tới đáy danh sách để trải nghiệm mượt mà hơn.\r\n * @default \"100px\"\r\n */\r\n rootMargin?: string;\r\n\r\n /**\r\n * Ngưỡng hiển thị threshold của IntersectionObserver (từ 0.0 đến 1.0).\r\n * @default 0\r\n */\r\n threshold?: number | number[];\r\n\r\n /**\r\n * Phần tử gốc hoặc RefObject làm khung cuộn cho IntersectionObserver.\r\n * Nếu là `null` hoặc `undefined`, sẽ dùng viewport của trình duyệt hoặc khung cuộn cha gần nhất.\r\n * @default null\r\n */\r\n root?: Element | null | RefObject<Element | null>;\r\n}\r\n\r\n/**\r\n * Kết quả trả về từ hook `useInfiniteScroll`.\r\n */\r\nexport interface UseInfiniteScrollReturn {\r\n /**\r\n * Ref callback gắn vào phần tử sentinel ở cuối danh sách để theo dõi bằng IntersectionObserver.\r\n */\r\n sentinelRef: (node: HTMLElement | null) => void;\r\n\r\n /**\r\n * Trạng thái phần tử sentinel có đang nằm trong tầm quan sát hay không.\r\n */\r\n isIntersecting: boolean;\r\n}\r\n\r\n/**\r\n * Hook `useInfiniteScroll` độc lập hỗ trợ tải dữ liệu vô tận qua IntersectionObserver.\r\n */\r\nexport function useInfiniteScroll({\r\n onLoadMore,\r\n hasMore = true,\r\n isLoading = false,\r\n disabled = false,\r\n rootMargin = \"100px\",\r\n threshold = 0,\r\n root = null,\r\n}: UseInfiniteScrollOptions): UseInfiniteScrollReturn {\r\n const [isIntersecting, setIsIntersecting] = useState(false);\r\n const [sentinelNode, setSentinelNode] = useState<HTMLElement | null>(null);\r\n\r\n // Chỉ cần 1 ref duy nhất làm mutex lock để tránh kích hoạt 2 lần cùng lúc trong 1 frame\r\n const isTriggeringRef = useRef(false);\r\n\r\n const canTrigger = hasMore && !isLoading && !disabled;\r\n\r\n const triggerLoadMore = useCallback(() => {\r\n if (!canTrigger || isTriggeringRef.current) return;\r\n\r\n isTriggeringRef.current = true;\r\n try {\r\n const result = onLoadMore();\r\n if (result && typeof (result as Promise<void>).then === \"function\") {\r\n (result as Promise<void>).finally(() => {\r\n isTriggeringRef.current = false;\r\n });\r\n } else {\r\n isTriggeringRef.current = false;\r\n }\r\n } catch {\r\n isTriggeringRef.current = false;\r\n }\r\n }, [canTrigger, onLoadMore]);\r\n\r\n // Sentinel ref callback\r\n const sentinelRef = useCallback((node: HTMLElement | null) => {\r\n setSentinelNode(node);\r\n }, []);\r\n\r\n // IntersectionObserver setup\r\n useEffect(() => {\r\n if (!sentinelNode || disabled || !hasMore) {\r\n return;\r\n }\r\n\r\n if (typeof window === \"undefined\" || !(\"IntersectionObserver\" in window)) {\r\n return;\r\n }\r\n\r\n const resolvedRoot =\r\n root && typeof root === \"object\" && \"current\" in root\r\n ? (root as RefObject<Element | null>).current\r\n : (root as Element | null);\r\n\r\n const observer = new IntersectionObserver(\r\n (entries) => {\r\n const [entry] = entries;\r\n if (!entry) return;\r\n\r\n const intersecting = entry.isIntersecting;\r\n setIsIntersecting(intersecting);\r\n\r\n if (intersecting) {\r\n triggerLoadMore();\r\n }\r\n },\r\n {\r\n root: resolvedRoot,\r\n rootMargin,\r\n threshold,\r\n }\r\n );\r\n\r\n observer.observe(sentinelNode);\r\n\r\n return () => {\r\n observer.disconnect();\r\n setIsIntersecting(false);\r\n };\r\n }, [sentinelNode, root, rootMargin, threshold, disabled, hasMore, triggerLoadMore]);\r\n\r\n // Khi tải xong (isLoading = false), nếu sentinel vẫn đang nằm trong tầm nhìn thì kích hoạt tải tiếp\r\n useEffect(() => {\r\n if (canTrigger && isIntersecting) {\r\n triggerLoadMore();\r\n }\r\n }, [canTrigger, isIntersecting, triggerLoadMore]);\r\n\r\n return {\r\n sentinelRef,\r\n isIntersecting,\r\n };\r\n}\r\n\r\nexport default useInfiniteScroll;\r\n","import { SkeletonObjectFit, SkeletonRadius, SkeletonVariant } from \"./types\";\n\n/**\n * Animation class theo variant.\n * Variant 'wave' dùng class CSS tùy chỉnh `.skeleton-wave` được định nghĩa trong styles.css.\n */\nexport const variantConfig: Record<SkeletonVariant, string> = {\n pulse: \"animate-pulse\",\n wave: \"skeleton-wave\",\n none: \"\",\n};\n\n/**\n * Bo góc theo radius.\n * Chỉ áp dụng khi shape === 'rectangle'.\n */\nexport const radiusConfig: Record<SkeletonRadius, string> = {\n none: \"rounded-none\",\n sm: \"rounded-sm\",\n md: \"rounded-md\",\n lg: \"rounded-lg\",\n xl: \"rounded-xl\",\n full: \"rounded-full\",\n};\n\n/**\n * Kiểu căn chỉnh ảnh (object-fit) cho LoadingImage.\n */\nexport const objectFitConfig: Record<SkeletonObjectFit, string> = {\n cover: \"object-cover\",\n contain: \"object-contain\",\n fill: \"object-fill\",\n none: \"object-none\",\n \"scale-down\": \"object-scale-down\",\n};\n","export const toStyle = (val: string | number | undefined): string | number | undefined =>\r\n typeof val === \"number\" ? val : val;\r\n\r\nexport const getSourceKey = (s: unknown): string | unknown => {\r\n if (!s) return \"\";\r\n if (typeof s === \"string\") return s;\r\n if (typeof File !== \"undefined\" && s instanceof File) {\r\n return `${s.name}-${s.size}-${s.lastModified}`;\r\n }\r\n if (typeof Blob !== \"undefined\" && s instanceof Blob) {\r\n return `${s.size}-${s.type}`;\r\n }\r\n if (typeof s === \"object\" && \"src\" in s && typeof (s as { src: unknown }).src === \"string\") {\r\n return (s as { src: string }).src;\r\n }\r\n return s;\r\n};","import { CSSProperties } from \"react\";\nimport { SkeletonProps } from \"./types\";\nimport { variantConfig, radiusConfig } from \"./constants\";\nimport { getSafeConfig } from \"@/utils/function\";\nimport { toStyle } from \"./utils\";\n\nexport default function Skeleton({\n ref,\n variant = \"pulse\",\n shape = \"rectangle\",\n radius = \"md\",\n width,\n height = \"1rem\",\n lines = 1,\n gap = \"0.5rem\",\n className = \"\",\n style,\n ...props\n}: SkeletonProps) {\n const animationClass = getSafeConfig(variant, variantConfig, \"pulse\");\n\n // Circle: bỏ qua radius prop, luôn full-round\n const roundedClass = shape === \"circle\" ? \"rounded-full\" : getSafeConfig(radius, radiusConfig, \"md\");\n\n const baseStyle: CSSProperties = {\n width: toStyle(width),\n height: toStyle(height),\n ...style,\n };\n\n const itemClass = [\"bg-neutral-200\", animationClass, roundedClass].filter(Boolean).join(\" \");\n\n if (lines > 1) {\n return (\n <div\n ref={ref}\n role=\"status\"\n aria-label=\"Loading...\"\n className={[\"flex flex-col\", className].filter(Boolean).join(\" \")}\n style={{ gap: toStyle(gap), ...style }}\n {...props}\n >\n {Array.from({ length: lines }).map((_, i) => {\n const isLast = i === lines - 1;\n return (\n <div\n key={i}\n className={itemClass}\n style={{\n width: isLast ? \"60%\" : (toStyle(width) ?? \"100%\"),\n height: toStyle(height),\n }}\n />\n );\n })}\n </div>\n );\n }\n\n return (\n <div\n ref={ref}\n role=\"status\"\n aria-label=\"Loading...\"\n className={[itemClass, className].filter(Boolean).join(\" \")}\n style={baseStyle}\n {...props}\n />\n );\n}\n","import type { TableAlign, TableSize, TableVariant } from \"./types\";\r\n\r\nexport const tableSizeConfig: Record<\r\n TableSize,\r\n {\r\n head: string;\r\n cell: string;\r\n text: string;\r\n icon: string;\r\n }\r\n> = {\r\n sm: {\r\n head: \"px-3 py-2 text-xs font-semibold\",\r\n cell: \"px-3 py-2 text-xs\",\r\n text: \"text-xs\",\r\n icon: \"w-3.5 h-3.5\",\r\n },\r\n md: {\r\n head: \"px-4 py-3 text-sm font-semibold\",\r\n cell: \"px-4 py-3 text-sm\",\r\n text: \"text-sm\",\r\n icon: \"w-4 h-4\",\r\n },\r\n lg: {\r\n head: \"px-5 py-4 text-base font-semibold\",\r\n cell: \"px-5 py-4 text-base\",\r\n text: \"text-base\",\r\n icon: \"w-5 h-5\",\r\n },\r\n};\r\n\r\nexport const tableVariantConfig: Record<\r\n TableVariant,\r\n {\r\n container: string;\r\n table: string;\r\n head: string;\r\n row: string;\r\n cell: string;\r\n }\r\n> = {\r\n default: {\r\n container: \"border border-neutral-200 shadow-xs\",\r\n table: \"border-collapse\",\r\n head: \"bg-neutral-50/80 border-b border-neutral-200 text-neutral-600\",\r\n row: \"border-b border-neutral-200/80 hover:bg-neutral-50/50 transition-colors\",\r\n cell: \"text-neutral-700\",\r\n },\r\n striped: {\r\n container: \"border border-neutral-200 shadow-xs\",\r\n table: \"border-collapse\",\r\n head: \"bg-neutral-100/80 border-b border-neutral-200 text-neutral-700\",\r\n row: \"border-b border-neutral-200/80 even:bg-neutral-50/60 hover:bg-neutral-100/40 transition-colors\",\r\n cell: \"text-neutral-700\",\r\n },\r\n bordered: {\r\n container: \"border border-neutral-200 shadow-xs\",\r\n table: \"border-collapse border border-neutral-200\",\r\n head: \"bg-neutral-50 border border-neutral-200 text-neutral-700\",\r\n row: \"border-b border-neutral-200 hover:bg-neutral-50/50 transition-colors\",\r\n cell: \"border border-neutral-200 text-neutral-700\",\r\n },\r\n};\r\n\r\nexport const tableAlignConfig: Record<TableAlign, string> = {\r\n left: \"text-left justify-start\",\r\n center: \"text-center justify-center\",\r\n right: \"text-right justify-end\",\r\n};\r\n\r\nexport const DEFAULT_PAGE_SIZE_OPTIONS = [10, 20, 50, 100];\r\nexport const DEFAULT_PAGE_SIZE = 10;\r\n"],"mappings":"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","names":["rankItem","compareItems","getSafeConfig","key","configMap","fallbackKey","rankAndFilterItems","items","query","fields","keyword","fieldsArray","accessors","field","item","res","record","val","dataVal","ranked","info","a","b","r","useCallback","useEffect","useRef","useState","useInfiniteScroll","onLoadMore","hasMore","isLoading","disabled","rootMargin","threshold","root","isIntersecting","setIsIntersecting","sentinelNode","setSentinelNode","isTriggeringRef","canTrigger","triggerLoadMore","result","sentinelRef","node","resolvedRoot","observer","entries","entry","intersecting","variantConfig","radiusConfig","objectFitConfig","toStyle","val","getSourceKey","s","jsx","Skeleton","ref","variant","shape","radius","width","height","lines","gap","className","style","props","animationClass","getSafeConfig","variantConfig","roundedClass","radiusConfig","baseStyle","toStyle","itemClass","_","i","isLast","tableSizeConfig","tableVariantConfig","tableAlignConfig","DEFAULT_PAGE_SIZE_OPTIONS","DEFAULT_PAGE_SIZE"]}