@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.
- package/CHANGELOG.md +39 -0
- package/README.md +3 -2
- package/package.json +1 -1
- package/src/components/app-shell/app-shell.tsx +6 -7
- package/src/components/app-shell/command-palette.tsx +306 -0
- package/src/components/app-shell/index.ts +12 -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 +41 -16
- package/src/components/shared/calendar-month-grid.tsx +182 -0
- package/src/components/shared/color-picker.tsx +180 -0
- package/src/components/shared/dropzone.tsx +333 -0
- package/src/components/shared/file-type-icon.tsx +171 -0
- package/src/components/shared/form/labeled-field.tsx +21 -0
- package/src/components/shared/form-dialog.tsx +15 -6
- package/src/components/shared/form-message.tsx +34 -3
- 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/tree-view.tsx +325 -0
- package/src/components/shared/ui-text.tsx +41 -0
- package/src/components/shared/upload-list.tsx +233 -0
- package/src/index.ts +46 -2
- package/src/lib/formatters.ts +17 -0
|
@@ -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
|
|
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>
|