@suphark/ui 0.5.0 → 0.6.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 +57 -0
- package/package.json +1 -1
- package/src/components/app-shell/app-nav.tsx +24 -8
- package/src/components/data-table/data-table-faceted-filter.tsx +14 -15
- package/src/components/data-table/data-table-row-actions.tsx +2 -1
- package/src/components/data-table/use-data-table.ts +7 -11
- package/src/components/pwa/pull-to-refresh.tsx +11 -5
- package/src/components/shared/action-form.tsx +78 -0
- package/src/components/shared/form/form-field-combobox.tsx +14 -5
- package/src/components/shared/form/form-field-radio-cards.tsx +61 -24
- package/src/components/shared/form/labeled-field.tsx +61 -0
- package/src/components/shared/form-dialog.tsx +1 -3
- package/src/components/shared/form-message.tsx +3 -0
- package/src/components/shared/navigation/stepper.tsx +96 -0
- package/src/components/shared/number/formatted-number.tsx +39 -102
- package/src/components/shared/theme/theme-toggle.tsx +8 -5
- package/src/components/shared/time/client-date-formatter.tsx +47 -154
- package/src/components/ui/relative-date.tsx +30 -9
- package/src/hooks/use-mounted.ts +16 -0
- package/src/hooks/use-ticker.ts +53 -0
- package/src/index.ts +6 -1
- package/src/lib/intl-cache.ts +31 -0
- package/src/next.ts +1 -0
package/CHANGELOG.md
CHANGED
|
@@ -2,6 +2,63 @@
|
|
|
2
2
|
|
|
3
3
|
รูปแบบตาม [Keep a Changelog](https://keepachangelog.com/) · semver: patch = หน้าตา/บั๊ก, minor = component/prop ใหม่, major = เปลี่ยน/ลบ props
|
|
4
4
|
|
|
5
|
+
## [0.6.0] — 2026-09-20
|
|
6
|
+
|
|
7
|
+
component สำเร็จรูปสำหรับฟอร์ม server action ที่ Pi Auth ประกอบเองอยู่ (DueBell/Mail/Files ใช้ได้เหมือนกัน) — ไม่เปลี่ยน API เดิม; **รวม 0.5.2 ที่ยังไม่ได้ publish**
|
|
8
|
+
|
|
9
|
+
### Added
|
|
10
|
+
|
|
11
|
+
- **`LabeledField`** + **`CheckboxField`** (barrel) — ช่องฟอร์มสำหรับ server action ธรรมดา (ไม่ใช่ react-hook-form): label ครอบ control + `hint` หรือ `error` (ตั้ง `data-invalid`) ประกอบจาก shadcn `Field/FieldLabel/FieldDescription/FieldError`; `CheckboxField` = Checkbox + label บรรทัดเดียว ใส่ `name` แล้วส่ง "on" ใน FormData — ยกจาก Pi Auth (42 ไฟล์) คง API เดิมเป๊ะ; ใช้จาก server component ได้ (ชื่อ `LabeledField` เพราะ `Field` ชนกับ shadcn และ `FormField` ชนกับ react-hook-form)
|
|
12
|
+
- **`ActionForm`** (barrel — ไม่พึ่ง next) — ฟอร์ม inline คู่กับ `FormDialog`: `useActionState` + `FormMessage` + ปุ่มส่ง (ป้ายค่าเริ่มต้น `UiText.save`), ไม่ใช้ `action=` บน `<form>` จึงคงค่าที่พิมพ์เมื่อ action คืน error; props `action`, `title?`, `submitLabel?`, `compact?` (ไม่มี Card, ปุ่ม sm, จัดแนวนอน wrap สำหรับแถวตาราง), `className?`, `onSuccess?(state)`; export type **`FormAction`** จาก barrel (FormDialog ใช้ชนิดเดียวกัน)
|
|
13
|
+
- **`Stepper`** (`@suphark/ui/next` — ใช้ next/link) — แถบขั้นตอน wizard ทุกขั้นกดเข้าได้: `steps[{ key, href, label, status: done|todo|problem, statusLabel? }]`, `current`, `ariaLabel?`; แนวนอนเลื่อนได้บนจอแคบ แนวตั้งบน lg; วงกลม เลข/✓/⚠ + `StatusBadge` ใต้ชื่อ; `aria-current="step"`; server component ได้; types `StepperStep`, `StepStatus`
|
|
14
|
+
- **`FormFieldRadioCards`** (backward-compatible): ใช้ **โดยไม่มี `control`** ในฟอร์ม server action ได้ — `name` ส่งต่อไป `RadioGroup` (มี hidden input ใน FormData) + `defaultValue` (uncontrolled) หรือ `value`+`onValueChange` (controlled); **`layout="row"`** = การ์ดแนวนอน ปุ่มวิทยุจริงซ้าย ไอคอน+ชื่อบรรทัดบน คำอธิบายบรรทัดล่าง (`grid sm:grid-cols-2`, checked = `border-primary bg-primary/5`); layout `grid` เดิม จุดเลือกมุมขวาบนตอนนี้ตามสถานะจริงของปุ่มวิทยุ (CSS `peer-data-checked`) จึงถูกทั้ง controlled/uncontrolled
|
|
15
|
+
- showcase: หน้า `labeled-field`, `action-form`, `stepper`, `form-field-radio-cards`; tests 8 กรณี (labeled-field, action-form ส่ง FormData/คงค่า/onSuccess/compact, stepper, radio-cards FormData + row)
|
|
16
|
+
|
|
17
|
+
## [0.5.2] — 2026-09-20 (ไม่ได้ publish แยก — รวมใน 0.6.0)
|
|
18
|
+
|
|
19
|
+
รอบรีวิวประสิทธิภาพ + บั๊กที่เหลือ — ไม่เปลี่ยน props
|
|
20
|
+
|
|
21
|
+
### Fixed
|
|
22
|
+
|
|
23
|
+
- **`PullToRefresh`** (`@suphark/ui/pwa`): effect ผูก `pullDistance`/`isRefreshing` เป็น deps → ถอด/ใส่ listener 4 ตัวบน `window` ใหม่ทุก `touchmove` และ `touchend` หลุดได้ระหว่างนั้น (ดึงแล้วไม่รีเฟรช/ป้ายค้าง) — อ่านค่าผ่าน ref ลงทะเบียนครั้งเดียว
|
|
24
|
+
- **`ThemeToggle`** เมื่อส่ง `persistedTheme`: effect ผูก `theme` ด้วย → ผู้ใช้กดเปลี่ยนแล้วถูก set กลับเป็นค่าที่บันทึกไว้ทันที (server action ยังไม่กลับ) — sync เฉพาะเมื่อ `persistedTheme` เปลี่ยน (รวมครั้งแรก)
|
|
25
|
+
- **`DataTableRowActions`**: `EditDialog` ถูก mount ทุกแถวตลอดเวลา (50 แถว = 50 Dialog+ฟอร์ม และ `initialData` stale) — mount เฉพาะตอนเปิด
|
|
26
|
+
- **`useDataTable`**: `onGlobalFilterChange` อ่านค่าเก่าจาก closure (พิมพ์เร็วใน tick เดียวหายตัวอักษร) → อ่านจาก `table.getState()`; `getPaginationRowModel` ไม่ใส่เมื่อ `manualPagination` (server แบ่งหน้าแล้ว ไม่ตัดซ้ำ); ตัด `useMemo` ที่ไม่มีผล
|
|
27
|
+
- **`FormFieldCombobox`**: cache ตัวเลือก key ด้วย function identity ใน `Map` module-level → inline fetcher ทำให้โตไม่จำกัด (memory leak) และไม่เคย hit — key ที่เป็นฟังก์ชันเก็บใน `WeakMap` (ส่ง `fetcherKey` ยังดีที่สุด)
|
|
28
|
+
|
|
29
|
+
### Changed (ประสิทธิภาพ)
|
|
30
|
+
|
|
31
|
+
- **`ClientDateFormatter`**: ทุก instance เคยมี `setInterval` ของตัวเอง + render เพิ่ม 1 รอบตอน mount → ใช้ **`useTicker()`** นาฬิกากลางแชร์ timer เดียวต่อ interval + `useMounted()` (ไม่มี render เพิ่ม) + cache `Intl.DateTimeFormat`
|
|
32
|
+
- **`FormattedNumber`**: จาก state + 2 effects (3 renders/เซลล์) และ `new Intl.NumberFormat` ทุกครั้ง → `useMounted()` + `useMemo` + cache formatter ต่อ locale+options (ตารางหลายร้อยเซลล์เห็นผล)
|
|
33
|
+
- **`DataTableFacetedFilter`**: `selectedValues` Set ใหม่ทุก render ทำให้ `useMemo` เรียง options ไม่เคย hit → memo จาก `filterValue` จริง
|
|
34
|
+
- **`AppNav`**: กลุ่มเมนูที่กางได้เปลี่ยนจาก remount ทั้ง subtree ด้วย `key` เมื่อลูก active/ค้นหา → Collapsible แบบ controlled (`NavCollapsible`) เปิดอัตโนมัติเมื่อสถานะเปลี่ยน ผู้ใช้กาง/หุบเองได้ ไม่ mount ใหม่ มี animation
|
|
35
|
+
- **`RelativeDate`** ใช้ `useMounted()` กลาง
|
|
36
|
+
|
|
37
|
+
### Added
|
|
38
|
+
|
|
39
|
+
- **`useMounted()`** (barrel) — true เฉพาะฝั่ง client หลัง hydrate ผ่าน `useSyncExternalStore` (ไม่มี render เพิ่ม) — ใช้กับค่าที่ server/browser ต่างกัน (เวลา, โซนเวลา, localStorage)
|
|
40
|
+
- **`useTicker(intervalMs)`** (barrel) — ตัวนับที่เพิ่มทุก interval, ทุก instance แชร์ `setInterval` เดียวต่อ interval (เริ่ม/หยุดตามผู้ใช้ตัวแรก/สุดท้าย); 0 = ไม่ tick
|
|
41
|
+
- **`getNumberFormat` / `getDateTimeFormat`** (`@suphark/ui/lib/intl-cache`, barrel) — Intl formatter ที่ cache ต่อ locale+options
|
|
42
|
+
- tests: `use-ticker` (แชร์ timer/หยุดเมื่อถอดหมด), `intl-cache`
|
|
43
|
+
|
|
44
|
+
### ไม่ได้แก้ตามที่ประเมินไว้ (0.5.2)
|
|
45
|
+
|
|
46
|
+
- `DataTableToolbar` render ชุดตัวกรอง 2 ครั้ง (desktop + Sheet มือถือ): ตรวจแล้ว `SheetContent` ของ Base UI ไม่ mount ลูกเมื่อปิด ชุดใน Sheet จึงมีเฉพาะตอนเปิด — ต้นทุนจริงมีแค่ชุด desktop ที่ `hidden` บนมือถือ (ปุ่ม + trigger ไม่มี popup) ไม่คุ้มเสี่ยง hydration flash จากการ gate ด้วย `useIsMobile()`
|
|
47
|
+
|
|
48
|
+
### Added (showcase/เครื่องมือ)
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
- showcase: หน้า **`/changelog`** render `packages/ui/CHANGELOG.md` ตอน build (parser เล็ก ๆ ไม่เพิ่ม dep: รุ่น/หมวด Added-Changed-Fixed/รายการ/inline code-bold-link) + ลิงก์ในเมนูบน
|
|
52
|
+
- **`pnpm check:changelog`** (`scripts/check-changelog.mjs`) — ทุกการเปลี่ยนแปลงใน `packages/ui/src`, `package.json`, `README.md`, `apps/showcase/src` ต้องมีบรรทัดใน CHANGELOG; ตรวจ working tree ก่อน commit (อยู่ใน `pnpm verify`) และช่วง commit ใน CI (`CHANGELOG_RANGE`)
|
|
53
|
+
|
|
54
|
+
## [0.5.1] — 2026-09-20
|
|
55
|
+
|
|
56
|
+
### Fixed
|
|
57
|
+
|
|
58
|
+
- **`RelativeDate` hydration mismatch (React #418) บน production** — เดิม render "วันนี้/เมื่อวาน" บน server ด้วย "ตอนนี้" ของ server (UTC และ module ที่โหลดไว้ก่อน) ซึ่งต่างจาก browser ได้ทั้งโซนเวลาและข้ามวัน → บน Vercel หน้า `/shell` (ตารางมี RelativeDate) hydrate พังทั้งหน้าและ React render ใหม่ทั้ง tree (เห็น theme script ซ้ำ 2 ตัว) — ตอนนี้คำนวณเฉพาะฝั่ง client หลัง mount (`useSyncExternalStore` guard แบบเดียวกับ `ClientDateFormatter`) ก่อน mount แสดงไอคอน + ช่องว่างขนาดเดิม
|
|
59
|
+
- `RelativeDate`: เอาข้อความเฉพาะ supplychain ("วันที่อ้างอิงราคาจริง") ออกจาก tooltip → prop ใหม่ `titlePrefix` (ค่าเริ่มต้น "วันที่") และ `emptyText`; สีเป็น semantic token (`text-muted-foreground`) แทน `text-slate-*` ตามกฎ design system (className/iconClassName ทับได้เหมือนเดิม)
|
|
60
|
+
- showcase: launch config `showcase-prod` (:3202, `next start` หลัง build) สำหรับตรวจ production build ในเครื่อง
|
|
61
|
+
|
|
5
62
|
## [0.5.0] — 2026-09-19
|
|
6
63
|
|
|
7
64
|
ยกของที่พิสูจน์แล้วจาก Pi Done (todo/kanban) เข้าแพ็กเกจ — ไม่เปลี่ยน props เดิม
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@suphark/ui",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.6.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 เอง",
|
|
@@ -32,6 +32,7 @@ import { cn } from "@suphark/ui/lib/utils";
|
|
|
32
32
|
import { ChevronRight, FlaskConical, Pin } from "lucide-react";
|
|
33
33
|
import Link from "next/link";
|
|
34
34
|
import { usePathname } from "next/navigation";
|
|
35
|
+
import { type ReactNode, useEffect, useState } from "react";
|
|
35
36
|
import { type AppNavGroup, type AppNavItem, isNavActive } from "./types";
|
|
36
37
|
|
|
37
38
|
/** ปุ่ม pin — แสดงเมื่อแอปส่ง `pinnedHrefs` + `onTogglePin` (supplychain เก็บใน user preferences) */
|
|
@@ -179,14 +180,8 @@ export function AppNavGroupMenu({
|
|
|
179
180
|
}
|
|
180
181
|
|
|
181
182
|
// กางปกติ — เปิดค้างเมื่อลูกตัวใดตัวหนึ่ง active หรือกำลังค้นหา
|
|
182
|
-
const open = forceOpen || childActive;
|
|
183
183
|
return (
|
|
184
|
-
<
|
|
185
|
-
key={`${item.title}-${open}`}
|
|
186
|
-
render={<SidebarMenuItem />}
|
|
187
|
-
defaultOpen={open}
|
|
188
|
-
className="group/collapsible"
|
|
189
|
-
>
|
|
184
|
+
<NavCollapsible key={item.title} autoOpen={forceOpen || childActive}>
|
|
190
185
|
<CollapsibleTrigger
|
|
191
186
|
render={<SidebarMenuButton tooltip={item.title} suppressHydrationWarning />}
|
|
192
187
|
>
|
|
@@ -227,7 +222,7 @@ export function AppNavGroupMenu({
|
|
|
227
222
|
})}
|
|
228
223
|
</SidebarMenuSub>
|
|
229
224
|
</CollapsibleContent>
|
|
230
|
-
</
|
|
225
|
+
</NavCollapsible>
|
|
231
226
|
);
|
|
232
227
|
})}
|
|
233
228
|
</SidebarMenu>
|
|
@@ -235,6 +230,27 @@ export function AppNavGroupMenu({
|
|
|
235
230
|
);
|
|
236
231
|
}
|
|
237
232
|
|
|
233
|
+
/**
|
|
234
|
+
* กลุ่มย่อยที่กางได้: เปิดอัตโนมัติเมื่อ `autoOpen` เปลี่ยนเป็น true/false (ลูก active หรือกำลังค้นหา)
|
|
235
|
+
* แต่ผู้ใช้ยังกาง/หุบเองได้ระหว่างนั้น — ควบคุม state เอง (เดิม remount ทั้ง subtree ด้วย key ทุกครั้งที่ค่าเปลี่ยน)
|
|
236
|
+
*/
|
|
237
|
+
function NavCollapsible({ autoOpen, children }: { autoOpen: boolean; children: ReactNode }) {
|
|
238
|
+
const [open, setOpen] = useState(autoOpen);
|
|
239
|
+
useEffect(() => {
|
|
240
|
+
setOpen(autoOpen);
|
|
241
|
+
}, [autoOpen]);
|
|
242
|
+
return (
|
|
243
|
+
<Collapsible
|
|
244
|
+
render={<SidebarMenuItem />}
|
|
245
|
+
open={open}
|
|
246
|
+
onOpenChange={setOpen}
|
|
247
|
+
className="group/collapsible"
|
|
248
|
+
>
|
|
249
|
+
{children}
|
|
250
|
+
</Collapsible>
|
|
251
|
+
);
|
|
252
|
+
}
|
|
253
|
+
|
|
238
254
|
/** ทุกกลุ่มเมนู */
|
|
239
255
|
export function AppNav({
|
|
240
256
|
nav,
|
|
@@ -39,19 +39,20 @@ export function DataTableFacetedFilter<TData, TValue>({
|
|
|
39
39
|
}: DataTableFacetedFilterProps<TData, TValue>) {
|
|
40
40
|
const facets = column?.getFacetedUniqueValues();
|
|
41
41
|
const filterValue = column?.getFilterValue();
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
const aSelected = selectedValues.has(String(a.value));
|
|
49
|
-
const bSelected = selectedValues.has(String(b.value));
|
|
42
|
+
// Set + ลำดับ memo จากค่า filter จริง (เดิมสร้าง Set ใหม่ทุก render ทำให้ memo ไม่เคย hit)
|
|
43
|
+
const { selectedValues, sortedOptions } = React.useMemo(() => {
|
|
44
|
+
const selected = new Set(Array.isArray(filterValue) ? filterValue.map(String) : []);
|
|
45
|
+
const sorted = [...options].sort((a, b) => {
|
|
46
|
+
const aSelected = selected.has(String(a.value));
|
|
47
|
+
const bSelected = selected.has(String(b.value));
|
|
50
48
|
if (aSelected && !bSelected) return -1;
|
|
51
49
|
if (!aSelected && bSelected) return 1;
|
|
52
50
|
return 0;
|
|
53
51
|
});
|
|
54
|
-
|
|
52
|
+
return { selectedValues: selected, sortedOptions: sorted };
|
|
53
|
+
}, [options, filterValue]);
|
|
54
|
+
|
|
55
|
+
const areAllSelected = options.length > 0 && selectedValues.size === options.length;
|
|
55
56
|
|
|
56
57
|
return (
|
|
57
58
|
<Popover>
|
|
@@ -117,12 +118,10 @@ export function DataTableFacetedFilter<TData, TValue>({
|
|
|
117
118
|
<CommandItem
|
|
118
119
|
key={valueAsString}
|
|
119
120
|
onSelect={() => {
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
}
|
|
125
|
-
const filterValues = Array.from(selectedValues);
|
|
121
|
+
const next = new Set(selectedValues);
|
|
122
|
+
if (isSelected) next.delete(valueAsString);
|
|
123
|
+
else next.add(valueAsString);
|
|
124
|
+
const filterValues = Array.from(next);
|
|
126
125
|
column?.setFilterValue(filterValues.length ? filterValues : undefined);
|
|
127
126
|
}}
|
|
128
127
|
>
|
|
@@ -137,7 +137,8 @@ export function DataTableRowActions<TData>({
|
|
|
137
137
|
</DropdownMenuContent>
|
|
138
138
|
</DropdownMenu>
|
|
139
139
|
|
|
140
|
-
{
|
|
140
|
+
{/* mount เฉพาะตอนเปิด — ไม่งั้นทุกแถว mount Dialog+ฟอร์มค้างไว้ (50 แถว = 50 ฟอร์ม) และ initialData stale */}
|
|
141
|
+
{EditDialog && isEditDialogOpen && (
|
|
141
142
|
<EditDialog initialData={item} open={isEditDialogOpen} onOpenChange={setIsEditDialogOpen} />
|
|
142
143
|
)}
|
|
143
144
|
|
|
@@ -154,12 +154,9 @@ export function useDataTable<TData, TValue>({
|
|
|
154
154
|
return undefined;
|
|
155
155
|
}, [globalFilterFn, globalFilterKeys]);
|
|
156
156
|
|
|
157
|
-
const memoizedData = React.useMemo(() => data, [data]);
|
|
158
|
-
const memoizedColumns = React.useMemo(() => columns, [columns]);
|
|
159
|
-
|
|
160
157
|
const table = useReactTable({
|
|
161
|
-
data
|
|
162
|
-
columns
|
|
158
|
+
data,
|
|
159
|
+
columns,
|
|
163
160
|
pageCount: pageCount ?? (manualPagination ? -1 : undefined),
|
|
164
161
|
rowCount,
|
|
165
162
|
manualPagination,
|
|
@@ -205,7 +202,9 @@ export function useDataTable<TData, TValue>({
|
|
|
205
202
|
onGlobalFilterChange: (updater) => {
|
|
206
203
|
setGlobalFilter(updater);
|
|
207
204
|
if (onGlobalFilterChange) {
|
|
208
|
-
|
|
205
|
+
// อ่านค่าปัจจุบันจาก table ไม่ใช่ closure ของ render ก่อน (พิมพ์เร็วสองตัวอักษรใน tick เดียวจะหายตัว)
|
|
206
|
+
const current = table.getState().globalFilter as string;
|
|
207
|
+
const next = typeof updater === "function" ? updater(current) : updater;
|
|
209
208
|
onGlobalFilterChange((next as string) ?? "");
|
|
210
209
|
}
|
|
211
210
|
},
|
|
@@ -218,15 +217,12 @@ export function useDataTable<TData, TValue>({
|
|
|
218
217
|
globalFilterFn: finalGlobalFilterFn,
|
|
219
218
|
getCoreRowModel: getCoreRowModel(),
|
|
220
219
|
getFilteredRowModel: getFilteredRowModel(),
|
|
221
|
-
|
|
220
|
+
// server แบ่งหน้าให้แล้ว (manualPagination) → ไม่ต้องตัดซ้ำฝั่ง client
|
|
221
|
+
getPaginationRowModel: manualPagination ? undefined : getPaginationRowModel(),
|
|
222
222
|
getSortedRowModel: getSortedRowModel(),
|
|
223
223
|
getFacetedRowModel: getFacetedRowModel(),
|
|
224
224
|
getFacetedUniqueValues: getFacetedUniqueValuesProp,
|
|
225
225
|
});
|
|
226
226
|
|
|
227
|
-
// NOTE: If manualPagination is true, getPaginationRowModel might not be needed?
|
|
228
|
-
// TanStack docs say: if manualPagination is true, you don't need getPaginationRowModel IF you only provide the current page data.
|
|
229
|
-
// But usage of getPaginationRowModel is safe even with manualPagination as it just slices.
|
|
230
|
-
|
|
231
227
|
return { table, initialColumnFilters };
|
|
232
228
|
}
|
|
@@ -46,8 +46,14 @@ export function PullToRefresh({
|
|
|
46
46
|
const isPullingRef = useRef(false);
|
|
47
47
|
const [pullDistance, setPullDistance] = useState(0);
|
|
48
48
|
const [isRefreshing, setIsRefreshing] = useState(false);
|
|
49
|
+
// listener อ่านค่าล่าสุดผ่าน ref — ไม่ผูก state เป็น deps ของ effect (เดิมถอด/ใส่ listener ใหม่ทุก touchmove)
|
|
50
|
+
const pullDistanceRef = useRef(0);
|
|
51
|
+
const isRefreshingRef = useRef(false);
|
|
52
|
+
pullDistanceRef.current = pullDistance;
|
|
53
|
+
isRefreshingRef.current = isRefreshing;
|
|
49
54
|
|
|
50
55
|
useEffect(() => {
|
|
56
|
+
const isRefreshing = () => isRefreshingRef.current;
|
|
51
57
|
const isMobileViewport = () => window.innerWidth < MOBILE_BREAKPOINT;
|
|
52
58
|
const getRoot = () => document.querySelector<HTMLElement>(rootSelector);
|
|
53
59
|
const isRootAtTop = (root: HTMLElement) => root.scrollTop <= 0;
|
|
@@ -60,7 +66,7 @@ export function PullToRefresh({
|
|
|
60
66
|
|
|
61
67
|
const handleTouchStart = (event: TouchEvent) => {
|
|
62
68
|
const touch = event.touches[0];
|
|
63
|
-
if (!isMobileViewport() || isRefreshing || event.touches.length !== 1 || !touch) {
|
|
69
|
+
if (!isMobileViewport() || isRefreshing() || event.touches.length !== 1 || !touch) {
|
|
64
70
|
resetPull();
|
|
65
71
|
return;
|
|
66
72
|
}
|
|
@@ -76,7 +82,7 @@ export function PullToRefresh({
|
|
|
76
82
|
const handleTouchMove = (event: TouchEvent) => {
|
|
77
83
|
const start = startRef.current;
|
|
78
84
|
const touch = event.touches[0];
|
|
79
|
-
if (!start || isRefreshing || event.touches.length !== 1 || !touch) return;
|
|
85
|
+
if (!start || isRefreshing() || event.touches.length !== 1 || !touch) return;
|
|
80
86
|
|
|
81
87
|
const root = getRoot();
|
|
82
88
|
if (!root || !isRootAtTop(root)) {
|
|
@@ -99,9 +105,9 @@ export function PullToRefresh({
|
|
|
99
105
|
};
|
|
100
106
|
|
|
101
107
|
const handleTouchEnd = () => {
|
|
102
|
-
const shouldRefresh =
|
|
108
|
+
const shouldRefresh = pullDistanceRef.current >= ACTIVATION_DISTANCE;
|
|
103
109
|
resetPull();
|
|
104
|
-
if (!shouldRefresh || isRefreshing) return;
|
|
110
|
+
if (!shouldRefresh || isRefreshing()) return;
|
|
105
111
|
setIsRefreshing(true);
|
|
106
112
|
router.refresh();
|
|
107
113
|
window.setTimeout(() => setIsRefreshing(false), 700);
|
|
@@ -117,7 +123,7 @@ export function PullToRefresh({
|
|
|
117
123
|
window.removeEventListener("touchend", handleTouchEnd);
|
|
118
124
|
window.removeEventListener("touchcancel", resetPull);
|
|
119
125
|
};
|
|
120
|
-
}, [
|
|
126
|
+
}, [router, rootSelector]);
|
|
121
127
|
|
|
122
128
|
const isVisible = pullDistance > 0 || isRefreshing;
|
|
123
129
|
const progress = Math.min(1, pullDistance / ACTIVATION_DISTANCE);
|
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
import { Button } from "@suphark/ui/components/ui/button";
|
|
4
|
+
import { Card, CardContent } from "@suphark/ui/components/ui/card";
|
|
5
|
+
import { cn } from "@suphark/ui/lib/utils";
|
|
6
|
+
import { type ReactNode, startTransition, useActionState } from "react";
|
|
7
|
+
import { type FormAction, FormMessage, type FormState } from "./form-message";
|
|
8
|
+
import { useUiText } from "./ui-text";
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* ฟอร์ม inline คู่กับ `FormDialog` (อันนั้นอยู่ในกล่องโต้ตอบ อันนี้อยู่บนหน้า): `useActionState` + `FormMessage` + ปุ่มส่ง
|
|
12
|
+
* ไม่ใช้ `action=` บน <form> — React 19 รีเซ็ตฟอร์ม uncontrolled หลัง action จบทุกครั้ง (แม้คืน error)
|
|
13
|
+
* ค่าที่พิมพ์จะหาย; เรียก action ใน transition เองแทน ฟอร์มจึงคงค่าไว้ให้แก้ต่อ (แบบเดียวกับ FormDialog)
|
|
14
|
+
* ไม่พึ่ง next (ไม่ refresh เอง — ให้ server action `revalidatePath` หรือส่ง `onSuccess`)
|
|
15
|
+
*
|
|
16
|
+
* <ActionForm action={updateName} title="ชื่อองค์กร" submitLabel="บันทึก">
|
|
17
|
+
* <LabeledField label="ชื่อ"><Input name="name" defaultValue={org.name} /></LabeledField>
|
|
18
|
+
* </ActionForm>
|
|
19
|
+
*/
|
|
20
|
+
export function ActionForm({
|
|
21
|
+
action,
|
|
22
|
+
title,
|
|
23
|
+
submitLabel,
|
|
24
|
+
children,
|
|
25
|
+
compact = false,
|
|
26
|
+
className,
|
|
27
|
+
onSuccess,
|
|
28
|
+
}: {
|
|
29
|
+
action: FormAction;
|
|
30
|
+
title?: ReactNode;
|
|
31
|
+
submitLabel?: ReactNode;
|
|
32
|
+
children: ReactNode;
|
|
33
|
+
/** ฟอร์มเล็กในแถวตาราง — ไม่มีกรอบ Card, ปุ่ม size sm, จัดแนวนอน wrap */
|
|
34
|
+
compact?: boolean;
|
|
35
|
+
className?: string;
|
|
36
|
+
/** เรียกเมื่อ action คืน `ok: true` (เช่น router.refresh, toast) */
|
|
37
|
+
onSuccess?: (state: Extract<FormState, { ok: true }>) => void;
|
|
38
|
+
}) {
|
|
39
|
+
const text = useUiText();
|
|
40
|
+
const [state, formAction, pending] = useActionState<FormState, FormData>(async (prev, fd) => {
|
|
41
|
+
const next = await action(prev, fd);
|
|
42
|
+
if (next?.ok) onSuccess?.(next);
|
|
43
|
+
return next;
|
|
44
|
+
}, null);
|
|
45
|
+
|
|
46
|
+
const form = (
|
|
47
|
+
<form
|
|
48
|
+
onSubmit={(e) => {
|
|
49
|
+
e.preventDefault();
|
|
50
|
+
const fd = new FormData(e.currentTarget);
|
|
51
|
+
startTransition(() => formAction(fd));
|
|
52
|
+
}}
|
|
53
|
+
className={cn(
|
|
54
|
+
compact ? "flex flex-wrap items-end gap-2" : "flex flex-col gap-3",
|
|
55
|
+
compact && className,
|
|
56
|
+
)}
|
|
57
|
+
>
|
|
58
|
+
{title && !compact && <h2 className="font-semibold text-sm">{title}</h2>}
|
|
59
|
+
{children}
|
|
60
|
+
<FormMessage state={state} className={compact ? "basis-full" : undefined} />
|
|
61
|
+
<Button
|
|
62
|
+
type="submit"
|
|
63
|
+
size={compact ? "sm" : "default"}
|
|
64
|
+
disabled={pending}
|
|
65
|
+
className={compact ? undefined : "self-start"}
|
|
66
|
+
>
|
|
67
|
+
{submitLabel ?? text.save}
|
|
68
|
+
</Button>
|
|
69
|
+
</form>
|
|
70
|
+
);
|
|
71
|
+
|
|
72
|
+
if (compact) return form;
|
|
73
|
+
return (
|
|
74
|
+
<Card className={className}>
|
|
75
|
+
<CardContent>{form}</CardContent>
|
|
76
|
+
</Card>
|
|
77
|
+
);
|
|
78
|
+
}
|
|
@@ -43,8 +43,17 @@ interface FormFieldComboboxProps<T extends FieldValues> {
|
|
|
43
43
|
onSelect?: (option: { label: string; value: string; [key: string]: any }) => void;
|
|
44
44
|
}
|
|
45
45
|
|
|
46
|
-
|
|
47
|
-
|
|
46
|
+
type CacheEntry = { label: string; value: string; subLabel?: string }[];
|
|
47
|
+
// cache ตัวเลือกข้าม mount: key เป็น string (`fetcherKey`) เก็บใน Map; key เป็นฟังก์ชัน (ไม่ส่ง fetcherKey)
|
|
48
|
+
// เก็บใน WeakMap — inline arrow ที่ identity เปลี่ยนทุก render จะถูก GC ไม่โตค้างตลอดอายุแท็บ
|
|
49
|
+
const stringCache = new Map<string, CacheEntry>();
|
|
50
|
+
const fnCache = new WeakMap<object, CacheEntry>();
|
|
51
|
+
const cacheGet = (key: string | object) =>
|
|
52
|
+
typeof key === "string" ? stringCache.get(key) : fnCache.get(key);
|
|
53
|
+
const cacheSet = (key: string | object, data: CacheEntry) => {
|
|
54
|
+
if (typeof key === "string") stringCache.set(key, data);
|
|
55
|
+
else fnCache.set(key, data);
|
|
56
|
+
};
|
|
48
57
|
|
|
49
58
|
export function FormFieldCombobox<T extends FieldValues>({
|
|
50
59
|
control,
|
|
@@ -61,7 +70,7 @@ export function FormFieldCombobox<T extends FieldValues>({
|
|
|
61
70
|
}: FormFieldComboboxProps<T>) {
|
|
62
71
|
const cacheKey = fetcherKey ?? fetcher;
|
|
63
72
|
const [options, setOptions] = useState<{ label: string; value: string; subLabel?: string }[]>(
|
|
64
|
-
() =>
|
|
73
|
+
() => cacheGet(cacheKey) || [],
|
|
65
74
|
);
|
|
66
75
|
const [isLoading, startTransition] = useTransition();
|
|
67
76
|
const [open, setOpen] = React.useState(false);
|
|
@@ -73,14 +82,14 @@ export function FormFieldCombobox<T extends FieldValues>({
|
|
|
73
82
|
|
|
74
83
|
useEffect(() => {
|
|
75
84
|
// แสดงค่าจากแคชก่อนเพื่อลดการกระพริบ แล้วค่อยโหลดค่าล่าสุดทับ
|
|
76
|
-
const cached =
|
|
85
|
+
const cached = cacheGet(cacheKey);
|
|
77
86
|
if (cached) setOptions(cached);
|
|
78
87
|
|
|
79
88
|
startTransition(async () => {
|
|
80
89
|
try {
|
|
81
90
|
const data = await fetcherRef.current();
|
|
82
91
|
setOptions(data);
|
|
83
|
-
|
|
92
|
+
cacheSet(cacheKey, data);
|
|
84
93
|
} catch (error) {
|
|
85
94
|
console.error("Failed to fetch combobox options:", error);
|
|
86
95
|
}
|
|
@@ -17,49 +17,95 @@ type RadioCardOption = {
|
|
|
17
17
|
|
|
18
18
|
interface FormFieldRadioCardsProps<T extends FieldValues = any> {
|
|
19
19
|
control?: Control<T>;
|
|
20
|
-
name
|
|
20
|
+
/** กับ react-hook-form = ชื่อ field; ไม่มี `control` = `name` ของ RadioGroup (ส่งค่าใน FormData ของ server action) */
|
|
21
|
+
name?: Path<T> | string;
|
|
21
22
|
value?: string;
|
|
23
|
+
/** ค่าเริ่มต้นเมื่อไม่มี control และไม่คุม value เอง (ฟอร์ม server action) */
|
|
24
|
+
defaultValue?: string;
|
|
22
25
|
onValueChange?: (value: string) => void;
|
|
23
26
|
label?: string;
|
|
24
27
|
options: RadioCardOption[];
|
|
25
28
|
disabled?: boolean;
|
|
26
29
|
className?: string;
|
|
27
30
|
size?: "sm" | "md" | "lg";
|
|
31
|
+
/**
|
|
32
|
+
* `grid` (ค่าเริ่มต้น) = การ์ดสี่เหลี่ยมไอคอนกลาง จุดเลือกมุมขวาบน;
|
|
33
|
+
* `row` = การ์ดแนวนอน: ปุ่มวิทยุจริงซ้าย, ไอคอน+ชื่อบรรทัดบน, คำอธิบายบรรทัดล่าง (แบบ Pi Auth เลือกประเภทแอป)
|
|
34
|
+
*/
|
|
35
|
+
layout?: "grid" | "row";
|
|
28
36
|
}
|
|
29
37
|
|
|
30
38
|
export function FormFieldRadioCards<T extends FieldValues = any>({
|
|
31
39
|
control,
|
|
32
40
|
name,
|
|
33
41
|
value: propValue,
|
|
42
|
+
defaultValue,
|
|
34
43
|
onValueChange: propOnValueChange,
|
|
35
44
|
label,
|
|
36
45
|
options,
|
|
37
46
|
disabled,
|
|
38
47
|
className,
|
|
39
48
|
size = "md",
|
|
49
|
+
layout = "grid",
|
|
40
50
|
}: FormFieldRadioCardsProps<T>) {
|
|
41
51
|
const baseId = useId();
|
|
42
52
|
|
|
43
|
-
const renderContent = (
|
|
53
|
+
const renderContent = (
|
|
54
|
+
value: string | undefined,
|
|
55
|
+
onChange: (val: string) => void,
|
|
56
|
+
groupName?: string,
|
|
57
|
+
) => (
|
|
44
58
|
<div className="space-y-3">
|
|
45
59
|
{label && <Label className="block font-semibold text-base">{label}</Label>}
|
|
46
60
|
<RadioGroup
|
|
61
|
+
name={groupName}
|
|
47
62
|
onValueChange={(val) => {
|
|
48
|
-
if (val !== null) onChange(val);
|
|
63
|
+
if (val !== null) onChange(String(val));
|
|
49
64
|
}}
|
|
50
65
|
value={value}
|
|
66
|
+
// base-ui-ok: ค่าเริ่มต้นของฟอร์ม server action — ไม่ถูกเปลี่ยนหลัง mount
|
|
67
|
+
defaultValue={value === undefined ? defaultValue : undefined}
|
|
51
68
|
disabled={disabled}
|
|
52
|
-
className={cn(
|
|
69
|
+
className={cn(
|
|
70
|
+
"grid grid-cols-1 gap-4",
|
|
71
|
+
layout === "grid" ? "md:grid-cols-2" : "gap-3 sm:grid-cols-2",
|
|
72
|
+
className,
|
|
73
|
+
)}
|
|
53
74
|
>
|
|
54
75
|
{options.map((option) => {
|
|
55
76
|
const id = `${baseId}-${option.value}`;
|
|
77
|
+
if (layout === "row") {
|
|
78
|
+
return (
|
|
79
|
+
<Label
|
|
80
|
+
key={option.value}
|
|
81
|
+
htmlFor={id}
|
|
82
|
+
className={cn(
|
|
83
|
+
"flex cursor-pointer items-start gap-3 rounded-lg border p-3 font-normal transition-colors hover:bg-muted/40 has-data-checked:border-primary has-data-checked:bg-primary/5",
|
|
84
|
+
disabled && "cursor-not-allowed opacity-50",
|
|
85
|
+
)}
|
|
86
|
+
>
|
|
87
|
+
<RadioGroupItem value={option.value} id={id} className="mt-0.5" />
|
|
88
|
+
<span className="flex min-w-0 flex-col gap-0.5">
|
|
89
|
+
<span className="flex items-center gap-1.5 font-medium text-sm">
|
|
90
|
+
{option.icon && (
|
|
91
|
+
<span className={cn("[&_svg]:size-4", option.colorClass)}>{option.icon}</span>
|
|
92
|
+
)}
|
|
93
|
+
{option.label}
|
|
94
|
+
</span>
|
|
95
|
+
{option.description && (
|
|
96
|
+
<span className="text-muted-foreground text-xs">{option.description}</span>
|
|
97
|
+
)}
|
|
98
|
+
</span>
|
|
99
|
+
</Label>
|
|
100
|
+
);
|
|
101
|
+
}
|
|
56
102
|
return (
|
|
57
103
|
<div key={option.value} className="relative">
|
|
58
104
|
<RadioGroupItem value={option.value} id={id} className="peer sr-only" />
|
|
59
105
|
<Label
|
|
60
106
|
htmlFor={id}
|
|
61
107
|
className={cn(
|
|
62
|
-
"flex cursor-pointer flex-col items-center justify-center rounded-xl border-2 transition-all hover:border-primary hover:bg-accent peer-data-checked:border-primary peer-data-checked:bg-primary/5",
|
|
108
|
+
"flex cursor-pointer flex-col items-center justify-center rounded-xl border-2 transition-all hover:border-primary hover:bg-accent peer-data-checked:border-primary peer-data-checked:bg-primary/5 peer-data-checked:[&>[data-dot]]:scale-110 peer-data-checked:[&>[data-dot]]:border-primary peer-data-checked:[&>[data-dot]]:bg-primary peer-data-checked:[&>[data-dot]>span]:opacity-100",
|
|
63
109
|
size === "sm" && "min-h-[70px] gap-1 p-2",
|
|
64
110
|
size === "md" && "min-h-[100px] gap-2 p-4",
|
|
65
111
|
size === "lg" && "min-h-[120px] gap-4 p-6",
|
|
@@ -77,29 +123,19 @@ export function FormFieldRadioCards<T extends FieldValues = any>({
|
|
|
77
123
|
{option.label}
|
|
78
124
|
</div>
|
|
79
125
|
{option.description && (
|
|
80
|
-
<div
|
|
81
|
-
className={cn(
|
|
82
|
-
"font-normal text-muted-foreground leading-tight",
|
|
83
|
-
size === "sm" ? "text-xs" : "text-xs",
|
|
84
|
-
)}
|
|
85
|
-
>
|
|
126
|
+
<div className="font-normal text-muted-foreground text-xs leading-tight">
|
|
86
127
|
{option.description}
|
|
87
128
|
</div>
|
|
88
129
|
)}
|
|
89
130
|
</div>
|
|
90
131
|
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
)}
|
|
132
|
+
{/* จุดเลือกมุมขวาบน — ตามสถานะจริงของปุ่มวิทยุ (peer) จึงถูกทั้ง controlled/uncontrolled */}
|
|
133
|
+
<span
|
|
134
|
+
data-dot=""
|
|
135
|
+
className="absolute top-2 right-2 flex h-4 w-4 items-center justify-center rounded-full border-2 border-muted transition-all"
|
|
96
136
|
>
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
<div className="h-1.5 w-1.5 rounded-full bg-white" />
|
|
100
|
-
</div>
|
|
101
|
-
)}
|
|
102
|
-
</div>
|
|
137
|
+
<span className="h-1.5 w-1.5 rounded-full bg-white opacity-0" />
|
|
138
|
+
</span>
|
|
103
139
|
</Label>
|
|
104
140
|
</div>
|
|
105
141
|
);
|
|
@@ -112,7 +148,7 @@ export function FormFieldRadioCards<T extends FieldValues = any>({
|
|
|
112
148
|
return (
|
|
113
149
|
<FormField
|
|
114
150
|
control={control}
|
|
115
|
-
name={name}
|
|
151
|
+
name={name as Path<T>}
|
|
116
152
|
render={({ field }) => (
|
|
117
153
|
<FormItem className="space-y-3">
|
|
118
154
|
<FormControl>{renderContent(field.value, field.onChange)}</FormControl>
|
|
@@ -123,5 +159,6 @@ export function FormFieldRadioCards<T extends FieldValues = any>({
|
|
|
123
159
|
);
|
|
124
160
|
}
|
|
125
161
|
|
|
126
|
-
|
|
162
|
+
// ไม่มี control: คุม value เอง (`value` + `onValueChange`) หรือปล่อย uncontrolled ด้วย `name` + `defaultValue` (server action)
|
|
163
|
+
return renderContent(propValue, propOnValueChange ?? (() => {}), name);
|
|
127
164
|
}
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
import { Checkbox } from "@suphark/ui/components/ui/checkbox";
|
|
2
|
+
import { Field, FieldDescription, FieldError, FieldLabel } from "@suphark/ui/components/ui/field";
|
|
3
|
+
import { Label } from "@suphark/ui/components/ui/label";
|
|
4
|
+
import { cn } from "@suphark/ui/lib/utils";
|
|
5
|
+
import type { ComponentProps, ReactNode } from "react";
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* ช่องฟอร์มสำหรับ server action ธรรมดา (ไม่ใช่ react-hook-form): label + control + hint หรือ error
|
|
9
|
+
* ประกอบจาก shadcn Field/FieldLabel/FieldDescription/FieldError — ยกจาก Pi Auth (ใช้ 42 ไฟล์ คง API เดิม)
|
|
10
|
+
* ใช้ได้จาก server component (ไม่มี state)
|
|
11
|
+
*
|
|
12
|
+
* <LabeledField label="ชื่อแอป" hint="แสดงในหน้าเข้าสู่ระบบ" error={state?.errors?.name}>
|
|
13
|
+
* <Input name="name" required />
|
|
14
|
+
* </LabeledField>
|
|
15
|
+
*/
|
|
16
|
+
export function LabeledField({
|
|
17
|
+
label,
|
|
18
|
+
hint,
|
|
19
|
+
error,
|
|
20
|
+
children,
|
|
21
|
+
className,
|
|
22
|
+
}: {
|
|
23
|
+
label: ReactNode;
|
|
24
|
+
/** ข้อความช่วยใต้ช่อง — ซ่อนเมื่อมี error */
|
|
25
|
+
hint?: ReactNode;
|
|
26
|
+
error?: ReactNode;
|
|
27
|
+
children: ReactNode;
|
|
28
|
+
className?: string;
|
|
29
|
+
}) {
|
|
30
|
+
return (
|
|
31
|
+
<Field className={cn("gap-1.5", className)} data-invalid={error ? true : undefined}>
|
|
32
|
+
<FieldLabel className="w-full flex-col items-stretch gap-1.5 font-normal [&_[data-slot=native-select-wrapper]]:w-full">
|
|
33
|
+
<span className="font-medium text-sm">{label}</span>
|
|
34
|
+
{children}
|
|
35
|
+
</FieldLabel>
|
|
36
|
+
{error ? (
|
|
37
|
+
<FieldError className="text-xs">{error}</FieldError>
|
|
38
|
+
) : (
|
|
39
|
+
hint && <FieldDescription className="text-xs">{hint}</FieldDescription>
|
|
40
|
+
)}
|
|
41
|
+
</Field>
|
|
42
|
+
);
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/**
|
|
46
|
+
* Checkbox ของ shadcn + label บรรทัดเดียว — ใส่ `name` แล้วส่ง "on" ใน FormData ของ server action ได้
|
|
47
|
+
*
|
|
48
|
+
* <CheckboxField name="allowSignup" defaultChecked label="เปิดให้สมัครเอง" />
|
|
49
|
+
*/
|
|
50
|
+
export function CheckboxField({
|
|
51
|
+
label,
|
|
52
|
+
className,
|
|
53
|
+
...props
|
|
54
|
+
}: ComponentProps<typeof Checkbox> & { label: ReactNode }) {
|
|
55
|
+
return (
|
|
56
|
+
<Label className={cn("flex items-center gap-2 font-normal", className)}>
|
|
57
|
+
<Checkbox {...props} />
|
|
58
|
+
<span className="text-sm">{label}</span>
|
|
59
|
+
</Label>
|
|
60
|
+
);
|
|
61
|
+
}
|