@suphark/ui 0.7.0 → 0.8.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 +15 -0
- package/README.md +3 -2
- package/package.json +1 -1
- package/src/components/app-shell/command-palette.tsx +63 -13
- package/src/components/app-shell/index.ts +1 -0
- package/src/components/shared/action-form.tsx +29 -13
- package/src/components/shared/color-picker.tsx +180 -0
- package/src/components/shared/dropzone.tsx +333 -0
- package/src/components/shared/file-type-icon.tsx +171 -0
- package/src/components/shared/form-dialog.tsx +3 -3
- package/src/components/shared/form-message.tsx +13 -6
- package/src/components/shared/tree-view.tsx +325 -0
- package/src/components/shared/upload-list.tsx +233 -0
- package/src/index.ts +24 -1
- package/src/lib/formatters.ts +17 -0
package/CHANGELOG.md
CHANGED
|
@@ -2,6 +2,21 @@
|
|
|
2
2
|
|
|
3
3
|
รูปแบบตาม [Keep a Changelog](https://keepachangelog.com/) · semver: patch = หน้าตา/บั๊ก, minor = component/prop ใหม่, major = เปลี่ยน/ลบ props
|
|
4
4
|
|
|
5
|
+
## [0.8.0] — 2026-09-21
|
|
6
|
+
|
|
7
|
+
รอบ feedback หลังทุกแอปย้ายมา 0.7.0 ครบ 6/6 — สเปกจาก Pi Done (ColorPicker, CommandPalette client-side), Pi Mail (ActionForm), Pi Files (file explorer 4 ตัว) ทำรวดเดียว; minor ทั้งหมด ไม่เปลี่ยน API เดิม
|
|
8
|
+
|
|
9
|
+
### Added
|
|
10
|
+
|
|
11
|
+
- **`ColorPicker<T>`** + **`ColorSwatch`** (barrel) — กริดเลือกสีจากชุดที่แอปส่ง `colors[{ value, label, swatchClassName? | swatch? (CSS) }]` (แพ็กเกจไม่ bundle palette): ปุ่ม `aria-pressed` + `aria-label`/`title`, ที่เลือก = `ring-2 ring-primary ring-offset-1` + ✓ ขาว, hover scale-105, roving tabindex + ลูกศร ↑↓←→/Home/End (จำนวนคอลัมน์อ่านจาก grid จริง), `columnsClassName` (grid-cols-5), `size` sm|md (size-7/size-8), `language`/`text`; `ColorSwatch` จุดสี size-3.5 สำหรับ trigger/badge (`color` CSS หรือ class) — ไม่มี popover ในตัว แอปครอบเองแบบ IconPicker (สเปกจาก Pi Done แทน metadata-color-picker 9 จุด)
|
|
12
|
+
- **`CommandPalette` โหมด client-side / controlled** (ขอจาก Pi Done): `filter(query) => CommandPaletteSection[]` คำนวณฝั่งแอปทุกครั้งที่ query เปลี่ยน (เรียกแม้ query ว่าง — ใช้แสดง action/ล่าสุด, ไม่ debounce) แสดงก่อนผลจาก `search`; item ใน section รับ `onSelect()` (action ล้วน `href` ไม่บังคับแล้ว; มีทั้งคู่เรียก onSelect ก่อนแล้วไป href) + `shortcut` (ป้ายท้ายรายการ); `open`/`onOpenChange` คุมจากภายนอก, `showTrigger={false}` ซ่อนปุ่มของแพ็กเกจ, `shortcut={null}` ไม่ดักคีย์ลัด (แอปดักเอง); type `CommandPaletteSectionItem`
|
|
13
|
+
- **`ActionForm`** (ข้อสังเกตจาก Pi Mail): `hideSuccessMessage` ซ่อนข้อความสำเร็จเมื่อแอปแจ้งผลเอง (ใน compact ข้อความดันปุ่มตกบรรทัด — ยังแสดง error), `submitDisabled` + `submitProps` (variant/size/className ของปุ่มส่ง); **`FormState<TData>`** — `ok: true` มี `data?: TData` และ `ActionForm<TData>`/`FormDialog<TData>`/`FormAction<TData>` generic → `onSuccess(s)` ได้ `s.data` ไม่ต้อง cast (`TData` ค่าเริ่มต้น `undefined` — โค้ดเดิมไม่ต้องแก้)
|
|
14
|
+
- **`TreeView`** (barrel, สเปกจาก Pi Files) — ต้นไม้ทั่วไป `role=tree` ตาม WAI-ARIA (treeitem มี aria-level/expanded/selected, roving tabindex): `nodes[{ id, label, icon?, hasChildren?, children?, disabled?, badge? }]`, `selectedId`/`onSelect(id, node)`, `expandedIds`/`defaultExpandedIds`/`onExpandedChange` (controlled ได้), `loadChildren(id, node)` โหลดลูก lazy (Spinner ระหว่างโหลด, cache ในตัว, error = ลูกว่าง), `onDrop(targetId, e, node)`/`canDrop` HTML5 dnd ไฮไลต์ node (`data-drop-target`), `renderLabel(node, { expanded, selected, level })`, คีย์บอร์ด ↑↓ เลื่อน → ขยาย/เข้าไปลูกแรก ← ยุบ/กลับแม่ Home/End Enter/Space เลือก, `indent` (12px/ระดับ), `emptyState`, `label`; สีตาม token ของ Sidebar (เลือก = `bg-sidebar-accent`) — ลูกศรยุบ/ขยายไม่ใช่ button (ไม่ซ้อน focusable ใน treeitem)
|
|
15
|
+
- **`Dropzone`** (barrel, สเปกจาก Pi Files) — พื้นที่ลากวางไฟล์: การ์ดเดี่ยว (เส้นประ + ไอคอน + ข้อความ + ปุ่มเลือกไฟล์; คลิกการ์ด/ปุ่ม/Enter เปิด picker) หรือ `overlay` ห่อ `children` แสดงชั้น "วางไฟล์ที่นี่" เฉพาะตอนลากไฟล์เข้าหน้า (ฟัง window + กัน browser เปิดไฟล์เมื่อวางนอกโซน); `onFiles(File[])`, `multiple` (true), `accept` (นามสกุล/กลุ่ม/mime — export `matchesAccept`), `maxBytes`, `onRejected(files, "size" | "type" | "multiple")`, `disabled`, `directory` (`webkitdirectory` + วางโฟลเดอร์ไล่อ่านไฟล์ข้างในผ่าน `webkitGetAsEntry`), `hint` (ค่าเริ่มต้นแสดง maxBytes), `icon`, `language`/`text`; data-attribute `data-active`/`data-disabled`
|
|
16
|
+
- **`UploadList`** (barrel, สเปกจาก Pi Files) — รายการอัปโหลด `items[{ id, name, sizeBytes, progress 0–1, status queued|uploading|done|error|aborted, error?, mime? }]` พร้อม `ProgressBar` ต่อแถว (queued จาง), `FileTypeIcon`, สรุป "x/y เสร็จ"/"เสร็จทั้งหมด" (aria-live), `onCancel`/`onRetry`/`onClearDone`/`onClose`, ยุบ/ขยาย (`defaultCollapsed`), `floating` มุมล่างขวาแบบ Drive (w-80 ไม่เกินจอ), `title`, `language`/`text`; `items` ว่าง = ไม่ render
|
|
17
|
+
- **`FileTypeIcon`** + **`getFileKind(mime, filename)`** (barrel) — ไอคอน lucide ตามชนิดไฟล์ (folder/image/video/audio/pdf/doc/sheet/slides/zip/code/text/default) จาก mime ก่อน นามสกุลสำรอง สีตาม token (pdf destructive, sheet success, folder/slides warning, สื่อ primary), `size` px, `data-kind`; **`formatBytes(bytes, locale)`** (lib/formatters) ฐาน 1024 "1.5 KB"/"12 MB"
|
|
18
|
+
- showcase: หน้า `color-picker` (กริด + ครอบ Popover), `tree-view` (lazy + drop ไฟล์จริง), `dropzone` (การ์ด + overlay), `upload-list` (mock progress + ลอย), `file-type-icon`; `command-palette` เพิ่มแบบ client-side คุม open เอง; `action-form` เพิ่ม hideSuccessMessage/submitDisabled/data (SecretReveal) — 95 หน้า; tests 17 กรณีเพิ่ม (รวม 119)
|
|
19
|
+
|
|
5
20
|
## [0.7.0] — 2026-09-20
|
|
6
21
|
|
|
7
22
|
component ระดับแอปที่ Pi Auth และ Pi Done เขียนซ้ำกัน — สเปกจาก Pi Auth ออกแบบให้ทั้งสองแอปใช้ร่วม (ไม่พึ่ง i18n/auth ของแอป ทุกข้อความรับเป็น prop หรือ `UiText`)
|
package/README.md
CHANGED
|
@@ -60,6 +60,7 @@ import { ThemeProvider, Toaster } from "@suphark/ui";
|
|
|
60
60
|
```ts
|
|
61
61
|
// ทุกอย่างที่ไม่พึ่ง Next — primitives, shared, theme, hooks, cn
|
|
62
62
|
import { Button, Card, FormFieldInput, StatusBadge, ThemeToggle, ConfirmDialog, useConfirm, cn } from "@suphark/ui";
|
|
63
|
+
// file explorer / picker: TreeView, Dropzone, UploadList, FileTypeIcon, ColorPicker, IconPicker, CalendarMonthGrid ก็อยู่ barrel
|
|
63
64
|
|
|
64
65
|
// ของที่ใช้ next/navigation หรือ next/link + โครงแอป
|
|
65
66
|
import { AppShell, UserMenu, filterNav, FormDialog, RowActions, LinkButton, RefreshButton, PageShell } from "@suphark/ui/next";
|
|
@@ -82,12 +83,12 @@ import { ChartContainer } from "@suphark/ui/components/ui/chart";
|
|
|
82
83
|
| โฟลเดอร์ | คืออะไร | กติกา |
|
|
83
84
|
|---|---|---|
|
|
84
85
|
| `src/components/ui/*` | shadcn/ui ต้นฉบับ 59 ตัว (+ `__tests__`) | **ห้ามแก้มือ** — เพิ่ม/อัปเดตด้วย `pnpm dlx shadcn@latest add <name>` ใน `packages/ui` (`components.json` ตั้ง alias ไว้แล้ว) |
|
|
85
|
-
| `src/components/shared/*` | ของที่เราประกอบเอง: ปุ่มมาตรฐาน (`button/`), form-field บน react-hook-form (`form/`), layout, theme, combobox,
|
|
86
|
+
| `src/components/shared/*` | ของที่เราประกอบเอง: ปุ่มมาตรฐาน (`button/`), form-field บน react-hook-form (`form/`), layout, theme, combobox, ชุดจาก Pi Auth (FormDialog, NameIdFields, StatusBadge, RowMenu, …), picker (IconPicker/ColorPicker/CalendarMonthGrid), file explorer (TreeView/Dropzone/UploadList/FileTypeIcon) | แก้ได้ แต่คง props เดิมให้มากที่สุด (minor = เพิ่ม, major = เปลี่ยน) |
|
|
86
87
|
| `src/components/data-table/*` | DataTable บน tanstack (toolbar, faceted filter, pagination, view options, row actions, ลากเรียงแถว) | entry `@suphark/ui/data-table` |
|
|
87
88
|
| `src/components/pwa/*` | PWA: ลงทะเบียน/อัปเดต service worker, การ์ดชวนติดตั้ง, ดึงลงเพื่อรีเฟรช | entry `@suphark/ui/pwa` |
|
|
88
89
|
| `src/components/app-shell/*` | AppShell (sidebar + header + เนื้อหา), AppNav, AppBrand, UserMenu, HeaderBreadcrumbs, NavLink, filterNav/searchNav | export ผ่าน `@suphark/ui/next` (ใช้ next/link, next/navigation) |
|
|
89
90
|
| `src/components/design-system/*` | หน้า showcase (render ใน `apps/showcase`) | ไม่อยู่ใน barrel |
|
|
90
|
-
| `src/lib/*`, `src/hooks/*` | `cn`, formatter
|
|
91
|
+
| `src/lib/*`, `src/hooks/*` | `cn`, formatter วันที่ไทย + `formatBytes`, สีสถานะ, permission context, `useIsMobile`, `useCopyToClipboard`, `useMounted`, `useTicker` | |
|
|
91
92
|
| `src/styles.css` | token (slate + success/warning/sidebar/chart), radius, ฟอนต์, scrollbar, dark variant | |
|
|
92
93
|
| `scripts/*.mjs` | audit บังคับ: Base UI props (`default*`/`onSelect`), typography scale | แอปปลายทางรันได้จาก `node_modules/@suphark/ui/scripts` |
|
|
93
94
|
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@suphark/ui",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.8.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 เอง",
|
|
@@ -17,7 +17,7 @@ import { useMounted } from "@suphark/ui/hooks/use-mounted";
|
|
|
17
17
|
import { cn } from "@suphark/ui/lib/utils";
|
|
18
18
|
import { Search } from "lucide-react";
|
|
19
19
|
import { useRouter } from "next/navigation";
|
|
20
|
-
import { type ReactNode, useEffect, useMemo, useState, useTransition } from "react";
|
|
20
|
+
import { type ReactNode, useEffect, useMemo, useRef, useState, useTransition } from "react";
|
|
21
21
|
|
|
22
22
|
export interface CommandPaletteItem {
|
|
23
23
|
href: string;
|
|
@@ -29,10 +29,23 @@ export interface CommandPaletteItem {
|
|
|
29
29
|
keywords?: string[];
|
|
30
30
|
}
|
|
31
31
|
|
|
32
|
+
export interface CommandPaletteSectionItem {
|
|
33
|
+
id: string;
|
|
34
|
+
/** ไปหน้า (router.push / `onNavigate`) — ไม่ส่งถ้าเป็น action ล้วน */
|
|
35
|
+
href?: string;
|
|
36
|
+
/** action ฝั่ง client (สร้างงาน, สลับ sidebar …) — เรียกหลังปิดกล่อง; ถ้ามีทั้งคู่ เรียก onSelect ก่อนแล้วไป href */
|
|
37
|
+
onSelect?: () => void;
|
|
38
|
+
label: string;
|
|
39
|
+
description?: string;
|
|
40
|
+
icon?: ReactNode;
|
|
41
|
+
/** ป้ายคีย์ลัดท้ายรายการ (แสดงอย่างเดียว เช่น "G H") */
|
|
42
|
+
shortcut?: string;
|
|
43
|
+
}
|
|
44
|
+
|
|
32
45
|
export interface CommandPaletteSection {
|
|
33
46
|
key: string;
|
|
34
47
|
heading: string;
|
|
35
|
-
items:
|
|
48
|
+
items: CommandPaletteSectionItem[];
|
|
36
49
|
}
|
|
37
50
|
|
|
38
51
|
export interface CommandPaletteText {
|
|
@@ -65,7 +78,9 @@ function useModKeyLabel(mounted: boolean): string {
|
|
|
65
78
|
|
|
66
79
|
/**
|
|
67
80
|
* ค้นหาแบบ ⌘K / Ctrl+K ทั้งแอป: หน้าในเมนู (แอปกรองสิทธิ์มาแล้ว) กรองฝั่ง client + ส่วนจาก server action
|
|
68
|
-
* (`search`) เมื่อพิมพ์ ≥ `minQuery` ตัว (debounce; ผลของ query ล่าสุดเท่านั้นที่แสดง)
|
|
81
|
+
* (`search`) เมื่อพิมพ์ ≥ `minQuery` ตัว (debounce; ผลของ query ล่าสุดเท่านั้นที่แสดง) + ส่วนที่แอปคำนวณเองฝั่ง client
|
|
82
|
+
* (`filter(query)` — ข้อมูลใน memory/offline-first, รายการ action ที่มี `onSelect`) แสดงก่อนผลจาก server
|
|
83
|
+
* เปิด/ปิดคุมจากภายนอกได้ (`open`/`onOpenChange`) และซ่อนปุ่มเรียกของแพ็กเกจ (`showTrigger={false}`) เมื่อแอปมีปุ่ม/คีย์ลัดเอง
|
|
69
84
|
* ปุ่มเรียก: `compact` = ไอคอนใน header, ค่าเริ่มต้น = ปุ่มเต็มกว้าง + Kbd ใน `sidebarHeader` ของ AppShell
|
|
70
85
|
* ข้อความรับเป็น prop `text` (ค่าเริ่มต้นจาก `UiText.palette` หรือไทย) — ไม่พึ่ง i18n ของแอป
|
|
71
86
|
* ยกจาก Pi Auth (features/console/command-palette) ให้ Pi Done/DueBell ใช้ร่วม
|
|
@@ -73,7 +88,11 @@ function useModKeyLabel(mounted: boolean): string {
|
|
|
73
88
|
export function CommandPalette({
|
|
74
89
|
pages,
|
|
75
90
|
search,
|
|
91
|
+
filter,
|
|
76
92
|
compact = false,
|
|
93
|
+
showTrigger = true,
|
|
94
|
+
open: controlledOpen,
|
|
95
|
+
onOpenChange,
|
|
77
96
|
shortcut = "k",
|
|
78
97
|
minQuery = 2,
|
|
79
98
|
debounceMs = 150,
|
|
@@ -84,9 +103,19 @@ export function CommandPalette({
|
|
|
84
103
|
pages: CommandPaletteItem[];
|
|
85
104
|
/** server action ของแอป — คืน section ตามลำดับที่ต้องการแสดง */
|
|
86
105
|
search?: (query: string) => Promise<CommandPaletteSection[]>;
|
|
106
|
+
/**
|
|
107
|
+
* section ที่แอปคำนวณเองฝั่ง client ทุกครั้งที่ query เปลี่ยน (query ว่างก็เรียก — ใช้แสดงรายการ action/ล่าสุด);
|
|
108
|
+
* ไม่ debounce เพราะไม่มี IO — ให้ memo ฝั่งแอปถ้าชุดข้อมูลใหญ่
|
|
109
|
+
*/
|
|
110
|
+
filter?: (query: string) => CommandPaletteSection[];
|
|
87
111
|
compact?: boolean;
|
|
88
|
-
/**
|
|
89
|
-
|
|
112
|
+
/** ซ่อนปุ่มเรียกของแพ็กเกจ (แอปเปิดเองผ่าน `open` หรือคีย์ลัด) */
|
|
113
|
+
showTrigger?: boolean;
|
|
114
|
+
/** คุมการเปิดจากภายนอก (ใช้คู่ `onOpenChange`) — ไม่ส่ง = คุมเองในตัว */
|
|
115
|
+
open?: boolean;
|
|
116
|
+
onOpenChange?: (open: boolean) => void;
|
|
117
|
+
/** ตัวอักษรคีย์ลัดร่วมกับ ⌘/Ctrl (ค่าเริ่มต้น "k"); `null` = ไม่ดักคีย์ลัด (แอปดักเอง) */
|
|
118
|
+
shortcut?: string | null;
|
|
90
119
|
minQuery?: number;
|
|
91
120
|
debounceMs?: number;
|
|
92
121
|
text?: Partial<CommandPaletteText>;
|
|
@@ -99,7 +128,12 @@ export function CommandPalette({
|
|
|
99
128
|
const router = useRouter();
|
|
100
129
|
const mounted = useMounted();
|
|
101
130
|
const mod = useModKeyLabel(mounted);
|
|
102
|
-
const [
|
|
131
|
+
const [uncontrolledOpen, setUncontrolledOpen] = useState(false);
|
|
132
|
+
const open = controlledOpen ?? uncontrolledOpen;
|
|
133
|
+
const setOpen = (next: boolean) => {
|
|
134
|
+
if (controlledOpen === undefined) setUncontrolledOpen(next);
|
|
135
|
+
onOpenChange?.(next);
|
|
136
|
+
};
|
|
103
137
|
const [query, setQuery] = useState("");
|
|
104
138
|
const [result, setResult] = useState<{ query: string; sections: CommandPaletteSection[] } | null>(
|
|
105
139
|
null,
|
|
@@ -107,12 +141,17 @@ export function CommandPalette({
|
|
|
107
141
|
const [pending, start] = useTransition();
|
|
108
142
|
|
|
109
143
|
// คีย์ลัดจากที่ไหนก็ได้ในแอป
|
|
144
|
+
const openRef = useRef(open);
|
|
145
|
+
openRef.current = open;
|
|
146
|
+
const setOpenRef = useRef(setOpen);
|
|
147
|
+
setOpenRef.current = setOpen;
|
|
110
148
|
useEffect(() => {
|
|
149
|
+
if (!shortcut) return;
|
|
111
150
|
const key = shortcut.toLowerCase();
|
|
112
151
|
const onKey = (e: KeyboardEvent) => {
|
|
113
152
|
if ((e.metaKey || e.ctrlKey) && e.key.toLowerCase() === key) {
|
|
114
153
|
e.preventDefault();
|
|
115
|
-
|
|
154
|
+
setOpenRef.current(!openRef.current);
|
|
116
155
|
}
|
|
117
156
|
};
|
|
118
157
|
window.addEventListener("keydown", onKey);
|
|
@@ -138,7 +177,9 @@ export function CommandPalette({
|
|
|
138
177
|
return () => clearTimeout(handle);
|
|
139
178
|
}, [q, search, shouldSearch, debounceMs]);
|
|
140
179
|
|
|
141
|
-
const
|
|
180
|
+
const localSections = filter ? filter(q) : [];
|
|
181
|
+
const remoteSections = shouldSearch && result?.query === q ? result.sections : [];
|
|
182
|
+
const sections = [...localSections, ...remoteSections];
|
|
142
183
|
const ql = q.toLowerCase();
|
|
143
184
|
const visiblePages = ql
|
|
144
185
|
? pages.filter((p) =>
|
|
@@ -158,18 +199,26 @@ export function CommandPalette({
|
|
|
158
199
|
if (onNavigate) onNavigate(href);
|
|
159
200
|
else router.push(href);
|
|
160
201
|
}
|
|
202
|
+
function pick(item: CommandPaletteSectionItem) {
|
|
203
|
+
close();
|
|
204
|
+
item.onSelect?.();
|
|
205
|
+
if (item.href) {
|
|
206
|
+
if (onNavigate) onNavigate(item.href);
|
|
207
|
+
else router.push(item.href);
|
|
208
|
+
}
|
|
209
|
+
}
|
|
161
210
|
|
|
162
|
-
const shortcutLabel = `${mod}${shortcut.toUpperCase()}`;
|
|
211
|
+
const shortcutLabel = `${mod}${(shortcut ?? "k").toUpperCase()}`;
|
|
163
212
|
|
|
164
213
|
return (
|
|
165
214
|
<>
|
|
166
|
-
{compact ? (
|
|
215
|
+
{!showTrigger ? null : compact ? (
|
|
167
216
|
<Button
|
|
168
217
|
type="button"
|
|
169
218
|
variant="ghost"
|
|
170
219
|
size="icon"
|
|
171
220
|
aria-label={text.placeholder}
|
|
172
|
-
title={`${text.placeholder} (${shortcutLabel})`}
|
|
221
|
+
title={shortcut ? `${text.placeholder} (${shortcutLabel})` : text.placeholder}
|
|
173
222
|
className={cn("text-muted-foreground", className)}
|
|
174
223
|
onClick={() => setOpen(true)}
|
|
175
224
|
>
|
|
@@ -188,7 +237,7 @@ export function CommandPalette({
|
|
|
188
237
|
>
|
|
189
238
|
<Search />
|
|
190
239
|
<span className="flex-1 truncate text-left">{text.placeholder}</span>
|
|
191
|
-
<Kbd className="hidden sm:inline-flex">{shortcutLabel}</Kbd>
|
|
240
|
+
{shortcut && <Kbd className="hidden sm:inline-flex">{shortcutLabel}</Kbd>}
|
|
192
241
|
</Button>
|
|
193
242
|
)}
|
|
194
243
|
<CommandDialog
|
|
@@ -229,7 +278,7 @@ export function CommandPalette({
|
|
|
229
278
|
<CommandItem
|
|
230
279
|
key={it.id}
|
|
231
280
|
value={`${s.key}:${it.id}`}
|
|
232
|
-
onSelect={() =>
|
|
281
|
+
onSelect={() => pick(it)}
|
|
233
282
|
>
|
|
234
283
|
<span className="flex size-4 shrink-0 items-center justify-center text-muted-foreground [&_svg]:size-4">
|
|
235
284
|
{it.icon}
|
|
@@ -240,6 +289,7 @@ export function CommandPalette({
|
|
|
240
289
|
<span className="text-muted-foreground"> · {it.description}</span>
|
|
241
290
|
)}
|
|
242
291
|
</span>
|
|
292
|
+
{it.shortcut && <CommandShortcut>{it.shortcut}</CommandShortcut>}
|
|
243
293
|
</CommandItem>
|
|
244
294
|
))}
|
|
245
295
|
</CommandGroup>
|
|
@@ -3,7 +3,7 @@
|
|
|
3
3
|
import { Button } from "@suphark/ui/components/ui/button";
|
|
4
4
|
import { Card, CardContent } from "@suphark/ui/components/ui/card";
|
|
5
5
|
import { cn } from "@suphark/ui/lib/utils";
|
|
6
|
-
import { type ReactNode, startTransition, useActionState } from "react";
|
|
6
|
+
import { type ComponentProps, type ReactNode, startTransition, useActionState } from "react";
|
|
7
7
|
import {
|
|
8
8
|
type FormAction,
|
|
9
9
|
FormMessage,
|
|
@@ -24,8 +24,9 @@ import { useUiText } from "./ui-text";
|
|
|
24
24
|
* </ActionForm>
|
|
25
25
|
* // error รายช่อง: action คืน { ok: false, error: "…", errors: { email: "รูปแบบไม่ถูกต้อง" } }
|
|
26
26
|
* <ActionForm action={save}>{({ fieldError }) => <LabeledField label="อีเมล" error={fieldError("email")}>…</LabeledField>}</ActionForm>
|
|
27
|
+
* // payload เมื่อสำเร็จ: action คืน { ok: true, data: { key } } → <ActionForm<{ key: string }> onSuccess={(s) => setKey(s.data?.key)}>
|
|
27
28
|
*/
|
|
28
|
-
export function ActionForm({
|
|
29
|
+
export function ActionForm<TData = undefined>({
|
|
29
30
|
action,
|
|
30
31
|
title,
|
|
31
32
|
submitLabel,
|
|
@@ -33,8 +34,11 @@ export function ActionForm({
|
|
|
33
34
|
compact = false,
|
|
34
35
|
className,
|
|
35
36
|
onSuccess,
|
|
37
|
+
hideSuccessMessage = false,
|
|
38
|
+
submitDisabled = false,
|
|
39
|
+
submitProps,
|
|
36
40
|
}: {
|
|
37
|
-
action: FormAction
|
|
41
|
+
action: FormAction<TData>;
|
|
38
42
|
title?: ReactNode;
|
|
39
43
|
submitLabel?: ReactNode;
|
|
40
44
|
/** เนื้อฟอร์ม — เป็นฟังก์ชันได้เพื่อรับ `{ state, pending, errors, fieldError }` (error รายช่องจาก `FormState.errors`) */
|
|
@@ -42,15 +46,24 @@ export function ActionForm({
|
|
|
42
46
|
/** ฟอร์มเล็กในแถวตาราง — ไม่มีกรอบ Card, ปุ่ม size sm, จัดแนวนอน wrap */
|
|
43
47
|
compact?: boolean;
|
|
44
48
|
className?: string;
|
|
45
|
-
/** เรียกเมื่อ action คืน `ok: true` (เช่น router.refresh, toast) */
|
|
46
|
-
onSuccess?: (state: Extract<FormState
|
|
49
|
+
/** เรียกเมื่อ action คืน `ok: true` (เช่น router.refresh, toast) — `state.data` คือ payload ของ `FormState<TData>` */
|
|
50
|
+
onSuccess?: (state: Extract<FormState<TData>, { ok: true }>) => void;
|
|
51
|
+
/** ไม่แสดงข้อความสำเร็จใต้ฟอร์ม (แอปแจ้งผลด้วย toast/Notice เอง — ใน compact ข้อความจะดันปุ่มตกบรรทัด) ยังแสดง error */
|
|
52
|
+
hideSuccessMessage?: boolean;
|
|
53
|
+
/** ปิดปุ่มส่งตามเงื่อนไขของแอป (เช่น ยังไม่มีรายการให้เลือก) — รวมกับ pending */
|
|
54
|
+
submitDisabled?: boolean;
|
|
55
|
+
/** props เพิ่มของปุ่มส่ง (variant, size, className, …) */
|
|
56
|
+
submitProps?: Omit<ComponentProps<typeof Button>, "type" | "disabled" | "children">;
|
|
47
57
|
}) {
|
|
48
58
|
const text = useUiText();
|
|
49
|
-
const [state, formAction, pending] = useActionState<FormState
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
59
|
+
const [state, formAction, pending] = useActionState<FormState<TData>, FormData>(
|
|
60
|
+
async (prev, fd) => {
|
|
61
|
+
const next = await action(prev, fd);
|
|
62
|
+
if (next?.ok) onSuccess?.(next);
|
|
63
|
+
return next;
|
|
64
|
+
},
|
|
65
|
+
null,
|
|
66
|
+
);
|
|
54
67
|
|
|
55
68
|
const form = (
|
|
56
69
|
<form
|
|
@@ -66,12 +79,15 @@ export function ActionForm({
|
|
|
66
79
|
>
|
|
67
80
|
{title && !compact && <h2 className="font-semibold text-sm">{title}</h2>}
|
|
68
81
|
{typeof children === "function" ? children(formRenderContext(state, pending)) : children}
|
|
69
|
-
|
|
82
|
+
{!(hideSuccessMessage && state?.ok) && (
|
|
83
|
+
<FormMessage state={state} className={compact ? "basis-full" : undefined} />
|
|
84
|
+
)}
|
|
70
85
|
<Button
|
|
71
|
-
type="submit"
|
|
72
86
|
size={compact ? "sm" : "default"}
|
|
73
|
-
disabled={pending}
|
|
74
87
|
className={compact ? undefined : "self-start"}
|
|
88
|
+
{...submitProps}
|
|
89
|
+
type="submit"
|
|
90
|
+
disabled={pending || submitDisabled}
|
|
75
91
|
>
|
|
76
92
|
{submitLabel ?? text.save}
|
|
77
93
|
</Button>
|
|
@@ -0,0 +1,180 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
import { cn } from "@suphark/ui/lib/utils";
|
|
4
|
+
import { Check } from "lucide-react";
|
|
5
|
+
import {
|
|
6
|
+
type ComponentProps,
|
|
7
|
+
type CSSProperties,
|
|
8
|
+
type KeyboardEvent,
|
|
9
|
+
useRef,
|
|
10
|
+
useState,
|
|
11
|
+
} from "react";
|
|
12
|
+
|
|
13
|
+
export interface ColorPickerItem<T extends string = string> {
|
|
14
|
+
value: T;
|
|
15
|
+
label: string;
|
|
16
|
+
/** class สีพื้น เช่น `bg-amber-500` (แอปเป็นคนเลือก palette — แพ็กเกจไม่ bundle) */
|
|
17
|
+
swatchClassName?: string;
|
|
18
|
+
/** สี CSS ตรง ๆ (hex/oklch) เมื่อไม่ใช้ class เช่นสีที่ผู้ใช้กำหนดเอง */
|
|
19
|
+
swatch?: string;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export interface ColorPickerText {
|
|
23
|
+
/** ป้ายกลุ่ม (aria-label ของกริด) */
|
|
24
|
+
label: string;
|
|
25
|
+
/** ต่อท้าย aria-label ของช่องที่เลือกอยู่ */
|
|
26
|
+
selected: string;
|
|
27
|
+
ariaLabel: (label: string) => string;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
const TEXT: Record<"th" | "en", ColorPickerText> = {
|
|
31
|
+
th: { label: "เลือกสี", selected: "เลือกอยู่", ariaLabel: (l) => `สี ${l}` },
|
|
32
|
+
en: { label: "Choose color", selected: "selected", ariaLabel: (l) => `${l} color` },
|
|
33
|
+
};
|
|
34
|
+
|
|
35
|
+
export interface ColorPickerProps<T extends string = string> {
|
|
36
|
+
value: T | null | undefined;
|
|
37
|
+
onChange: (value: T) => void;
|
|
38
|
+
colors: ReadonlyArray<ColorPickerItem<T>>;
|
|
39
|
+
/** คอลัมน์ของกริด (ค่าเริ่มต้น grid-cols-5) */
|
|
40
|
+
columnsClassName?: string;
|
|
41
|
+
/** sm = ช่อง size-7, md = size-8 (ค่าเริ่มต้น) */
|
|
42
|
+
size?: "sm" | "md";
|
|
43
|
+
language?: "th" | "en";
|
|
44
|
+
text?: Partial<ColorPickerText>;
|
|
45
|
+
className?: string;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
function swatchStyle(item: { swatch?: string }): CSSProperties | undefined {
|
|
49
|
+
return item.swatch ? { backgroundColor: item.swatch } : undefined;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/**
|
|
53
|
+
* จุดสีเล็กสำหรับ trigger/badge/รายการ — ใช้ `color` (CSS) หรือ class สี (`className="bg-amber-500"`)
|
|
54
|
+
* <ColorSwatch color="#f59e0b" /> · <ColorSwatch className="bg-amber-500" />
|
|
55
|
+
*/
|
|
56
|
+
export function ColorSwatch({
|
|
57
|
+
color,
|
|
58
|
+
className,
|
|
59
|
+
style,
|
|
60
|
+
...props
|
|
61
|
+
}: ComponentProps<"span"> & { color?: string }) {
|
|
62
|
+
return (
|
|
63
|
+
<span
|
|
64
|
+
aria-hidden="true"
|
|
65
|
+
className={cn(
|
|
66
|
+
"inline-block size-3.5 shrink-0 rounded-full border border-black/10",
|
|
67
|
+
className,
|
|
68
|
+
)}
|
|
69
|
+
style={color ? { backgroundColor: color, ...style } : style}
|
|
70
|
+
{...props}
|
|
71
|
+
/>
|
|
72
|
+
);
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/**
|
|
76
|
+
* กริดเลือกสีจากชุดที่แอปส่ง (แทน metadata-color-picker ของ Pi Done) — ปุ่มสี่เหลี่ยม `aria-pressed`,
|
|
77
|
+
* ที่เลือก = ring + ✓, คีย์บอร์ดเลื่อนในกริดด้วยลูกศร (roving tabindex; คอลัมน์อ่านจาก grid จริง)
|
|
78
|
+
* ไม่มี trigger/popover ในตัว — แอปครอบ Popover เองแบบเดียวกับ IconPicker
|
|
79
|
+
*/
|
|
80
|
+
export function ColorPicker<T extends string = string>({
|
|
81
|
+
value,
|
|
82
|
+
onChange,
|
|
83
|
+
colors,
|
|
84
|
+
columnsClassName = "grid-cols-5",
|
|
85
|
+
size = "md",
|
|
86
|
+
language = "th",
|
|
87
|
+
text: textProp,
|
|
88
|
+
className,
|
|
89
|
+
}: ColorPickerProps<T>) {
|
|
90
|
+
const text: ColorPickerText = { ...TEXT[language], ...textProp };
|
|
91
|
+
const gridRef = useRef<HTMLDivElement>(null);
|
|
92
|
+
// ช่องที่รับ Tab: ค่าที่เลือก ไม่มีก็ช่องแรก; เปลี่ยนตามลูกศรโดยไม่ต้องเปลี่ยน value
|
|
93
|
+
const [focusIndex, setFocusIndex] = useState<number | null>(null);
|
|
94
|
+
const selectedIndex = colors.findIndex((c) => c.value === value);
|
|
95
|
+
const tabIndexAt = focusIndex ?? (selectedIndex >= 0 ? selectedIndex : 0);
|
|
96
|
+
|
|
97
|
+
function columnCount(): number {
|
|
98
|
+
const el = gridRef.current;
|
|
99
|
+
if (!el) return 1;
|
|
100
|
+
const cols = getComputedStyle(el).gridTemplateColumns.split(" ").filter(Boolean).length;
|
|
101
|
+
return cols > 0 ? cols : 1;
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
function moveFocus(e: KeyboardEvent<HTMLButtonElement>, from: number) {
|
|
105
|
+
const cols = columnCount();
|
|
106
|
+
const last = colors.length - 1;
|
|
107
|
+
let next: number | null = null;
|
|
108
|
+
switch (e.key) {
|
|
109
|
+
case "ArrowRight":
|
|
110
|
+
next = Math.min(from + 1, last);
|
|
111
|
+
break;
|
|
112
|
+
case "ArrowLeft":
|
|
113
|
+
next = Math.max(from - 1, 0);
|
|
114
|
+
break;
|
|
115
|
+
case "ArrowDown":
|
|
116
|
+
next = Math.min(from + cols, last);
|
|
117
|
+
break;
|
|
118
|
+
case "ArrowUp":
|
|
119
|
+
next = Math.max(from - cols, 0);
|
|
120
|
+
break;
|
|
121
|
+
case "Home":
|
|
122
|
+
next = 0;
|
|
123
|
+
break;
|
|
124
|
+
case "End":
|
|
125
|
+
next = last;
|
|
126
|
+
break;
|
|
127
|
+
}
|
|
128
|
+
if (next === null) return;
|
|
129
|
+
e.preventDefault();
|
|
130
|
+
setFocusIndex(next);
|
|
131
|
+
const buttons = gridRef.current?.querySelectorAll<HTMLButtonElement>("button");
|
|
132
|
+
buttons?.[next]?.focus();
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
return (
|
|
136
|
+
<div
|
|
137
|
+
ref={gridRef}
|
|
138
|
+
role="group"
|
|
139
|
+
aria-label={text.label}
|
|
140
|
+
className={cn("grid w-fit gap-1.5", columnsClassName, className)}
|
|
141
|
+
>
|
|
142
|
+
{colors.map((item, index) => {
|
|
143
|
+
const isSelected = item.value === value;
|
|
144
|
+
return (
|
|
145
|
+
<button
|
|
146
|
+
key={item.value}
|
|
147
|
+
type="button"
|
|
148
|
+
tabIndex={index === tabIndexAt ? 0 : -1}
|
|
149
|
+
aria-pressed={isSelected}
|
|
150
|
+
aria-label={
|
|
151
|
+
isSelected
|
|
152
|
+
? `${text.ariaLabel(item.label)} (${text.selected})`
|
|
153
|
+
: text.ariaLabel(item.label)
|
|
154
|
+
}
|
|
155
|
+
title={item.label}
|
|
156
|
+
onClick={() => onChange(item.value)}
|
|
157
|
+
onKeyDown={(e) => moveFocus(e, index)}
|
|
158
|
+
onFocus={() => setFocusIndex(index)}
|
|
159
|
+
className={cn(
|
|
160
|
+
"relative flex items-center justify-center rounded-md border border-black/10 outline-none transition-[transform,box-shadow] hover:scale-105",
|
|
161
|
+
"focus-visible:ring-2 focus-visible:ring-ring/50",
|
|
162
|
+
"aria-pressed:ring-2 aria-pressed:ring-primary aria-pressed:ring-offset-1 aria-pressed:ring-offset-background",
|
|
163
|
+
size === "sm" ? "size-7" : "size-8",
|
|
164
|
+
item.swatchClassName,
|
|
165
|
+
)}
|
|
166
|
+
style={swatchStyle(item)}
|
|
167
|
+
>
|
|
168
|
+
{isSelected && (
|
|
169
|
+
<Check
|
|
170
|
+
className="size-4 text-white drop-shadow-[0_1px_1px_rgba(0,0,0,0.6)]"
|
|
171
|
+
strokeWidth={3}
|
|
172
|
+
aria-hidden="true"
|
|
173
|
+
/>
|
|
174
|
+
)}
|
|
175
|
+
</button>
|
|
176
|
+
);
|
|
177
|
+
})}
|
|
178
|
+
</div>
|
|
179
|
+
);
|
|
180
|
+
}
|