@suphark/ui 0.59.1 → 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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@suphark/ui",
3
- "version": "0.59.1",
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,108 @@
1
+ "use client";
2
+
3
+ import { Checkbox } from "@suphark/ui/components/ui/checkbox";
4
+ import { cn } from "@suphark/ui/lib/utils";
5
+ import type { Table } from "@tanstack/react-table";
6
+ import { ChevronDown } from "lucide-react";
7
+ import * as React from "react";
8
+ import type { FacetedFilterConfig } from "./types";
9
+
10
+ /**
11
+ * `variant="quiet"`: ตัวกรองแบบรายการติ๊กในแผ่นตัวกรอง — หัวข้อเปิดไว้ตั้งแต่แรก แถวสูง 44px
12
+ * แต่ละค่ามีจำนวนแถวกำกับ (แทนปุ่ม popover ต่อคอลัมน์ของตัวกรองปกติ)
13
+ */
14
+ export function DataTableFilterSections<TData>({
15
+ table,
16
+ filters,
17
+ }: {
18
+ table: Table<TData>;
19
+ filters: FacetedFilterConfig[];
20
+ }) {
21
+ return (
22
+ <div className="flex flex-col">
23
+ {filters.map((filter) => (
24
+ <FilterSection key={filter.columnId} table={table} filter={filter} />
25
+ ))}
26
+ </div>
27
+ );
28
+ }
29
+
30
+ function FilterSection<TData>({
31
+ table,
32
+ filter,
33
+ }: {
34
+ table: Table<TData>;
35
+ filter: FacetedFilterConfig;
36
+ }) {
37
+ const [open, setOpen] = React.useState(true);
38
+ const id = React.useId();
39
+ const column = table.getColumn(filter.columnId);
40
+ if (!column) return null;
41
+ // จำนวนต่อค่า: ใช้ที่แอปส่งมา (`getFacetedUniqueValues`) ถ้ามี ไม่งั้นนับเองจากแถวที่ผ่านตัวกรองคอลัมน์อื่น
42
+ const provided = column.getFacetedUniqueValues();
43
+ const counted = new Map<unknown, number>();
44
+ if (!provided || provided.size === 0) {
45
+ for (const row of column.getFacetedRowModel().rows) {
46
+ const key = String(row.getValue(filter.columnId));
47
+ counted.set(key, (counted.get(key) ?? 0) + 1);
48
+ }
49
+ }
50
+ const facets = provided && provided.size > 0 ? provided : counted;
51
+ const current = column.getFilterValue();
52
+ const selected = new Set(Array.isArray(current) ? current.map(String) : []);
53
+
54
+ const toggle = (value: string) => {
55
+ const next = new Set(selected);
56
+ if (next.has(value)) next.delete(value);
57
+ else next.add(value);
58
+ column.setFilterValue(next.size ? Array.from(next) : undefined);
59
+ };
60
+
61
+ return (
62
+ <section className="border-b last:border-b-0">
63
+ <button
64
+ type="button"
65
+ aria-expanded={open}
66
+ aria-controls={id}
67
+ onClick={() => setOpen((value) => !value)}
68
+ className="flex h-11 w-full items-center justify-between text-muted-foreground text-sm outline-none focus-visible:ring-3 focus-visible:ring-ring/50"
69
+ >
70
+ <span>
71
+ {filter.title}
72
+ {selected.size > 0 && (
73
+ <span className="ms-1.5 font-semibold text-foreground tabular-nums">
74
+ {selected.size}
75
+ </span>
76
+ )}
77
+ </span>
78
+ <ChevronDown
79
+ aria-hidden="true"
80
+ className={cn("size-4 transition-transform", open && "rotate-180")}
81
+ />
82
+ </button>
83
+ <div id={id} hidden={!open}>
84
+ {filter.options.map((option) => {
85
+ const value = String(option.value);
86
+ const count = facets?.get(option.value) ?? facets?.get(value);
87
+ return (
88
+ <label
89
+ key={value}
90
+ className="flex h-11 cursor-pointer items-center gap-3 border-t text-sm first:border-t-0"
91
+ >
92
+ <Checkbox
93
+ aria-label={option.label}
94
+ checked={selected.has(value)}
95
+ onCheckedChange={() => toggle(value)}
96
+ />
97
+ {option.icon && <option.icon className="size-4 text-muted-foreground" />}
98
+ <span className="min-w-0 flex-1 truncate">{option.label}</span>
99
+ {count !== undefined && (
100
+ <span className="text-muted-foreground text-xs tabular-nums">{count}</span>
101
+ )}
102
+ </label>
103
+ );
104
+ })}
105
+ </div>
106
+ </section>
107
+ );
108
+ }
@@ -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
+ }
@@ -26,6 +26,7 @@ import type React from "react";
26
26
  import { useEffect, useRef, useState } from "react";
