@suphark/ui 0.6.1 → 0.8.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/CHANGELOG.md +39 -0
- package/README.md +3 -2
- package/package.json +1 -1
- package/src/components/app-shell/app-shell.tsx +6 -7
- package/src/components/app-shell/command-palette.tsx +306 -0
- package/src/components/app-shell/index.ts +12 -0
- package/src/components/app-shell/organization-switcher.tsx +150 -0
- package/src/components/app-shell/user-menu.tsx +8 -3
- package/src/components/data-table/data-table.tsx +34 -26
- package/src/components/data-table/types.ts +4 -0
- package/src/components/shared/action-form.tsx +41 -16
- package/src/components/shared/calendar-month-grid.tsx +182 -0
- package/src/components/shared/color-picker.tsx +180 -0
- package/src/components/shared/dropzone.tsx +333 -0
- package/src/components/shared/file-type-icon.tsx +171 -0
- package/src/components/shared/form/labeled-field.tsx +21 -0
- package/src/components/shared/form-dialog.tsx +15 -6
- package/src/components/shared/form-message.tsx +34 -3
- package/src/components/shared/icon-picker.tsx +192 -0
- package/src/components/shared/progress-bar.tsx +83 -0
- package/src/components/shared/selection-button.tsx +29 -0
- package/src/components/shared/theme/theme-menu-items.tsx +31 -14
- package/src/components/shared/tree-view.tsx +325 -0
- package/src/components/shared/ui-text.tsx +41 -0
- package/src/components/shared/upload-list.tsx +233 -0
- package/src/index.ts +46 -2
- package/src/lib/formatters.ts +17 -0
|
@@ -0,0 +1,325 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
import { Spinner } from "@suphark/ui/components/ui/spinner";
|
|
4
|
+
import { cn } from "@suphark/ui/lib/utils";
|
|
5
|
+
import { ChevronRight } from "lucide-react";
|
|
6
|
+
import {
|
|
7
|
+
type DragEvent,
|
|
8
|
+
type KeyboardEvent,
|
|
9
|
+
type ReactNode,
|
|
10
|
+
useCallback,
|
|
11
|
+
useMemo,
|
|
12
|
+
useRef,
|
|
13
|
+
useState,
|
|
14
|
+
} from "react";
|
|
15
|
+
|
|
16
|
+
export interface TreeNode {
|
|
17
|
+
id: string;
|
|
18
|
+
label: ReactNode;
|
|
19
|
+
icon?: ReactNode;
|
|
20
|
+
/** มีลูกแต่ยังไม่โหลด (ใช้กับ `loadChildren`) */
|
|
21
|
+
hasChildren?: boolean;
|
|
22
|
+
children?: TreeNode[];
|
|
23
|
+
disabled?: boolean;
|
|
24
|
+
/** ท้ายแถว เช่น จำนวน */
|
|
25
|
+
badge?: ReactNode;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export interface TreeViewProps {
|
|
29
|
+
nodes: TreeNode[];
|
|
30
|
+
selectedId?: string | null;
|
|
31
|
+
onSelect?: (id: string, node: TreeNode) => void;
|
|
32
|
+
/** คุม node ที่ขยายจากภายนอก (ใช้คู่ `onExpandedChange`) */
|
|
33
|
+
expandedIds?: ReadonlySet<string> | readonly string[];
|
|
34
|
+
defaultExpandedIds?: readonly string[];
|
|
35
|
+
onExpandedChange?: (ids: string[]) => void;
|
|
36
|
+
/** โหลดลูกแบบ lazy เมื่อขยาย node ที่ `hasChildren` และยังไม่มี `children` — cache ในตัว */
|
|
37
|
+
loadChildren?: (id: string, node: TreeNode) => Promise<TreeNode[]>;
|
|
38
|
+
/** HTML5 drag & drop: วางไฟล์/รายการลง node (ไฮไลต์ node ขณะลากทับ) */
|
|
39
|
+
onDrop?: (targetId: string, e: DragEvent<HTMLElement>, node: TreeNode) => void;
|
|
40
|
+
/** คืน false = node นี้ไม่รับวาง (ค่าเริ่มต้นรับทุก node เมื่อมี onDrop) */
|
|
41
|
+
canDrop?: (targetId: string, e: DragEvent<HTMLElement>, node: TreeNode) => boolean;
|
|
42
|
+
renderLabel?: (
|
|
43
|
+
node: TreeNode,
|
|
44
|
+
ctx: { expanded: boolean; selected: boolean; level: number },
|
|
45
|
+
) => ReactNode;
|
|
46
|
+
/** แสดงเมื่อ `nodes` ว่าง */
|
|
47
|
+
emptyState?: ReactNode;
|
|
48
|
+
/** aria-label ของ tree */
|
|
49
|
+
label?: string;
|
|
50
|
+
/** ระยะเยื้องต่อระดับ (px, ค่าเริ่มต้น 12) */
|
|
51
|
+
indent?: number;
|
|
52
|
+
className?: string;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
interface Row {
|
|
56
|
+
node: TreeNode;
|
|
57
|
+
level: number;
|
|
58
|
+
parentId: string | null;
|
|
59
|
+
expanded: boolean;
|
|
60
|
+
isBranch: boolean;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
function toSet(ids?: ReadonlySet<string> | readonly string[]): Set<string> {
|
|
64
|
+
if (!ids) return new Set();
|
|
65
|
+
return ids instanceof Set ? new Set(ids) : new Set(ids as readonly string[]);
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/**
|
|
69
|
+
* ต้นไม้ทั่วไป (โฟลเดอร์/หมวด/องค์กร) — `role=tree` ตาม WAI-ARIA: treeitem มี aria-expanded/selected/level,
|
|
70
|
+
* roving tabindex + คีย์บอร์ด ↑↓ เลื่อน, → ขยาย/เข้าไปลูกแรก, ← ยุบ/กลับไปแม่, Home/End, Enter/Space เลือก
|
|
71
|
+
* ลูกโหลด lazy ผ่าน `loadChildren` (Spinner ระหว่างโหลด, cache ในตัว); วางไฟล์ลง node ด้วย `onDrop`
|
|
72
|
+
* สีตาม token ของ Sidebar (แถวที่เลือก = bg-sidebar-accent) เพื่อวางใน sidebar ได้กลมกลืน
|
|
73
|
+
*/
|
|
74
|
+
export function TreeView({
|
|
75
|
+
nodes,
|
|
76
|
+
selectedId = null,
|
|
77
|
+
onSelect,
|
|
78
|
+
expandedIds: controlledExpanded,
|
|
79
|
+
defaultExpandedIds,
|
|
80
|
+
onExpandedChange,
|
|
81
|
+
loadChildren,
|
|
82
|
+
onDrop,
|
|
83
|
+
canDrop,
|
|
84
|
+
renderLabel,
|
|
85
|
+
emptyState,
|
|
86
|
+
label,
|
|
87
|
+
indent = 12,
|
|
88
|
+
className,
|
|
89
|
+
}: TreeViewProps) {
|
|
90
|
+
const [uncontrolledExpanded, setUncontrolledExpanded] = useState(() => toSet(defaultExpandedIds));
|
|
91
|
+
const expanded =
|
|
92
|
+
controlledExpanded !== undefined ? toSet(controlledExpanded) : uncontrolledExpanded;
|
|
93
|
+
const [loaded, setLoaded] = useState<Map<string, TreeNode[]>>(() => new Map());
|
|
94
|
+
const [loading, setLoading] = useState<Set<string>>(() => new Set());
|
|
95
|
+
const [focusId, setFocusId] = useState<string | null>(null);
|
|
96
|
+
const [dropTarget, setDropTarget] = useState<string | null>(null);
|
|
97
|
+
const itemRefs = useRef(new Map<string, HTMLElement>());
|
|
98
|
+
|
|
99
|
+
const setExpanded = useCallback(
|
|
100
|
+
(next: Set<string>) => {
|
|
101
|
+
if (controlledExpanded === undefined) setUncontrolledExpanded(next);
|
|
102
|
+
onExpandedChange?.([...next]);
|
|
103
|
+
},
|
|
104
|
+
[controlledExpanded, onExpandedChange],
|
|
105
|
+
);
|
|
106
|
+
|
|
107
|
+
const childrenOf = useCallback(
|
|
108
|
+
(node: TreeNode): TreeNode[] | undefined => node.children ?? loaded.get(node.id),
|
|
109
|
+
[loaded],
|
|
110
|
+
);
|
|
111
|
+
|
|
112
|
+
// แถวที่มองเห็น (ตามลำดับบนจอ) — ใช้ทั้ง render และเดินด้วยคีย์บอร์ด
|
|
113
|
+
const rows = useMemo(() => {
|
|
114
|
+
const out: Row[] = [];
|
|
115
|
+
const walk = (list: TreeNode[], level: number, parentId: string | null) => {
|
|
116
|
+
for (const node of list) {
|
|
117
|
+
const kids = childrenOf(node);
|
|
118
|
+
const isBranch = Boolean(node.hasChildren) || (kids?.length ?? 0) > 0;
|
|
119
|
+
const isExpanded = isBranch && expanded.has(node.id);
|
|
120
|
+
out.push({ node, level, parentId, expanded: isExpanded, isBranch });
|
|
121
|
+
if (isExpanded && kids) walk(kids, level + 1, node.id);
|
|
122
|
+
}
|
|
123
|
+
};
|
|
124
|
+
walk(nodes, 1, null);
|
|
125
|
+
return out;
|
|
126
|
+
}, [nodes, expanded, childrenOf]);
|
|
127
|
+
|
|
128
|
+
const tabStopId = focusId ?? selectedId ?? rows[0]?.node.id ?? null;
|
|
129
|
+
|
|
130
|
+
async function toggle(row: Row) {
|
|
131
|
+
const { node } = row;
|
|
132
|
+
if (!row.isBranch) return;
|
|
133
|
+
const next = new Set(expanded);
|
|
134
|
+
if (row.expanded) {
|
|
135
|
+
next.delete(node.id);
|
|
136
|
+
setExpanded(next);
|
|
137
|
+
return;
|
|
138
|
+
}
|
|
139
|
+
next.add(node.id);
|
|
140
|
+
setExpanded(next);
|
|
141
|
+
if (!childrenOf(node) && loadChildren && !loading.has(node.id)) {
|
|
142
|
+
setLoading((s) => new Set(s).add(node.id));
|
|
143
|
+
try {
|
|
144
|
+
const kids = await loadChildren(node.id, node);
|
|
145
|
+
setLoaded((m) => new Map(m).set(node.id, kids));
|
|
146
|
+
} catch {
|
|
147
|
+
setLoaded((m) => new Map(m).set(node.id, []));
|
|
148
|
+
} finally {
|
|
149
|
+
setLoading((s) => {
|
|
150
|
+
const n = new Set(s);
|
|
151
|
+
n.delete(node.id);
|
|
152
|
+
return n;
|
|
153
|
+
});
|
|
154
|
+
}
|
|
155
|
+
}
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
function focusRow(id: string) {
|
|
159
|
+
setFocusId(id);
|
|
160
|
+
itemRefs.current.get(id)?.focus();
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
function onKeyDown(e: KeyboardEvent<HTMLElement>, index: number) {
|
|
164
|
+
const row = rows[index];
|
|
165
|
+
if (!row) return;
|
|
166
|
+
let handled = true;
|
|
167
|
+
switch (e.key) {
|
|
168
|
+
case "ArrowDown": {
|
|
169
|
+
const next = rows[index + 1];
|
|
170
|
+
if (next) focusRow(next.node.id);
|
|
171
|
+
break;
|
|
172
|
+
}
|
|
173
|
+
case "ArrowUp": {
|
|
174
|
+
const prev = rows[index - 1];
|
|
175
|
+
if (prev) focusRow(prev.node.id);
|
|
176
|
+
break;
|
|
177
|
+
}
|
|
178
|
+
case "ArrowRight":
|
|
179
|
+
if (row.isBranch && !row.expanded) void toggle(row);
|
|
180
|
+
else if (row.expanded) {
|
|
181
|
+
const next = rows[index + 1];
|
|
182
|
+
if (next && next.parentId === row.node.id) focusRow(next.node.id);
|
|
183
|
+
}
|
|
184
|
+
break;
|
|
185
|
+
case "ArrowLeft":
|
|
186
|
+
if (row.expanded) void toggle(row);
|
|
187
|
+
else if (row.parentId) focusRow(row.parentId);
|
|
188
|
+
break;
|
|
189
|
+
case "Home":
|
|
190
|
+
if (rows[0]) focusRow(rows[0].node.id);
|
|
191
|
+
break;
|
|
192
|
+
case "End": {
|
|
193
|
+
const last = rows[rows.length - 1];
|
|
194
|
+
if (last) focusRow(last.node.id);
|
|
195
|
+
break;
|
|
196
|
+
}
|
|
197
|
+
case "Enter":
|
|
198
|
+
case " ":
|
|
199
|
+
if (!row.node.disabled) onSelect?.(row.node.id, row.node);
|
|
200
|
+
break;
|
|
201
|
+
default:
|
|
202
|
+
handled = false;
|
|
203
|
+
}
|
|
204
|
+
if (handled) e.preventDefault();
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
if (nodes.length === 0) return <>{emptyState ?? null}</>;
|
|
208
|
+
|
|
209
|
+
return (
|
|
210
|
+
<ul
|
|
211
|
+
role="tree"
|
|
212
|
+
aria-label={label}
|
|
213
|
+
data-slot="tree-view"
|
|
214
|
+
className={cn("flex flex-col gap-0.5 text-sm", className)}
|
|
215
|
+
>
|
|
216
|
+
{rows.map((row, index) => {
|
|
217
|
+
const { node, level } = row;
|
|
218
|
+
const selected = node.id === selectedId;
|
|
219
|
+
const isLoading = loading.has(node.id);
|
|
220
|
+
const droppable = Boolean(onDrop) && !node.disabled;
|
|
221
|
+
const labelContent = renderLabel ? (
|
|
222
|
+
renderLabel(node, { expanded: row.expanded, selected, level })
|
|
223
|
+
) : (
|
|
224
|
+
<span className="min-w-0 flex-1 truncate">{node.label}</span>
|
|
225
|
+
);
|
|
226
|
+
return (
|
|
227
|
+
<li
|
|
228
|
+
key={node.id}
|
|
229
|
+
ref={(el) => {
|
|
230
|
+
if (el) itemRefs.current.set(node.id, el);
|
|
231
|
+
else itemRefs.current.delete(node.id);
|
|
232
|
+
}}
|
|
233
|
+
role="treeitem"
|
|
234
|
+
aria-level={level}
|
|
235
|
+
aria-selected={selected}
|
|
236
|
+
aria-expanded={row.isBranch ? row.expanded : undefined}
|
|
237
|
+
aria-disabled={node.disabled || undefined}
|
|
238
|
+
tabIndex={node.id === tabStopId ? 0 : -1}
|
|
239
|
+
data-id={node.id}
|
|
240
|
+
data-selected={selected || undefined}
|
|
241
|
+
data-drop-target={dropTarget === node.id || undefined}
|
|
242
|
+
onFocus={() => setFocusId(node.id)}
|
|
243
|
+
onKeyDown={(e) => onKeyDown(e, index)}
|
|
244
|
+
onClick={(e) => {
|
|
245
|
+
e.stopPropagation();
|
|
246
|
+
if (!node.disabled) onSelect?.(node.id, node);
|
|
247
|
+
}}
|
|
248
|
+
onDragOver={
|
|
249
|
+
droppable
|
|
250
|
+
? (e) => {
|
|
251
|
+
if (canDrop && !canDrop(node.id, e, node)) return;
|
|
252
|
+
e.preventDefault();
|
|
253
|
+
e.stopPropagation();
|
|
254
|
+
if (dropTarget !== node.id) setDropTarget(node.id);
|
|
255
|
+
}
|
|
256
|
+
: undefined
|
|
257
|
+
}
|
|
258
|
+
onDragLeave={
|
|
259
|
+
droppable
|
|
260
|
+
? (e) => {
|
|
261
|
+
// ออกจากแถวจริง (ไม่ใช่ย้ายไปลูกในแถวเดียวกัน)
|
|
262
|
+
if (!e.currentTarget.contains(e.relatedTarget as Node | null)) {
|
|
263
|
+
setDropTarget((t) => (t === node.id ? null : t));
|
|
264
|
+
}
|
|
265
|
+
}
|
|
266
|
+
: undefined
|
|
267
|
+
}
|
|
268
|
+
onDrop={
|
|
269
|
+
droppable
|
|
270
|
+
? (e) => {
|
|
271
|
+
if (canDrop && !canDrop(node.id, e, node)) return;
|
|
272
|
+
e.preventDefault();
|
|
273
|
+
e.stopPropagation();
|
|
274
|
+
setDropTarget(null);
|
|
275
|
+
onDrop?.(node.id, e, node);
|
|
276
|
+
}
|
|
277
|
+
: undefined
|
|
278
|
+
}
|
|
279
|
+
style={{ paddingInlineStart: (level - 1) * indent }}
|
|
280
|
+
className={cn(
|
|
281
|
+
"group/tree-item flex h-8 cursor-pointer items-center gap-1 rounded-md pr-2 outline-none transition-colors",
|
|
282
|
+
"hover:bg-sidebar-accent/60 hover:text-sidebar-accent-foreground",
|
|
283
|
+
"focus-visible:ring-2 focus-visible:ring-ring/50",
|
|
284
|
+
"data-selected:bg-sidebar-accent data-selected:font-medium data-selected:text-sidebar-accent-foreground",
|
|
285
|
+
"data-drop-target:bg-primary/10 data-drop-target:ring-2 data-drop-target:ring-primary/60",
|
|
286
|
+
"aria-disabled:pointer-events-none aria-disabled:opacity-50",
|
|
287
|
+
)}
|
|
288
|
+
>
|
|
289
|
+
{/* ลูกศรยุบ/ขยาย — ไม่ใช่ button เพื่อไม่ซ้อน focusable ใน treeitem (คีย์บอร์ดใช้ ←/→) */}
|
|
290
|
+
<span
|
|
291
|
+
aria-hidden="true"
|
|
292
|
+
onClick={(e) => {
|
|
293
|
+
e.stopPropagation();
|
|
294
|
+
void toggle(row);
|
|
295
|
+
}}
|
|
296
|
+
className={cn(
|
|
297
|
+
"flex size-6 shrink-0 items-center justify-center rounded-sm text-muted-foreground",
|
|
298
|
+
row.isBranch
|
|
299
|
+
? "hover:bg-sidebar-accent hover:text-sidebar-accent-foreground"
|
|
300
|
+
: "invisible",
|
|
301
|
+
)}
|
|
302
|
+
>
|
|
303
|
+
{isLoading ? (
|
|
304
|
+
<Spinner className="size-3.5" />
|
|
305
|
+
) : (
|
|
306
|
+
<ChevronRight
|
|
307
|
+
className={cn("size-4 transition-transform", row.expanded && "rotate-90")}
|
|
308
|
+
/>
|
|
309
|
+
)}
|
|
310
|
+
</span>
|
|
311
|
+
{node.icon && (
|
|
312
|
+
<span className="flex size-4 shrink-0 items-center justify-center [&_svg]:size-4">
|
|
313
|
+
{node.icon}
|
|
314
|
+
</span>
|
|
315
|
+
)}
|
|
316
|
+
{labelContent}
|
|
317
|
+
{node.badge && (
|
|
318
|
+
<span className="ml-auto shrink-0 text-muted-foreground text-xs">{node.badge}</span>
|
|
319
|
+
)}
|
|
320
|
+
</li>
|
|
321
|
+
);
|
|
322
|
+
})}
|
|
323
|
+
</ul>
|
|
324
|
+
);
|
|
325
|
+
}
|
|
@@ -30,6 +30,23 @@ export interface UiText {
|
|
|
30
30
|
chooseTitle?: string;
|
|
31
31
|
/** DialogsProvider: หัวกล่อง checklist (optional — ค่าเริ่มต้น "เลือกรายการ") */
|
|
32
32
|
checklistTitle?: string;
|
|
33
|
+
/** CommandPalette (optional — ค่าเริ่มต้นไทยในตัว) */
|
|
34
|
+
palette?: Partial<{
|
|
35
|
+
placeholder: string;
|
|
36
|
+
title: string;
|
|
37
|
+
description: string;
|
|
38
|
+
empty: string;
|
|
39
|
+
searching: string;
|
|
40
|
+
pages: string;
|
|
41
|
+
}>;
|
|
42
|
+
/** OrganizationSwitcher (optional) */
|
|
43
|
+
switcher?: Partial<{ choose: string; organization: string }>;
|
|
44
|
+
/** UserMenu: aria-label ปุ่มเปิดเมนู (optional — "เมนูผู้ใช้") */
|
|
45
|
+
userMenu?: string;
|
|
46
|
+
/** UserMenu: ปุ่มออกจากระบบ (optional — "ออกจากระบบ") */
|
|
47
|
+
signOut?: string;
|
|
48
|
+
/** ThemeMenuItems: หัวกลุ่ม + ตัวเลือก (optional) */
|
|
49
|
+
theme?: Partial<{ label: string; light: string; dark: string; system: string }>;
|
|
33
50
|
}
|
|
34
51
|
|
|
35
52
|
export const UI_TEXT_TH: UiText = {
|
|
@@ -50,6 +67,18 @@ export const UI_TEXT_TH: UiText = {
|
|
|
50
67
|
promptTitle: "กรอกข้อมูล",
|
|
51
68
|
chooseTitle: "เลือกการทำรายการ",
|
|
52
69
|
checklistTitle: "เลือกรายการ",
|
|
70
|
+
palette: {
|
|
71
|
+
placeholder: "ค้นหาหรือสั่งงาน…",
|
|
72
|
+
title: "ค้นหา",
|
|
73
|
+
description: "พิมพ์เพื่อค้นหาหน้าและข้อมูล",
|
|
74
|
+
empty: "ไม่พบผลลัพธ์",
|
|
75
|
+
searching: "กำลังค้นหา…",
|
|
76
|
+
pages: "หน้า",
|
|
77
|
+
},
|
|
78
|
+
switcher: { choose: "เลือกองค์กร", organization: "องค์กร" },
|
|
79
|
+
userMenu: "เมนูผู้ใช้",
|
|
80
|
+
signOut: "ออกจากระบบ",
|
|
81
|
+
theme: { label: "ธีม", light: "สว่าง", dark: "มืด", system: "ตามระบบ" },
|
|
53
82
|
};
|
|
54
83
|
|
|
55
84
|
export const UI_TEXT_EN: UiText = {
|
|
@@ -70,6 +99,18 @@ export const UI_TEXT_EN: UiText = {
|
|
|
70
99
|
promptTitle: "Input required",
|
|
71
100
|
chooseTitle: "Choose an action",
|
|
72
101
|
checklistTitle: "Choose options",
|
|
102
|
+
palette: {
|
|
103
|
+
placeholder: "Search or jump to…",
|
|
104
|
+
title: "Search",
|
|
105
|
+
description: "Type to search pages and records",
|
|
106
|
+
empty: "No results",
|
|
107
|
+
searching: "Searching…",
|
|
108
|
+
pages: "Pages",
|
|
109
|
+
},
|
|
110
|
+
switcher: { choose: "Choose organization", organization: "Organization" },
|
|
111
|
+
userMenu: "User menu",
|
|
112
|
+
signOut: "Sign out",
|
|
113
|
+
theme: { label: "Theme", light: "Light", dark: "Dark", system: "System" },
|
|
73
114
|
};
|
|
74
115
|
|
|
75
116
|
const UiTextContext = createContext<UiText>(UI_TEXT_TH);
|
|
@@ -0,0 +1,233 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
import { Button } from "@suphark/ui/components/ui/button";
|
|
4
|
+
import { formatBytes } from "@suphark/ui/lib/formatters";
|
|
5
|
+
import { cn } from "@suphark/ui/lib/utils";
|
|
6
|
+
import { AlertCircle, Check, ChevronDown, RotateCcw, X } from "lucide-react";
|
|
7
|
+
import { type ReactNode, useState } from "react";
|
|
8
|
+
import { FileTypeIcon } from "./file-type-icon";
|
|
9
|
+
import { ProgressBar } from "./progress-bar";
|
|
10
|
+
|
|
11
|
+
export type UploadStatus = "queued" | "uploading" | "done" | "error" | "aborted";
|
|
12
|
+
|
|
13
|
+
export interface UploadItem {
|
|
14
|
+
id: string;
|
|
15
|
+
name: string;
|
|
16
|
+
sizeBytes: number;
|
|
17
|
+
/** 0–1 */
|
|
18
|
+
progress: number;
|
|
19
|
+
status: UploadStatus;
|
|
20
|
+
/** ข้อความเมื่อ status = error */
|
|
21
|
+
error?: string;
|
|
22
|
+
/** mime สำหรับไอคอน (ไม่ส่ง = เดาจากนามสกุล) */
|
|
23
|
+
mime?: string;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export interface UploadListText {
|
|
27
|
+
title: string;
|
|
28
|
+
/** สรุป "x/y เสร็จ" */
|
|
29
|
+
summary: (done: number, total: number) => string;
|
|
30
|
+
allDone: string;
|
|
31
|
+
queued: string;
|
|
32
|
+
aborted: string;
|
|
33
|
+
cancel: string;
|
|
34
|
+
retry: string;
|
|
35
|
+
clearDone: string;
|
|
36
|
+
collapse: string;
|
|
37
|
+
expand: string;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
const TEXT: Record<"th" | "en", UploadListText> = {
|
|
41
|
+
th: {
|
|
42
|
+
title: "อัปโหลด",
|
|
43
|
+
summary: (d, t) => `${d}/${t} เสร็จ`,
|
|
44
|
+
allDone: "อัปโหลดเสร็จทั้งหมด",
|
|
45
|
+
queued: "รอคิว",
|
|
46
|
+
aborted: "ยกเลิกแล้ว",
|
|
47
|
+
cancel: "ยกเลิก",
|
|
48
|
+
retry: "ลองใหม่",
|
|
49
|
+
clearDone: "ล้างรายการที่เสร็จ",
|
|
50
|
+
collapse: "ยุบ",
|
|
51
|
+
expand: "ขยาย",
|
|
52
|
+
},
|
|
53
|
+
en: {
|
|
54
|
+
title: "Uploads",
|
|
55
|
+
summary: (d, t) => `${d}/${t} done`,
|
|
56
|
+
allDone: "All uploads complete",
|
|
57
|
+
queued: "Queued",
|
|
58
|
+
aborted: "Cancelled",
|
|
59
|
+
cancel: "Cancel",
|
|
60
|
+
retry: "Retry",
|
|
61
|
+
clearDone: "Clear completed",
|
|
62
|
+
collapse: "Collapse",
|
|
63
|
+
expand: "Expand",
|
|
64
|
+
},
|
|
65
|
+
};
|
|
66
|
+
|
|
67
|
+
export interface UploadListProps {
|
|
68
|
+
items: UploadItem[];
|
|
69
|
+
onCancel?: (id: string) => void;
|
|
70
|
+
onRetry?: (id: string) => void;
|
|
71
|
+
/** แสดงปุ่มล้างรายการที่เสร็จ (done/aborted) */
|
|
72
|
+
onClearDone?: () => void;
|
|
73
|
+
/** ปุ่มปิดทั้งแผง (เช่นเมื่อเสร็จหมดแล้ว) */
|
|
74
|
+
onClose?: () => void;
|
|
75
|
+
/** ลอยมุมล่างขวาแบบ Google Drive */
|
|
76
|
+
floating?: boolean;
|
|
77
|
+
defaultCollapsed?: boolean;
|
|
78
|
+
title?: ReactNode;
|
|
79
|
+
language?: "th" | "en";
|
|
80
|
+
text?: Partial<UploadListText>;
|
|
81
|
+
className?: string;
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
/**
|
|
85
|
+
* รายการอัปโหลดพร้อม progress ต่อไฟล์ + สรุปรวม (x/y เสร็จ) ยุบ/ขยายได้ — ใช้ `ProgressBar` และ `FileTypeIcon`
|
|
86
|
+
* แอปเป็นคนคุมคิว/XHR เอง ส่งเฉพาะ `items` เข้ามา (ยกเลิก/ลองใหม่ยิง callback กลับ)
|
|
87
|
+
*/
|
|
88
|
+
export function UploadList({
|
|
89
|
+
items,
|
|
90
|
+
onCancel,
|
|
91
|
+
onRetry,
|
|
92
|
+
onClearDone,
|
|
93
|
+
onClose,
|
|
94
|
+
floating = false,
|
|
95
|
+
defaultCollapsed = false,
|
|
96
|
+
title,
|
|
97
|
+
language = "th",
|
|
98
|
+
text: textProp,
|
|
99
|
+
className,
|
|
100
|
+
}: UploadListProps) {
|
|
101
|
+
const text: UploadListText = { ...TEXT[language], ...textProp };
|
|
102
|
+
const [collapsed, setCollapsed] = useState(defaultCollapsed);
|
|
103
|
+
const total = items.length;
|
|
104
|
+
const done = items.filter((i) => i.status === "done").length;
|
|
105
|
+
const settled = items.every((i) => i.status === "done" || i.status === "aborted");
|
|
106
|
+
const hasClearable = items.some((i) => i.status === "done" || i.status === "aborted");
|
|
107
|
+
if (total === 0) return null;
|
|
108
|
+
|
|
109
|
+
return (
|
|
110
|
+
<section
|
|
111
|
+
data-slot="upload-list"
|
|
112
|
+
aria-label={typeof title === "string" ? title : text.title}
|
|
113
|
+
className={cn(
|
|
114
|
+
"flex flex-col overflow-hidden rounded-xl border bg-card text-card-foreground shadow-sm",
|
|
115
|
+
floating && "fixed right-4 bottom-4 z-50 w-[min(20rem,calc(100vw-2rem))] shadow-lg",
|
|
116
|
+
className,
|
|
117
|
+
)}
|
|
118
|
+
>
|
|
119
|
+
<header className="flex items-center gap-2 border-b bg-muted/40 px-3 py-2">
|
|
120
|
+
<div className="min-w-0 flex-1">
|
|
121
|
+
<p className="truncate font-semibold text-sm">{title ?? text.title}</p>
|
|
122
|
+
<p className="text-muted-foreground text-xs" aria-live="polite">
|
|
123
|
+
{settled && done === total ? text.allDone : text.summary(done, total)}
|
|
124
|
+
</p>
|
|
125
|
+
</div>
|
|
126
|
+
{onClearDone && hasClearable && (
|
|
127
|
+
<Button type="button" variant="ghost" size="sm" onClick={onClearDone}>
|
|
128
|
+
{text.clearDone}
|
|
129
|
+
</Button>
|
|
130
|
+
)}
|
|
131
|
+
<Button
|
|
132
|
+
type="button"
|
|
133
|
+
variant="ghost"
|
|
134
|
+
size="icon-sm"
|
|
135
|
+
aria-label={collapsed ? text.expand : text.collapse}
|
|
136
|
+
aria-expanded={!collapsed}
|
|
137
|
+
onClick={() => setCollapsed((c) => !c)}
|
|
138
|
+
>
|
|
139
|
+
<ChevronDown className={cn("transition-transform", collapsed && "rotate-180")} />
|
|
140
|
+
</Button>
|
|
141
|
+
{onClose && (
|
|
142
|
+
<Button
|
|
143
|
+
type="button"
|
|
144
|
+
variant="ghost"
|
|
145
|
+
size="icon-sm"
|
|
146
|
+
aria-label={text.cancel}
|
|
147
|
+
onClick={onClose}
|
|
148
|
+
>
|
|
149
|
+
<X />
|
|
150
|
+
</Button>
|
|
151
|
+
)}
|
|
152
|
+
</header>
|
|
153
|
+
{!collapsed && (
|
|
154
|
+
<ul className="max-h-72 divide-y overflow-y-auto">
|
|
155
|
+
{items.map((item) => {
|
|
156
|
+
const pct = Math.round(Math.min(1, Math.max(0, item.progress)) * 100);
|
|
157
|
+
const cancelable = item.status === "queued" || item.status === "uploading";
|
|
158
|
+
const retryable = item.status === "error" || item.status === "aborted";
|
|
159
|
+
return (
|
|
160
|
+
<li
|
|
161
|
+
key={item.id}
|
|
162
|
+
data-status={item.status}
|
|
163
|
+
className="flex items-center gap-3 px-3 py-2"
|
|
164
|
+
>
|
|
165
|
+
<FileTypeIcon mime={item.mime} filename={item.name} size={20} />
|
|
166
|
+
<div className="min-w-0 flex-1">
|
|
167
|
+
<div className="flex items-baseline gap-2">
|
|
168
|
+
<p className="min-w-0 flex-1 truncate text-sm">{item.name}</p>
|
|
169
|
+
<span className="shrink-0 text-muted-foreground text-xs tabular-nums">
|
|
170
|
+
{item.status === "uploading"
|
|
171
|
+
? `${pct}%`
|
|
172
|
+
: item.status === "queued"
|
|
173
|
+
? text.queued
|
|
174
|
+
: item.status === "aborted"
|
|
175
|
+
? text.aborted
|
|
176
|
+
: formatBytes(item.sizeBytes)}
|
|
177
|
+
</span>
|
|
178
|
+
</div>
|
|
179
|
+
{(item.status === "uploading" || item.status === "queued") && (
|
|
180
|
+
<ProgressBar
|
|
181
|
+
value={pct}
|
|
182
|
+
className="mt-1"
|
|
183
|
+
aria-label={item.name}
|
|
184
|
+
indicatorClassName={
|
|
185
|
+
item.status === "queued" ? "bg-muted-foreground/40" : undefined
|
|
186
|
+
}
|
|
187
|
+
/>
|
|
188
|
+
)}
|
|
189
|
+
{item.status === "error" && (
|
|
190
|
+
<p
|
|
191
|
+
role="alert"
|
|
192
|
+
className="mt-0.5 flex items-center gap-1 text-destructive text-xs"
|
|
193
|
+
>
|
|
194
|
+
<AlertCircle className="size-3.5 shrink-0" aria-hidden="true" />
|
|
195
|
+
<span className="truncate">{item.error ?? "error"}</span>
|
|
196
|
+
</p>
|
|
197
|
+
)}
|
|
198
|
+
</div>
|
|
199
|
+
{item.status === "done" && (
|
|
200
|
+
<span className="flex size-5 shrink-0 items-center justify-center rounded-full bg-success text-success-foreground">
|
|
201
|
+
<Check className="size-3" strokeWidth={3} aria-hidden="true" />
|
|
202
|
+
</span>
|
|
203
|
+
)}
|
|
204
|
+
{cancelable && onCancel && (
|
|
205
|
+
<Button
|
|
206
|
+
type="button"
|
|
207
|
+
variant="ghost"
|
|
208
|
+
size="icon-sm"
|
|
209
|
+
aria-label={`${text.cancel} ${item.name}`}
|
|
210
|
+
onClick={() => onCancel(item.id)}
|
|
211
|
+
>
|
|
212
|
+
<X />
|
|
213
|
+
</Button>
|
|
214
|
+
)}
|
|
215
|
+
{retryable && onRetry && (
|
|
216
|
+
<Button
|
|
217
|
+
type="button"
|
|
218
|
+
variant="ghost"
|
|
219
|
+
size="icon-sm"
|
|
220
|
+
aria-label={`${text.retry} ${item.name}`}
|
|
221
|
+
onClick={() => onRetry(item.id)}
|
|
222
|
+
>
|
|
223
|
+
<RotateCcw />
|
|
224
|
+
</Button>
|
|
225
|
+
)}
|
|
226
|
+
</li>
|
|
227
|
+
);
|
|
228
|
+
})}
|
|
229
|
+
</ul>
|
|
230
|
+
)}
|
|
231
|
+
</section>
|
|
232
|
+
);
|
|
233
|
+
}
|