@suphark/ui 0.7.0 → 0.9.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";
@@ -97,6 +112,20 @@ export { type DecimalLike, FormattedNumber } from "./components/shared/number/fo
97
112
  export { PageHeader } from "./components/shared/page-header";
98
113
  export { PageSkeleton } from "./components/shared/page-skeleton";
99
114
  export { ProgressBar, type ProgressTone } from "./components/shared/progress-bar";
115
+ export {
116
+ describeCondition,
117
+ emptyGroup,
118
+ isRuleGroup,
119
+ isRuleNot,
120
+ RuleBuilder,
121
+ type RuleBuilderProps,
122
+ type RuleBuilderText,
123
+ type RuleCondition,
124
+ type RuleFieldDef,
125
+ type RuleGroup,
126
+ type RuleLeaf,
127
+ type RuleNot,
128
+ } from "./components/shared/rule-builder";
100
129
  export { SecretReveal } from "./components/shared/secret-reveal";
101
130
  export { SectionCard } from "./components/shared/section-card";
102
131
  export { SelectionButton } from "./components/shared/selection-button";
@@ -109,6 +138,7 @@ export {
109
138
  } from "./components/shared/text/primary-secondary-text";
110
139
  export * from "./components/shared/theme";
111
140
  export { ClientDateFormatter } from "./components/shared/time/client-date-formatter";
141
+ export { type TreeNode, TreeView, type TreeViewProps } from "./components/shared/tree-view";
112
142
  export {
113
143
  UI_TEXT_EN,
114
144
  UI_TEXT_TH,
@@ -116,6 +146,13 @@ export {
116
146
  UiTextProvider,
117
147
  useUiText,
118
148
  } from "./components/shared/ui-text";
149
+ export {
150
+ type UploadItem,
151
+ UploadList,
152
+ type UploadListProps,
153
+ type UploadListText,
154
+ type UploadStatus,
155
+ } from "./components/shared/upload-list";
119
156
  // ---------- shadcn/ui primitives ----------
120
157
  export * from "./components/ui/accordion";
121
158
  export * from "./components/ui/alert";
@@ -183,7 +220,7 @@ export { useMounted } from "./hooks/use-mounted";
183
220
  export { useTicker } from "./hooks/use-ticker";
184
221
  export { useTrackedState } from "./hooks/use-tracked-state";
185
222
  export { copyText, selectContents } from "./lib/copy-text";
186
- export { formatRelativeThaiDate, formatThaiFullDate } from "./lib/formatters";
223
+ export { formatBytes, formatRelativeThaiDate, formatThaiFullDate } from "./lib/formatters";
187
224
  export { getDateTimeFormat, getNumberFormat } from "./lib/intl-cache";
188
225
  export {
189
226
  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
+ }
package/src/styles.css CHANGED
@@ -217,6 +217,19 @@ body,
217
217
  font-family: var(--font-sans);
218
218
  }
219
219
 
220
+ /*
221
+ * CommandItem ที่ active (ลูกศร/hover) มองไม่ออก: shadcn ใช้ `data-selected:bg-muted` แต่ Tailwind `data-selected:`
222
+ * = `[data-selected]` (มีแอตทริบิวต์) ขณะที่ cmdk ใส่ `data-selected="false"` ให้รายการอื่นด้วย → ทุกแถวได้ bg-muted
223
+ * เท่ากัน (รายงานจาก Pi Done ใน dark mode) → เน้นเฉพาะ "true" โดยผสม foreground; override ที่นี่แบบไม่อยู่ใน layer
224
+ * จึงชนะ utility โดยไม่แก้ command.tsx ของ shadcn
225
+ */
226
+ [data-slot="command-item"][data-selected="true"] {
227
+ background-color: color-mix(in oklch, var(--muted), var(--foreground) 8%);
228
+ }
229
+ .dark [data-slot="command-item"][data-selected="true"] {
230
+ background-color: color-mix(in oklch, var(--muted), var(--foreground) 14%);
231
+ }
232
+
220
233
  /* ผู้ใช้ตั้ง "ลดการเคลื่อนไหว" → ตัด animation/transition ของทุก component (tw-animate, animate-spin, dialog zoom …) */
221
234
  @media (prefers-reduced-motion: reduce) {
222
235
  *,