27
27
  import type { FacetedFilterConfig } from ".";
28
28
  import { DataTableFacetedFilter } from "./data-table-faceted-filter";
29
+ import { DataTableFilterSections } from "./data-table-filter-sections";
29
30
  import { DataTableViewOptions } from "./data-table-view-option";
30
31
 
31
32
  interface DataTableToolbarProps<TData> {
@@ -39,6 +40,8 @@ interface DataTableToolbarProps<TData> {
39
40
  variant?: "default" | "quiet";
40
41
  density?: "comfortable" | "compact";
41
42
  onDensityChange?: (density: "comfortable" | "compact") => void;
43
+ /** `quiet`: ตัวนับแสดง "กำลังโหลด…" */
44
+ isLoading?: boolean;
42
45
  }
43
46
 
44
47
  export function DataTableToolbar<TData>({
@@ -51,6 +54,7 @@ export function DataTableToolbar<TData>({
51
54
  variant = "default",
52
55
  density = "comfortable",
53
56
  onDensityChange,
57
+ isLoading,
54
58
  }: DataTableToolbarProps<TData>) {
55
59
  const text = useUiText();
56
60
  const isMobile = useIsMobile();
@@ -159,23 +163,47 @@ export function DataTableToolbar<TData>({
159
163
  {text.table?.filtersDescription ?? "เลือกได้หลายเงื่อนไข ผลลัพธ์จะอัปเดตทันที"}
160
164
  </SheetDescription>
161
165
  <SheetClose
162
- render={<Button variant="ghost" size="icon-sm" className="absolute top-3 right-3" />}
166
+ render={
167
+ <Button
168
+ variant="ghost"
169
+ size="icon-sm"
170
+ className="absolute top-3 right-3 pointer-coarse:h-11 pointer-coarse:w-11"
171
+ />
172
+ }
163
173
  >
164
174
  <X />
165
175
  <span className="sr-only">{text.table?.closeFilters ?? "ปิดตัวกรอง"}</span>
166
176
  </SheetClose>
167
177
  </SheetHeader>
168
178
  <div className="flex min-h-0 flex-1 flex-col gap-2 overflow-y-auto px-4">
169
- {renderFilterControls(true)}
179
+ {variant === "quiet" && facetedFilters ? (
180
+ <DataTableFilterSections table={table} filters={facetedFilters} />
181
+ ) : (
182
+ renderFilterControls(true)
183
+ )}
170
184
  {toolbarFilters}
171
185
  </div>
172
- {isFiltered && (
173
- <SheetFooter className="border-t">
174
- <Button variant="secondary" onClick={resetUserFilters}>
175
- <X />
176
- ล้างตัวกรองทั้งหมด
186
+ {variant === "quiet" ? (
187
+ <SheetFooter className="grid grid-cols-2 gap-2 border-t">
188
+ <Button variant="outline" className="pointer-coarse:h-11" onClick={resetUserFilters}>
189
+ {text.table?.clearFilters ?? "ล้าง"}
177
190
  </Button>
191
+ <SheetClose render={<Button className="pointer-coarse:h-11" />}>
192
+ {(text.table?.showResults ?? "ดู {count} รายการ").replace(
193
+ "{count}",
194
+ String(table.getFilteredRowModel().rows.length),
195
+ )}
196
+ </SheetClose>
178
197
  </SheetFooter>
198
+ ) : (
199
+ isFiltered && (
200
+ <SheetFooter className="border-t">
201
+ <Button variant="secondary" onClick={resetUserFilters}>
202
+ <X />
203
+ ล้างตัวกรองทั้งหมด
204
+ </Button>
205
+ </SheetFooter>
206
+ )
179
207
  )}
180
208
  </SheetContent>
181
209
  </Sheet>
@@ -255,9 +283,11 @@ export function DataTableToolbar<TData>({
255
283
  })}
256
284
  <div className="ml-auto flex items-center gap-1">
257
285
  <span className="me-1 text-muted-foreground text-xs tabular-nums">
258
- {(text.table?.shownOf ?? "{shown} จาก {total} รายการ")
259
- .replace("{shown}", String(shown))
260
- .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))}
261
291
  </span>
262
292
  {actionsComponent}
263
293
  {onDensityChange && (