@suphark/ui 0.61.0 → 0.62.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 +8 -0
- package/package.json +1 -1
- package/src/components/data-table/data-table-states.tsx +6 -85
- package/src/components/shared/quiet-table.ts +5 -0
- package/src/components/shared/simple-table.tsx +190 -5
- package/src/components/shared/table-states.tsx +91 -0
- package/src/index.ts +1 -0
package/CHANGELOG.md
CHANGED
|
@@ -2,6 +2,14 @@
|
|
|
2
2
|
|
|
3
3
|
รูปแบบตาม [Keep a Changelog](https://keepachangelog.com/) · semver: patch = หน้าตา/บั๊ก, minor = component/prop ใหม่, major = เปลี่ยน/ลบ props
|
|
4
4
|
|
|
5
|
+
## [0.62.0] — 2026-10-24
|
|
6
|
+
|
|
7
|
+
### Added
|
|
8
|
+
- `SimpleTable variant="quiet"` (opt-in, ค่าเริ่มต้น `default` เหมือนเดิม — เข้าชุดกับ `DataTable variant="quiet"`): หัวตารางเรียบ · แถวพื้นตอนชี้ · แถวที่เลือกมีแถบสีซ้าย · `StatusBadge` เป็นจุดสี · ช่องติ๊กและปุ่มท้ายแถว (คอลัมน์ที่ `key: "actions"`) โผล่เมื่อชี้/โฟกัส (จอสัมผัสเห็นตลอด)
|
|
9
|
+
- `SimpleTableColumn.mobile` (`QuietMobileSlot` = `title | end | sub | status | meta | hidden`): บนจอ < sm ของ `quiet` แสดงเป็นรายการแถวละ 3 บรรทัดแทนตารางเลื่อนแนวนอน (ช่องติ๊กอยู่ซ้ายรายการ พื้นที่แตะ 44px) · ไม่ใส่ slot ใดเลย = ตารางเหมือนเดิม
|
|
10
|
+
- `SimpleTable isLoading` (skeleton), `error` + `onRetry` (กล่องผิดพลาดแบบเดียวกับ DataTable) — ใช้ได้ทั้งสอง variant · `empty` เดิมของแอปยังใช้เหมือนเดิม
|
|
11
|
+
- `QuietMobileSlot` (type), ภายใน: `shared/table-states.tsx` (กล่องสถานะ/skeleton ที่ DataTable ใช้ร่วมกัน ไม่พึ่ง tanstack)
|
|
12
|
+
|
|
5
13
|
## [0.61.0] — 2026-10-24
|
|
6
14
|
|
|
7
15
|
### Added
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@suphark/ui",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.62.0",
|
|
4
4
|
"private": false,
|
|
5
5
|
"type": "module",
|
|
6
6
|
"description": "Design system กลางของ Suphark apps: shadcn/ui (base-nova, Base UI, Tailwind v4) + shared components + token/ฟอนต์ Noto Sans Thai Looped — ส่งเป็น source ให้แอป Next transpile เอง",
|
|
@@ -1,56 +1,12 @@
|
|
|
1
1
|
"use client";
|
|
2
2
|
|
|
3
3
|
import { Button } from "@suphark/ui/components/shared/button/button";
|
|
4
|
+
import { TableStateBox as StateBox } from "@suphark/ui/components/shared/table-states";
|
|
4
5
|
import { useUiText } from "@suphark/ui/components/shared/ui-text";
|
|
5
|
-
import { cn } from "@suphark/ui/lib/utils";
|
|
6
6
|
import type { Table } from "@tanstack/react-table";
|
|
7
|
-
import { Inbox, SearchX
|
|
7
|
+
import { Inbox, SearchX } from "lucide-react";
|
|
8
8
|
import type React from "react";
|
|
9
9
|
|
|
10
|
-
/**
|
|
11
|
-
* กล่องสถานะกลางตาราง (ไม่พบ / ว่าง / ผิดพลาด) — บนมือถือจำกัดความกว้างเท่าจอ
|
|
12
|
-
* เพื่อไม่ให้ไปอยู่กลางตารางที่เลื่อนแนวนอน (sticky ที่ขอบซ้ายของกล่องเลื่อน)
|
|
13
|
-
*/
|
|
14
|
-
function StateBox({
|
|
15
|
-
icon,
|
|
16
|
-
title,
|
|
17
|
-
hint,
|
|
18
|
-
tone,
|
|
19
|
-
children,
|
|
20
|
-
}: {
|
|
21
|
-
icon: React.ReactNode;
|
|
22
|
-
title: string;
|
|
23
|
-
hint?: string;
|
|
24
|
-
tone?: "danger";
|
|
25
|
-
children?: React.ReactNode;
|
|
26
|
-
}) {
|
|
27
|
-
return (
|
|
28
|
-
<div
|
|
29
|
-
role={tone === "danger" ? "alert" : "status"}
|
|
30
|
-
className="sticky left-0 mx-auto flex w-[calc(100vw-3rem)] max-w-full flex-col items-center gap-1.5 px-4 py-8 text-center sm:w-auto"
|
|
31
|
-
>
|
|
32
|
-
<div
|
|
33
|
-
aria-hidden="true"
|
|
34
|
-
className={cn(
|
|
35
|
-
"flex size-10 items-center justify-center rounded-full [&_svg]:size-5",
|
|
36
|
-
tone === "danger"
|
|
37
|
-
? "bg-destructive-subtle text-destructive-subtle-foreground"
|
|
38
|
-
: "bg-muted text-muted-foreground",
|
|
39
|
-
)}
|
|
40
|
-
>
|
|
41
|
-
{icon}
|
|
42
|
-
</div>
|
|
43
|
-
<h3 className="mt-1.5 font-semibold text-sm">{title}</h3>
|
|
44
|
-
{hint && <p className="max-w-[34ch] text-muted-foreground text-xs">{hint}</p>}
|
|
45
|
-
{children && (
|
|
46
|
-
<div className="mt-2 flex flex-wrap justify-center gap-2 [&_button]:pointer-coarse:h-11">
|
|
47
|
-
{children}
|
|
48
|
-
</div>
|
|
49
|
-
)}
|
|
50
|
-
</div>
|
|
51
|
-
);
|
|
52
|
-
}
|
|
53
|
-
|
|
54
10
|
/** ไม่พบรายการ: มีตัวกรอง/คำค้นอยู่ → ล้างได้ทันที · ไม่มีเลย → ว่างจริง (+ปุ่มที่แอปส่งมา) */
|
|
55
11
|
export function DataTableEmptyState<TData>({
|
|
56
12
|
table,
|
|
@@ -109,42 +65,7 @@ export function DataTableEmptyState<TData>({
|
|
|
109
65
|
);
|
|
110
66
|
}
|
|
111
67
|
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
<StateBox
|
|
117
|
-
tone="danger"
|
|
118
|
-
icon={<TriangleAlert />}
|
|
119
|
-
title={text.table?.errorTitle ?? "โหลดรายการไม่สำเร็จ"}
|
|
120
|
-
hint={text.table?.errorHint ?? "การเชื่อมต่อขัดข้อง ลองใหม่อีกครั้ง"}
|
|
121
|
-
>
|
|
122
|
-
{onRetry && (
|
|
123
|
-
<Button size="sm" onClick={onRetry}>
|
|
124
|
-
{text.table?.retry ?? "ลองใหม่"}
|
|
125
|
-
</Button>
|
|
126
|
-
)}
|
|
127
|
-
</StateBox>
|
|
128
|
-
);
|
|
129
|
-
}
|
|
130
|
-
|
|
131
|
-
/** skeleton ของรายการมือถือ (แถวละ 3 บรรทัด) ระหว่างโหลด */
|
|
132
|
-
export function QuietMobileSkeleton({ rows }: { rows: number }) {
|
|
133
|
-
const text = useUiText();
|
|
134
|
-
return (
|
|
135
|
-
<ul
|
|
136
|
-
aria-busy="true"
|
|
137
|
-
aria-label={text.table?.loading ?? "กำลังโหลด…"}
|
|
138
|
-
className="divide-y overflow-hidden rounded-xl border bg-card sm:hidden"
|
|
139
|
-
>
|
|
140
|
-
{Array.from({ length: Math.min(rows, 5) }).map((_, i) => (
|
|
141
|
-
// biome-ignore lint/suspicious/noArrayIndexKey: แถว skeleton ไม่มีตัวตน ใช้ตำแหน่งเป็น key ได้
|
|
142
|
-
<li key={i} className="flex flex-col gap-2 px-4 py-3">
|
|
143
|
-
<div className="h-3 w-1/2 animate-pulse rounded bg-muted" />
|
|
144
|
-
<div className="h-3 w-4/5 animate-pulse rounded bg-muted" />
|
|
145
|
-
<div className="h-3 w-1/3 animate-pulse rounded bg-muted" />
|
|
146
|
-
</li>
|
|
147
|
-
))}
|
|
148
|
-
</ul>
|
|
149
|
-
);
|
|
150
|
-
}
|
|
68
|
+
export {
|
|
69
|
+
QuietMobileSkeleton,
|
|
70
|
+
TableErrorState as DataTableErrorState,
|
|
71
|
+
} from "@suphark/ui/components/shared/table-states";
|
|
@@ -0,0 +1,5 @@
|
|
|
1
|
+
/** ตำแหน่งของคอลัมน์ในรายการมือถือของตาราง `variant="quiet"` (ชนิดเดียวกับ `DataTableMobileSlot`) */
|
|
2
|
+
export type QuietMobileSlot = "title" | "end" | "sub" | "status" | "meta" | "hidden";
|
|
3
|
+
|
|
4
|
+
/** ลำดับบรรทัดของรายการมือถือ (`meta` ต่อท้าย `status`, `end` ต่อท้าย `title`) */
|
|
5
|
+
export const QUIET_MOBILE_SLOT_ORDER: QuietMobileSlot[] = ["title", "end", "sub", "status", "meta"];
|
|
@@ -12,6 +12,8 @@ import {
|
|
|
12
12
|
import { cn } from "@suphark/ui/lib/utils";
|
|
13
13
|
import type { ReactNode } from "react";
|
|
14
14
|
import { EmptyRow } from "./empty-row";
|
|
15
|
+
import { QUIET_MOBILE_SLOT_ORDER, type QuietMobileSlot } from "./quiet-table";
|
|
16
|
+
import { QuietMobileSkeleton, TableErrorState } from "./table-states";
|
|
15
17
|
|
|
16
18
|
export interface SimpleTableColumn<T> {
|
|
17
19
|
/** key ของคอลัมน์ — ถ้าตรงกับ field ของแถวและไม่ส่ง cell จะแสดงค่านั้นตรง ๆ */
|
|
@@ -21,6 +23,12 @@ export interface SimpleTableColumn<T> {
|
|
|
21
23
|
align?: "left" | "center" | "right";
|
|
22
24
|
/** class ของทั้งหัวและเซลล์ เช่น "w-32" หรือ "hidden md:table-cell" (ซ่อนบนมือถือ) */
|
|
23
25
|
className?: string;
|
|
26
|
+
/**
|
|
27
|
+
* `variant="quiet"` บนจอ < sm: ตำแหน่งในรายการแถวละ 3 บรรทัด (`title` ชื่อ · `end` ยอดต่อท้ายชื่อ · `sub` บรรทัดรอง ·
|
|
28
|
+
* `status` สถานะ · `meta` ข้อความเล็กต่อท้ายสถานะ · `hidden` ไม่แสดง) — ไม่ใส่ slot ใดเลย = ตารางเลื่อนแนวนอนเหมือนเดิม
|
|
29
|
+
* คอลัมน์ที่ `key === "actions"` ถือเป็นปุ่มท้ายแถว (quiet: โผล่เมื่อชี้/โฟกัส)
|
|
30
|
+
*/
|
|
31
|
+
mobile?: QuietMobileSlot;
|
|
24
32
|
}
|
|
25
33
|
|
|
26
34
|
/**
|
|
@@ -45,6 +53,10 @@ export function SimpleTable<T>({
|
|
|
45
53
|
selectAllLabel = "เลือกทั้งหมด",
|
|
46
54
|
caption,
|
|
47
55
|
className,
|
|
56
|
+
variant = "default",
|
|
57
|
+
isLoading,
|
|
58
|
+
error,
|
|
59
|
+
onRetry,
|
|
48
60
|
}: {
|
|
49
61
|
columns: ReadonlyArray<SimpleTableColumn<T>>;
|
|
50
62
|
rows: ReadonlyArray<T>;
|
|
@@ -62,7 +74,21 @@ export function SimpleTable<T>({
|
|
|
62
74
|
/** คำอธิบายตารางสำหรับ screen reader (sr-only) */
|
|
63
75
|
caption?: string;
|
|
64
76
|
className?: string;
|
|
77
|
+
/**
|
|
78
|
+
* `quiet` = หัวเรียบ แถวเรียบ สถานะเป็นจุดสี ช่องติ๊ก/ปุ่มท้ายแถว (`key: "actions"`) โผล่เมื่อชี้ (จอสัมผัสเห็นตลอด)
|
|
79
|
+
* และรายการบนมือถือผ่าน `columns[].mobile` — เข้าชุดกับ `DataTable variant="quiet"`
|
|
80
|
+
*/
|
|
81
|
+
variant?: "default" | "quiet";
|
|
82
|
+
/** แสดง skeleton แทนแถว */
|
|
83
|
+
isLoading?: boolean;
|
|
84
|
+
/** โหลดไม่สำเร็จ: แสดงกล่องผิดพลาดแทนแถว (ข้อความกลาง ไม่แสดง error ดิบ) */
|
|
85
|
+
error?: boolean;
|
|
86
|
+
/** ปุ่ม "ลองใหม่" ของกล่องผิดพลาด — ไม่ส่ง = ไม่มีปุ่ม */
|
|
87
|
+
onRetry?: () => void;
|
|
65
88
|
}) {
|
|
89
|
+
const quiet = variant === "quiet";
|
|
90
|
+
const hasMobileSlots = quiet && columns.some((c) => c.mobile);
|
|
91
|
+
const hasRows = rows.length > 0 && !isLoading && !error;
|
|
66
92
|
const alignClass = (a?: "left" | "center" | "right") =>
|
|
67
93
|
a === "right" ? "text-right tabular-nums" : a === "center" ? "text-center" : undefined;
|
|
68
94
|
|
|
@@ -78,8 +104,18 @@ export function SimpleTable<T>({
|
|
|
78
104
|
else next.delete(key);
|
|
79
105
|
onSelectedChange?.([...next]);
|
|
80
106
|
}
|
|
81
|
-
|
|
82
|
-
|
|
107
|
+
const tableColSpan = columns.length + (selectable ? 1 : 0);
|
|
108
|
+
const showMobileList = hasMobileSlots && hasRows;
|
|
109
|
+
const showMobileSkeleton = hasMobileSlots && Boolean(isLoading) && !error;
|
|
110
|
+
const body = (
|
|
111
|
+
<div
|
|
112
|
+
className={cn(
|
|
113
|
+
"overflow-hidden rounded-xl border bg-card",
|
|
114
|
+
quiet && QUIET_SIMPLE_TABLE,
|
|
115
|
+
(showMobileList || showMobileSkeleton) && "max-sm:hidden",
|
|
116
|
+
className,
|
|
117
|
+
)}
|
|
118
|
+
>
|
|
83
119
|
<Table>
|
|
84
120
|
{caption && <caption className="sr-only">{caption}</caption>}
|
|
85
121
|
<TableHeader>
|
|
@@ -104,8 +140,26 @@ export function SimpleTable<T>({
|
|
|
104
140
|
</TableRow>
|
|
105
141
|
</TableHeader>
|
|
106
142
|
<TableBody>
|
|
107
|
-
{
|
|
108
|
-
<
|
|
143
|
+
{error ? (
|
|
144
|
+
<TableRow>
|
|
145
|
+
<TableCell colSpan={tableColSpan}>
|
|
146
|
+
<TableErrorState onRetry={onRetry} />
|
|
147
|
+
</TableCell>
|
|
148
|
+
</TableRow>
|
|
149
|
+
) : isLoading ? (
|
|
150
|
+
Array.from({ length: 3 }).map((_, i) => (
|
|
151
|
+
// biome-ignore lint/suspicious/noArrayIndexKey: แถว skeleton ไม่มีตัวตน ใช้ตำแหน่งเป็น key ได้
|
|
152
|
+
<TableRow key={i}>
|
|
153
|
+
{Array.from({ length: tableColSpan }).map((_cell, j) => (
|
|
154
|
+
// biome-ignore lint/suspicious/noArrayIndexKey: เซลล์ skeleton เช่นกัน
|
|
155
|
+
<TableCell key={j}>
|
|
156
|
+
<div className="h-4 w-full animate-pulse rounded bg-muted" />
|
|
157
|
+
</TableCell>
|
|
158
|
+
))}
|
|
159
|
+
</TableRow>
|
|
160
|
+
))
|
|
161
|
+
) : rows.length === 0 ? (
|
|
162
|
+
<EmptyRow colSpan={tableColSpan}>{empty}</EmptyRow>
|
|
109
163
|
) : (
|
|
110
164
|
rows.map((row, i) => (
|
|
111
165
|
<TableRow
|
|
@@ -126,7 +180,11 @@ export function SimpleTable<T>({
|
|
|
126
180
|
</TableCell>
|
|
127
181
|
)}
|
|
128
182
|
{columns.map((c) => (
|
|
129
|
-
<TableCell
|
|
183
|
+
<TableCell
|
|
184
|
+
key={c.key}
|
|
185
|
+
data-slot={c.key === "actions" ? "simple-table-row-actions" : undefined}
|
|
186
|
+
className={cn(alignClass(c.align), c.className)}
|
|
187
|
+
>
|
|
130
188
|
{c.cell
|
|
131
189
|
? c.cell(row, i)
|
|
132
190
|
: String((row as Record<string, unknown>)[c.key] ?? "")}
|
|
@@ -139,4 +197,131 @@ export function SimpleTable<T>({
|
|
|
139
197
|
</Table>
|
|
140
198
|
</div>
|
|
141
199
|
);
|
|
200
|
+
if (!quiet) return body;
|
|
201
|
+
return (
|
|
202
|
+
<div data-variant="quiet" className="flex flex-col gap-2">
|
|
203
|
+
{showMobileList && (
|
|
204
|
+
<QuietSimpleMobileList
|
|
205
|
+
columns={columns}
|
|
206
|
+
rows={rows}
|
|
207
|
+
getRowKey={getRowKey}
|
|
208
|
+
selectable={selectable}
|
|
209
|
+
selectedKeys={selectedKeys}
|
|
210
|
+
selectLabel={selectLabel}
|
|
211
|
+
toggleRow={toggleRow}
|
|
212
|
+
onRowClick={onRowClick}
|
|
213
|
+
/>
|
|
214
|
+
)}
|
|
215
|
+
{showMobileSkeleton && <QuietMobileSkeleton rows={3} />}
|
|
216
|
+
{body}
|
|
217
|
+
</div>
|
|
218
|
+
);
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
/** `variant="quiet"`: สไตล์ผ่านตัวเลือก CSS (ไม่แตะ markup ของเซลล์ที่แอปส่งมา) — ชุดเดียวกับ `DataTable` quiet */
|
|
222
|
+
const QUIET_SIMPLE_TABLE = [
|
|
223
|
+
"shadow-none",
|
|
224
|
+
"[&_thead]:bg-muted/40 [&_th]:h-9 [&_th]:font-semibold [&_th]:text-label [&_th]:text-muted-foreground",
|
|
225
|
+
"[&_tbody_tr]:hover:bg-muted/40",
|
|
226
|
+
"[&_tbody_tr[data-selected]]:bg-primary/[0.07] [&_tbody_tr[data-selected]]:shadow-[inset_3px_0_0_var(--primary)]",
|
|
227
|
+
"[@media(hover:hover)]:[&_tbody_tr:not(:hover):not(:focus-within):not([data-selected])_[role=checkbox]]:opacity-0",
|
|
228
|
+
"[@media(hover:hover)]:[&_tbody_tr:not(:hover):not(:focus-within)_[data-slot=simple-table-row-actions]]:opacity-0",
|
|
229
|
+
"[&_[role=checkbox]]:transition-opacity [&_[data-slot=simple-table-row-actions]]:transition-opacity",
|
|
230
|
+
"[&_td]:py-2.5",
|
|
231
|
+
].join(" ");
|
|
232
|
+
|
|
233
|
+
function QuietSimpleMobileList<T>({
|
|
234
|
+
columns,
|
|
235
|
+
rows,
|
|
236
|
+
getRowKey,
|
|
237
|
+
selectable,
|
|
238
|
+
selectedKeys,
|
|
239
|
+
selectLabel,
|
|
240
|
+
toggleRow,
|
|
241
|
+
onRowClick,
|
|
242
|
+
}: {
|
|
243
|
+
columns: ReadonlyArray<SimpleTableColumn<T>>;
|
|
244
|
+
rows: ReadonlyArray<T>;
|
|
245
|
+
getRowKey: (row: T, index: number) => string | number;
|
|
246
|
+
selectable: boolean;
|
|
247
|
+
selectedKeys: Set<string | number>;
|
|
248
|
+
selectLabel: string;
|
|
249
|
+
toggleRow: (key: string | number, checked: boolean) => void;
|
|
250
|
+
onRowClick?: (row: T) => void;
|
|
251
|
+
}) {
|
|
252
|
+
const pick = (slot: QuietMobileSlot) => columns.filter((c) => c.mobile === slot);
|
|
253
|
+
const render = (c: SimpleTableColumn<T>, row: T, i: number) => (
|
|
254
|
+
<span key={c.key}>
|
|
255
|
+
{c.cell ? c.cell(row, i) : String((row as Record<string, unknown>)[c.key] ?? "")}
|
|
256
|
+
</span>
|
|
257
|
+
);
|
|
258
|
+
return (
|
|
259
|
+
<ul className="divide-y overflow-hidden rounded-xl border bg-card sm:hidden">
|
|
260
|
+
{rows.map((row, i) => {
|
|
261
|
+
const key = getRowKey(row, i);
|
|
262
|
+
const isSelected = selectedKeys.has(key);
|
|
263
|
+
return (
|
|
264
|
+
<li
|
|
265
|
+
key={key}
|
|
266
|
+
data-selected={isSelected || undefined}
|
|
267
|
+
onClick={onRowClick ? () => onRowClick(row) : undefined}
|
|
268
|
+
className={cn(
|
|
269
|
+
"flex items-start gap-3 px-4 py-2.5 data-selected:bg-primary/[0.07] data-selected:shadow-[inset_3px_0_0_var(--primary)]",
|
|
270
|
+
onRowClick && "cursor-pointer",
|
|
271
|
+
)}
|
|
272
|
+
>
|
|
273
|
+
{selectable && (
|
|
274
|
+
// ขยายพื้นที่แตะรอบช่องติ๊ก 16px เป็น 44px
|
|
275
|
+
<div className="-m-3.5 -mt-3 shrink-0 p-3.5">
|
|
276
|
+
<Checkbox
|
|
277
|
+
aria-label={selectLabel}
|
|
278
|
+
checked={isSelected}
|
|
279
|
+
onCheckedChange={(checked) => toggleRow(key, checked === true)}
|
|
280
|
+
onClick={(event) => event.stopPropagation()}
|
|
281
|
+
/>
|
|
282
|
+
</div>
|
|
283
|
+
)}
|
|
284
|
+
<div className="flex min-w-0 flex-1 flex-col gap-0.5">
|
|
285
|
+
{QUIET_MOBILE_SLOT_ORDER.map((slot) => {
|
|
286
|
+
const found = pick(slot);
|
|
287
|
+
if (slot === "meta" || slot === "end" || found.length === 0) return null;
|
|
288
|
+
if (slot === "title") {
|
|
289
|
+
return (
|
|
290
|
+
<div
|
|
291
|
+
key={slot}
|
|
292
|
+
className="flex items-baseline justify-between gap-2 font-semibold text-sm"
|
|
293
|
+
>
|
|
294
|
+
<span className="min-w-0 truncate">
|
|
295
|
+
{found.map((c) => render(c, row, i))}
|
|
296
|
+
</span>
|
|
297
|
+
<span className="shrink-0 tabular-nums">
|
|
298
|
+
{pick("end").map((c) => render(c, row, i))}
|
|
299
|
+
</span>
|
|
300
|
+
</div>
|
|
301
|
+
);
|
|
302
|
+
}
|
|
303
|
+
if (slot === "sub") {
|
|
304
|
+
return (
|
|
305
|
+
<div key={slot} className="truncate text-muted-foreground text-sm">
|
|
306
|
+
{found.map((c) => render(c, row, i))}
|
|
307
|
+
</div>
|
|
308
|
+
);
|
|
309
|
+
}
|
|
310
|
+
return (
|
|
311
|
+
<div key={slot} className="flex items-center gap-1.5 text-xs">
|
|
312
|
+
{found.map((c) => render(c, row, i))}
|
|
313
|
+
{pick("meta").map((c) => (
|
|
314
|
+
<span key={c.key} className="text-muted-foreground">
|
|
315
|
+
· {render(c, row, i)}
|
|
316
|
+
</span>
|
|
317
|
+
))}
|
|
318
|
+
</div>
|
|
319
|
+
);
|
|
320
|
+
})}
|
|
321
|
+
</div>
|
|
322
|
+
</li>
|
|
323
|
+
);
|
|
324
|
+
})}
|
|
325
|
+
</ul>
|
|
326
|
+
);
|
|
142
327
|
}
|
|
@@ -0,0 +1,91 @@
|
|
|
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 { TriangleAlert } from "lucide-react";
|
|
7
|
+
import type React from "react";
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* กล่องสถานะกลางตาราง (ไม่พบ / ว่าง / ผิดพลาด) — บนมือถือจำกัดความกว้างเท่าจอ
|
|
11
|
+
* เพื่อไม่ให้ไปอยู่กลางตารางที่เลื่อนแนวนอน (sticky ที่ขอบซ้ายของกล่องเลื่อน)
|
|
12
|
+
*/
|
|
13
|
+
export function TableStateBox({
|
|
14
|
+
icon,
|
|
15
|
+
title,
|
|
16
|
+
hint,
|
|
17
|
+
tone,
|
|
18
|
+
children,
|
|
19
|
+
}: {
|
|
20
|
+
icon: React.ReactNode;
|
|
21
|
+
title: string;
|
|
22
|
+
hint?: string;
|
|
23
|
+
tone?: "danger";
|
|
24
|
+
children?: React.ReactNode;
|
|
25
|
+
}) {
|
|
26
|
+
return (
|
|
27
|
+
<div
|
|
28
|
+
role={tone === "danger" ? "alert" : "status"}
|
|
29
|
+
className="sticky left-0 mx-auto flex w-[calc(100vw-3rem)] max-w-full flex-col items-center gap-1.5 px-4 py-8 text-center sm:w-auto"
|
|
30
|
+
>
|
|
31
|
+
<div
|
|
32
|
+
aria-hidden="true"
|
|
33
|
+
className={cn(
|
|
34
|
+
"flex size-10 items-center justify-center rounded-full [&_svg]:size-5",
|
|
35
|
+
tone === "danger"
|
|
36
|
+
? "bg-destructive-subtle text-destructive-subtle-foreground"
|
|
37
|
+
: "bg-muted text-muted-foreground",
|
|
38
|
+
)}
|
|
39
|
+
>
|
|
40
|
+
{icon}
|
|
41
|
+
</div>
|
|
42
|
+
<h3 className="mt-1.5 font-semibold text-sm">{title}</h3>
|
|
43
|
+
{hint && <p className="max-w-[34ch] text-muted-foreground text-xs">{hint}</p>}
|
|
44
|
+
{children && (
|
|
45
|
+
<div className="mt-2 flex flex-wrap justify-center gap-2 [&_button]:pointer-coarse:h-11">
|
|
46
|
+
{children}
|
|
47
|
+
</div>
|
|
48
|
+
)}
|
|
49
|
+
</div>
|
|
50
|
+
);
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/** โหลดไม่สำเร็จ: ข้อความกลาง ๆ (ไม่แสดงข้อความดิบของระบบ) + ปุ่มลองใหม่เมื่อส่ง `onRetry` */
|
|
54
|
+
export function TableErrorState({ onRetry }: { onRetry?: () => void }) {
|
|
55
|
+
const text = useUiText();
|
|
56
|
+
return (
|
|
57
|
+
<TableStateBox
|
|
58
|
+
tone="danger"
|
|
59
|
+
icon={<TriangleAlert />}
|
|
60
|
+
title={text.table?.errorTitle ?? "โหลดรายการไม่สำเร็จ"}
|
|
61
|
+
hint={text.table?.errorHint ?? "การเชื่อมต่อขัดข้อง ลองใหม่อีกครั้ง"}
|
|
62
|
+
>
|
|
63
|
+
{onRetry && (
|
|
64
|
+
<Button size="sm" onClick={onRetry}>
|
|
65
|
+
{text.table?.retry ?? "ลองใหม่"}
|
|
66
|
+
</Button>
|
|
67
|
+
)}
|
|
68
|
+
</TableStateBox>
|
|
69
|
+
);
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/** skeleton ของรายการมือถือ (แถวละ 3 บรรทัด) ระหว่างโหลด */
|
|
73
|
+
export function QuietMobileSkeleton({ rows }: { rows: number }) {
|
|
74
|
+
const text = useUiText();
|
|
75
|
+
return (
|
|
76
|
+
<ul
|
|
77
|
+
aria-busy="true"
|
|
78
|
+
aria-label={text.table?.loading ?? "กำลังโหลด…"}
|
|
79
|
+
className="divide-y overflow-hidden rounded-xl border bg-card sm:hidden"
|
|
80
|
+
>
|
|
81
|
+
{Array.from({ length: Math.min(rows, 5) }).map((_, i) => (
|
|
82
|
+
// biome-ignore lint/suspicious/noArrayIndexKey: แถว skeleton ไม่มีตัวตน ใช้ตำแหน่งเป็น key ได้
|
|
83
|
+
<li key={i} className="flex flex-col gap-2 px-4 py-3">
|
|
84
|
+
<div className="h-3 w-1/2 animate-pulse rounded bg-muted" />
|
|
85
|
+
<div className="h-3 w-4/5 animate-pulse rounded bg-muted" />
|
|
86
|
+
<div className="h-3 w-1/3 animate-pulse rounded bg-muted" />
|
|
87
|
+
</li>
|
|
88
|
+
))}
|
|
89
|
+
</ul>
|
|
90
|
+
);
|
|
91
|
+
}
|
package/src/index.ts
CHANGED
|
@@ -210,6 +210,7 @@ export { PageHeader } from "./components/shared/page-header";
|
|
|
210
210
|
export { PageSkeleton } from "./components/shared/page-skeleton";
|
|
211
211
|
export { type Presence, PresenceDot } from "./components/shared/presence-dot";
|
|
212
212
|
export { ProgressBar, type ProgressTone } from "./components/shared/progress-bar";
|
|
213
|
+
export type { QuietMobileSlot } from "./components/shared/quiet-table";
|
|
213
214
|
export {
|
|
214
215
|
type RecordAccent,
|
|
215
216
|
RecordCard,
|