@suphark/ui 0.7.0 → 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.
@@ -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
+ }
@@ -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
+ }
package/src/index.ts CHANGED
@@ -24,6 +24,13 @@ export {
24
24
  WEEKDAYS_TH,
25
25
  } from "./components/shared/calendar-month-grid";
26
26
  export { ClientRedirect } from "./components/shared/client-redirect";
27
+ export {
28
+ ColorPicker,
29
+ type ColorPickerItem,
30
+ type ColorPickerProps,
31
+ type ColorPickerText,
32
+ ColorSwatch,
33
+ } from "./components/shared/color-picker";
27
34
  export { Combobox } from "./components/shared/combobox";
28
35
  export {
29
36
  ConfirmDialog,
@@ -44,9 +51,17 @@ export {
44
51
  type PromptSpec,
45
52
  useDialogs,
46
53
  } from "./components/shared/dialogs-provider";
54
+ export {
55
+ Dropzone,
56
+ type DropzoneProps,
57
+ type DropzoneRejectReason,
58
+ type DropzoneText,
59
+ matchesAccept,
60
+ } from "./components/shared/dropzone";
47
61
  export { EmptyRow } from "./components/shared/empty-row";
48
62
  export { EmptyState } from "./components/shared/empty-state";
49
63
  export { EnvSnippet, randomSecret } from "./components/shared/env-snippet";
64
+ export { type FileKind, FileTypeIcon, getFileKind } from "./components/shared/file-type-icon";
50
65
  export { FormFieldBoolean } from "./components/shared/form/form-field-boolean";
51
66
  export { FormFieldColorSelect } from "./components/shared/form/form-field-color-select";
52
67
  export { FormFieldCombobox } from "./components/shared/form/form-field-combobox";
@@ -109,6 +124,7 @@ export {
109
124
  } from "./components/shared/text/primary-secondary-text";
110
125
  export * from "./components/shared/theme";
111
126
  export { ClientDateFormatter } from "./components/shared/time/client-date-formatter";
127
+ export { type TreeNode, TreeView, type TreeViewProps } from "./components/shared/tree-view";
112
128
  export {
113
129
  UI_TEXT_EN,
114
130
  UI_TEXT_TH,
@@ -116,6 +132,13 @@ export {
116
132
  UiTextProvider,
117
133
  useUiText,
118
134
  } from "./components/shared/ui-text";
135
+ export {
136
+ type UploadItem,
137
+ UploadList,
138
+ type UploadListProps,
139
+ type UploadListText,
140
+ type UploadStatus,
141
+ } from "./components/shared/upload-list";
119
142
  // ---------- shadcn/ui primitives ----------
120
143
  export * from "./components/ui/accordion";
121
144
  export * from "./components/ui/alert";
@@ -183,7 +206,7 @@ export { useMounted } from "./hooks/use-mounted";
183
206
  export { useTicker } from "./hooks/use-ticker";
184
207
  export { useTrackedState } from "./hooks/use-tracked-state";
185
208
  export { copyText, selectContents } from "./lib/copy-text";
186
- export { formatRelativeThaiDate, formatThaiFullDate } from "./lib/formatters";
209
+ export { formatBytes, formatRelativeThaiDate, formatThaiFullDate } from "./lib/formatters";
187
210
  export { getDateTimeFormat, getNumberFormat } from "./lib/intl-cache";
188
211
  export {
189
212
  type AtomicPermissionRequirement,
@@ -44,3 +44,20 @@ export function formatThaiFullDate(date: Date | string | number | null | undefin
44
44
 
45
45
  return `${d.getDate()} ${THAI_MONTH_SHORT[d.getMonth()]} ${d.getFullYear() + 543}`;
46
46
  }
47
+
48
+ /**
49
+ * ขนาดไฟล์อ่านง่าย (ฐาน 1024): 0 B, 1.5 KB, 12.3 MB, 1.2 GB — ตัวเลขทศนิยม 1 ตำแหน่งเมื่อ < 10
50
+ * formatBytes(1536) → "1.5 KB"
51
+ */
52
+ export function formatBytes(bytes: number | null | undefined, locale = "th-TH"): string {
53
+ if (bytes == null || !Number.isFinite(bytes) || bytes < 0) return "—";
54
+ const units = ["B", "KB", "MB", "GB", "TB"] as const;
55
+ let value = bytes;
56
+ let i = 0;
57
+ while (value >= 1024 && i < units.length - 1) {
58
+ value /= 1024;
59
+ i += 1;
60
+ }
61
+ const digits = i === 0 ? 0 : value < 10 ? 1 : 0;
62
+ return `${value.toLocaleString(locale, { maximumFractionDigits: digits })} ${units[i]}`;
63
+ }