@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.
Files changed (38) hide show
  1. package/CHANGELOG.md +35 -0
  2. package/README.md +20 -0
  3. package/package.json +5 -1
  4. package/scripts/audit.mjs +218 -0
  5. package/src/components/app-shell/app-brand.tsx +1 -0
  6. package/src/components/app-shell/app-nav.tsx +2 -4
  7. package/src/components/app-shell/app-shell.tsx +6 -7
  8. package/src/components/app-shell/command-palette.tsx +256 -0
  9. package/src/components/app-shell/index.ts +11 -0
  10. package/src/components/app-shell/organization-switcher.tsx +150 -0
  11. package/src/components/app-shell/user-menu.tsx +8 -3
  12. package/src/components/data-table/data-table.tsx +34 -26
  13. package/src/components/data-table/types.ts +4 -0
  14. package/src/components/design-system/sections/showcase-data-display.tsx +2 -2
  15. package/src/components/design-system/sections/showcase-guidelines-recipes.tsx +1 -1
  16. package/src/components/design-system/sections/showcase-overlays-navigation.tsx +2 -2
  17. package/src/components/design-system/sections/showcase-primitives.tsx +1 -1
  18. package/src/components/shared/action-form.tsx +12 -3
  19. package/src/components/shared/button/floating-save-button.tsx +1 -1
  20. package/src/components/shared/calendar-month-grid.tsx +182 -0
  21. package/src/components/shared/form/form-field-color-select.tsx +1 -1
  22. package/src/components/shared/form/form-field-combobox.tsx +1 -1
  23. package/src/components/shared/form/form-field-date.tsx +1 -1
  24. package/src/components/shared/form/form-field-input.tsx +6 -6
  25. package/src/components/shared/form/form-field-radio-cards.tsx +4 -0
  26. package/src/components/shared/form/labeled-field.tsx +21 -0
  27. package/src/components/shared/form-dialog.tsx +12 -3
  28. package/src/components/shared/form-message.tsx +26 -6
  29. package/src/components/shared/icon-picker.tsx +192 -0
  30. package/src/components/shared/notice.tsx +1 -0
  31. package/src/components/shared/progress-bar.tsx +83 -0
  32. package/src/components/shared/selection-button.tsx +29 -0
  33. package/src/components/shared/status-badge.tsx +1 -0
  34. package/src/components/shared/theme/theme-menu-items.tsx +31 -14
  35. package/src/components/shared/theme/theme-toggle.tsx +3 -3
  36. package/src/components/shared/ui-text.tsx +41 -0
  37. package/src/index.ts +22 -1
  38. 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-red-500 ring-red-500" : "")}>
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-red-600" : "")}
190
+ className={cn(isDuplicate ? "text-destructive" : "")}
191
191
  />
192
192
  {isDuplicate && (
193
193
  <InputGroupAddon>
194
- <CircleAlert className="h-4 w-4 text-red-500" />
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-green-500" /> : <Copy className="h-4 w-4" />}
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-red-500">{uniqueErrorMessage}</p>
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-green-500" /> : <Copy className="h-4 w-4" />}
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 { type FormAction, FormMessage, type FormState } from "./form-message";
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
- children: ReactNode;
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 = { ok: true; message?: string } | { ok: false; error: string } | null;
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
+ }
@@ -1,3 +1,4 @@
1
+ /* ui-audit-ok-file: โทน badge ใช้ palette 50/800 คู่ dark โดยตั้งใจ — รอตัดสินใจ token ระดับ tone (todo) */
1
2
  import { Badge } from "@suphark/ui/components/ui/badge";
2
3
  import { cn } from "@suphark/ui/lib/utils";
3
4
  import type { ComponentProps } from "react";
@@ -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 OPTIONS: Array<{ value: Theme; label: string; icon: ReactNode }> = [
15
- { value: "light", label: "สว่าง", icon: <SunIcon /> },
16
- { value: "dark", label: "มืด", icon: <MoonIcon /> },
17
- { value: "system", label: "ตามระบบ", icon: <MonitorIcon /> },
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
- /** กลุ่มตัวเลือกธีมสำหรับใส่ใน DropdownMenu (เช่นเมนูผู้ใช้) — ยกจาก Pi Auth มาบน next-themes */
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">{label}</DropdownMenuLabel>
33
- <DropdownMenuRadioGroup value={theme} onValueChange={(v) => setTheme(v as Theme)}>
34
- {OPTIONS.map((o) => (
35
- <DropdownMenuRadioItem key={o.value} value={o.value}>
36
- {o.icon}
37
- {labels?.[o.value] ?? o.label}
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-amber-500" />
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-blue-500" />
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-slate-500" />
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);