@suphark/ui 0.24.0 → 0.25.1
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 +60 -0
- package/package.json +1 -1
- package/src/components/app-shell/command-palette.tsx +8 -1
- package/src/components/data-table/data-table-sortable.tsx +154 -0
- package/src/components/data-table/data-table-toolbar.tsx +33 -4
- package/src/components/data-table/data-table.tsx +30 -142
- package/src/components/data-table/index.ts +9 -9
- package/src/components/data-table/use-data-table.ts +8 -5
- package/src/components/pwa/pull-to-refresh.tsx +21 -1
- package/src/components/shared/action-menu.tsx +25 -6
- package/src/components/shared/auth-parts.tsx +5 -3
- package/src/components/shared/button/confirm-action-button.tsx +17 -3
- package/src/components/shared/chat-thread.tsx +7 -0
- package/src/components/shared/client-redirect.tsx +16 -1
- package/src/components/shared/form/date-time-picker.tsx +150 -0
- package/src/components/shared/form/form-field-autocomplete.tsx +93 -0
- package/src/components/shared/form/form-field-input.tsx +23 -81
- package/src/components/shared/form/number-input.tsx +154 -0
- package/src/components/shared/form/tags-input.tsx +197 -0
- package/src/components/shared/form/time-picker.tsx +321 -0
- package/src/components/shared/month-stepper.tsx +10 -4
- package/src/components/shared/row-actions.tsx +9 -3
- package/src/components/shared/settings-layout.tsx +17 -6
- package/src/components/shared/step-form.tsx +304 -0
- package/src/components/shared/time/client-date-formatter.tsx +2 -1
- package/src/components/shared/tree-view.tsx +6 -2
- package/src/components/shared/ui-text.tsx +68 -0
- package/src/data-table.ts +12 -1
- package/src/hooks/use-table-search-params.ts +10 -2
- package/src/hooks/use-ticker.ts +11 -5
- package/src/index.ts +430 -66
- package/src/lib/formatters.ts +1 -1
- package/src/lib/month.ts +14 -2
- package/src/next.ts +38 -1
package/CHANGELOG.md
CHANGED
|
@@ -2,6 +2,66 @@
|
|
|
2
2
|
|
|
3
3
|
รูปแบบตาม [Keep a Changelog](https://keepachangelog.com/) · semver: patch = หน้าตา/บั๊ก, minor = component/prop ใหม่, major = เปลี่ยน/ลบ props
|
|
4
4
|
|
|
5
|
+
## [0.25.1] — 2026-10-02
|
|
6
|
+
|
|
7
|
+
แก้ตามผลรีวิว codebase (bug / security / performance) — ไม่เปลี่ยน props
|
|
8
|
+
|
|
9
|
+
### Fixed
|
|
10
|
+
|
|
11
|
+
- **`NumberInput`**: input ซ่อนของ Base UI (ที่ส่งค่าใน FormData) ได้ `step` ไปด้วย → browser ปฏิเสธค่าทศนิยม/ค่าที่ไม่ลง step นับจาก `min` แล้ว **บล็อก submit เงียบ ๆ** (เช่น 1500.5 เมื่อ step=1, 150 เมื่อ min=0 step=100 — DueBell เจอ) → input ซ่อนใช้ `step="any"` (ปุ่ม −/+ ยังเลื่อนตาม `step`; min/max ยังตรวจ)
|
|
12
|
+
- **`TimePicker`**: พิมพ์ผิดรูปแบบแล้วกดส่ง ฟอร์มเคยส่งค่าเดิมไปเงียบ ๆ → ช่องเป็น invalid จริง (`setCustomValidity`, ข้อความ `UiText.time.invalid`) ฟอร์มไม่ส่งจนแก้
|
|
13
|
+
- **`DateTimePicker`**: แยกไฟล์ (`form/date-time-picker.tsx`); ข้อความวัน/เวลาและค่าใน input ซ่อนคำนวณหลัง hydrate (เดิม server UTC กับ browser ต่างกัน 7 ชม. = hydration mismatch เมื่อมีค่าเริ่มต้น); **`name` ส่ง `"YYYY-MM-DDTHH:mm+07:00"` (มี offset)** แทนค่าที่ไม่มีโซนเวลา → `new Date(formData.get(name))` บน server ได้เวลาที่ถูก (เดิมคลาด 7 ชม.)
|
|
14
|
+
- `TimeColumns`: เลื่อนเฉพาะในคอลัมน์ (เดิม `scrollIntoView` เลื่อนทั้งหน้าทุกครั้งที่เลือก)
|
|
15
|
+
- **`MonthStepper` / `useMonthCursor`**: "เดือนนี้" คิดตามเวลาไทย (`Asia/Bangkok`) ให้ server กับ browser ตรงกัน — เดิมช่วง 00:00–06:59 ของวันที่ 1 ทุกเดือน server (UTC) เห็นเดือนก่อน = hydration mismatch; `currentMonthKey(now?, timeZone?)` รับโซนเวลาได้ (ไม่ส่ง = เครื่องเหมือนเดิม)
|
|
16
|
+
- **`StepForm`**: ดับเบิลคลิกปุ่มส่ง/ถัดไปเคยเรียก `onSubmit` 2 ครั้ง หรือข้ามหน้าตรวจทาน (คลิกที่สองตกที่ปุ่ม "ส่ง" ตำแหน่งเดิม) → กันซ้ำแบบ synchronous + ไม่นับคลิกภายใน 500 ms หลังเปลี่ยนขั้น; input ซ่อนของ Base UI ผิด → โฟกัสช่องที่มองเห็นของมันเอง (เดิมโฟกัสช่องแรกของขั้น)
|
|
17
|
+
- **`RowActions` / `ConfirmActionButton`**: action ที่คืน `FormState` `{ ok: false, error }` (ไม่ได้ throw) เคยถูกถือว่าสำเร็จ (ปิด dialog + refresh ไม่มีข้อความ) → toast error + คง dialog ไว้ (`failedResult`)
|
|
18
|
+
- **`ActionMenu`**: รายการที่มี `href` + `confirm` เคยไปเลยโดยไม่ถาม → ถามก่อนแล้วค่อยไป; `onClick` ที่ throw/reject แสดง toast และคง dialog ไว้ (เดิม unhandled rejection เงียบ)
|
|
19
|
+
- `ChatThread`: `onSend` ล้มเหลว = toast + คงข้อความในช่องไว้ให้ลองใหม่
|
|
20
|
+
- `CommandPalette`: คีย์ลัดเทียบ `e.code` — แป้นภาษาไทย Ctrl+K ใช้ได้แล้ว (เดิม `e.key` = "า")
|
|
21
|
+
- `SettingsLayout`: มือถือกด "กลับ" แล้วคืนโฟกัสไปหัวข้อเดิม (เดิมหลุดไป body); จอกว้างไม่ย้ายโฟกัสไปหัวข้อ (เดิมย้ายแค่ครั้งแรก)
|
|
22
|
+
- `TagsInput`: วางหลายค่าแทรกที่ cursor ของข้อความที่พิมพ์ค้าง (เดิมทิ้งข้อความนั้น)
|
|
23
|
+
- `ProviderButtons`: ปุ่มที่ `pendingId` ตรงแสดง spinner และกดซ้ำไม่ได้ (รวมแบบ `href`)
|
|
24
|
+
- `useTicker`: subscribe คงที่ข้าม render — เดิมสมัครใหม่ทุก render ทำให้ "x นาทีที่แล้ว" ไม่อัปเดตถ้า parent render บ่อยกว่า interval
|
|
25
|
+
- `useTableSearchParams`: setter หลายตัวติดกัน (เช่น setFilter แล้ว setSort) ต่อจากค่าที่เพิ่งตั้ง (เดิมตัวหลังทับตัวแรก)
|
|
26
|
+
- `useDataTable`: sync `columnFilters` ตามค่า (เดิมตามตัวอ้างอิง → array literal ทำให้ render ซ้ำ + ทับตัวกรองที่ผู้ใช้เพิ่งเลือก)
|
|
27
|
+
|
|
28
|
+
### Security
|
|
29
|
+
|
|
30
|
+
- `ClientRedirect`: รับเฉพาะ http(s)/path — ปฏิเสธ `javascript:`/`data:` (`location.replace` ไม่มีตัวกันแบบ `<a href>`/router)
|
|
31
|
+
- showcase: `/login` ใช้ `safeNextPath` (`src/lib/safe-next.ts`) กัน open redirect `next=/%09/evil.com` (WHATWG URL ตัด tab → `//evil.com`); production (`VERCEL_ENV=production`) ที่ไม่มี env ของ Pi Auth หรือตั้ง `PI_AUTH_DISABLED` = build ล้ม แทนเปิดเว็บสาธารณะเงียบ ๆ — ตัว `safeNextPath` ของ `@suphark/auth-client` แก้ใน repo auth แล้ว (0.6.1 รอ publish)
|
|
32
|
+
|
|
33
|
+
### Performance
|
|
34
|
+
|
|
35
|
+
- **`DataTable`**: ส่วนลากเรียง (dnd-kit ~16 kB gzip) แยกเป็น `data-table-sortable.tsx` โหลดแบบ lazy เฉพาะเมื่อส่ง `onReorder` — เดิมทุกตารางพกไปด้วย
|
|
36
|
+
- **`FormFieldInput`**: variant `autocomplete` (cmdk + Radix Dialog ที่ cmdk พ่วงมา) แยกเป็น `form-field-autocomplete.tsx` โหลดแบบ lazy — เดิมทุกฟอร์มที่ใช้ช่องข้อความธรรมดาพก cmdk
|
|
37
|
+
- `DataTable` ช่องค้นหา: ส่งให้ตารางเมื่อหยุดพิมพ์ 250 ms (Enter = ทันที) — เดิมกรอง/เรียง/render ทุกแถว (และ `router.replace` ถ้าผูก URL) ทุกตัวอักษร
|
|
38
|
+
- `TimePicker` ไม่พา react-day-picker มาด้วยแล้ว (แยก `DateTimePicker`); import locale ตรงไฟล์ (`date-fns/locale/th`, `date-fns/locale/en-US`, `react-day-picker/locale/th`) — dev server ไม่ต้อง compile locale ทั้ง ~700 ไฟล์
|
|
39
|
+
- `PullToRefresh`: ติดตั้ง `touchmove` แบบ non-passive เฉพาะตอนจอมือถือ + หน้าอยู่บนสุด (เดิมติดตลอด → browser ต้องรอ JS ก่อนเลื่อนทุกครั้ง)
|
|
40
|
+
- `TreeView`: `expandedIds` แบบ controlled ไม่ทำให้ memo ของแถวคำนวณใหม่ทุก render
|
|
41
|
+
- showcase: หน้า number-input อธิบาย step ตามพฤติกรรมใหม่
|
|
42
|
+
- tests 12 กรณี (`fixes-0-25-1.test.tsx`, `lazy-parts.test.tsx` + StepForm/NumberInput ใน `inputs-0-25.test.tsx`)
|
|
43
|
+
|
|
44
|
+
## [0.25.0] — 2026-10-02
|
|
45
|
+
|
|
46
|
+
input ใหม่ตามแผนจาก shadcnstudio.com — เพิ่มอย่างเดียว
|
|
47
|
+
|
|
48
|
+
### Added
|
|
49
|
+
|
|
50
|
+
- **`NumberInput`** — ช่องตัวเลขพร้อมปุ่ม −/+ (Base UI NumberField): `min`/`max`/`step`/`largeStep` (Shift+ลูกศร), `format` (Intl เช่น ทศนิยม 2 ตำแหน่ง/สกุลเงิน), `locale` (ค่าเริ่มต้น th-TH), `prefix`/`suffix` (฿, %, ชิ้น), `hideButtons`, `size` sm|default, `name` ส่งค่าดิบใน FormData — แทน `<Input type="number">` + InputGroup + state
|
|
51
|
+
- **`TimePicker`** — ช่องเวลา 24 ชม. ค่า `"HH:mm"`: พิมพ์ได้เลย (`"1430"`, `"9.15"`, `"14:30 น."` → แปลงตอน blur/Enter, ผิดรูปแบบ = `aria-invalid`) หรือกดไอคอนนาฬิกาเลือกจากคอลัมน์ชั่วโมง/นาที (`minuteStep`, ปุ่ม "ตอนนี้"); `name` ส่งใน FormData
|
|
52
|
+
- **`DateTimePicker`** — ปฏิทิน (ภาษาไทย) + คอลัมน์เวลาในกล่องเดียว ค่า `Date | null`, `defaultTime` เมื่อเลือกวันครั้งแรก, ปุ่มล้าง; `name` ส่ง `"YYYY-MM-DDTHH:mm"` (เวลาท้องถิ่น แบบ datetime-local)
|
|
53
|
+
- `TimeColumns` (คอลัมน์ listbox ใช้ลูกศร/Home/End), `parseTimeInput`, `toLocalDateTimeString`, `type TimeValue`
|
|
54
|
+
- **`TagsInput`** — พิมพ์หลายค่าเป็นป้าย: Enter/จุลภาค (+ เว้นวรรคถ้า `splitOnSpace`) เพิ่ม, วางหลายค่าพร้อมกัน, Backspace ลบตัวสุดท้าย, ไม่รับค่าซ้ำ (`allowDuplicates`), `max`, `transform`, `validate` คืนข้อความ error (ค่าที่ไม่ผ่านคงอยู่ในช่องให้แก้), ปลอดภัยกับ IME ไทย; `name` ส่งทุกค่า → `formData.getAll(name)` — redirect URI/โดเมน/อีเมล
|
|
55
|
+
- **`StepForm`** — ฟอร์มหลายขั้น (wizard) จาก array `steps` (`title`, `description`, `content`, `validate(formData)`): แถบขั้นตอน (ขั้นที่ผ่านแล้วกดย้อนได้, จอแคบแสดง "ขั้นที่ x จาก y"), ปุ่มย้อนกลับ/ถัดไป/ส่ง, "ถัดไป" ตรวจ required/pattern + `validate` ของขั้นนั้น, ส่ง = ตรวจทุกขั้นอีกครั้งแล้วพากลับไปขั้นที่ผิด, input ตัวแทนที่ซ่อนของ Base UI ผิด = แสดงข้อความใต้ฟอร์ม; ทุกขั้นอยู่ใน form เดียว → `onSubmit(formData)` ได้ค่าครบ (ส่ง server action ต่อได้), throw = แสดงข้อความ, ปุ่มหมุนระหว่างรอ, โฟกัสย้ายไปหัวข้อขั้นใหม่
|
|
56
|
+
- `UiText` key ใหม่: `number`, `time`, `tags`, `stepForm`
|
|
57
|
+
- showcase: หน้า `/components/{number-input,time-picker,tags-input,step-form}` (129 หน้า); tests 11 กรณี
|
|
58
|
+
|
|
59
|
+
### Performance
|
|
60
|
+
|
|
61
|
+
- **barrel ไม่ใช้ `export *` แล้ว** (`index.ts` 66 บรรทัด, `next.ts`, `data-table.ts`, `components/data-table/index.ts` → รายชื่อ export ชัด ๆ แยก `type`; รายชื่อ export เท่าเดิมทุกตัว ตรวจด้วย TypeScript แล้ว) — วัดบน showcase (Next 16.3 Turbopack): server component ที่ import จาก `@suphark/ui` แบบ `export *` ทำให้ client component แทบทั้งแพ็กเกจ (ปฏิทิน, date-fns, cmdk, input-otp, accordion, questionnaire …) ถูกส่งไปทุกหน้า — **root layout ที่ใช้แค่ `ThemeProvider`/`Toaster` ลดจาก 988 kB (gzip 311 kB) เหลือ 61 kB (gzip 18 kB)**, หน้า `/login` 991 → 114 kB; แอปไม่ต้องแก้โค้ด แค่ bump (`optimizePackageImports` / `turbopackRemoveUnused*` ไม่ช่วย — ลองแล้ว)
|
|
62
|
+
- `scripts/barrel-exports.mjs`: `pnpm check:barrel` (ใน verify + CI — เจอ `export *` ใน `packages/ui/src` = ล้ม) และ `pnpm fix:barrel` (แปลงให้หลัง `shadcn add`)
|
|
63
|
+
- หมายเหตุ: ตัวเลข date-fns 824 kB บน bundlephobia คือทั้งไลบรารี — ใน build จริงมีเฉพาะฟังก์ชันที่ใช้ + locale ไทย/อังกฤษ
|
|
64
|
+
|
|
5
65
|
## [0.24.0] — 2026-10-02
|
|
6
66
|
|
|
7
67
|
เติมของเดิมตามที่สำรวจ shadcnstudio.com (หยิบเฉพาะ "หน้าที่" ที่ยังไม่มี ทำเป็นตัวเดียว + props) — เพิ่มอย่างเดียว ค่าเริ่มต้นหน้าตาเดิม
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@suphark/ui",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.25.1",
|
|
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 เอง",
|
|
@@ -148,8 +148,15 @@ export function CommandPalette({
|
|
|
148
148
|
useEffect(() => {
|
|
149
149
|
if (!shortcut) return;
|
|
150
150
|
const key = shortcut.toLowerCase();
|
|
151
|
+
// เทียบตำแหน่งปุ่ม (e.code) สำหรับตัวอักษร/ตัวเลข — แป้นไทย Ctrl+K ให้ e.key = "า" แต่ e.code ยังเป็น "KeyK"
|
|
152
|
+
const code = /^[a-z]$/.test(key)
|
|
153
|
+
? `Key${key.toUpperCase()}`
|
|
154
|
+
: /^\d$/.test(key)
|
|
155
|
+
? `Digit${key}`
|
|
156
|
+
: null;
|
|
151
157
|
const onKey = (e: KeyboardEvent) => {
|
|
152
|
-
|
|
158
|
+
const matches = e.key.toLowerCase() === key || (code !== null && e.code === code);
|
|
159
|
+
if ((e.metaKey || e.ctrlKey) && matches) {
|
|
153
160
|
e.preventDefault();
|
|
154
161
|
setOpenRef.current(!openRef.current);
|
|
155
162
|
}
|
|
@@ -0,0 +1,154 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* ส่วนลากเรียงแถวของ DataTable (dnd-kit) — แยกไฟล์และโหลดแบบ lazy เฉพาะเมื่อส่ง `onReorder`
|
|
5
|
+
* เดิม import ที่ data-table.tsx ทำให้ทุกตารางพก dnd-kit (~16 kB gzip) แม้แทบไม่มีตารางไหนลากเรียง
|
|
6
|
+
*/
|
|
7
|
+
import {
|
|
8
|
+
closestCenter,
|
|
9
|
+
DndContext,
|
|
10
|
+
type DragEndEvent,
|
|
11
|
+
KeyboardSensor,
|
|
12
|
+
PointerSensor,
|
|
13
|
+
useSensor,
|
|
14
|
+
useSensors,
|
|
15
|
+
} from "@dnd-kit/core";
|
|
16
|
+
import {
|
|
17
|
+
arrayMove,
|
|
18
|
+
SortableContext,
|
|
19
|
+
sortableKeyboardCoordinates,
|
|
20
|
+
useSortable,
|
|
21
|
+
verticalListSortingStrategy,
|
|
22
|
+
} from "@dnd-kit/sortable";
|
|
23
|
+
import { CSS } from "@dnd-kit/utilities";
|
|
24
|
+
import { TableCell, TableRow } from "@suphark/ui/components/ui/table";
|
|
25
|
+
import { cn } from "@suphark/ui/lib/utils";
|
|
26
|
+
import { flexRender, type Row, type Table } from "@tanstack/react-table";
|
|
27
|
+
import type { CSSProperties, ReactNode } from "react";
|
|
28
|
+
import { toast } from "sonner";
|
|
29
|
+
|
|
30
|
+
interface RowStyleProps<TData> {
|
|
31
|
+
enableColumnResizing?: boolean;
|
|
32
|
+
getRowClassName?: (row: Row<TData>) => string;
|
|
33
|
+
stickyEdgeColumns?: boolean;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
function DraggableRow<TData>({
|
|
37
|
+
row,
|
|
38
|
+
enableColumnResizing,
|
|
39
|
+
getRowClassName,
|
|
40
|
+
stickyEdgeColumns,
|
|
41
|
+
}: RowStyleProps<TData> & { row: Row<TData> }) {
|
|
42
|
+
const { attributes, listeners, setNodeRef, transform, transition, isDragging } = useSortable({
|
|
43
|
+
id: row.id,
|
|
44
|
+
});
|
|
45
|
+
|
|
46
|
+
const style: CSSProperties = {
|
|
47
|
+
transform: CSS.Transform.toString(transform),
|
|
48
|
+
transition,
|
|
49
|
+
opacity: isDragging ? 0.8 : 1,
|
|
50
|
+
zIndex: isDragging ? 1 : 0,
|
|
51
|
+
position: "relative",
|
|
52
|
+
};
|
|
53
|
+
|
|
54
|
+
return (
|
|
55
|
+
<TableRow
|
|
56
|
+
ref={setNodeRef}
|
|
57
|
+
style={style}
|
|
58
|
+
data-state={row.getIsSelected() && "selected"}
|
|
59
|
+
className={cn(stickyEdgeColumns && "group/row", getRowClassName?.(row))}
|
|
60
|
+
>
|
|
61
|
+
{row.getVisibleCells().map((cell) => {
|
|
62
|
+
const isDndColumn = cell.column.id === "dnd-handle";
|
|
63
|
+
return (
|
|
64
|
+
<TableCell
|
|
65
|
+
key={cell.id}
|
|
66
|
+
{...(isDndColumn ? { ...attributes, ...listeners } : {})}
|
|
67
|
+
style={{
|
|
68
|
+
width: enableColumnResizing ? cell.column.getSize() : undefined,
|
|
69
|
+
}}
|
|
70
|
+
className={cn(
|
|
71
|
+
stickyEdgeColumns &&
|
|
72
|
+
cell.column.id === "select" &&
|
|
73
|
+
"sticky left-0 z-10 bg-card group-hover/row:bg-muted group-data-[state=selected]/row:bg-muted",
|
|
74
|
+
stickyEdgeColumns &&
|
|
75
|
+
cell.column.id === "actions" &&
|
|
76
|
+
"sticky right-0 z-10 bg-card group-hover/row:bg-muted group-data-[state=selected]/row:bg-muted",
|
|
77
|
+
)}
|
|
78
|
+
>
|
|
79
|
+
{flexRender(cell.column.columnDef.cell, cell.getContext())}
|
|
80
|
+
</TableCell>
|
|
81
|
+
);
|
|
82
|
+
})}
|
|
83
|
+
</TableRow>
|
|
84
|
+
);
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
/** แถวที่ลากได้ (อยู่ใน TableBody) */
|
|
88
|
+
export function SortableTableRows<TData>({
|
|
89
|
+
rows,
|
|
90
|
+
...rowProps
|
|
91
|
+
}: RowStyleProps<TData> & { rows: Row<TData>[] }) {
|
|
92
|
+
return (
|
|
93
|
+
<SortableContext items={rows.map((row) => row.id)} strategy={verticalListSortingStrategy}>
|
|
94
|
+
{rows.map((row) => (
|
|
95
|
+
<DraggableRow key={row.id} row={row} {...rowProps} />
|
|
96
|
+
))}
|
|
97
|
+
</SortableContext>
|
|
98
|
+
);
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
/** DndContext + sensor + คำนวณลำดับใหม่ (ครอบตารางทั้งตัว) */
|
|
102
|
+
export function SortableTableProvider<TData>({
|
|
103
|
+
table,
|
|
104
|
+
data,
|
|
105
|
+
getRowId,
|
|
106
|
+
onReorder,
|
|
107
|
+
children,
|
|
108
|
+
}: {
|
|
109
|
+
table: Table<TData>;
|
|
110
|
+
data: TData[];
|
|
111
|
+
getRowId?: (row: TData) => string;
|
|
112
|
+
onReorder: (data: TData[]) => void;
|
|
113
|
+
children: ReactNode;
|
|
114
|
+
}) {
|
|
115
|
+
const sensors = useSensors(
|
|
116
|
+
useSensor(PointerSensor, {
|
|
117
|
+
activationConstraint: {
|
|
118
|
+
distance: 5,
|
|
119
|
+
},
|
|
120
|
+
}),
|
|
121
|
+
useSensor(KeyboardSensor, {
|
|
122
|
+
coordinateGetter: sortableKeyboardCoordinates,
|
|
123
|
+
}),
|
|
124
|
+
);
|
|
125
|
+
|
|
126
|
+
function handleDragEnd(event: DragEndEvent) {
|
|
127
|
+
const state = table.getState();
|
|
128
|
+
const isFiltered = state.columnFilters.length > 0 || state.globalFilter;
|
|
129
|
+
const isSorted = state.sorting.length > 0;
|
|
130
|
+
|
|
131
|
+
if (isFiltered || isSorted) {
|
|
132
|
+
toast.warning("Cannot reorder while filtering or sorting is active.");
|
|
133
|
+
return;
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
const { active, over } = event;
|
|
137
|
+
if (over && active.id !== over.id) {
|
|
138
|
+
const idOf = (item: TData) =>
|
|
139
|
+
getRowId ? getRowId(item) : (item as { id?: string | number }).id;
|
|
140
|
+
const oldIndex = data.findIndex((item) => idOf(item) === active.id);
|
|
141
|
+
const newIndex = data.findIndex((item) => idOf(item) === over.id);
|
|
142
|
+
|
|
143
|
+
if (oldIndex !== -1 && newIndex !== -1) {
|
|
144
|
+
onReorder(arrayMove(data, oldIndex, newIndex));
|
|
145
|
+
}
|
|
146
|
+
}
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
return (
|
|
150
|
+
<DndContext sensors={sensors} collisionDetection={closestCenter} onDragEnd={handleDragEnd}>
|
|
151
|
+
{children}
|
|
152
|
+
</DndContext>
|
|
153
|
+
);
|
|
154
|
+
}
|
|
@@ -21,6 +21,7 @@ import {
|
|
|
21
21
|
import type { Table } from "@tanstack/react-table";
|
|
22
22
|
import { Search, SlidersHorizontal, X } from "lucide-react";
|
|
23
23
|
import type React from "react";
|
|
24
|
+
import { useEffect, useRef, useState } from "react";
|
|
24
25
|
import type { FacetedFilterConfig } from ".";
|
|
25
26
|
import { DataTableFacetedFilter } from "./data-table-faceted-filter";
|
|
26
27
|
import { DataTableViewOptions } from "./data-table-view-option";
|
|
@@ -43,6 +44,31 @@ export function DataTableToolbar<TData>({
|
|
|
43
44
|
preservedFilterIds = [],
|
|
44
45
|
}: DataTableToolbarProps<TData>) {
|
|
45
46
|
const globalFilter = table.getState().globalFilter;
|
|
47
|
+
// ค้นหา: เก็บข้อความในช่องเอง แล้วส่งให้ตารางเมื่อหยุดพิมพ์ 250 ms — เดิมกรอง/เรียง/render ทุกแถวทุกตัวอักษร
|
|
48
|
+
// (และถ้าแอปผูก globalFilter กับ URL = router.replace ทุกตัวอักษร)
|
|
49
|
+
const [searchDraft, setSearchDraft] = useState<string>((globalFilter as string) ?? "");
|
|
50
|
+
const committed = useRef<string>((globalFilter as string) ?? "");
|
|
51
|
+
useEffect(() => {
|
|
52
|
+
// ค่าเปลี่ยนจากข้างนอก (ล้างตัวกรอง / URL) → ตามให้ตรง
|
|
53
|
+
const external = (globalFilter as string) ?? "";
|
|
54
|
+
if (external !== committed.current) {
|
|
55
|
+
committed.current = external;
|
|
56
|
+
setSearchDraft(external);
|
|
57
|
+
}
|
|
58
|
+
}, [globalFilter]);
|
|
59
|
+
useEffect(() => {
|
|
60
|
+
if (searchDraft === committed.current) return;
|
|
61
|
+
const timer = setTimeout(() => {
|
|
62
|
+
committed.current = searchDraft;
|
|
63
|
+
table.setGlobalFilter(searchDraft);
|
|
64
|
+
}, 250);
|
|
65
|
+
return () => clearTimeout(timer);
|
|
66
|
+
}, [searchDraft, table]);
|
|
67
|
+
const commitSearch = (value: string) => {
|
|
68
|
+
setSearchDraft(value);
|
|
69
|
+
committed.current = value;
|
|
70
|
+
table.setGlobalFilter(value);
|
|
71
|
+
};
|
|
46
72
|
const preservedFilterIdSet = new Set(preservedFilterIds);
|
|
47
73
|
const userColumnFilters = table
|
|
48
74
|
.getState()
|
|
@@ -88,15 +114,18 @@ export function DataTableToolbar<TData>({
|
|
|
88
114
|
name="table-search"
|
|
89
115
|
autoComplete="off"
|
|
90
116
|
placeholder={globalFilterPlaceholder}
|
|
91
|
-
value={
|
|
92
|
-
onChange={(event) =>
|
|
117
|
+
value={searchDraft}
|
|
118
|
+
onChange={(event) => setSearchDraft(event.target.value)}
|
|
119
|
+
onKeyDown={(event) => {
|
|
120
|
+
if (event.key === "Enter") commitSearch(searchDraft);
|
|
121
|
+
}}
|
|
93
122
|
/>
|
|
94
|
-
{!!
|
|
123
|
+
{!!searchDraft && (
|
|
95
124
|
<InputGroupAddon align="inline-end">
|
|
96
125
|
<InputGroupButton
|
|
97
126
|
variant="ghost"
|
|
98
127
|
aria-label="ล้างคำค้นหา"
|
|
99
|
-
onClick={() =>
|
|
128
|
+
onClick={() => commitSearch("")}
|
|
100
129
|
>
|
|
101
130
|
<X aria-hidden="true" className="size-4" />
|
|
102
131
|
</InputGroupButton>
|
|
@@ -1,22 +1,5 @@
|
|
|
1
1
|
"use client";
|
|
2
2
|
|
|
3
|
-
import {
|
|
4
|
-
closestCenter,
|
|
5
|
-
DndContext,
|
|
6
|
-
type DragEndEvent,
|
|
7
|
-
KeyboardSensor,
|
|
8
|
-
PointerSensor,
|
|
9
|
-
useSensor,
|
|
10
|
-
useSensors,
|
|
11
|
-
} from "@dnd-kit/core";
|
|
12
|
-
import {
|
|
13
|
-
arrayMove,
|
|
14
|
-
SortableContext,
|
|
15
|
-
sortableKeyboardCoordinates,
|
|
16
|
-
useSortable,
|
|
17
|
-
verticalListSortingStrategy,
|
|
18
|
-
} from "@dnd-kit/sortable";
|
|
19
|
-
import { CSS } from "@dnd-kit/utilities";
|
|
20
3
|
import {
|
|
21
4
|
TableBody,
|
|
22
5
|
TableCell,
|
|
@@ -26,72 +9,22 @@ import {
|
|
|
26
9
|
Table as UiTable,
|
|
27
10
|
} from "@suphark/ui/components/ui/table";
|
|
28
11
|
import { cn } from "@suphark/ui/lib/utils";
|
|
29
|
-
import { flexRender
|
|
30
|
-
import type { CSSProperties } from "react";
|
|
12
|
+
import { flexRender } from "@tanstack/react-table";
|
|
31
13
|
import * as React from "react";
|
|
32
|
-
import { toast } from "sonner";
|
|
33
14
|
import { DataTablePagination } from "./data-table-pagination";
|
|
34
15
|
import { DataTableToolbar } from "./data-table-toolbar";
|
|
35
16
|
import type { DataTableProps } from "./types";
|
|
36
17
|
import { useDataTable } from "./use-data-table";
|
|
37
18
|
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
enableColumnResizing,
|
|
48
|
-
getRowClassName,
|
|
49
|
-
stickyEdgeColumns,
|
|
50
|
-
}: DraggableRowProps<TData>) {
|
|
51
|
-
const { attributes, listeners, setNodeRef, transform, transition, isDragging } = useSortable({
|
|
52
|
-
id: row.id,
|
|
53
|
-
});
|
|
54
|
-
|
|
55
|
-
const style: CSSProperties = {
|
|
56
|
-
transform: CSS.Transform.toString(transform),
|
|
57
|
-
transition,
|
|
58
|
-
opacity: isDragging ? 0.8 : 1,
|
|
59
|
-
zIndex: isDragging ? 1 : 0,
|
|
60
|
-
position: "relative",
|
|
61
|
-
};
|
|
62
|
-
|
|
63
|
-
return (
|
|
64
|
-
<TableRow
|
|
65
|
-
ref={setNodeRef}
|
|
66
|
-
style={style}
|
|
67
|
-
data-state={row.getIsSelected() && "selected"}
|
|
68
|
-
className={cn(stickyEdgeColumns && "group/row", getRowClassName?.(row))}
|
|
69
|
-
>
|
|
70
|
-
{row.getVisibleCells().map((cell) => {
|
|
71
|
-
const isDndColumn = cell.column.id === "dnd-handle";
|
|
72
|
-
return (
|
|
73
|
-
<TableCell
|
|
74
|
-
key={cell.id}
|
|
75
|
-
{...(isDndColumn ? { ...attributes, ...listeners } : {})}
|
|
76
|
-
style={{
|
|
77
|
-
width: enableColumnResizing ? cell.column.getSize() : undefined,
|
|
78
|
-
}}
|
|
79
|
-
className={cn(
|
|
80
|
-
stickyEdgeColumns &&
|
|
81
|
-
cell.column.id === "select" &&
|
|
82
|
-
"sticky left-0 z-10 bg-card group-hover/row:bg-muted group-data-[state=selected]/row:bg-muted",
|
|
83
|
-
stickyEdgeColumns &&
|
|
84
|
-
cell.column.id === "actions" &&
|
|
85
|
-
"sticky right-0 z-10 bg-card group-hover/row:bg-muted group-data-[state=selected]/row:bg-muted",
|
|
86
|
-
)}
|
|
87
|
-
>
|
|
88
|
-
{flexRender(cell.column.columnDef.cell, cell.getContext())}
|
|
89
|
-
</TableCell>
|
|
90
|
-
);
|
|
91
|
-
})}
|
|
92
|
-
</TableRow>
|
|
93
|
-
);
|
|
94
|
-
}
|
|
19
|
+
// ส่วนลากเรียง (dnd-kit) โหลดเฉพาะตารางที่ส่ง onReorder
|
|
20
|
+
// (React.lazy ทิ้ง generic ของ TData — cast กลับเป็นชนิดของต้นฉบับ)
|
|
21
|
+
type SortableModule = typeof import("./data-table-sortable");
|
|
22
|
+
const SortableTableProvider = React.lazy(() =>
|
|
23
|
+
import("./data-table-sortable").then((m) => ({ default: m.SortableTableProvider })),
|
|
24
|
+
) as unknown as SortableModule["SortableTableProvider"];
|
|
25
|
+
const SortableTableRows = React.lazy(() =>
|
|
26
|
+
import("./data-table-sortable").then((m) => ({ default: m.SortableTableRows })),
|
|
27
|
+
) as unknown as SortableModule["SortableTableRows"];
|
|
95
28
|
|
|
96
29
|
/**
|
|
97
30
|
* DataTable
|
|
@@ -181,51 +114,7 @@ export function DataTable<TData, TValue>({
|
|
|
181
114
|
enableRowSelection,
|
|
182
115
|
});
|
|
183
116
|
|
|
184
|
-
const
|
|
185
|
-
useSensor(PointerSensor, {
|
|
186
|
-
activationConstraint: {
|
|
187
|
-
distance: 5,
|
|
188
|
-
},
|
|
189
|
-
}),
|
|
190
|
-
useSensor(KeyboardSensor, {
|
|
191
|
-
coordinateGetter: sortableKeyboardCoordinates,
|
|
192
|
-
}),
|
|
193
|
-
);
|
|
194
|
-
|
|
195
|
-
function handleDragEnd(event: DragEndEvent) {
|
|
196
|
-
if (!onReorder) return;
|
|
197
|
-
|
|
198
|
-
const state = table.getState();
|
|
199
|
-
const isFiltered = state.columnFilters.length > 0 || state.globalFilter;
|
|
200
|
-
const isSorted = state.sorting.length > 0;
|
|
201
|
-
|
|
202
|
-
if (isFiltered || isSorted) {
|
|
203
|
-
toast.warning("Cannot reorder while filtering or sorting is active.");
|
|
204
|
-
return;
|
|
205
|
-
}
|
|
206
|
-
|
|
207
|
-
const { active, over } = event;
|
|
208
|
-
if (over && active.id !== over.id) {
|
|
209
|
-
const oldIndex = data.findIndex((item) => {
|
|
210
|
-
const id = getRowId ? getRowId(item) : (item as { id?: string | number }).id;
|
|
211
|
-
return id === active.id;
|
|
212
|
-
});
|
|
213
|
-
const newIndex = data.findIndex((item) => {
|
|
214
|
-
const id = getRowId ? getRowId(item) : (item as { id?: string | number }).id;
|
|
215
|
-
return id === over.id;
|
|
216
|
-
});
|
|
217
|
-
|
|
218
|
-
if (oldIndex !== -1 && newIndex !== -1) {
|
|
219
|
-
const newData = arrayMove(data, oldIndex, newIndex);
|
|
220
|
-
onReorder(newData);
|
|
221
|
-
}
|
|
222
|
-
}
|
|
223
|
-
}
|
|
224
|
-
|
|
225
|
-
const tableRows = table.getRowModel().rows;
|
|
226
|
-
const rowIds = React.useMemo(() => tableRows.map((row) => row.id), [tableRows]);
|
|
227
|
-
|
|
228
|
-
const tableContent = (
|
|
117
|
+
const renderTable = (sortable: boolean) => (
|
|
229
118
|
<UiTable
|
|
230
119
|
className={cn(
|
|
231
120
|
"w-full border-collapse",
|
|
@@ -292,18 +181,13 @@ export function DataTable<TData, TValue>({
|
|
|
292
181
|
</TableRow>
|
|
293
182
|
))
|
|
294
183
|
) : table.getRowModel().rows?.length ? (
|
|
295
|
-
|
|
296
|
-
<
|
|
297
|
-
{table.getRowModel().rows
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
getRowClassName={getRowClassName}
|
|
303
|
-
stickyEdgeColumns={stickyEdgeColumns}
|
|
304
|
-
/>
|
|
305
|
-
))}
|
|
306
|
-
</SortableContext>
|
|
184
|
+
sortable ? (
|
|
185
|
+
<SortableTableRows
|
|
186
|
+
rows={table.getRowModel().rows}
|
|
187
|
+
enableColumnResizing={enableColumnResizing}
|
|
188
|
+
getRowClassName={getRowClassName}
|
|
189
|
+
stickyEdgeColumns={stickyEdgeColumns}
|
|
190
|
+
/>
|
|
307
191
|
) : (
|
|
308
192
|
table.getRowModel().rows.map((row) => (
|
|
309
193
|
<TableRow
|
|
@@ -374,15 +258,19 @@ export function DataTable<TData, TValue>({
|
|
|
374
258
|
<>
|
|
375
259
|
<div className="overflow-x-auto rounded-lg border bg-card shadow-sm max-sm:-mx-2 max-sm:rounded-none max-sm:border-x-0">
|
|
376
260
|
{onReorder ? (
|
|
377
|
-
|
|
378
|
-
|
|
379
|
-
|
|
380
|
-
|
|
381
|
-
|
|
382
|
-
|
|
383
|
-
|
|
261
|
+
// ระหว่างโหลดส่วนลากเรียง แสดงตารางปกติไปก่อน (ลากไม่ได้ชั่วคราว)
|
|
262
|
+
<React.Suspense fallback={renderTable(false)}>
|
|
263
|
+
<SortableTableProvider
|
|
264
|
+
table={table}
|
|
265
|
+
data={data}
|
|
266
|
+
getRowId={getRowId}
|
|
267
|
+
onReorder={onReorder}
|
|
268
|
+
>
|
|
269
|
+
{renderTable(true)}
|
|
270
|
+
</SortableTableProvider>
|
|
271
|
+
</React.Suspense>
|
|
384
272
|
) : (
|
|
385
|
-
|
|
273
|
+
renderTable(false)
|
|
386
274
|
)}
|
|
387
275
|
</div>
|
|
388
276
|
{showPagination && (
|
|
@@ -1,9 +1,9 @@
|
|
|
1
|
-
export
|
|
2
|
-
export
|
|
3
|
-
export
|
|
4
|
-
export
|
|
5
|
-
export
|
|
6
|
-
export
|
|
7
|
-
export
|
|
8
|
-
export
|
|
9
|
-
export
|
|
1
|
+
export { DataTable } from "./data-table";
|
|
2
|
+
export { DataTableColumnHeader } from "./data-table-column-header";
|
|
3
|
+
export { DataTableDateRangeFilter } from "./data-table-date-range-filter";
|
|
4
|
+
export { DataTableFacetedFilter } from "./data-table-faceted-filter";
|
|
5
|
+
export { DataTablePagination } from "./data-table-pagination";
|
|
6
|
+
export { DataTableToolbar } from "./data-table-toolbar";
|
|
7
|
+
export { DataTableViewOptions } from "./data-table-view-option";
|
|
8
|
+
export type { DataTableProps, FacetedFilterConfig } from "./types";
|
|
9
|
+
export { useDataTable } from "./use-data-table";
|
|
@@ -66,12 +66,15 @@ export function useDataTable<TData, TValue>({
|
|
|
66
66
|
columnFiltersProp ?? initialColumnFilters,
|
|
67
67
|
);
|
|
68
68
|
|
|
69
|
-
// Sync with prop if provided
|
|
69
|
+
// Sync with prop if provided — เทียบด้วยค่าเหมือน sorting/pagination: ผู้เรียกมักส่ง array literal ใหม่ทุก render
|
|
70
|
+
// ถ้าผูกกับตัวอ้างอิง effect จะรันทุก render → render ซ้ำ + ล้าง memo ของ row model + ทับตัวกรองที่ผู้ใช้เพิ่งเลือก
|
|
71
|
+
const columnFiltersKey = columnFiltersProp ? JSON.stringify(columnFiltersProp) : undefined;
|
|
70
72
|
React.useEffect(() => {
|
|
71
|
-
if (
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
73
|
+
if (columnFiltersKey === undefined) return;
|
|
74
|
+
setColumnFilters((current) =>
|
|
75
|
+
JSON.stringify(current) === columnFiltersKey ? current : JSON.parse(columnFiltersKey),
|
|
76
|
+
);
|
|
77
|
+
}, [columnFiltersKey]);
|
|
75
78
|
const [sorting, setSorting] = React.useState<SortingState>(
|
|
76
79
|
() => sortingProp ?? (initialState?.sorting as SortingState | undefined) ?? [],
|
|
77
80
|
);
|
|
@@ -113,11 +113,31 @@ export function PullToRefresh({
|
|
|
113
113
|
window.setTimeout(() => setIsRefreshing(false), 700);
|
|
114
114
|
};
|
|
115
115
|
|
|
116
|
+
// touchmove ต้อง non-passive (preventDefault ระหว่างดึง) ซึ่งทำให้ browser ต้องรอ JS ก่อนเลื่อนหน้าทุกครั้ง
|
|
117
|
+
// → ติดตั้งเฉพาะตอนที่ดึงได้จริง (จอมือถือ + หน้าอยู่บนสุด) ถอดออกเมื่อเลื่อนลงไปแล้ว
|
|
118
|
+
let moveAttached = false;
|
|
119
|
+
const syncMoveListener = () => {
|
|
120
|
+
const root = getRoot();
|
|
121
|
+
const want = isMobileViewport() && !!root && isRootAtTop(root);
|
|
122
|
+
if (want && !moveAttached) {
|
|
123
|
+
window.addEventListener("touchmove", handleTouchMove, { passive: false });
|
|
124
|
+
moveAttached = true;
|
|
125
|
+
} else if (!want && moveAttached && !isPullingRef.current) {
|
|
126
|
+
window.removeEventListener("touchmove", handleTouchMove);
|
|
127
|
+
moveAttached = false;
|
|
128
|
+
}
|
|
129
|
+
};
|
|
130
|
+
const root = getRoot();
|
|
131
|
+
syncMoveListener();
|
|
132
|
+
root?.addEventListener("scroll", syncMoveListener, { passive: true });
|
|
133
|
+
window.addEventListener("resize", syncMoveListener, { passive: true });
|
|
134
|
+
|
|
116
135
|
window.addEventListener("touchstart", handleTouchStart, { passive: true });
|
|
117
|
-
window.addEventListener("touchmove", handleTouchMove, { passive: false });
|
|
118
136
|
window.addEventListener("touchend", handleTouchEnd, { passive: true });
|
|
119
137
|
window.addEventListener("touchcancel", resetPull, { passive: true });
|
|
120
138
|
return () => {
|
|
139
|
+
root?.removeEventListener("scroll", syncMoveListener);
|
|
140
|
+
window.removeEventListener("resize", syncMoveListener);
|
|
121
141
|
window.removeEventListener("touchstart", handleTouchStart);
|
|
122
142
|
window.removeEventListener("touchmove", handleTouchMove);
|
|
123
143
|
window.removeEventListener("touchend", handleTouchEnd);
|