@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 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.5.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
- <Collapsible
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
- </Collapsible>
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
- const selectedValues = new Set(Array.isArray(filterValue) ? filterValue.map(String) : []);
43
-
44
- const areAllSelected = options.length > 0 && selectedValues.size === options.length;
45
-
46
- const sortedOptions = React.useMemo(() => {
47
- return [...options].sort((a, b) => {
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
- }, [options, selectedValues]);
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
- if (isSelected) {
121
- selectedValues.delete(valueAsString);
122
- } else {
123
- selectedValues.add(valueAsString);
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
- {EditDialog && (
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: memoizedData,
162
- columns: memoizedColumns,
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
- const next = typeof updater === "function" ? updater(globalFilter) : updater;
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
- getPaginationRowModel: getPaginationRowModel(),
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 = pullDistance >= ACTIVATION_DISTANCE;
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
- }, [isRefreshing, pullDistance, router, rootSelector]);
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
- // Singleton client-side cache to persist combobox options across mounts/renders
47
- const comboboxCache = new Map<any, { label: string; value: string; subLabel?: string }[]>();
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
- () => comboboxCache.get(cacheKey) || [],
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 = comboboxCache.get(cacheKey);
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
- comboboxCache.set(cacheKey, data);
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?: Path<T>;
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 = (value: string, onChange: (val: string) => void) => (
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("grid grid-cols-1 gap-4 md:grid-cols-2", className)}
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
- <div
92
- className={cn(
93
- "absolute top-2 right-2 h-4 w-4 rounded-full border-2 border-muted transition-all",
94
- value === option.value && "scale-110 border-primary bg-primary",
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
- {value === option.value && (
98
- <div className="flex h-full w-full items-center justify-center">
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
- return renderContent(propValue || "", propOnValueChange || (() => {}));
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
+ }