@suphark/ui 0.60.0 → 0.61.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,13 @@
2
2
 
3
3
  รูปแบบตาม [Keep a Changelog](https://keepachangelog.com/) · semver: patch = หน้าตา/บั๊ก, minor = component/prop ใหม่, major = เปลี่ยน/ลบ props
4
4
 
5
+ ## [0.61.0] — 2026-10-24
6
+
7
+ ### Added
8
+ - `DataTable` สถานะตาราง (ตาม mockup): **ผิดพลาด** — prop `error` แสดงกล่อง "โหลดรายการไม่สำเร็จ" แทนตาราง (ข้อความกลาง ไม่แสดง error ดิบ, ซ่อนเลขหน้า) + `onRetry` = ปุ่ม "ลองใหม่" (ทั้งสอง variant) · `variant="quiet"`: **ไม่พบ** — บอกคำค้น "ไม่พบรายการที่ตรงกับ …" + ปุ่ม "ล้างตัวกรองทั้งหมด"/"ล้างคำค้น" (เคารพ `preservedFilterIds`), **ว่างจริง** — `resourceName` ("ยังไม่มีใบสั่งซื้อ") + `emptyAction` (ปุ่มสร้าง; ไม่ส่ง = ไม่มีปุ่ม), **โหลด** — ตัวนับ "กำลังโหลด…" และ skeleton รายการ 3 บรรทัดบนมือถือ · กล่องสถานะกว้างไม่เกินจอบนมือถือ ปุ่มสูง 44px บนจอสัมผัส · `emptyStateComponent` ของแอปยังทับได้ทั้งหมด
9
+ - `DataTableEmptyState`, `DataTableErrorState` (ภายใน `data-table-states.tsx`), `UiText.table.loading` / `emptyTitle` / `emptyTitleOf` / `emptyHint` / `noMatch` / `noMatchQuery` / `noMatchHint` / `clearAllFilters` / `clearQuery` / `errorTitle` / `errorHint` / `retry`
10
+ - showcase: เดโม DataTable สลับสถานะ ปกติ/กำลังโหลด/ว่างจริง/ผิดพลาดได้
11
+
5
12
  ## [0.60.0] — 2026-10-24
6
13
 
7
14
  ### Added
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@suphark/ui",
3
- "version": "0.60.0",
3
+ "version": "0.61.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,150 @@
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 type { Table } from "@tanstack/react-table";
7
+ import { Inbox, SearchX, TriangleAlert } from "lucide-react";
8
+ import type React from "react";
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
+ /** ไม่พบรายการ: มีตัวกรอง/คำค้นอยู่ → ล้างได้ทันที · ไม่มีเลย → ว่างจริง (+ปุ่มที่แอปส่งมา) */
55
+ export function DataTableEmptyState<TData>({
56
+ table,
57
+ preservedFilterIds = [],
58
+ resourceName,
59
+ emptyAction,
60
+ }: {
61
+ table: Table<TData>;
62
+ preservedFilterIds?: string[];
63
+ resourceName?: string;
64
+ emptyAction?: React.ReactNode;
65
+ }) {
66
+ const text = useUiText();
67
+ const keep = new Set(preservedFilterIds);
68
+ const query = String(table.getState().globalFilter ?? "").trim();
69
+ const hasColumnFilters = table.getState().columnFilters.some((filter) => !keep.has(filter.id));
70
+ if (!query && !hasColumnFilters) {
71
+ return (
72
+ <StateBox
73
+ icon={<Inbox />}
74
+ title={
75
+ resourceName
76
+ ? (text.table?.emptyTitleOf ?? "ยังไม่มี{resource}").replace("{resource}", resourceName)
77
+ : (text.table?.emptyTitle ?? "ยังไม่มีรายการ")
78
+ }
79
+ hint={text.table?.emptyHint ?? "เมื่อสร้างแล้วจะแสดงที่นี่"}
80
+ >
81
+ {emptyAction}
82
+ </StateBox>
83
+ );
84
+ }
85
+ const clearQuery = () => table.setGlobalFilter("");
86
+ const clearAll = () => {
87
+ table.setColumnFilters((filters) => filters.filter((filter) => keep.has(filter.id)));
88
+ table.setGlobalFilter("");
89
+ };
90
+ return (
91
+ <StateBox
92
+ icon={<SearchX />}
93
+ title={
94
+ query
95
+ ? (text.table?.noMatchQuery ?? 'ไม่พบรายการที่ตรงกับ "{query}"').replace("{query}", query)
96
+ : (text.table?.noMatch ?? "ไม่พบรายการที่ตรงกัน")
97
+ }
98
+ hint={text.table?.noMatchHint ?? "ลองคำค้นอื่น หรือเอาตัวกรองออกบางตัว"}
99
+ >
100
+ <Button size="sm" onClick={clearAll}>
101
+ {text.table?.clearAllFilters ?? "ล้างตัวกรองทั้งหมด"}
102
+ </Button>
103
+ {query && hasColumnFilters && (
104
+ <Button size="sm" variant="outline" onClick={clearQuery}>
105
+ {text.table?.clearQuery ?? "ล้างคำค้น"}
106
+ </Button>
107
+ )}
108
+ </StateBox>
109
+ );
110
+ }
111
+
112
+ /** โหลดไม่สำเร็จ: ข้อความกลาง ๆ (ไม่แสดงข้อความดิบของระบบ) + ปุ่มลองใหม่เมื่อส่ง `onRetry` */
113
+ export function DataTableErrorState({ onRetry }: { onRetry?: () => void }) {
114
+ const text = useUiText();
115
+ return (
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
+ }
@@ -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
  }
@@ -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",