@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.
@@ -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
+ }
@@ -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>