@suphark/ui 0.60.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 +15 -0
- package/package.json +1 -1
- package/src/components/data-table/data-table-states.tsx +71 -0
- package/src/components/data-table/data-table-toolbar.tsx +8 -3
- package/src/components/data-table/data-table.tsx +28 -4
- package/src/components/data-table/types.ts +8 -0
- 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/components/shared/ui-text.tsx +37 -0
- package/src/index.ts +1 -0
package/CHANGELOG.md
CHANGED
|
@@ -2,6 +2,21 @@
|
|
|
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
|
+
|
|
13
|
+
## [0.61.0] — 2026-10-24
|
|
14
|
+
|
|
15
|
+
### Added
|
|
16
|
+
- `DataTable` สถานะตาราง (ตาม mockup): **ผิดพลาด** — prop `error` แสดงกล่อง "โหลดรายการไม่สำเร็จ" แทนตาราง (ข้อความกลาง ไม่แสดง error ดิบ, ซ่อนเลขหน้า) + `onRetry` = ปุ่ม "ลองใหม่" (ทั้งสอง variant) · `variant="quiet"`: **ไม่พบ** — บอกคำค้น "ไม่พบรายการที่ตรงกับ …" + ปุ่ม "ล้างตัวกรองทั้งหมด"/"ล้างคำค้น" (เคารพ `preservedFilterIds`), **ว่างจริง** — `resourceName` ("ยังไม่มีใบสั่งซื้อ") + `emptyAction` (ปุ่มสร้าง; ไม่ส่ง = ไม่มีปุ่ม), **โหลด** — ตัวนับ "กำลังโหลด…" และ skeleton รายการ 3 บรรทัดบนมือถือ · กล่องสถานะกว้างไม่เกินจอบนมือถือ ปุ่มสูง 44px บนจอสัมผัส · `emptyStateComponent` ของแอปยังทับได้ทั้งหมด
|
|
17
|
+
- `DataTableEmptyState`, `DataTableErrorState` (ภายใน `data-table-states.tsx`), `UiText.table.loading` / `emptyTitle` / `emptyTitleOf` / `emptyHint` / `noMatch` / `noMatchQuery` / `noMatchHint` / `clearAllFilters` / `clearQuery` / `errorTitle` / `errorHint` / `retry`
|
|
18
|
+
- showcase: เดโม DataTable สลับสถานะ ปกติ/กำลังโหลด/ว่างจริง/ผิดพลาดได้
|
|
19
|
+
|
|
5
20
|
## [0.60.0] — 2026-10-24
|
|
6
21
|
|
|
7
22
|
### 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 เอง",
|
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
import { Button } from "@suphark/ui/components/shared/button/button";
|
|
4
|
+
import { TableStateBox as StateBox } from "@suphark/ui/components/shared/table-states";
|
|
5
|
+
import { useUiText } from "@suphark/ui/components/shared/ui-text";
|
|
6
|
+
import type { Table } from "@tanstack/react-table";
|
|
7
|
+
import { Inbox, SearchX } from "lucide-react";
|
|
8
|
+
import type React from "react";
|
|
9
|
+
|
|
10
|
+
/** ไม่พบรายการ: มีตัวกรอง/คำค้นอยู่ → ล้างได้ทันที · ไม่มีเลย → ว่างจริง (+ปุ่มที่แอปส่งมา) */
|
|
11
|
+
export function DataTableEmptyState<TData>({
|
|
12
|
+
table,
|
|
13
|
+
preservedFilterIds = [],
|
|
14
|
+
resourceName,
|
|
15
|
+
emptyAction,
|
|
16
|
+
}: {
|
|
17
|
+
table: Table<TData>;
|
|
18
|
+
preservedFilterIds?: string[];
|
|
19
|
+
resourceName?: string;
|
|
20
|
+
emptyAction?: React.ReactNode;
|
|
21
|
+
}) {
|
|
22
|
+
const text = useUiText();
|
|
23
|
+
const keep = new Set(preservedFilterIds);
|
|
24
|
+
const query = String(table.getState().globalFilter ?? "").trim();
|
|
25
|
+
const hasColumnFilters = table.getState().columnFilters.some((filter) => !keep.has(filter.id));
|
|
26
|
+
if (!query && !hasColumnFilters) {
|
|
27
|
+
return (
|
|
28
|
+
<StateBox
|
|
29
|
+
icon={<Inbox />}
|
|
30
|
+
title={
|
|
31
|
+
resourceName
|
|
32
|
+
? (text.table?.emptyTitleOf ?? "ยังไม่มี{resource}").replace("{resource}", resourceName)
|
|
33
|
+
: (text.table?.emptyTitle ?? "ยังไม่มีรายการ")
|
|
34
|
+
}
|
|
35
|
+
hint={text.table?.emptyHint ?? "เมื่อสร้างแล้วจะแสดงที่นี่"}
|
|
36
|
+
>
|
|
37
|
+
{emptyAction}
|
|
38
|
+
</StateBox>
|
|
39
|
+
);
|
|
40
|
+
}
|
|
41
|
+
const clearQuery = () => table.setGlobalFilter("");
|
|
42
|
+
const clearAll = () => {
|
|
43
|
+
table.setColumnFilters((filters) => filters.filter((filter) => keep.has(filter.id)));
|
|
44
|
+
table.setGlobalFilter("");
|
|
45
|
+
};
|
|
46
|
+
return (
|
|
47
|
+
<StateBox
|
|
48
|
+
icon={<SearchX />}
|
|
49
|
+
title={
|
|
50
|
+
query
|
|
51
|
+
? (text.table?.noMatchQuery ?? 'ไม่พบรายการที่ตรงกับ "{query}"').replace("{query}", query)
|
|
52
|
+
: (text.table?.noMatch ?? "ไม่พบรายการที่ตรงกัน")
|
|
53
|
+
}
|
|
54
|
+
hint={text.table?.noMatchHint ?? "ลองคำค้นอื่น หรือเอาตัวกรองออกบางตัว"}
|
|
55
|
+
>
|
|
56
|
+
<Button size="sm" onClick={clearAll}>
|
|
57
|
+
{text.table?.clearAllFilters ?? "ล้างตัวกรองทั้งหมด"}
|
|
58
|
+
</Button>
|
|
59
|
+
{query && hasColumnFilters && (
|
|
60
|
+
<Button size="sm" variant="outline" onClick={clearQuery}>
|
|
61
|
+
{text.table?.clearQuery ?? "ล้างคำค้น"}
|
|
62
|
+
</Button>
|
|
63
|
+
)}
|
|
64
|
+
</StateBox>
|
|
65
|
+
);
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
export {
|
|
69
|
+
QuietMobileSkeleton,
|
|
70
|
+
TableErrorState as DataTableErrorState,
|
|
71
|
+
} from "@suphark/ui/components/shared/table-states";
|
|
@@ -40,6 +40,8 @@ interface DataTableToolbarProps<TData> {
|
|
|
40
40
|
variant?: "default" | "quiet";
|
|
41
41
|
density?: "comfortable" | "compact";
|
|
42
42
|
onDensityChange?: (density: "comfortable" | "compact") => void;
|
|
43
|
+
/** `quiet`: ตัวนับแสดง "กำลังโหลด…" */
|
|
44
|
+
isLoading?: boolean;
|
|
43
45
|
}
|
|
44
46
|
|
|
45
47
|
export function DataTableToolbar<TData>({
|
|
@@ -52,6 +54,7 @@ export function DataTableToolbar<TData>({
|
|
|
52
54
|
variant = "default",
|
|
53
55
|
density = "comfortable",
|
|
54
56
|
onDensityChange,
|
|
57
|
+
isLoading,
|
|
55
58
|
}: DataTableToolbarProps<TData>) {
|
|
56
59
|
const text = useUiText();
|
|
57
60
|
const isMobile = useIsMobile();
|
|
@@ -280,9 +283,11 @@ export function DataTableToolbar<TData>({
|
|
|
280
283
|
})}
|
|
281
284
|
<div className="ml-auto flex items-center gap-1">
|
|
282
285
|
<span className="me-1 text-muted-foreground text-xs tabular-nums">
|
|
283
|
-
{
|
|
284
|
-
.
|
|
285
|
-
.
|
|
286
|
+
{isLoading
|
|
287
|
+
? (text.table?.loading ?? "กำลังโหลด…")
|
|
288
|
+
: (text.table?.shownOf ?? "{shown} จาก {total} รายการ")
|
|
289
|
+
.replace("{shown}", String(shown))
|
|
290
|
+
.replace("{total}", String(total))}
|
|
286
291
|
</span>
|
|
287
292
|
{actionsComponent}
|
|
288
293
|
{onDensityChange && (
|
|
@@ -15,6 +15,7 @@ import { cn } from "@suphark/ui/lib/utils";
|
|
|
15
15
|
import { flexRender } from "@tanstack/react-table";
|
|
16
16
|
import * as React from "react";
|
|
17
17
|
import { DataTablePagination } from "./data-table-pagination";
|
|
18
|
+
import { DataTableEmptyState, DataTableErrorState, QuietMobileSkeleton } from "./data-table-states";
|
|
18
19
|
import { DataTableToolbar } from "./data-table-toolbar";
|
|
19
20
|
import type { DataTableMobileSlot, DataTableProps } from "./types";
|
|
20
21
|
import { useDataTable } from "./use-data-table";
|
|
@@ -86,6 +87,10 @@ export function DataTable<TData, TValue>({
|
|
|
86
87
|
defaultDensity = "comfortable",
|
|
87
88
|
selectionActions,
|
|
88
89
|
densityStorageKey,
|
|
90
|
+
resourceName,
|
|
91
|
+
emptyAction,
|
|
92
|
+
error,
|
|
93
|
+
onRetry,
|
|
89
94
|
}: DataTableProps<TData, TValue>) {
|
|
90
95
|
const text = useUiText();
|
|
91
96
|
const quiet = variant === "quiet";
|
|
@@ -193,7 +198,13 @@ export function DataTable<TData, TValue>({
|
|
|
193
198
|
))}
|
|
194
199
|
</TableHeader>
|
|
195
200
|
<TableBody>
|
|
196
|
-
{
|
|
201
|
+
{error ? (
|
|
202
|
+
<TableRow>
|
|
203
|
+
<TableCell colSpan={table.getVisibleLeafColumns().length}>
|
|
204
|
+
<DataTableErrorState onRetry={onRetry} />
|
|
205
|
+
</TableCell>
|
|
206
|
+
</TableRow>
|
|
207
|
+
) : isLoading ? (
|
|
197
208
|
Array.from({ length: initialPageSize }).map((_, i) => (
|
|
198
209
|
// biome-ignore lint/suspicious/noArrayIndexKey: แถว skeleton ไม่มีตัวตน ใช้ตำแหน่งเป็น key ได้
|
|
199
210
|
<TableRow key={i}>
|
|
@@ -246,7 +257,17 @@ export function DataTable<TData, TValue>({
|
|
|
246
257
|
) : (
|
|
247
258
|
<TableRow>
|
|
248
259
|
<TableCell colSpan={table.getVisibleLeafColumns().length} className="h-24 text-center">
|
|
249
|
-
{emptyStateComponent ||
|
|
260
|
+
{emptyStateComponent ||
|
|
261
|
+
(quiet ? (
|
|
262
|
+
<DataTableEmptyState
|
|
263
|
+
table={table}
|
|
264
|
+
preservedFilterIds={preservedFilterIds}
|
|
265
|
+
resourceName={resourceName}
|
|
266
|
+
emptyAction={emptyAction}
|
|
267
|
+
/>
|
|
268
|
+
) : (
|
|
269
|
+
(text.table?.noResults ?? "ไม่พบข้อมูล")
|
|
270
|
+
))}
|
|
250
271
|
</TableCell>
|
|
251
272
|
</TableRow>
|
|
252
273
|
)}
|
|
@@ -258,8 +279,10 @@ export function DataTable<TData, TValue>({
|
|
|
258
279
|
.getAllLeafColumns()
|
|
259
280
|
.some((column) => (column.columnDef.meta as { mobile?: string } | undefined)?.mobile);
|
|
260
281
|
const mobileList =
|
|
261
|
-
quiet && hasMobileSlots && !isLoading && table.getRowModel().rows.length > 0 ? (
|
|
282
|
+
quiet && hasMobileSlots && !isLoading && !error && table.getRowModel().rows.length > 0 ? (
|
|
262
283
|
<QuietMobileList table={table} />
|
|
284
|
+
) : quiet && hasMobileSlots && isLoading && !error ? (
|
|
285
|
+
<QuietMobileSkeleton rows={initialPageSize} />
|
|
263
286
|
) : null;
|
|
264
287
|
|
|
265
288
|
const customView = renderCustomView?.(table);
|
|
@@ -280,6 +303,7 @@ export function DataTable<TData, TValue>({
|
|
|
280
303
|
variant={variant}
|
|
281
304
|
density={density}
|
|
282
305
|
onDensityChange={setDensity}
|
|
306
|
+
isLoading={isLoading}
|
|
283
307
|
/>
|
|
284
308
|
)}
|
|
285
309
|
{customView ? (
|
|
@@ -321,7 +345,7 @@ export function DataTable<TData, TValue>({
|
|
|
321
345
|
renderTable(false)
|
|
322
346
|
)}
|
|
323
347
|
</div>
|
|
324
|
-
{showPagination && (
|
|
348
|
+
{showPagination && !error && (
|
|
325
349
|
<div
|
|
326
350
|
className={
|
|
327
351
|
stickyPagination
|
|
@@ -122,6 +122,14 @@ export interface DataTableProps<TData, TValue> {
|
|
|
122
122
|
* ไม่ส่ง = ไม่จำ (กลับเป็น `defaultDensity` ทุกครั้ง) · localStorage ใช้ไม่ได้ก็ทำงานต่อโดยไม่จำ
|
|
123
123
|
*/
|
|
124
124
|
densityStorageKey?: string;
|
|
125
|
+
/** ชื่อชนิดข้อมูลสำหรับสถานะว่าง เช่น "ใบสั่งซื้อ" → "ยังไม่มีใบสั่งซื้อ" (`quiet`) */
|
|
126
|
+
resourceName?: string;
|
|
127
|
+
/** `quiet`: ปุ่ม/ลิงก์ในสถานะ "ว่างจริง" (ไม่มีข้อมูลและไม่มีตัวกรอง) เช่น ปุ่ม "สร้างใบสั่งซื้อ" — ไม่ส่ง = ไม่มีปุ่ม */
|
|
128
|
+
emptyAction?: React.ReactNode;
|
|
129
|
+
/** โหลดไม่สำเร็จ: แสดงสถานะผิดพลาดแทนตาราง (ทั้งสอง variant) — ข้อความกลาง ๆ ไม่แสดง error ดิบ */
|
|
130
|
+
error?: boolean;
|
|
131
|
+
/** ปุ่ม "ลองใหม่" ของสถานะผิดพลาด — ไม่ส่ง = ไม่มีปุ่ม */
|
|
132
|
+
onRetry?: () => void;
|
|
125
133
|
/** `quiet`: ปุ่มคำสั่งในแคปซูลเลือกหมู่ (ส่งออก/ลบ …) — ไม่ส่ง = แคปซูลมีแค่จำนวนที่เลือกและปุ่มล้าง */
|
|
126
134
|
selectionActions?: (table: Table<TData>) => React.ReactNode;
|
|
127
135
|
}
|
|
@@ -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
|
+
}
|
|
@@ -87,6 +87,19 @@ export interface UiText {
|
|
|
87
87
|
density: string;
|
|
88
88
|
/** `variant="quiet"` มือถือ — `{count}` */
|
|
89
89
|
selectedCount: string;
|
|
90
|
+
/** สถานะตาราง: `emptyTitleOf` ใช้ `{resource}`, `noMatchQuery` ใช้ `{query}` */
|
|
91
|
+
loading: string;
|
|
92
|
+
emptyTitle: string;
|
|
93
|
+
emptyTitleOf: string;
|
|
94
|
+
emptyHint: string;
|
|
95
|
+
noMatch: string;
|
|
96
|
+
noMatchQuery: string;
|
|
97
|
+
noMatchHint: string;
|
|
98
|
+
clearAllFilters: string;
|
|
99
|
+
clearQuery: string;
|
|
100
|
+
errorTitle: string;
|
|
101
|
+
errorHint: string;
|
|
102
|
+
retry: string;
|
|
90
103
|
/** ข้อความในแคปซูลเลือกหมู่ (สั้นกว่า `selectedCount` เพราะหัวรายการมือถือบอก "เลือก N รายการ" อยู่แล้ว) */
|
|
91
104
|
selectedPill: string;
|
|
92
105
|
selectAll: string;
|
|
@@ -332,6 +345,18 @@ export const UI_TEXT_TH: UiText = {
|
|
|
332
345
|
shownOf: "{shown} จาก {total} รายการ",
|
|
333
346
|
density: "ความหนาแน่นของแถว",
|
|
334
347
|
selectedCount: "เลือก {count} รายการ",
|
|
348
|
+
loading: "กำลังโหลด…",
|
|
349
|
+
emptyTitle: "ยังไม่มีรายการ",
|
|
350
|
+
emptyTitleOf: "ยังไม่มี{resource}",
|
|
351
|
+
emptyHint: "เมื่อสร้างแล้วจะแสดงที่นี่",
|
|
352
|
+
noMatch: "ไม่พบรายการที่ตรงกัน",
|
|
353
|
+
noMatchQuery: 'ไม่พบรายการที่ตรงกับ "{query}"',
|
|
354
|
+
noMatchHint: "ลองคำค้นอื่น หรือเอาตัวกรองออกบางตัว",
|
|
355
|
+
clearAllFilters: "ล้างตัวกรองทั้งหมด",
|
|
356
|
+
clearQuery: "ล้างคำค้น",
|
|
357
|
+
errorTitle: "โหลดรายการไม่สำเร็จ",
|
|
358
|
+
errorHint: "การเชื่อมต่อขัดข้อง ลองใหม่อีกครั้ง",
|
|
359
|
+
retry: "ลองใหม่",
|
|
335
360
|
selectedPill: "{count} รายการ",
|
|
336
361
|
selectAll: "เลือกทั้งหมด",
|
|
337
362
|
longPressHint: "กดค้างที่แถวเพื่อเลือกหมู่",
|
|
@@ -555,6 +580,18 @@ export const UI_TEXT_EN: UiText = {
|
|
|
555
580
|
shownOf: "{shown} of {total} items",
|
|
556
581
|
density: "Row density",
|
|
557
582
|
selectedCount: "{count} selected",
|
|
583
|
+
loading: "Loading…",
|
|
584
|
+
emptyTitle: "Nothing here yet",
|
|
585
|
+
emptyTitleOf: "No {resource} yet",
|
|
586
|
+
emptyHint: "Items will show up here once created.",
|
|
587
|
+
noMatch: "No matching items",
|
|
588
|
+
noMatchQuery: 'No items match "{query}"',
|
|
589
|
+
noMatchHint: "Try another search, or remove some filters.",
|
|
590
|
+
clearAllFilters: "Clear all filters",
|
|
591
|
+
clearQuery: "Clear search",
|
|
592
|
+
errorTitle: "Couldn't load the list",
|
|
593
|
+
errorHint: "The connection dropped. Try again.",
|
|
594
|
+
retry: "Try again",
|
|
558
595
|
selectedPill: "{count} items",
|
|
559
596
|
selectAll: "Select all",
|
|
560
597
|
longPressHint: "Press and hold a row to select several",
|
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,
|