@suphark/ui 0.27.1 → 0.29.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 +34 -0
- package/README.md +26 -0
- package/package.json +2 -1
- package/src/components/app-shell/app-shell.tsx +26 -7
- package/src/components/data-table/data-table-pagination.tsx +9 -4
- package/src/components/data-table/data-table-row-actions.tsx +14 -9
- package/src/components/data-table/data-table.tsx +3 -1
- package/src/components/shared/activity-feed.tsx +114 -0
- package/src/components/shared/inline-edit.tsx +209 -0
- package/src/components/shared/selection-bar.tsx +94 -0
- package/src/components/shared/simple-table.tsx +51 -2
- package/src/components/shared/simple-tabs.tsx +54 -13
- package/src/components/shared/ui-text.tsx +70 -0
- package/src/components/sortable/sortable-list.tsx +450 -0
- package/src/index.ts +11 -0
- package/src/lib/sortable-order.ts +142 -0
- package/src/sortable.ts +31 -0
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
import { Button } from "@suphark/ui/components/shared/button/button";
|
|
4
|
+
import { useUiText } from "@suphark/ui/components/shared/ui-text";
|
|
5
|
+
import { cn } from "@suphark/ui/lib/utils";
|
|
6
|
+
import { XIcon } from "lucide-react";
|
|
7
|
+
import type { ReactNode } from "react";
|
|
8
|
+
|
|
9
|
+
export interface SelectionAction {
|
|
10
|
+
label: string;
|
|
11
|
+
onClick: () => void;
|
|
12
|
+
icon?: ReactNode;
|
|
13
|
+
destructive?: boolean;
|
|
14
|
+
disabled?: boolean;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
export interface SelectionBarProps {
|
|
18
|
+
/** จำนวนที่เลือก — 0 = ไม่แสดงอะไรเลย */
|
|
19
|
+
count: number;
|
|
20
|
+
/** ล้างการเลือก (เช่น `table.resetRowSelection`) */
|
|
21
|
+
onClear: () => void;
|
|
22
|
+
/** ปุ่มคำสั่งหมู่ — ใส่เป็น array หรือจะส่ง `children` เป็น element เองก็ได้ */
|
|
23
|
+
actions?: ReadonlyArray<SelectionAction>;
|
|
24
|
+
children?: ReactNode;
|
|
25
|
+
/** ข้อความนับ ใช้ `{count}` เช่น "เลือก {count} รายการ" (ไม่ส่ง = `UiText.selection.selected`) */
|
|
26
|
+
label?: string;
|
|
27
|
+
/** floating = แถบลอยกลางล่างจอ (ค่าเริ่มต้น) · inline = แถบในหน้า เหนือตาราง */
|
|
28
|
+
variant?: "floating" | "inline";
|
|
29
|
+
className?: string;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* แถบคำสั่งหมู่ที่โผล่เมื่อเลือกหลายแถว — "เลือก N รายการ" + ปุ่มคำสั่ง + ปุ่มล้าง
|
|
34
|
+
* ใช้คู่ `DataTable` (`table.getFilteredSelectedRowModel().rows`) หรือ `SimpleTable` ที่แอปคุม state เอง
|
|
35
|
+
* คำสั่งที่ย้อนกลับไม่ได้ให้ส่ง element ของ `ConfirmActionButton`/`DeleteManyButton` ผ่าน `children`
|
|
36
|
+
*
|
|
37
|
+
* <SelectionBar count={selected.length} onClear={() => table.resetRowSelection()}
|
|
38
|
+
* actions={[{ label: "ส่งออก", onClick: exportRows }, { label: "ลบ", destructive: true, onClick: removeRows }]} />
|
|
39
|
+
*/
|
|
40
|
+
export function SelectionBar({
|
|
41
|
+
count,
|
|
42
|
+
onClear,
|
|
43
|
+
actions,
|
|
44
|
+
children,
|
|
45
|
+
label,
|
|
46
|
+
variant = "floating",
|
|
47
|
+
className,
|
|
48
|
+
}: SelectionBarProps) {
|
|
49
|
+
const text = useUiText();
|
|
50
|
+
if (count <= 0) return null;
|
|
51
|
+
|
|
52
|
+
const template = label ?? text.selection?.selected ?? "เลือก {count} รายการ";
|
|
53
|
+
const message = template.replace("{count}", String(count));
|
|
54
|
+
|
|
55
|
+
return (
|
|
56
|
+
<div
|
|
57
|
+
data-slot="selection-bar"
|
|
58
|
+
// อ่านออกเสียงจำนวนที่เลือกให้ screen reader โดยไม่ขัดจังหวะ
|
|
59
|
+
aria-live="polite"
|
|
60
|
+
className={cn(
|
|
61
|
+
"flex items-center gap-2 rounded-xl border bg-card px-3 py-2 shadow-lg",
|
|
62
|
+
variant === "floating" &&
|
|
63
|
+
"-translate-x-1/2 fixed bottom-[calc(1rem+env(safe-area-inset-bottom))] left-1/2 z-40 max-w-[calc(100vw-2rem)]",
|
|
64
|
+
className,
|
|
65
|
+
)}
|
|
66
|
+
>
|
|
67
|
+
<span className="shrink-0 font-medium text-sm tabular-nums">{message}</span>
|
|
68
|
+
<div className="flex min-w-0 flex-wrap items-center gap-1.5">
|
|
69
|
+
{actions?.map((action) => (
|
|
70
|
+
<Button
|
|
71
|
+
key={action.label}
|
|
72
|
+
size="sm"
|
|
73
|
+
variant={action.destructive ? "destructive" : "outline"}
|
|
74
|
+
disabled={action.disabled}
|
|
75
|
+
onClick={action.onClick}
|
|
76
|
+
>
|
|
77
|
+
{action.icon}
|
|
78
|
+
{action.label}
|
|
79
|
+
</Button>
|
|
80
|
+
))}
|
|
81
|
+
{children}
|
|
82
|
+
</div>
|
|
83
|
+
<Button
|
|
84
|
+
variant="ghost"
|
|
85
|
+
size="icon-sm"
|
|
86
|
+
aria-label={text.selection?.clear ?? "ล้างการเลือก"}
|
|
87
|
+
onClick={onClear}
|
|
88
|
+
className="ml-auto shrink-0"
|
|
89
|
+
>
|
|
90
|
+
<XIcon />
|
|
91
|
+
</Button>
|
|
92
|
+
</div>
|
|
93
|
+
);
|
|
94
|
+
}
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
"use client";
|
|
2
2
|
|
|
3
|
+
import { Checkbox } from "@suphark/ui/components/ui/checkbox";
|
|
3
4
|
import {
|
|
4
5
|
Table,
|
|
5
6
|
TableBody,
|
|
@@ -38,6 +39,10 @@ export function SimpleTable<T>({
|
|
|
38
39
|
getRowKey,
|
|
39
40
|
empty = "ไม่มีข้อมูล",
|
|
40
41
|
onRowClick,
|
|
42
|
+
selected,
|
|
43
|
+
onSelectedChange,
|
|
44
|
+
selectLabel = "เลือกแถว",
|
|
45
|
+
selectAllLabel = "เลือกทั้งหมด",
|
|
41
46
|
caption,
|
|
42
47
|
className,
|
|
43
48
|
}: {
|
|
@@ -45,6 +50,13 @@ export function SimpleTable<T>({
|
|
|
45
50
|
rows: ReadonlyArray<T>;
|
|
46
51
|
getRowKey: (row: T, index: number) => string | number;
|
|
47
52
|
empty?: ReactNode;
|
|
53
|
+
/** คีย์ของแถวที่เลือก — ส่งคู่กับ `onSelectedChange` เพื่อเปิดช่องติ๊กหน้าแถว (ใช้คู่ `SelectionBar`) */
|
|
54
|
+
selected?: ReadonlyArray<string | number>;
|
|
55
|
+
onSelectedChange?: (selected: Array<string | number>) => void;
|
|
56
|
+
/** aria-label ของช่องติ๊กแต่ละแถว (ค่าเริ่มต้น "เลือกแถว") */
|
|
57
|
+
selectLabel?: string;
|
|
58
|
+
/** aria-label ของช่องติ๊กบนหัวตาราง (ค่าเริ่มต้น "เลือกทั้งหมด") */
|
|
59
|
+
selectAllLabel?: string;
|
|
48
60
|
/** คลิกทั้งแถว (เช่นเปิดรายละเอียด) — ปุ่มในแถวควร stopPropagation เอง */
|
|
49
61
|
onRowClick?: (row: T) => void;
|
|
50
62
|
/** คำอธิบายตารางสำหรับ screen reader (sr-only) */
|
|
@@ -53,12 +65,37 @@ export function SimpleTable<T>({
|
|
|
53
65
|
}) {
|
|
54
66
|
const alignClass = (a?: "left" | "center" | "right") =>
|
|
55
67
|
a === "right" ? "text-right tabular-nums" : a === "center" ? "text-center" : undefined;
|
|
68
|
+
|
|
69
|
+
const selectable = Boolean(onSelectedChange);
|
|
70
|
+
const selectedKeys = new Set(selected ?? []);
|
|
71
|
+
const allKeys = rows.map((row, i) => getRowKey(row, i));
|
|
72
|
+
const allSelected = allKeys.length > 0 && allKeys.every((k) => selectedKeys.has(k));
|
|
73
|
+
const someSelected = !allSelected && allKeys.some((k) => selectedKeys.has(k));
|
|
74
|
+
|
|
75
|
+
function toggleRow(key: string | number, checked: boolean) {
|
|
76
|
+
const next = new Set(selectedKeys);
|
|
77
|
+
if (checked) next.add(key);
|
|
78
|
+
else next.delete(key);
|
|
79
|
+
onSelectedChange?.([...next]);
|
|
80
|
+
}
|
|
56
81
|
return (
|
|
57
82
|
<div className={cn("overflow-hidden rounded-xl border bg-card", className)}>
|
|
58
83
|
<Table>
|
|
59
84
|
{caption && <caption className="sr-only">{caption}</caption>}
|
|
60
85
|
<TableHeader>
|
|
61
86
|
<TableRow>
|
|
87
|
+
{selectable && (
|
|
88
|
+
<TableHead className="w-10">
|
|
89
|
+
<Checkbox
|
|
90
|
+
aria-label={selectAllLabel}
|
|
91
|
+
checked={allSelected}
|
|
92
|
+
indeterminate={someSelected}
|
|
93
|
+
// ครึ่งหนึ่ง: ซ่อนเครื่องหมายถูก แสดงขีดแทน (ไฟล์ shadcn ไม่มีไอคอนขีด)
|
|
94
|
+
className="data-indeterminate:border-primary data-indeterminate:bg-primary data-indeterminate:text-primary-foreground data-indeterminate:before:absolute data-indeterminate:before:h-0.5 data-indeterminate:before:w-2 data-indeterminate:before:rounded-full data-indeterminate:before:bg-current [&[data-indeterminate]_svg]:hidden"
|
|
95
|
+
onCheckedChange={(checked) => onSelectedChange?.(checked ? allKeys : [])}
|
|
96
|
+
/>
|
|
97
|
+
</TableHead>
|
|
98
|
+
)}
|
|
62
99
|
{columns.map((c) => (
|
|
63
100
|
<TableHead key={c.key} className={cn(alignClass(c.align), c.className)}>
|
|
64
101
|
{c.header}
|
|
@@ -68,14 +105,26 @@ export function SimpleTable<T>({
|
|
|
68
105
|
</TableHeader>
|
|
69
106
|
<TableBody>
|
|
70
107
|
{rows.length === 0 ? (
|
|
71
|
-
<EmptyRow colSpan={columns.length}>{empty}</EmptyRow>
|
|
108
|
+
<EmptyRow colSpan={columns.length + (selectable ? 1 : 0)}>{empty}</EmptyRow>
|
|
72
109
|
) : (
|
|
73
110
|
rows.map((row, i) => (
|
|
74
111
|
<TableRow
|
|
75
112
|
key={getRowKey(row, i)}
|
|
76
113
|
onClick={onRowClick ? () => onRowClick(row) : undefined}
|
|
77
|
-
|
|
114
|
+
data-selected={selectedKeys.has(getRowKey(row, i)) || undefined}
|
|
115
|
+
className={cn(onRowClick && "cursor-pointer", "data-selected:bg-muted/50")}
|
|
78
116
|
>
|
|
117
|
+
{selectable && (
|
|
118
|
+
<TableCell className="w-10">
|
|
119
|
+
<Checkbox
|
|
120
|
+
aria-label={selectLabel}
|
|
121
|
+
checked={selectedKeys.has(getRowKey(row, i))}
|
|
122
|
+
onCheckedChange={(checked) => toggleRow(getRowKey(row, i), checked === true)}
|
|
123
|
+
// คลิกช่องติ๊กไม่ให้ไปโดน onRowClick ของทั้งแถว
|
|
124
|
+
onClick={(event) => event.stopPropagation()}
|
|
125
|
+
/>
|
|
126
|
+
</TableCell>
|
|
127
|
+
)}
|
|
79
128
|
{columns.map((c) => (
|
|
80
129
|
<TableCell key={c.key} className={cn(alignClass(c.align), c.className)}>
|
|
81
130
|
{c.cell
|
|
@@ -8,7 +8,7 @@ import {
|
|
|
8
8
|
} from "@suphark/ui/components/ui/accordion";
|
|
9
9
|
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@suphark/ui/components/ui/tabs";
|
|
10
10
|
import { cn } from "@suphark/ui/lib/utils";
|
|
11
|
-
import type
|
|
11
|
+
import { type ReactNode, useCallback, useEffect, useRef, useState } from "react";
|
|
12
12
|
|
|
13
13
|
export interface SimpleTabItem {
|
|
14
14
|
value: string;
|
|
@@ -49,6 +49,26 @@ export function SimpleTabs({
|
|
|
49
49
|
className?: string;
|
|
50
50
|
listClassName?: string;
|
|
51
51
|
}) {
|
|
52
|
+
const listRef = useRef<HTMLDivElement>(null);
|
|
53
|
+
const [edges, setEdges] = useState({ start: false, end: false });
|
|
54
|
+
|
|
55
|
+
const updateEdges = useCallback(() => {
|
|
56
|
+
const el = listRef.current;
|
|
57
|
+
if (!el) return;
|
|
58
|
+
const max = el.scrollWidth - el.clientWidth;
|
|
59
|
+
// 4px กันเศษจากการปัดทศนิยม
|
|
60
|
+
setEdges({ start: el.scrollLeft > 4, end: el.scrollLeft < max - 4 });
|
|
61
|
+
}, []);
|
|
62
|
+
|
|
63
|
+
useEffect(() => {
|
|
64
|
+
updateEdges();
|
|
65
|
+
const el = listRef.current;
|
|
66
|
+
if (!el || typeof ResizeObserver === "undefined") return;
|
|
67
|
+
const observer = new ResizeObserver(updateEdges);
|
|
68
|
+
observer.observe(el);
|
|
69
|
+
return () => observer.disconnect();
|
|
70
|
+
}, [updateEdges]);
|
|
71
|
+
|
|
52
72
|
return (
|
|
53
73
|
<Tabs
|
|
54
74
|
value={value}
|
|
@@ -57,18 +77,39 @@ export function SimpleTabs({
|
|
|
57
77
|
onValueChange={(v) => onValueChange?.(String(v))}
|
|
58
78
|
className={className}
|
|
59
79
|
>
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
80
|
+
{/* แท็บเยอะเกินความกว้าง = เลื่อนแนวนอนได้ + เงาบอกว่ายังมีต่อ (ซ่อนแถบเลื่อน) */}
|
|
81
|
+
<div className="relative">
|
|
82
|
+
<TabsList
|
|
83
|
+
ref={listRef}
|
|
84
|
+
onScroll={updateEdges}
|
|
85
|
+
variant={variant}
|
|
86
|
+
className={cn(
|
|
87
|
+
fullWidth && "w-full",
|
|
88
|
+
"no-scrollbar max-w-full overflow-x-auto scroll-smooth",
|
|
89
|
+
listClassName,
|
|
90
|
+
)}
|
|
91
|
+
>
|
|
92
|
+
{items.map((item) => (
|
|
93
|
+
<TabsTrigger key={item.value} value={item.value} disabled={item.disabled}>
|
|
94
|
+
{item.icon}
|
|
95
|
+
{item.label}
|
|
96
|
+
{item.badge}
|
|
97
|
+
</TabsTrigger>
|
|
98
|
+
))}
|
|
99
|
+
</TabsList>
|
|
100
|
+
{edges.start && (
|
|
101
|
+
<span
|
|
102
|
+
aria-hidden="true"
|
|
103
|
+
className="pointer-events-none absolute inset-y-0 left-0 w-6 rounded-l-lg bg-gradient-to-r from-background to-transparent"
|
|
104
|
+
/>
|
|
105
|
+
)}
|
|
106
|
+
{edges.end && (
|
|
107
|
+
<span
|
|
108
|
+
aria-hidden="true"
|
|
109
|
+
className="pointer-events-none absolute inset-y-0 right-0 w-6 rounded-r-lg bg-gradient-to-l from-background to-transparent"
|
|
110
|
+
/>
|
|
111
|
+
)}
|
|
112
|
+
</div>
|
|
72
113
|
{items.map((item) => (
|
|
73
114
|
<TabsContent key={item.value} value={item.value}>
|
|
74
115
|
{item.content}
|
|
@@ -20,6 +20,33 @@ export interface UiText {
|
|
|
20
20
|
confirmTitle?: string;
|
|
21
21
|
/** RowActions/ConfirmActionButton: action โยน error (optional — ค่าเริ่มต้น "ดำเนินการไม่สำเร็จ") */
|
|
22
22
|
actionFailed?: string;
|
|
23
|
+
/** InlineEdit: ปุ่มดินสอ (optional — "แก้ไข") */
|
|
24
|
+
edit?: string;
|
|
25
|
+
/** SortableList (optional) — ที่จับลาก + คำอธิบายสำหรับ screen reader */
|
|
26
|
+
sortable?: Partial<{
|
|
27
|
+
handle: string;
|
|
28
|
+
instructions: string;
|
|
29
|
+
/** ใช้ `{index}` / `{total}` */
|
|
30
|
+
started: string;
|
|
31
|
+
moved: string;
|
|
32
|
+
dropped: string;
|
|
33
|
+
cancelled: string;
|
|
34
|
+
}>;
|
|
35
|
+
/** SelectionBar (optional) — `selected` ใช้ `{count}` */
|
|
36
|
+
selection?: Partial<{ selected: string; clear: string }>;
|
|
37
|
+
/** DataTable (optional) — `rowsSelected` ใช้ `{selected}`/`{total}`, `pageOf` ใช้ `{page}`/`{pages}` */
|
|
38
|
+
table?: Partial<{
|
|
39
|
+
rowsPerPage: string;
|
|
40
|
+
pageOf: string;
|
|
41
|
+
rowsSelected: string;
|
|
42
|
+
noResults: string;
|
|
43
|
+
deleteTitle: string;
|
|
44
|
+
/** ใช้ `{name}` และ `{resource}` (ชื่อชนิดข้อมูลที่ส่งมาทาง `resourceName`) */
|
|
45
|
+
deleteDescription: string;
|
|
46
|
+
deleting: string;
|
|
47
|
+
deleted: string;
|
|
48
|
+
delete: string;
|
|
49
|
+
}>;
|
|
23
50
|
/** DialogsProvider: ปุ่มปิดของ alert (optional — ค่าเริ่มต้น "ตกลง") */
|
|
24
51
|
ok?: string;
|
|
25
52
|
/** DialogsProvider: หัวกล่อง alert (optional — ค่าเริ่มต้น "แจ้งให้ทราบ") */
|
|
@@ -135,6 +162,27 @@ export const UI_TEXT_TH: UiText = {
|
|
|
135
162
|
confirm: "ยืนยัน",
|
|
136
163
|
confirmTitle: "ยืนยันการทำรายการ",
|
|
137
164
|
actionFailed: "ดำเนินการไม่สำเร็จ",
|
|
165
|
+
edit: "แก้ไข",
|
|
166
|
+
selection: { selected: "เลือก {count} รายการ", clear: "ล้างการเลือก" },
|
|
167
|
+
sortable: {
|
|
168
|
+
handle: "ลากเพื่อจัดลำดับ",
|
|
169
|
+
instructions: "กด Space เพื่อเริ่มลาก ใช้ปุ่มลูกศรเลื่อน แล้วกด Space อีกครั้งเพื่อวาง (Esc = ยกเลิก)",
|
|
170
|
+
started: "เริ่มลากรายการที่ {index} จาก {total}",
|
|
171
|
+
moved: "ย้ายไปตำแหน่งที่ {index} จาก {total}",
|
|
172
|
+
dropped: "วางที่ตำแหน่งที่ {index} จาก {total}",
|
|
173
|
+
cancelled: "ยกเลิกการลาก",
|
|
174
|
+
},
|
|
175
|
+
table: {
|
|
176
|
+
rowsPerPage: "แถวต่อหน้า",
|
|
177
|
+
pageOf: "หน้า {page} จาก {pages}",
|
|
178
|
+
rowsSelected: "เลือก {selected} จาก {total} แถว",
|
|
179
|
+
noResults: "ไม่พบข้อมูล",
|
|
180
|
+
deleteTitle: "ยืนยันการลบ",
|
|
181
|
+
deleteDescription: "ลบ{resource} “{name}” แล้วกู้คืนไม่ได้",
|
|
182
|
+
deleting: "กำลังลบ{resource} “{name}”…",
|
|
183
|
+
deleted: "ลบ{resource} “{name}” แล้ว",
|
|
184
|
+
delete: "ลบ",
|
|
185
|
+
},
|
|
138
186
|
ok: "ตกลง",
|
|
139
187
|
alertTitle: "แจ้งให้ทราบ",
|
|
140
188
|
promptTitle: "กรอกข้อมูล",
|
|
@@ -233,6 +281,28 @@ export const UI_TEXT_EN: UiText = {
|
|
|
233
281
|
confirm: "Confirm",
|
|
234
282
|
confirmTitle: "Please confirm",
|
|
235
283
|
actionFailed: "Action failed",
|
|
284
|
+
edit: "Edit",
|
|
285
|
+
selection: { selected: "{count} selected", clear: "Clear selection" },
|
|
286
|
+
sortable: {
|
|
287
|
+
handle: "Drag to reorder",
|
|
288
|
+
instructions:
|
|
289
|
+
"Press Space to start dragging, use arrow keys to move, Space again to drop (Esc to cancel)",
|
|
290
|
+
started: "Picked up item {index} of {total}",
|
|
291
|
+
moved: "Moved to position {index} of {total}",
|
|
292
|
+
dropped: "Dropped at position {index} of {total}",
|
|
293
|
+
cancelled: "Dragging cancelled",
|
|
294
|
+
},
|
|
295
|
+
table: {
|
|
296
|
+
rowsPerPage: "Rows per page",
|
|
297
|
+
pageOf: "Page {page} of {pages}",
|
|
298
|
+
rowsSelected: "{selected} of {total} row(s) selected",
|
|
299
|
+
noResults: "No results",
|
|
300
|
+
deleteTitle: "Are you sure?",
|
|
301
|
+
deleteDescription: "Deleting{resource} “{name}” cannot be undone",
|
|
302
|
+
deleting: "Deleting{resource} “{name}”…",
|
|
303
|
+
deleted: "“{name}”{resource} deleted",
|
|
304
|
+
delete: "Delete",
|
|
305
|
+
},
|
|
236
306
|
ok: "OK",
|
|
237
307
|
alertTitle: "Notice",
|
|
238
308
|
promptTitle: "Input required",
|