@suphark/ui 0.6.1 → 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.
@@ -2,6 +2,7 @@
2
2
 
3
3
  import { Initials } from "@suphark/ui/components/shared/initials";
4
4
  import { SwitcherChevron } from "@suphark/ui/components/shared/switcher-chevron";
5
+ import { useUiText } from "@suphark/ui/components/shared/ui-text";
5
6
  import { Button } from "@suphark/ui/components/ui/button";
6
7
  import {
7
8
  DropdownMenu,
@@ -41,6 +42,8 @@ export interface UserMenuProps {
41
42
  variant?: "sidebar" | "header";
42
43
  align?: "start" | "end";
43
44
  className?: string;
45
+ /** aria-label ของปุ่มเปิดเมนู (ค่าเริ่มต้น `UiText.userMenu` "เมนูผู้ใช้") — ต่อท้ายชื่อผู้ใช้ให้ */
46
+ menuLabel?: string;
44
47
  }
45
48
 
46
49
  /** เมนูผู้ใช้ (ยกจาก supplychain NavUser + Pi Auth UserMenu) */
@@ -48,11 +51,13 @@ export function UserMenu({
48
51
  user,
49
52
  children,
50
53
  onSignOut,
51
- signOutLabel = "ออกจากระบบ",
54
+ signOutLabel,
52
55
  variant = "sidebar",
53
56
  align = variant === "header" ? "end" : "start",
54
57
  className,
58
+ menuLabel,
55
59
  }: UserMenuProps) {
60
+ const text = useUiText();
56
61
  const [pending, start] = useTransition();
57
62
  const description = user.description ?? user.email;
58
63
  return (
@@ -61,7 +66,7 @@ export function UserMenu({
61
66
  render={
62
67
  <Button
63
68
  variant="ghost"
64
- aria-label={`เปิดเมนูผู้ใช้ ${user.name}`}
69
+ aria-label={`${menuLabel ?? text.userMenu ?? "เมนูผู้ใช้"}: ${user.name}`}
65
70
  className={cn(
66
71
  variant === "sidebar"
67
72
  ? "h-auto w-full justify-start gap-2.5 px-2 py-2 text-left group-data-[collapsible=icon]:size-8 group-data-[collapsible=icon]:p-0"
@@ -122,7 +127,7 @@ export function UserMenu({
122
127
  <DropdownMenuSeparator />
123
128
  <DropdownMenuItem disabled={pending} onClick={() => start(() => onSignOut())}>
124
129
  <LogOut aria-hidden="true" />
125
- {signOutLabel}
130
+ {signOutLabel ?? text.signOut ?? "ออกจากระบบ"}
126
131
  </DropdownMenuItem>
127
132
  </>
128
133
  )}
@@ -139,6 +139,8 @@ export function DataTable<TData, TValue>({
139
139
  enableColumnResizing = false,
140
140
  enableRowSelection = true,
141
141
  isLoading,
142
+ showToolbar = true,
143
+ showPagination = true,
142
144
  pageSizeOptions,
143
145
  toolbarFilters,
144
146
  preservedFilterIds,
@@ -347,22 +349,26 @@ export function DataTable<TData, TValue>({
347
349
 
348
350
  return (
349
351
  <div className={cn("flex flex-col gap-4", containerClassName)}>
350
- <DataTableToolbar
351
- table={table}
352
- facetedFilters={facetedFilters}
353
- actionsComponent={actionsComponent?.(table)}
354
- globalFilterPlaceholder={globalFilterPlaceholder}
355
- toolbarFilters={toolbarFilters?.(table)}
356
- preservedFilterIds={preservedFilterIds}
357
- />
352
+ {showToolbar && (
353
+ <DataTableToolbar
354
+ table={table}
355
+ facetedFilters={facetedFilters}
356
+ actionsComponent={actionsComponent?.(table)}
357
+ globalFilterPlaceholder={globalFilterPlaceholder}
358
+ toolbarFilters={toolbarFilters?.(table)}
359
+ preservedFilterIds={preservedFilterIds}
360
+ />
361
+ )}
358
362
  {customView ? (
359
363
  <>
360
364
  {customView}
361
- <DataTablePagination
362
- table={table}
363
- pageSizeOptions={pageSizeOptions}
364
- onPageSizeChange={onPageSizeChange}
365
- />
365
+ {showPagination && (
366
+ <DataTablePagination
367
+ table={table}
368
+ pageSizeOptions={pageSizeOptions}
369
+ onPageSizeChange={onPageSizeChange}
370
+ />
371
+ )}
366
372
  </>
367
373
  ) : (
368
374
  <>
@@ -379,19 +385,21 @@ export function DataTable<TData, TValue>({
379
385
  tableContent
380
386
  )}
381
387
  </div>
382
- <div
383
- className={
384
- stickyPagination
385
- ? "sticky bottom-0 z-20 border-t bg-background py-2 max-sm:-mx-2"
386
- : undefined
387
- }
388
- >
389
- <DataTablePagination
390
- table={table}
391
- pageSizeOptions={pageSizeOptions}
392
- onPageSizeChange={onPageSizeChange}
393
- />
394
- </div>
388
+ {showPagination && (
389
+ <div
390
+ className={
391
+ stickyPagination
392
+ ? "sticky bottom-0 z-20 border-t bg-background py-2 max-sm:-mx-2"
393
+ : undefined
394
+ }
395
+ >
396
+ <DataTablePagination
397
+ table={table}
398
+ pageSizeOptions={pageSizeOptions}
399
+ onPageSizeChange={onPageSizeChange}
400
+ />
401
+ </div>
402
+ )}
395
403
  </>
396
404
  )}
397
405
  </div>
@@ -95,6 +95,10 @@ export interface DataTableProps<TData, TValue> {
95
95
  initialPageSize?: number;
96
96
  meta?: Record<string, unknown>;
97
97
  isLoading?: boolean;
98
+ /** ซ่อนแถบค้นหา/ตัวกรอง/View (ตารางตั้งค่าเล็ก ๆ) */
99
+ showToolbar?: boolean;
100
+ /** ซ่อน pagination (แถวน้อย ไม่ต้องแบ่งหน้า) */
101
+ showPagination?: boolean;
98
102
 
99
103
  // Advanced
100
104
  enableColumnResizing?: boolean;
@@ -3,8 +3,14 @@
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";
7
- import { type FormAction, FormMessage, type FormState } from "./form-message";
6
+ import { type ComponentProps, type ReactNode, startTransition, useActionState } from "react";
7
+ import {
8
+ type FormAction,
9
+ FormMessage,
10
+ type FormRenderContext,
11
+ type FormState,
12
+ formRenderContext,
13
+ } from "./form-message";
8
14
  import { useUiText } from "./ui-text";
9
15
 
10
16
  /**
@@ -16,8 +22,11 @@ import { useUiText } from "./ui-text";
16
22
  * <ActionForm action={updateName} title="ชื่อองค์กร" submitLabel="บันทึก">
17
23
  * <LabeledField label="ชื่อ"><Input name="name" defaultValue={org.name} /></LabeledField>
18
24
  * </ActionForm>
25
+ * // error รายช่อง: action คืน { ok: false, error: "…", errors: { email: "รูปแบบไม่ถูกต้อง" } }
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)}>
19
28
  */
20
- export function ActionForm({
29
+ export function ActionForm<TData = undefined>({
21
30
  action,
22
31
  title,
23
32
  submitLabel,
@@ -25,23 +34,36 @@ export function ActionForm({
25
34
  compact = false,
26
35
  className,
27
36
  onSuccess,
37
+ hideSuccessMessage = false,
38
+ submitDisabled = false,
39
+ submitProps,
28
40
  }: {
29
- action: FormAction;
41
+ action: FormAction<TData>;
30
42
  title?: ReactNode;
31
43
  submitLabel?: ReactNode;
32
- children: ReactNode;
44
+ /** เนื้อฟอร์ม — เป็นฟังก์ชันได้เพื่อรับ `{ state, pending, errors, fieldError }` (error รายช่องจาก `FormState.errors`) */
45
+ children: ReactNode | ((ctx: FormRenderContext) => ReactNode);
33
46
  /** ฟอร์มเล็กในแถวตาราง — ไม่มีกรอบ Card, ปุ่ม size sm, จัดแนวนอน wrap */
34
47
  compact?: boolean;
35
48
  className?: string;
36
- /** เรียกเมื่อ action คืน `ok: true` (เช่น router.refresh, toast) */
37
- onSuccess?: (state: Extract<FormState, { ok: true }>) => void;
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">;
38
57
  }) {
39
58
  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);
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
+ );
45
67
 
46
68
  const form = (
47
69
  <form
@@ -56,13 +78,16 @@ export function ActionForm({
56
78
  )}
57
79
  >
58
80
  {title && !compact && <h2 className="font-semibold text-sm">{title}</h2>}
59
- {children}
60
- <FormMessage state={state} className={compact ? "basis-full" : undefined} />
81
+ {typeof children === "function" ? children(formRenderContext(state, pending)) : children}
82
+ {!(hideSuccessMessage && state?.ok) && (
83
+ <FormMessage state={state} className={compact ? "basis-full" : undefined} />
84
+ )}
61
85
  <Button
62
- type="submit"
63
86
  size={compact ? "sm" : "default"}
64
- disabled={pending}
65
87
  className={compact ? undefined : "self-start"}
88
+ {...submitProps}
89
+ type="submit"
90
+ disabled={pending || submitDisabled}
66
91
  >
67
92
  {submitLabel ?? text.save}
68
93
  </Button>
@@ -0,0 +1,182 @@
1
+ "use client";
2
+
3
+ import { useMounted } from "@suphark/ui/hooks/use-mounted";
4
+ import { cn } from "@suphark/ui/lib/utils";
5
+ import { Check } from "lucide-react";
6
+ import { type ReactNode, useMemo } from "react";
7
+
8
+ /** บริบทของช่องวันที่ให้ `renderDay`/`dayLabel` */
9
+ export interface CalendarDayContext<T> {
10
+ day: number;
11
+ date: Date;
12
+ items: T[];
13
+ isToday: boolean;
14
+ isSelected: boolean;
15
+ isWeekend: boolean;
16
+ }
17
+
18
+ export interface CalendarDayState {
19
+ /** ทุกรายการของวันนั้นเสร็จแล้ว → `data-complete` + ✓ (เมื่อไม่มี renderDay) */
20
+ complete?: boolean;
21
+ hasItems?: boolean;
22
+ }
23
+
24
+ export interface CalendarMonthGridProps<T> {
25
+ /** เดือนที่แสดง `"YYYY-MM"` */
26
+ month: string;
27
+ /** วันที่ที่เลือก (1–31) */
28
+ selectedDay?: number | null;
29
+ onSelectDay?: (day: number, date: Date) => void;
30
+ /** รายการต่อวัน (key = วันที่ 1–31) */
31
+ itemsByDay?: Record<number, T[]>;
32
+ /** เนื้อหาใต้เลขวัน (จุดสถานะ ฯลฯ) — ไม่ส่ง หรือคืน null/undefined = ✓ ของแพ็กเกจเมื่อ complete */
33
+ renderDay?: (ctx: CalendarDayContext<T>) => ReactNode;
34
+ /** สถานะของช่องจากรายการ → data-attribute ให้ CSS ของแอปแต่งได้ */
35
+ dayState?: (items: T[], ctx: Omit<CalendarDayContext<T>, "items">) => CalendarDayState;
36
+ /** วันเริ่มสัปดาห์: 0 = อาทิตย์ (ค่าเริ่มต้น, ไทย), 1 = จันทร์ */
37
+ weekStartsOn?: 0 | 1;
38
+ /** ชื่อวัน 7 ตัวเริ่มที่อาทิตย์เสมอ (ค่าเริ่มต้นไทย) */
39
+ weekdayLabels?: readonly string[];
40
+ /** "วันนี้" — ไม่ส่ง = อ่านจาก browser หลัง mount (server ไม่รู้โซนเวลาผู้ใช้) */
41
+ today?: Date;
42
+ /** aria-label ของปุ่มวัน (ค่าเริ่มต้น "วันที่ N มี M รายการ") */
43
+ dayLabel?: (ctx: CalendarDayContext<T>) => string;
44
+ /** คำอธิบายสัญลักษณ์ใต้ตาราง */
45
+ legend?: ReactNode;
46
+ className?: string;
47
+ /** class เพิ่มของปุ่มวัน */
48
+ dayClassName?: string | ((ctx: CalendarDayContext<T>) => string | undefined);
49
+ }
50
+
51
+ export const WEEKDAYS_TH = ["อา", "จ", "อ", "พ", "พฤ", "ศ", "ส"] as const;
52
+ export const WEEKDAYS_EN = ["Su", "Mo", "Tu", "We", "Th", "Fr", "Sa"] as const;
53
+
54
+ function parseMonth(month: string): { year: number; monthIndex: number } {
55
+ const [y, m] = month.split("-");
56
+ const year = Number(y);
57
+ const monthIndex = Number(m) - 1;
58
+ if (!Number.isFinite(year) || !Number.isFinite(monthIndex) || monthIndex < 0 || monthIndex > 11) {
59
+ throw new Error(`CalendarMonthGrid: month ต้องเป็น "YYYY-MM" ได้ "${month}"`);
60
+ }
61
+ return { year, monthIndex };
62
+ }
63
+
64
+ /**
65
+ * ตารางเดือน (ปฏิทินเลือกวัน) ที่แอปคุมเนื้อหาต่อวันเอง — ยกจาก DueBell/Pi Done ให้ใช้ร่วม
66
+ * ปุ่มวันมี `aria-pressed` + data-attribute แบบ boolean (มี/ไม่มี ไม่มีค่า "true"): `data-selected`, `data-today`,
67
+ * `data-has-items`, `data-complete`, `data-weekend` → CSS ของแอปใช้ `[data-selected]` / Tailwind `data-selected:`
68
+ * SSR ได้: "วันนี้" อ่านจาก browser หลัง mount (หรือส่ง `today`)
69
+ */
70
+ export function CalendarMonthGrid<T = unknown>({
71
+ month,
72
+ selectedDay = null,
73
+ onSelectDay,
74
+ itemsByDay = {},
75
+ renderDay,
76
+ dayState,
77
+ weekStartsOn = 0,
78
+ weekdayLabels = WEEKDAYS_TH,
79
+ today: todayProp,
80
+ dayLabel,
81
+ legend,
82
+ className,
83
+ dayClassName,
84
+ }: CalendarMonthGridProps<T>) {
85
+ const mounted = useMounted();
86
+ const { year, monthIndex } = parseMonth(month);
87
+ // today: prop > browser หลัง mount > ไม่มี (server) — ไม่ใช้ new Date() ตอน render บน server
88
+ const today = todayProp ?? (mounted ? new Date() : null);
89
+ const isThisMonth = !!today && today.getFullYear() === year && today.getMonth() === monthIndex;
90
+
91
+ const { headers, blanks, days } = useMemo(() => {
92
+ const firstWeekday = new Date(year, monthIndex, 1).getDay(); // 0 = อาทิตย์
93
+ const daysInMonth = new Date(year, monthIndex + 1, 0).getDate();
94
+ const offset = (firstWeekday - weekStartsOn + 7) % 7;
95
+ const headers = Array.from({ length: 7 }, (_, i) => {
96
+ const weekday = (i + weekStartsOn) % 7;
97
+ return { weekday, label: weekdayLabels[weekday] ?? "" };
98
+ });
99
+ return {
100
+ headers,
101
+ blanks: Array.from({ length: offset }, (_, i) => i),
102
+ days: Array.from({ length: daysInMonth }, (_, i) => i + 1),
103
+ };
104
+ }, [year, monthIndex, weekStartsOn, weekdayLabels]);
105
+
106
+ return (
107
+ <div className={cn("rounded-xl border bg-card p-3 sm:p-4", className)}>
108
+ <div className="mb-2 grid grid-cols-7 gap-1 text-center">
109
+ {headers.map((h) => (
110
+ <span
111
+ key={h.weekday}
112
+ className={cn(
113
+ "font-semibold text-xs",
114
+ h.weekday === 0
115
+ ? "text-destructive"
116
+ : h.weekday === 6
117
+ ? "text-primary"
118
+ : "text-muted-foreground",
119
+ )}
120
+ >
121
+ {h.label}
122
+ </span>
123
+ ))}
124
+ </div>
125
+ <div className="grid grid-cols-7 gap-1.5" role="grid" aria-label={month}>
126
+ {blanks.map((b) => (
127
+ <div key={`blank-${b}`} className="aspect-square" aria-hidden="true" />
128
+ ))}
129
+ {days.map((day) => {
130
+ const date = new Date(year, monthIndex, day);
131
+ const items = itemsByDay[day] ?? [];
132
+ const weekday = date.getDay();
133
+ const base = {
134
+ day,
135
+ date,
136
+ isToday: isThisMonth && today?.getDate() === day,
137
+ isSelected: selectedDay === day,
138
+ isWeekend: weekday === 0 || weekday === 6,
139
+ };
140
+ const ctx: CalendarDayContext<T> = { ...base, items };
141
+ const state = dayState?.(items, base) ?? {};
142
+ const hasItems = state.hasItems ?? items.length > 0;
143
+ const complete = state.complete ?? false;
144
+ const extra = typeof dayClassName === "function" ? dayClassName(ctx) : dayClassName;
145
+ return (
146
+ <button
147
+ key={day}
148
+ type="button"
149
+ onClick={() => onSelectDay?.(day, date)}
150
+ aria-pressed={ctx.isSelected}
151
+ aria-label={dayLabel ? dayLabel(ctx) : `วันที่ ${day} มี ${items.length} รายการ`}
152
+ data-selected={ctx.isSelected || undefined}
153
+ data-today={ctx.isToday || undefined}
154
+ data-has-items={hasItems || undefined}
155
+ data-complete={complete || undefined}
156
+ data-weekend={ctx.isWeekend || undefined}
157
+ className={cn(
158
+ "relative flex aspect-square select-none flex-col items-center justify-between rounded-xl border p-1.5 font-semibold text-xs outline-none transition-[border-color,box-shadow,background-color]",
159
+ "hover:border-primary/50 focus-visible:ring-2 focus-visible:ring-ring/50",
160
+ "data-today:border-primary data-today:text-primary",
161
+ "data-selected:border-primary data-selected:bg-primary/10 data-selected:shadow-[0_0_0_1px_var(--primary)]",
162
+ "data-complete:border-success/40 data-complete:bg-success/10 data-complete:text-success",
163
+ extra,
164
+ )}
165
+ >
166
+ <span>{day}</span>
167
+ <span className="flex h-2 items-center justify-center gap-1">
168
+ {renderDay?.(ctx) ??
169
+ (complete && (
170
+ <span className="flex size-3.5 items-center justify-center rounded-full bg-success text-success-foreground">
171
+ <Check className="size-2.5" strokeWidth={3} aria-hidden="true" />
172
+ </span>
173
+ ))}
174
+ </span>
175
+ </button>
176
+ );
177
+ })}
178
+ </div>
179
+ {legend && <div className="mt-3 text-muted-foreground text-xs">{legend}</div>}
180
+ </div>
181
+ );
182
+ }
@@ -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
+ }