@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 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.60.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
- {(text.table?.shownOf ?? "{shown} จาก {total} รายการ")
284
- .replace("{shown}", String(shown))
285
- .replace("{total}", String(total))}
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
- {isLoading ? (
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 || (text.table?.noResults ?? "ไม่พบข้อมูล")}
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
- return (
82
- <div className={cn("overflow-hidden rounded-xl border bg-card", className)}>
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
- {rows.length === 0 ? (
108
- <EmptyRow colSpan={columns.length + (selectable ? 1 : 0)}>{empty}</EmptyRow>
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 key={c.key} className={cn(alignClass(c.align), c.className)}>
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,