@suphark/ui 0.6.1 → 0.7.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 +24 -0
- package/package.json +1 -1
- package/src/components/app-shell/app-shell.tsx +6 -7
- package/src/components/app-shell/command-palette.tsx +256 -0
- package/src/components/app-shell/index.ts +11 -0
- package/src/components/app-shell/organization-switcher.tsx +150 -0
- package/src/components/app-shell/user-menu.tsx +8 -3
- package/src/components/data-table/data-table.tsx +34 -26
- package/src/components/data-table/types.ts +4 -0
- package/src/components/shared/action-form.tsx +12 -3
- package/src/components/shared/calendar-month-grid.tsx +182 -0
- package/src/components/shared/form/labeled-field.tsx +21 -0
- package/src/components/shared/form-dialog.tsx +12 -3
- package/src/components/shared/form-message.tsx +25 -1
- package/src/components/shared/icon-picker.tsx +192 -0
- package/src/components/shared/progress-bar.tsx +83 -0
- package/src/components/shared/selection-button.tsx +29 -0
- package/src/components/shared/theme/theme-menu-items.tsx +31 -14
- package/src/components/shared/ui-text.tsx +41 -0
- package/src/index.ts +22 -1
|
@@ -4,7 +4,13 @@ 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
6
|
import { type ReactNode, startTransition, useActionState } from "react";
|
|
7
|
-
import {
|
|
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,6 +22,8 @@ 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>
|
|
19
27
|
*/
|
|
20
28
|
export function ActionForm({
|
|
21
29
|
action,
|
|
@@ -29,7 +37,8 @@ export function ActionForm({
|
|
|
29
37
|
action: FormAction;
|
|
30
38
|
title?: ReactNode;
|
|
31
39
|
submitLabel?: ReactNode;
|
|
32
|
-
|
|
40
|
+
/** เนื้อฟอร์ม — เป็นฟังก์ชันได้เพื่อรับ `{ state, pending, errors, fieldError }` (error รายช่องจาก `FormState.errors`) */
|
|
41
|
+
children: ReactNode | ((ctx: FormRenderContext) => ReactNode);
|
|
33
42
|
/** ฟอร์มเล็กในแถวตาราง — ไม่มีกรอบ Card, ปุ่ม size sm, จัดแนวนอน wrap */
|
|
34
43
|
compact?: boolean;
|
|
35
44
|
className?: string;
|
|
@@ -56,7 +65,7 @@ export function ActionForm({
|
|
|
56
65
|
)}
|
|
57
66
|
>
|
|
58
67
|
{title && !compact && <h2 className="font-semibold text-sm">{title}</h2>}
|
|
59
|
-
{children}
|
|
68
|
+
{typeof children === "function" ? children(formRenderContext(state, pending)) : children}
|
|
60
69
|
<FormMessage state={state} className={compact ? "basis-full" : undefined} />
|
|
61
70
|
<Button
|
|
62
71
|
type="submit"
|
|
@@ -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
|
+
}
|
|
@@ -19,6 +19,7 @@ export function LabeledField({
|
|
|
19
19
|
error,
|
|
20
20
|
children,
|
|
21
21
|
className,
|
|
22
|
+
htmlFor,
|
|
22
23
|
}: {
|
|
23
24
|
label: ReactNode;
|
|
24
25
|
/** ข้อความช่วยใต้ช่อง — ซ่อนเมื่อมี error */
|
|
@@ -26,7 +27,27 @@ export function LabeledField({
|
|
|
26
27
|
error?: ReactNode;
|
|
27
28
|
children: ReactNode;
|
|
28
29
|
className?: string;
|
|
30
|
+
/**
|
|
31
|
+
* id ของ control — ส่งมาแล้ว label จะไม่ครอบ control (ใช้ `htmlFor` แทน) เพื่อให้ accessible name ชัดใน tooling
|
|
32
|
+
* ที่ไม่คำนวณ label แบบ nested; ไม่ส่ง = label ครอบ control (ถูกต้องตาม HTML คลิก label แล้ว focus ได้)
|
|
33
|
+
*/
|
|
34
|
+
htmlFor?: string;
|
|
29
35
|
}) {
|
|
36
|
+
if (htmlFor) {
|
|
37
|
+
return (
|
|
38
|
+
<Field className={cn("gap-1.5", className)} data-invalid={error ? true : undefined}>
|
|
39
|
+
<FieldLabel htmlFor={htmlFor} className="font-medium text-sm">
|
|
40
|
+
{label}
|
|
41
|
+
</FieldLabel>
|
|
42
|
+
{children}
|
|
43
|
+
{error ? (
|
|
44
|
+
<FieldError className="text-xs">{error}</FieldError>
|
|
45
|
+
) : (
|
|
46
|
+
hint && <FieldDescription className="text-xs">{hint}</FieldDescription>
|
|
47
|
+
)}
|
|
48
|
+
</Field>
|
|
49
|
+
);
|
|
50
|
+
}
|
|
30
51
|
return (
|
|
31
52
|
<Field className={cn("gap-1.5", className)} data-invalid={error ? true : undefined}>
|
|
32
53
|
<FieldLabel className="w-full flex-col items-stretch gap-1.5 font-normal [&_[data-slot=native-select-wrapper]]:w-full">
|
|
@@ -20,7 +20,13 @@ import {
|
|
|
20
20
|
useEffect,
|
|
21
21
|
useState,
|
|
22
22
|
} from "react";
|
|
23
|
-
import {
|
|
23
|
+
import {
|
|
24
|
+
type FormAction,
|
|
25
|
+
FormMessage,
|
|
26
|
+
type FormRenderContext,
|
|
27
|
+
type FormState,
|
|
28
|
+
formRenderContext,
|
|
29
|
+
} from "./form-message";
|
|
24
30
|
import { useUiText } from "./ui-text";
|
|
25
31
|
|
|
26
32
|
/**
|
|
@@ -45,7 +51,8 @@ export function FormDialog({
|
|
|
45
51
|
description?: ReactNode;
|
|
46
52
|
action: FormAction;
|
|
47
53
|
submitLabel?: string;
|
|
48
|
-
|
|
54
|
+
/** เนื้อฟอร์ม — เป็นฟังก์ชันได้เพื่อรับ `{ state, pending, errors, fieldError }` */
|
|
55
|
+
children: ReactNode | ((ctx: FormRenderContext) => ReactNode);
|
|
49
56
|
/** ควบคุมจากภายนอก (เช่นเปิดจาก RowActions) */
|
|
50
57
|
open?: boolean;
|
|
51
58
|
onOpenChange?: (open: boolean) => void;
|
|
@@ -95,7 +102,9 @@ export function FormDialog({
|
|
|
95
102
|
{description && <DialogDescription>{description}</DialogDescription>}
|
|
96
103
|
</DialogHeader>
|
|
97
104
|
<div className="-mx-1 flex min-h-0 flex-1 flex-col gap-3 overflow-y-auto px-1">
|
|
98
|
-
{children
|
|
105
|
+
{typeof children === "function"
|
|
106
|
+
? children(formRenderContext(state, pending))
|
|
107
|
+
: children}
|
|
99
108
|
</div>
|
|
100
109
|
<FormMessage state={state} />
|
|
101
110
|
<DialogFooter>
|
|
@@ -3,7 +3,31 @@
|
|
|
3
3
|
import { cn } from "@suphark/ui/lib/utils";
|
|
4
4
|
import { useUiText } from "./ui-text";
|
|
5
5
|
|
|
6
|
-
export type FormState =
|
|
6
|
+
export type FormState =
|
|
7
|
+
| { ok: true; message?: string }
|
|
8
|
+
| {
|
|
9
|
+
ok: false;
|
|
10
|
+
/** ข้อความรวม (แสดงใน FormMessage) */
|
|
11
|
+
error: string;
|
|
12
|
+
/** error รายช่อง (key = ชื่อ input) — ส่งให้ LabeledField ผ่าน render-prop ของ ActionForm/FormDialog */
|
|
13
|
+
errors?: Record<string, string | undefined>;
|
|
14
|
+
}
|
|
15
|
+
| null;
|
|
16
|
+
|
|
17
|
+
/** ค่าที่ ActionForm/FormDialog ส่งให้ children แบบฟังก์ชัน */
|
|
18
|
+
export interface FormRenderContext {
|
|
19
|
+
state: FormState;
|
|
20
|
+
pending: boolean;
|
|
21
|
+
/** error รายช่องจาก state ล่าสุด (ว่างเมื่อสำเร็จ/ยังไม่ส่ง) */
|
|
22
|
+
errors: Record<string, string | undefined>;
|
|
23
|
+
/** helper: `error={fieldError("email")}` */
|
|
24
|
+
fieldError: (name: string) => string | undefined;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export function formRenderContext(state: FormState, pending: boolean): FormRenderContext {
|
|
28
|
+
const errors = state && !state.ok ? (state.errors ?? {}) : {};
|
|
29
|
+
return { state, pending, errors, fieldError: (name) => errors[name] };
|
|
30
|
+
}
|
|
7
31
|
|
|
8
32
|
/** server action สำหรับ FormDialog/ActionForm: (state ก่อนหน้า, FormData) → FormState */
|
|
9
33
|
export type FormAction = (prev: FormState, formData: FormData) => Promise<FormState>;
|
|
@@ -0,0 +1,192 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
import {
|
|
4
|
+
InputGroup,
|
|
5
|
+
InputGroupAddon,
|
|
6
|
+
InputGroupButton,
|
|
7
|
+
InputGroupInput,
|
|
8
|
+
} from "@suphark/ui/components/ui/input-group";
|
|
9
|
+
import { cn } from "@suphark/ui/lib/utils";
|
|
10
|
+
import type { LucideIcon } from "lucide-react";
|
|
11
|
+
import { Search, X } from "lucide-react";
|
|
12
|
+
import { type CSSProperties, useDeferredValue, useId, useMemo, useState } from "react";
|
|
13
|
+
import { SelectionButton } from "./selection-button";
|
|
14
|
+
|
|
15
|
+
export interface IconPickerItem<T extends string = string> {
|
|
16
|
+
name: T;
|
|
17
|
+
icon: LucideIcon;
|
|
18
|
+
labelTh: string;
|
|
19
|
+
labelEn: string;
|
|
20
|
+
keywords?: readonly string[];
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export interface IconPickerText {
|
|
24
|
+
searchPlaceholder: string;
|
|
25
|
+
searchLabel: string;
|
|
26
|
+
clear: string;
|
|
27
|
+
preview: string;
|
|
28
|
+
/** รับชื่อคำค้น */
|
|
29
|
+
empty: (query: string) => string;
|
|
30
|
+
showAll: string;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
const TEXT: Record<"th" | "en", IconPickerText> = {
|
|
34
|
+
th: {
|
|
35
|
+
searchPlaceholder: "ค้นหาไอคอน (เช่น บ้าน, house, รถ)…",
|
|
36
|
+
searchLabel: "ค้นหาไอคอน",
|
|
37
|
+
clear: "ล้างการค้นหา",
|
|
38
|
+
preview: "ตัวอย่างไอคอนและสี",
|
|
39
|
+
empty: (q) => `ไม่พบไอคอนที่ตรงกับ “${q}”`,
|
|
40
|
+
showAll: "แสดงไอคอนทั้งหมด",
|
|
41
|
+
},
|
|
42
|
+
en: {
|
|
43
|
+
searchPlaceholder: "Search icons (e.g. house, car, coffee)…",
|
|
44
|
+
searchLabel: "Search icons",
|
|
45
|
+
clear: "Clear search",
|
|
46
|
+
preview: "Icon & color preview",
|
|
47
|
+
empty: (q) => `No icons found for “${q}”`,
|
|
48
|
+
showAll: "Show all icons",
|
|
49
|
+
},
|
|
50
|
+
};
|
|
51
|
+
|
|
52
|
+
export interface IconPickerProps<T extends string = string> {
|
|
53
|
+
value: T | null | undefined;
|
|
54
|
+
onChange: (name: T) => void;
|
|
55
|
+
/** ชุดไอคอนของแอป (แพ็กเกจไม่ bundle ชุดใด — ไม่ดึง lucide ทั้งก้อน) */
|
|
56
|
+
icons: ReadonlyArray<IconPickerItem<T>>;
|
|
57
|
+
/** ภาษาของ label/ข้อความ (ค่าเริ่มต้น th) */
|
|
58
|
+
language?: "th" | "en";
|
|
59
|
+
/** ทับข้อความบางส่วน */
|
|
60
|
+
text?: Partial<IconPickerText>;
|
|
61
|
+
/** สีของประเภทรายการ (dynamic จากผู้ใช้) — ไอคอนที่เลือกและ preview ใช้ inline style จากสีนี้ */
|
|
62
|
+
accentColor?: string;
|
|
63
|
+
/** ความสูงของกริด (ค่าเริ่มต้น h-36) */
|
|
64
|
+
heightClassName?: string;
|
|
65
|
+
/** คอลัมน์ของกริด (ค่าเริ่มต้น grid-cols-6 sm:grid-cols-8) */
|
|
66
|
+
columnsClassName?: string;
|
|
67
|
+
className?: string;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/**
|
|
71
|
+
* เลือกไอคอนจากชุดที่แอปส่งมา: ค้นหา (labelTh/labelEn/keywords/name, ผ่าน useDeferredValue) + กริด SelectionButton
|
|
72
|
+
* + preview ไอคอนที่เลือกพร้อมสี — ยกจาก DueBell ให้ Pi Done (metadata icon) ใช้ร่วม
|
|
73
|
+
*/
|
|
74
|
+
export function IconPicker<T extends string = string>({
|
|
75
|
+
value,
|
|
76
|
+
onChange,
|
|
77
|
+
icons,
|
|
78
|
+
language = "th",
|
|
79
|
+
text: textProp,
|
|
80
|
+
accentColor,
|
|
81
|
+
heightClassName = "h-36",
|
|
82
|
+
columnsClassName = "grid-cols-6 sm:grid-cols-8",
|
|
83
|
+
className,
|
|
84
|
+
}: IconPickerProps<T>) {
|
|
85
|
+
const text: IconPickerText = { ...TEXT[language], ...textProp };
|
|
86
|
+
const [query, setQuery] = useState("");
|
|
87
|
+
const deferred = useDeferredValue(query);
|
|
88
|
+
const listId = useId();
|
|
89
|
+
const selected = icons.find((i) => i.name === value);
|
|
90
|
+
const labelOf = (i: IconPickerItem<T>) => (language === "en" ? i.labelEn : i.labelTh);
|
|
91
|
+
|
|
92
|
+
const filtered = useMemo(() => {
|
|
93
|
+
const q = deferred.trim().toLowerCase();
|
|
94
|
+
if (!q) return icons;
|
|
95
|
+
return icons.filter(
|
|
96
|
+
(i) =>
|
|
97
|
+
i.name.toLowerCase().includes(q) ||
|
|
98
|
+
i.labelTh.toLowerCase().includes(q) ||
|
|
99
|
+
i.labelEn.toLowerCase().includes(q) ||
|
|
100
|
+
(i.keywords ?? []).some((k) => k.toLowerCase().includes(q)),
|
|
101
|
+
);
|
|
102
|
+
}, [icons, deferred]);
|
|
103
|
+
|
|
104
|
+
const accentStyle: CSSProperties | undefined = accentColor
|
|
105
|
+
? { backgroundColor: `${accentColor}18`, borderColor: `${accentColor}99`, color: accentColor }
|
|
106
|
+
: undefined;
|
|
107
|
+
|
|
108
|
+
return (
|
|
109
|
+
<div className={cn("flex flex-col gap-2", className)}>
|
|
110
|
+
{selected && (
|
|
111
|
+
<div className="flex items-center gap-3 rounded-xl border p-2.5">
|
|
112
|
+
<span
|
|
113
|
+
className="grid size-10 shrink-0 place-items-center rounded-xl border bg-primary/10 text-primary"
|
|
114
|
+
style={accentStyle}
|
|
115
|
+
>
|
|
116
|
+
<selected.icon className="size-5" aria-hidden="true" />
|
|
117
|
+
</span>
|
|
118
|
+
<div className="min-w-0">
|
|
119
|
+
<p className="font-semibold text-[0.625rem] text-muted-foreground/70 uppercase tracking-wider">
|
|
120
|
+
{text.preview}
|
|
121
|
+
</p>
|
|
122
|
+
<p className="truncate font-bold text-foreground text-xs">{labelOf(selected)}</p>
|
|
123
|
+
</div>
|
|
124
|
+
</div>
|
|
125
|
+
)}
|
|
126
|
+
|
|
127
|
+
<InputGroup className="h-9">
|
|
128
|
+
<InputGroupAddon>
|
|
129
|
+
<Search aria-hidden="true" />
|
|
130
|
+
</InputGroupAddon>
|
|
131
|
+
<InputGroupInput
|
|
132
|
+
value={query}
|
|
133
|
+
onChange={(e) => setQuery(e.target.value)}
|
|
134
|
+
placeholder={text.searchPlaceholder}
|
|
135
|
+
aria-label={text.searchLabel}
|
|
136
|
+
aria-controls={listId}
|
|
137
|
+
autoComplete="off"
|
|
138
|
+
spellCheck={false}
|
|
139
|
+
/>
|
|
140
|
+
{query && (
|
|
141
|
+
<InputGroupAddon align="inline-end">
|
|
142
|
+
<InputGroupButton onClick={() => setQuery("")} aria-label={text.clear}>
|
|
143
|
+
<X aria-hidden="true" />
|
|
144
|
+
</InputGroupButton>
|
|
145
|
+
</InputGroupAddon>
|
|
146
|
+
)}
|
|
147
|
+
</InputGroup>
|
|
148
|
+
|
|
149
|
+
<div
|
|
150
|
+
id={listId}
|
|
151
|
+
role="group"
|
|
152
|
+
aria-label={text.searchLabel}
|
|
153
|
+
className={cn(
|
|
154
|
+
"grid content-start gap-2 overflow-y-auto rounded-xl border bg-muted/40 p-2",
|
|
155
|
+
heightClassName,
|
|
156
|
+
columnsClassName,
|
|
157
|
+
)}
|
|
158
|
+
>
|
|
159
|
+
{filtered.length > 0 ? (
|
|
160
|
+
filtered.map((item) => {
|
|
161
|
+
const isSelected = value === item.name;
|
|
162
|
+
const title = `${labelOf(item)} (${item.name})`;
|
|
163
|
+
return (
|
|
164
|
+
<SelectionButton
|
|
165
|
+
key={item.name}
|
|
166
|
+
selected={isSelected}
|
|
167
|
+
onClick={() => onChange(item.name)}
|
|
168
|
+
className="aspect-square h-auto w-full p-0"
|
|
169
|
+
aria-label={title}
|
|
170
|
+
title={title}
|
|
171
|
+
style={isSelected ? accentStyle : undefined}
|
|
172
|
+
>
|
|
173
|
+
<item.icon className="size-4" aria-hidden="true" />
|
|
174
|
+
</SelectionButton>
|
|
175
|
+
);
|
|
176
|
+
})
|
|
177
|
+
) : (
|
|
178
|
+
<div className="col-span-full flex min-h-24 flex-col items-center justify-center gap-1.5 py-4 text-center text-muted-foreground">
|
|
179
|
+
<p className="text-xs">{text.empty(deferred.trim())}</p>
|
|
180
|
+
<button
|
|
181
|
+
type="button"
|
|
182
|
+
onClick={() => setQuery("")}
|
|
183
|
+
className="text-primary text-xs underline-offset-4 hover:underline"
|
|
184
|
+
>
|
|
185
|
+
{text.showAll}
|
|
186
|
+
</button>
|
|
187
|
+
</div>
|
|
188
|
+
)}
|
|
189
|
+
</div>
|
|
190
|
+
</div>
|
|
191
|
+
);
|
|
192
|
+
}
|
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
import { Progress as ProgressPrimitive } from "@base-ui/react/progress";
|
|
4
|
+
import {
|
|
5
|
+
ProgressIndicator,
|
|
6
|
+
ProgressLabel,
|
|
7
|
+
ProgressTrack,
|
|
8
|
+
ProgressValue,
|
|
9
|
+
} from "@suphark/ui/components/ui/progress";
|
|
10
|
+
import { cn } from "@suphark/ui/lib/utils";
|
|
11
|
+
import type { CSSProperties, ReactNode } from "react";
|
|
12
|
+
|
|
13
|
+
export type ProgressTone = "default" | "success" | "warning" | "destructive";
|
|
14
|
+
|
|
15
|
+
const TONE: Record<ProgressTone, string> = {
|
|
16
|
+
default: "bg-primary",
|
|
17
|
+
success: "bg-success",
|
|
18
|
+
warning: "bg-warning",
|
|
19
|
+
destructive: "bg-destructive",
|
|
20
|
+
};
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* แถบความคืบหน้าที่ตั้งสี/ความสูง/ป้ายได้จาก props — ใช้แทนการประกอบ `Progress` ของ shadcn เอง
|
|
24
|
+
* (`Progress` ต่อท้าย Track+Indicator ให้เสมอ ส่ง children เข้าไปจะได้ 2 แถบ) — ยกจาก DueBell
|
|
25
|
+
*/
|
|
26
|
+
export function ProgressBar({
|
|
27
|
+
value,
|
|
28
|
+
max = 100,
|
|
29
|
+
label,
|
|
30
|
+
showValue = false,
|
|
31
|
+
format,
|
|
32
|
+
tone = "default",
|
|
33
|
+
size = "sm",
|
|
34
|
+
trackClassName,
|
|
35
|
+
indicatorClassName,
|
|
36
|
+
indicatorStyle,
|
|
37
|
+
className,
|
|
38
|
+
...props
|
|
39
|
+
}: Omit<ProgressPrimitive.Root.Props, "value" | "max" | "children" | "format"> & {
|
|
40
|
+
value: number | null;
|
|
41
|
+
max?: number;
|
|
42
|
+
label?: ReactNode;
|
|
43
|
+
/** แสดงตัวเลข (ค่าเริ่มต้น "N%") */
|
|
44
|
+
showValue?: boolean;
|
|
45
|
+
/** ปรับข้อความตัวเลข เช่น (v) => `${v} / ${max}` */
|
|
46
|
+
format?: (value: number | null, max: number) => ReactNode;
|
|
47
|
+
tone?: ProgressTone;
|
|
48
|
+
/** sm = h-1 (ค่าเริ่มต้น), md = h-2, lg = h-3 */
|
|
49
|
+
size?: "sm" | "md" | "lg";
|
|
50
|
+
trackClassName?: string;
|
|
51
|
+
/** ใส่ class สีเอง (เช่นสี dynamic ของผู้ใช้ผ่าน indicatorStyle) */
|
|
52
|
+
indicatorClassName?: string;
|
|
53
|
+
indicatorStyle?: CSSProperties;
|
|
54
|
+
}) {
|
|
55
|
+
return (
|
|
56
|
+
<ProgressPrimitive.Root
|
|
57
|
+
value={value}
|
|
58
|
+
max={max}
|
|
59
|
+
data-slot="progress"
|
|
60
|
+
className={cn("flex flex-wrap gap-2", className)}
|
|
61
|
+
{...props}
|
|
62
|
+
>
|
|
63
|
+
{(label || showValue) && (
|
|
64
|
+
<div className="flex w-full items-center gap-2">
|
|
65
|
+
{label && <ProgressLabel>{label}</ProgressLabel>}
|
|
66
|
+
{showValue &&
|
|
67
|
+
(format ? (
|
|
68
|
+
<span className="ml-auto text-muted-foreground text-sm tabular-nums">
|
|
69
|
+
{format(value, max)}
|
|
70
|
+
</span>
|
|
71
|
+
) : (
|
|
72
|
+
<ProgressValue />
|
|
73
|
+
))}
|
|
74
|
+
</div>
|
|
75
|
+
)}
|
|
76
|
+
<ProgressTrack
|
|
77
|
+
className={cn(size === "md" ? "h-2" : size === "lg" ? "h-3" : "h-1", trackClassName)}
|
|
78
|
+
>
|
|
79
|
+
<ProgressIndicator className={cn(TONE[tone], indicatorClassName)} style={indicatorStyle} />
|
|
80
|
+
</ProgressTrack>
|
|
81
|
+
</ProgressPrimitive.Root>
|
|
82
|
+
);
|
|
83
|
+
}
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
import { Button } from "@suphark/ui/components/ui/button";
|
|
4
|
+
import { cn } from "@suphark/ui/lib/utils";
|
|
5
|
+
import type { ComponentProps } from "react";
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* ปุ่มตัวเลือกแบบกดเลือก/ไม่เลือก (toggle) — `aria-pressed` + สีเน้นเมื่อเลือก
|
|
9
|
+
* ใช้เป็นช่องในตาราง IconPicker, ตัวเลือกสี/ประเภท, chip filter (ยกจาก DueBell)
|
|
10
|
+
*/
|
|
11
|
+
export function SelectionButton({
|
|
12
|
+
selected,
|
|
13
|
+
className,
|
|
14
|
+
type = "button",
|
|
15
|
+
...props
|
|
16
|
+
}: Omit<ComponentProps<typeof Button>, "aria-pressed" | "variant"> & { selected: boolean }) {
|
|
17
|
+
return (
|
|
18
|
+
<Button
|
|
19
|
+
{...props}
|
|
20
|
+
type={type}
|
|
21
|
+
variant="outline"
|
|
22
|
+
aria-pressed={selected}
|
|
23
|
+
className={cn(
|
|
24
|
+
"transition-colors aria-pressed:border-primary aria-pressed:bg-primary/10 aria-pressed:text-primary",
|
|
25
|
+
className,
|
|
26
|
+
)}
|
|
27
|
+
/>
|
|
28
|
+
);
|
|
29
|
+
}
|