@suphark/ui 0.6.0 → 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 +35 -0
- package/README.md +20 -0
- package/package.json +5 -1
- package/scripts/audit.mjs +218 -0
- package/src/components/app-shell/app-brand.tsx +1 -0
- package/src/components/app-shell/app-nav.tsx +2 -4
- 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/design-system/sections/showcase-data-display.tsx +2 -2
- package/src/components/design-system/sections/showcase-guidelines-recipes.tsx +1 -1
- package/src/components/design-system/sections/showcase-overlays-navigation.tsx +2 -2
- package/src/components/design-system/sections/showcase-primitives.tsx +1 -1
- package/src/components/shared/action-form.tsx +12 -3
- package/src/components/shared/button/floating-save-button.tsx +1 -1
- package/src/components/shared/calendar-month-grid.tsx +182 -0
- package/src/components/shared/form/form-field-color-select.tsx +1 -1
- package/src/components/shared/form/form-field-combobox.tsx +1 -1
- package/src/components/shared/form/form-field-date.tsx +1 -1
- package/src/components/shared/form/form-field-input.tsx +6 -6
- package/src/components/shared/form/form-field-radio-cards.tsx +4 -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 +26 -6
- package/src/components/shared/icon-picker.tsx +192 -0
- package/src/components/shared/notice.tsx +1 -0
- package/src/components/shared/progress-bar.tsx +83 -0
- package/src/components/shared/selection-button.tsx +29 -0
- package/src/components/shared/status-badge.tsx +1 -0
- package/src/components/shared/theme/theme-menu-items.tsx +31 -14
- package/src/components/shared/theme/theme-toggle.tsx +3 -3
- package/src/components/shared/ui-text.tsx +41 -0
- package/src/index.ts +22 -1
- package/src/lib/status-colors.ts +1 -0
|
@@ -182,16 +182,16 @@ function UniqueCheckInput<T extends FieldValues>({
|
|
|
182
182
|
|
|
183
183
|
return (
|
|
184
184
|
<div>
|
|
185
|
-
<InputGroup className={cn(isDuplicate ? "border-
|
|
185
|
+
<InputGroup className={cn(isDuplicate ? "border-destructive ring-destructive" : "")}>
|
|
186
186
|
<InputGroupInput
|
|
187
187
|
{...field}
|
|
188
188
|
{...props}
|
|
189
189
|
value={value || ""}
|
|
190
|
-
className={cn(isDuplicate ? "text-
|
|
190
|
+
className={cn(isDuplicate ? "text-destructive" : "")}
|
|
191
191
|
/>
|
|
192
192
|
{isDuplicate && (
|
|
193
193
|
<InputGroupAddon>
|
|
194
|
-
<CircleAlert className="h-4 w-4 text-
|
|
194
|
+
<CircleAlert className="h-4 w-4 text-destructive" />
|
|
195
195
|
</InputGroupAddon>
|
|
196
196
|
)}
|
|
197
197
|
{enableCopy && (
|
|
@@ -200,12 +200,12 @@ function UniqueCheckInput<T extends FieldValues>({
|
|
|
200
200
|
type="button"
|
|
201
201
|
title="Copy to clipboard"
|
|
202
202
|
>
|
|
203
|
-
{isCopied ? <Check className="h-4 w-4 text-
|
|
203
|
+
{isCopied ? <Check className="h-4 w-4 text-success" /> : <Copy className="h-4 w-4" />}
|
|
204
204
|
</InputGroupButton>
|
|
205
205
|
)}
|
|
206
206
|
</InputGroup>
|
|
207
207
|
{isDuplicate && (
|
|
208
|
-
<p className="mt-1 font-medium text-[0.8rem] text-
|
|
208
|
+
<p className="mt-1 font-medium text-[0.8rem] text-destructive">{uniqueErrorMessage}</p>
|
|
209
209
|
)}
|
|
210
210
|
</div>
|
|
211
211
|
);
|
|
@@ -224,7 +224,7 @@ function CopyableInput(props: ComponentProps<typeof InputGroupInput>) {
|
|
|
224
224
|
type="button"
|
|
225
225
|
title="Copy to clipboard"
|
|
226
226
|
>
|
|
227
|
-
{isCopied ? <Check className="h-4 w-4 text-
|
|
227
|
+
{isCopied ? <Check className="h-4 w-4 text-success" /> : <Copy className="h-4 w-4" />}
|
|
228
228
|
</InputGroupButton>
|
|
229
229
|
</InputGroup>
|
|
230
230
|
);
|
|
@@ -33,6 +33,8 @@ interface FormFieldRadioCardsProps<T extends FieldValues = any> {
|
|
|
33
33
|
* `row` = การ์ดแนวนอน: ปุ่มวิทยุจริงซ้าย, ไอคอน+ชื่อบรรทัดบน, คำอธิบายบรรทัดล่าง (แบบ Pi Auth เลือกประเภทแอป)
|
|
34
34
|
*/
|
|
35
35
|
layout?: "grid" | "row";
|
|
36
|
+
/** ชื่อกลุ่มสำหรับ screen reader เมื่อไม่แสดง `label` (ส่งต่อไป RadioGroup) */
|
|
37
|
+
"aria-label"?: string;
|
|
36
38
|
}
|
|
37
39
|
|
|
38
40
|
export function FormFieldRadioCards<T extends FieldValues = any>({
|
|
@@ -47,6 +49,7 @@ export function FormFieldRadioCards<T extends FieldValues = any>({
|
|
|
47
49
|
className,
|
|
48
50
|
size = "md",
|
|
49
51
|
layout = "grid",
|
|
52
|
+
"aria-label": ariaLabel,
|
|
50
53
|
}: FormFieldRadioCardsProps<T>) {
|
|
51
54
|
const baseId = useId();
|
|
52
55
|
|
|
@@ -59,6 +62,7 @@ export function FormFieldRadioCards<T extends FieldValues = any>({
|
|
|
59
62
|
{label && <Label className="block font-semibold text-base">{label}</Label>}
|
|
60
63
|
<RadioGroup
|
|
61
64
|
name={groupName}
|
|
65
|
+
aria-label={label ? undefined : ariaLabel}
|
|
62
66
|
onValueChange={(val) => {
|
|
63
67
|
if (val !== null) onChange(String(val));
|
|
64
68
|
}}
|
|
@@ -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>;
|
|
@@ -21,11 +45,7 @@ export function FormMessage({
|
|
|
21
45
|
return (
|
|
22
46
|
<p
|
|
23
47
|
role={state.ok ? "status" : "alert"}
|
|
24
|
-
className={cn(
|
|
25
|
-
"text-xs",
|
|
26
|
-
state.ok ? "text-emerald-700 dark:text-emerald-300" : "text-destructive",
|
|
27
|
-
className,
|
|
28
|
-
)}
|
|
48
|
+
className={cn("text-xs", state.ok ? "text-success" : "text-destructive", className)}
|
|
29
49
|
>
|
|
30
50
|
{state.ok ? (state.message ?? text.success) : state.error}
|
|
31
51
|
</p>
|
|
@@ -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
|
+
}
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
/* ui-audit-ok-file: โทน info/success/warning/danger ใช้ palette 50/900 คู่ dark โดยตั้งใจ — รอตัดสินใจ token ระดับ tone (todo) */
|
|
1
2
|
import { Alert, AlertDescription, AlertTitle } from "@suphark/ui/components/ui/alert";
|
|
2
3
|
import { cn } from "@suphark/ui/lib/utils";
|
|
3
4
|
import type { ComponentProps, ReactNode } from "react";
|
|
@@ -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
|
+
}
|
|
@@ -9,32 +9,49 @@ import {
|
|
|
9
9
|
import { MonitorIcon, MoonIcon, SunIcon } from "lucide-react";
|
|
10
10
|
import { useTheme } from "next-themes";
|
|
11
11
|
import type { ReactNode } from "react";
|
|
12
|
+
import { useUiText } from "../ui-text";
|
|
12
13
|
import type { Theme } from "./theme-provider";
|
|
13
14
|
|
|
14
|
-
const
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
15
|
+
const ICONS: Record<Theme, ReactNode> = {
|
|
16
|
+
light: <SunIcon />,
|
|
17
|
+
dark: <MoonIcon />,
|
|
18
|
+
system: <MonitorIcon />,
|
|
19
|
+
};
|
|
20
|
+
const ORDER: Theme[] = ["light", "dark", "system"];
|
|
19
21
|
|
|
20
|
-
/**
|
|
22
|
+
/**
|
|
23
|
+
* กลุ่มตัวเลือกธีมสำหรับใส่ใน DropdownMenu (เช่นเมนูผู้ใช้) — ยกจาก Pi Auth มาบน next-themes
|
|
24
|
+
* ข้อความค่าเริ่มต้นจาก `UiText.theme`; `onThemeChange` ให้แอปบันทึกธีมเป็น preference ของบัญชี (ซิงก์ข้ามเครื่อง)
|
|
25
|
+
*/
|
|
21
26
|
export function ThemeMenuItems({
|
|
22
|
-
label
|
|
27
|
+
label,
|
|
23
28
|
labels,
|
|
29
|
+
onThemeChange,
|
|
24
30
|
}: {
|
|
25
31
|
label?: ReactNode;
|
|
26
|
-
/** ข้อความแต่ละตัวเลือก (
|
|
32
|
+
/** ข้อความแต่ละตัวเลือก (ค่าเริ่มต้นจาก UiText.theme / ไทย) */
|
|
27
33
|
labels?: Partial<Record<Theme, ReactNode>>;
|
|
34
|
+
/** เรียกหลัง setTheme — เช่น server action บันทึก preference */
|
|
35
|
+
onThemeChange?: (theme: Theme) => void | Promise<void>;
|
|
28
36
|
}) {
|
|
29
37
|
const { theme = "system", setTheme } = useTheme();
|
|
38
|
+
const text = useUiText().theme;
|
|
30
39
|
return (
|
|
31
40
|
<DropdownMenuGroup>
|
|
32
|
-
<DropdownMenuLabel className="text-muted-foreground">
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
41
|
+
<DropdownMenuLabel className="text-muted-foreground">
|
|
42
|
+
{label ?? text?.label ?? "ธีม"}
|
|
43
|
+
</DropdownMenuLabel>
|
|
44
|
+
<DropdownMenuRadioGroup
|
|
45
|
+
value={theme}
|
|
46
|
+
onValueChange={(v) => {
|
|
47
|
+
setTheme(v as Theme);
|
|
48
|
+
void onThemeChange?.(v as Theme);
|
|
49
|
+
}}
|
|
50
|
+
>
|
|
51
|
+
{ORDER.map((value) => (
|
|
52
|
+
<DropdownMenuRadioItem key={value} value={value}>
|
|
53
|
+
{ICONS[value]}
|
|
54
|
+
{labels?.[value] ?? text?.[value] ?? value}
|
|
38
55
|
</DropdownMenuRadioItem>
|
|
39
56
|
))}
|
|
40
57
|
</DropdownMenuRadioGroup>
|
|
@@ -56,21 +56,21 @@ export function ThemeToggle({ persistedTheme, onThemeChange }: ThemeToggleProps)
|
|
|
56
56
|
onClick={() => handleThemeChange("light")}
|
|
57
57
|
className="flex cursor-pointer items-center gap-2"
|
|
58
58
|
>
|
|
59
|
-
<Sun className="h-4 w-4 text-
|
|
59
|
+
<Sun className="h-4 w-4 text-warning" />
|
|
60
60
|
<span>Light</span>
|
|
61
61
|
</DropdownMenuItem>
|
|
62
62
|
<DropdownMenuItem
|
|
63
63
|
onClick={() => handleThemeChange("dark")}
|
|
64
64
|
className="flex cursor-pointer items-center gap-2"
|
|
65
65
|
>
|
|
66
|
-
<Moon className="h-4 w-4 text-
|
|
66
|
+
<Moon className="h-4 w-4 text-primary" />
|
|
67
67
|
<span>Dark</span>
|
|
68
68
|
</DropdownMenuItem>
|
|
69
69
|
<DropdownMenuItem
|
|
70
70
|
onClick={() => handleThemeChange("system")}
|
|
71
71
|
className="flex cursor-pointer items-center gap-2"
|
|
72
72
|
>
|
|
73
|
-
<Laptop className="h-4 w-4 text-
|
|
73
|
+
<Laptop className="h-4 w-4 text-muted-foreground" />
|
|
74
74
|
<span>System</span>
|
|
75
75
|
</DropdownMenuItem>
|
|
76
76
|
</DropdownMenuContent>
|
|
@@ -30,6 +30,23 @@ export interface UiText {
|
|
|
30
30
|
chooseTitle?: string;
|
|
31
31
|
/** DialogsProvider: หัวกล่อง checklist (optional — ค่าเริ่มต้น "เลือกรายการ") */
|
|
32
32
|
checklistTitle?: string;
|
|
33
|
+
/** CommandPalette (optional — ค่าเริ่มต้นไทยในตัว) */
|
|
34
|
+
palette?: Partial<{
|
|
35
|
+
placeholder: string;
|
|
36
|
+
title: string;
|
|
37
|
+
description: string;
|
|
38
|
+
empty: string;
|
|
39
|
+
searching: string;
|
|
40
|
+
pages: string;
|
|
41
|
+
}>;
|
|
42
|
+
/** OrganizationSwitcher (optional) */
|
|
43
|
+
switcher?: Partial<{ choose: string; organization: string }>;
|
|
44
|
+
/** UserMenu: aria-label ปุ่มเปิดเมนู (optional — "เมนูผู้ใช้") */
|
|
45
|
+
userMenu?: string;
|
|
46
|
+
/** UserMenu: ปุ่มออกจากระบบ (optional — "ออกจากระบบ") */
|
|
47
|
+
signOut?: string;
|
|
48
|
+
/** ThemeMenuItems: หัวกลุ่ม + ตัวเลือก (optional) */
|
|
49
|
+
theme?: Partial<{ label: string; light: string; dark: string; system: string }>;
|
|
33
50
|
}
|
|
34
51
|
|
|
35
52
|
export const UI_TEXT_TH: UiText = {
|
|
@@ -50,6 +67,18 @@ export const UI_TEXT_TH: UiText = {
|
|
|
50
67
|
promptTitle: "กรอกข้อมูล",
|
|
51
68
|
chooseTitle: "เลือกการทำรายการ",
|
|
52
69
|
checklistTitle: "เลือกรายการ",
|
|
70
|
+
palette: {
|
|
71
|
+
placeholder: "ค้นหาหรือสั่งงาน…",
|
|
72
|
+
title: "ค้นหา",
|
|
73
|
+
description: "พิมพ์เพื่อค้นหาหน้าและข้อมูล",
|
|
74
|
+
empty: "ไม่พบผลลัพธ์",
|
|
75
|
+
searching: "กำลังค้นหา…",
|
|
76
|
+
pages: "หน้า",
|
|
77
|
+
},
|
|
78
|
+
switcher: { choose: "เลือกองค์กร", organization: "องค์กร" },
|
|
79
|
+
userMenu: "เมนูผู้ใช้",
|
|
80
|
+
signOut: "ออกจากระบบ",
|
|
81
|
+
theme: { label: "ธีม", light: "สว่าง", dark: "มืด", system: "ตามระบบ" },
|
|
53
82
|
};
|
|
54
83
|
|
|
55
84
|
export const UI_TEXT_EN: UiText = {
|
|
@@ -70,6 +99,18 @@ export const UI_TEXT_EN: UiText = {
|
|
|
70
99
|
promptTitle: "Input required",
|
|
71
100
|
chooseTitle: "Choose an action",
|
|
72
101
|
checklistTitle: "Choose options",
|
|
102
|
+
palette: {
|
|
103
|
+
placeholder: "Search or jump to…",
|
|
104
|
+
title: "Search",
|
|
105
|
+
description: "Type to search pages and records",
|
|
106
|
+
empty: "No results",
|
|
107
|
+
searching: "Searching…",
|
|
108
|
+
pages: "Pages",
|
|
109
|
+
},
|
|
110
|
+
switcher: { choose: "Choose organization", organization: "Organization" },
|
|
111
|
+
userMenu: "User menu",
|
|
112
|
+
signOut: "Sign out",
|
|
113
|
+
theme: { label: "Theme", light: "Light", dark: "Dark", system: "System" },
|
|
73
114
|
};
|
|
74
115
|
|
|
75
116
|
const UiTextContext = createContext<UiText>(UI_TEXT_TH);
|