@suphark/ui 0.23.0 → 0.25.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 +36 -0
- package/package.json +1 -1
- package/src/components/data-table/index.ts +9 -9
- package/src/components/shared/auth-layout.tsx +6 -1
- package/src/components/shared/form/number-input.tsx +144 -0
- package/src/components/shared/form/tags-input.tsx +192 -0
- package/src/components/shared/form/textarea-field.tsx +109 -0
- package/src/components/shared/form/time-picker.tsx +421 -0
- package/src/components/shared/initials.tsx +12 -2
- package/src/components/shared/notice.tsx +54 -4
- package/src/components/shared/presence-dot.tsx +31 -0
- package/src/components/shared/skeletons.tsx +176 -0
- package/src/components/shared/step-form.tsx +283 -0
- package/src/components/shared/ui-text.tsx +77 -0
- package/src/components/shared/user-avatars.tsx +68 -0
- package/src/components/shared/user-identity.tsx +5 -0
- package/src/data-table.ts +12 -1
- package/src/index.ts +434 -66
- package/src/next.ts +38 -1
|
@@ -0,0 +1,421 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
import { Button } from "@suphark/ui/components/ui/button";
|
|
4
|
+
import { Calendar } from "@suphark/ui/components/ui/calendar";
|
|
5
|
+
import {
|
|
6
|
+
InputGroup,
|
|
7
|
+
InputGroupAddon,
|
|
8
|
+
InputGroupButton,
|
|
9
|
+
InputGroupInput,
|
|
10
|
+
} from "@suphark/ui/components/ui/input-group";
|
|
11
|
+
import { Popover, PopoverContent, PopoverTrigger } from "@suphark/ui/components/ui/popover";
|
|
12
|
+
import { cn } from "@suphark/ui/lib/utils";
|
|
13
|
+
import { CalendarClockIcon, ClockIcon } from "lucide-react";
|
|
14
|
+
import { type KeyboardEvent, useEffect, useId, useRef, useState } from "react";
|
|
15
|
+
import { th } from "react-day-picker/locale";
|
|
16
|
+
import { useUiText } from "../ui-text";
|
|
17
|
+
|
|
18
|
+
/** "HH:mm" 24 ชั่วโมง */
|
|
19
|
+
export type TimeValue = string;
|
|
20
|
+
|
|
21
|
+
const pad = (n: number) => String(n).padStart(2, "0");
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* แปลงข้อความที่พิมพ์เป็น "HH:mm": "14:30" "14.30" "1430" "930" "9" "9:5" → null ถ้าไม่ใช่เวลาที่ถูกต้อง
|
|
25
|
+
*/
|
|
26
|
+
export function parseTimeInput(input: string): TimeValue | null {
|
|
27
|
+
const s = input.trim().replace(/\s*(น\.|นาฬิกา)$/, "");
|
|
28
|
+
if (!s) return null;
|
|
29
|
+
let h: number;
|
|
30
|
+
let m: number;
|
|
31
|
+
const sep = s.match(/^(\d{1,2})[:.](\d{1,2})$/);
|
|
32
|
+
if (sep) {
|
|
33
|
+
h = Number(sep[1]);
|
|
34
|
+
m = Number(sep[2]);
|
|
35
|
+
} else if (/^\d{1,4}$/.test(s)) {
|
|
36
|
+
if (s.length <= 2) {
|
|
37
|
+
h = Number(s);
|
|
38
|
+
m = 0;
|
|
39
|
+
} else {
|
|
40
|
+
h = Number(s.slice(0, s.length - 2));
|
|
41
|
+
m = Number(s.slice(-2));
|
|
42
|
+
}
|
|
43
|
+
} else {
|
|
44
|
+
return null;
|
|
45
|
+
}
|
|
46
|
+
if (h > 23 || m > 59) return null;
|
|
47
|
+
return `${pad(h)}:${pad(m)}`;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
function splitTime(value: TimeValue | null | undefined): [number, number] | null {
|
|
51
|
+
const parsed = value ? parseTimeInput(value) : null;
|
|
52
|
+
if (!parsed) return null;
|
|
53
|
+
const [h, m] = parsed.split(":").map(Number);
|
|
54
|
+
return [h ?? 0, m ?? 0];
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/** คอลัมน์เลือกค่า (listbox): คลิก หรือโฟกัสแล้วใช้ลูกศรขึ้น/ลง Home/End */
|
|
58
|
+
function TimeColumn({
|
|
59
|
+
label,
|
|
60
|
+
options,
|
|
61
|
+
selected,
|
|
62
|
+
onSelect,
|
|
63
|
+
}: {
|
|
64
|
+
label: string;
|
|
65
|
+
options: number[];
|
|
66
|
+
selected: number | null;
|
|
67
|
+
onSelect: (value: number) => void;
|
|
68
|
+
}) {
|
|
69
|
+
const baseId = useId();
|
|
70
|
+
const listRef = useRef<HTMLDivElement>(null);
|
|
71
|
+
const optionId = (v: number) => `${baseId}-${v}`;
|
|
72
|
+
|
|
73
|
+
// เลื่อนให้ค่าที่เลือกอยู่กลางคอลัมน์ทุกครั้งที่เปลี่ยน
|
|
74
|
+
useEffect(() => {
|
|
75
|
+
if (selected == null) return;
|
|
76
|
+
const el = listRef.current?.querySelector<HTMLElement>(`[data-value="${selected}"]`);
|
|
77
|
+
el?.scrollIntoView?.({ block: "center" });
|
|
78
|
+
}, [selected]);
|
|
79
|
+
|
|
80
|
+
const onKeyDown = (e: KeyboardEvent) => {
|
|
81
|
+
const i = selected == null ? -1 : options.indexOf(selected);
|
|
82
|
+
let next: number | undefined;
|
|
83
|
+
if (e.key === "ArrowDown") next = options[Math.min(i + 1, options.length - 1)];
|
|
84
|
+
else if (e.key === "ArrowUp") next = options[Math.max(i - 1, 0)];
|
|
85
|
+
else if (e.key === "Home") next = options[0];
|
|
86
|
+
else if (e.key === "End") next = options[options.length - 1];
|
|
87
|
+
if (next !== undefined) {
|
|
88
|
+
e.preventDefault();
|
|
89
|
+
onSelect(next);
|
|
90
|
+
}
|
|
91
|
+
};
|
|
92
|
+
|
|
93
|
+
return (
|
|
94
|
+
<div
|
|
95
|
+
ref={listRef}
|
|
96
|
+
role="listbox"
|
|
97
|
+
tabIndex={0}
|
|
98
|
+
aria-label={label}
|
|
99
|
+
aria-activedescendant={selected == null ? undefined : optionId(selected)}
|
|
100
|
+
onKeyDown={onKeyDown}
|
|
101
|
+
className="no-scrollbar flex max-h-56 w-14 flex-col gap-0.5 overflow-y-auto rounded-md p-0.5 outline-none focus-visible:ring-3 focus-visible:ring-ring/50"
|
|
102
|
+
>
|
|
103
|
+
{options.map((v) => (
|
|
104
|
+
<div
|
|
105
|
+
key={v}
|
|
106
|
+
id={optionId(v)}
|
|
107
|
+
role="option"
|
|
108
|
+
aria-selected={v === selected}
|
|
109
|
+
data-value={v}
|
|
110
|
+
tabIndex={-1}
|
|
111
|
+
onClick={() => onSelect(v)}
|
|
112
|
+
className={cn(
|
|
113
|
+
"flex h-8 shrink-0 cursor-default items-center justify-center rounded-md text-sm tabular-nums select-none hover:bg-muted",
|
|
114
|
+
v === selected && "bg-primary text-primary-foreground hover:bg-primary",
|
|
115
|
+
)}
|
|
116
|
+
>
|
|
117
|
+
{pad(v)}
|
|
118
|
+
</div>
|
|
119
|
+
))}
|
|
120
|
+
</div>
|
|
121
|
+
);
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
/** คอลัมน์ชั่วโมง + นาที (ใช้ใน TimePicker / DateTimePicker) */
|
|
125
|
+
export function TimeColumns({
|
|
126
|
+
value,
|
|
127
|
+
onValueChange,
|
|
128
|
+
minuteStep = 5,
|
|
129
|
+
className,
|
|
130
|
+
}: {
|
|
131
|
+
value: TimeValue | null | undefined;
|
|
132
|
+
onValueChange: (value: TimeValue) => void;
|
|
133
|
+
/** ระยะห่างนาทีในคอลัมน์ (ค่านาทีที่ไม่ลงตัวยังแสดงถ้าเลือกอยู่) */
|
|
134
|
+
minuteStep?: number;
|
|
135
|
+
className?: string;
|
|
136
|
+
}) {
|
|
137
|
+
const text = useUiText().time;
|
|
138
|
+
const current = splitTime(value);
|
|
139
|
+
const hours = Array.from({ length: 24 }, (_, i) => i);
|
|
140
|
+
const minutes = Array.from({ length: Math.ceil(60 / minuteStep) }, (_, i) => i * minuteStep);
|
|
141
|
+
if (current && !minutes.includes(current[1])) {
|
|
142
|
+
minutes.push(current[1]);
|
|
143
|
+
minutes.sort((a, b) => a - b);
|
|
144
|
+
}
|
|
145
|
+
const set = (h: number, m: number) => onValueChange(`${pad(h)}:${pad(m)}`);
|
|
146
|
+
return (
|
|
147
|
+
<div data-slot="time-columns" className={cn("flex gap-1", className)}>
|
|
148
|
+
<TimeColumn
|
|
149
|
+
label={text?.hour ?? "ชั่วโมง"}
|
|
150
|
+
options={hours}
|
|
151
|
+
selected={current?.[0] ?? null}
|
|
152
|
+
onSelect={(h) => set(h, current?.[1] ?? 0)}
|
|
153
|
+
/>
|
|
154
|
+
<TimeColumn
|
|
155
|
+
label={text?.minute ?? "นาที"}
|
|
156
|
+
options={minutes}
|
|
157
|
+
selected={current?.[1] ?? null}
|
|
158
|
+
onSelect={(m) => set(current?.[0] ?? 0, m)}
|
|
159
|
+
/>
|
|
160
|
+
</div>
|
|
161
|
+
);
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
function useControllable<T>(value: T | undefined, defaultValue: T, onChange?: (v: T) => void) {
|
|
165
|
+
const [inner, setInner] = useState(defaultValue);
|
|
166
|
+
const controlled = value !== undefined;
|
|
167
|
+
const current = controlled ? value : inner;
|
|
168
|
+
const set = (v: T) => {
|
|
169
|
+
if (!controlled) setInner(v);
|
|
170
|
+
onChange?.(v);
|
|
171
|
+
};
|
|
172
|
+
return [current, set] as const;
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
const nowTime = (): TimeValue => {
|
|
176
|
+
const d = new Date();
|
|
177
|
+
return `${pad(d.getHours())}:${pad(d.getMinutes())}`;
|
|
178
|
+
};
|
|
179
|
+
|
|
180
|
+
/**
|
|
181
|
+
* ช่องเวลา 24 ชม. — พิมพ์ได้เลย ("1430", "9.15", "14:30") หรือกดไอคอนนาฬิกาเลือกจากคอลัมน์ชั่วโมง/นาที
|
|
182
|
+
* ค่าเป็น string "HH:mm" (หรือ null) — `name` ส่งใน FormData ได้; พิมพ์ผิดรูปแบบช่องจะเป็นสีแดงจนแก้
|
|
183
|
+
*
|
|
184
|
+
* <TimePicker name="remindAt" defaultValue="09:00" minuteStep={15} aria-label="เวลาเตือน" />
|
|
185
|
+
*/
|
|
186
|
+
export function TimePicker({
|
|
187
|
+
value,
|
|
188
|
+
defaultValue = null,
|
|
189
|
+
onValueChange,
|
|
190
|
+
minuteStep = 5,
|
|
191
|
+
name,
|
|
192
|
+
id,
|
|
193
|
+
required,
|
|
194
|
+
disabled,
|
|
195
|
+
placeholder,
|
|
196
|
+
className,
|
|
197
|
+
"aria-label": ariaLabel,
|
|
198
|
+
"aria-describedby": ariaDescribedBy,
|
|
199
|
+
"aria-invalid": ariaInvalid,
|
|
200
|
+
}: {
|
|
201
|
+
value?: TimeValue | null;
|
|
202
|
+
defaultValue?: TimeValue | null;
|
|
203
|
+
onValueChange?: (value: TimeValue | null) => void;
|
|
204
|
+
minuteStep?: number;
|
|
205
|
+
name?: string;
|
|
206
|
+
id?: string;
|
|
207
|
+
required?: boolean;
|
|
208
|
+
disabled?: boolean;
|
|
209
|
+
/** ค่าเริ่มต้น "--:--" */
|
|
210
|
+
placeholder?: string;
|
|
211
|
+
className?: string;
|
|
212
|
+
"aria-label"?: string;
|
|
213
|
+
"aria-describedby"?: string;
|
|
214
|
+
"aria-invalid"?: boolean;
|
|
215
|
+
}) {
|
|
216
|
+
const ui = useUiText();
|
|
217
|
+
const text = ui.time;
|
|
218
|
+
const [time, setTime] = useControllable<TimeValue | null>(value, defaultValue, onValueChange);
|
|
219
|
+
const [draft, setDraft] = useState<string | null>(null);
|
|
220
|
+
const [open, setOpen] = useState(false);
|
|
221
|
+
const draftInvalid = draft != null && draft.trim() !== "" && parseTimeInput(draft) == null;
|
|
222
|
+
|
|
223
|
+
const commitDraft = () => {
|
|
224
|
+
if (draft == null) return;
|
|
225
|
+
if (draft.trim() === "") {
|
|
226
|
+
setTime(null);
|
|
227
|
+
setDraft(null);
|
|
228
|
+
return;
|
|
229
|
+
}
|
|
230
|
+
const parsed = parseTimeInput(draft);
|
|
231
|
+
if (parsed) {
|
|
232
|
+
setTime(parsed);
|
|
233
|
+
setDraft(null);
|
|
234
|
+
}
|
|
235
|
+
};
|
|
236
|
+
|
|
237
|
+
return (
|
|
238
|
+
<InputGroup data-slot="time-picker" className={cn("w-32", className)}>
|
|
239
|
+
<InputGroupInput
|
|
240
|
+
id={id}
|
|
241
|
+
inputMode="numeric"
|
|
242
|
+
autoComplete="off"
|
|
243
|
+
placeholder={placeholder ?? "--:--"}
|
|
244
|
+
value={draft ?? time ?? ""}
|
|
245
|
+
disabled={disabled}
|
|
246
|
+
required={required}
|
|
247
|
+
aria-label={ariaLabel}
|
|
248
|
+
aria-describedby={ariaDescribedBy}
|
|
249
|
+
aria-invalid={draftInvalid || ariaInvalid || undefined}
|
|
250
|
+
onChange={(e) => setDraft(e.currentTarget.value)}
|
|
251
|
+
onBlur={commitDraft}
|
|
252
|
+
onKeyDown={(e) => {
|
|
253
|
+
if (e.key === "Enter") {
|
|
254
|
+
e.preventDefault();
|
|
255
|
+
commitDraft();
|
|
256
|
+
}
|
|
257
|
+
}}
|
|
258
|
+
className="tabular-nums"
|
|
259
|
+
/>
|
|
260
|
+
{name && <input type="hidden" name={name} value={time ?? ""} />}
|
|
261
|
+
<InputGroupAddon align="inline-end">
|
|
262
|
+
<Popover open={open} onOpenChange={setOpen}>
|
|
263
|
+
<PopoverTrigger
|
|
264
|
+
render={
|
|
265
|
+
<InputGroupButton
|
|
266
|
+
size="icon-xs"
|
|
267
|
+
disabled={disabled}
|
|
268
|
+
aria-label={text?.choose ?? "เลือกเวลา"}
|
|
269
|
+
/>
|
|
270
|
+
}
|
|
271
|
+
>
|
|
272
|
+
<ClockIcon />
|
|
273
|
+
</PopoverTrigger>
|
|
274
|
+
<PopoverContent align="end" className="w-auto gap-2 p-2">
|
|
275
|
+
<TimeColumns
|
|
276
|
+
value={time}
|
|
277
|
+
minuteStep={minuteStep}
|
|
278
|
+
onValueChange={(v) => {
|
|
279
|
+
setTime(v);
|
|
280
|
+
setDraft(null);
|
|
281
|
+
}}
|
|
282
|
+
/>
|
|
283
|
+
<div className="flex justify-between gap-2 border-t pt-2">
|
|
284
|
+
<Button
|
|
285
|
+
type="button"
|
|
286
|
+
size="xs"
|
|
287
|
+
variant="ghost"
|
|
288
|
+
onClick={() => {
|
|
289
|
+
setTime(nowTime());
|
|
290
|
+
setDraft(null);
|
|
291
|
+
}}
|
|
292
|
+
>
|
|
293
|
+
{text?.now ?? "ตอนนี้"}
|
|
294
|
+
</Button>
|
|
295
|
+
<Button type="button" size="xs" onClick={() => setOpen(false)}>
|
|
296
|
+
{ui.done}
|
|
297
|
+
</Button>
|
|
298
|
+
</div>
|
|
299
|
+
</PopoverContent>
|
|
300
|
+
</Popover>
|
|
301
|
+
</InputGroupAddon>
|
|
302
|
+
</InputGroup>
|
|
303
|
+
);
|
|
304
|
+
}
|
|
305
|
+
|
|
306
|
+
/** "YYYY-MM-DDTHH:mm" ตามเวลาท้องถิ่น (รูปแบบเดียวกับ input datetime-local) — ใช้เป็นค่าใน FormData */
|
|
307
|
+
export function toLocalDateTimeString(date: Date): string {
|
|
308
|
+
return `${date.getFullYear()}-${pad(date.getMonth() + 1)}-${pad(date.getDate())}T${pad(date.getHours())}:${pad(date.getMinutes())}`;
|
|
309
|
+
}
|
|
310
|
+
|
|
311
|
+
/**
|
|
312
|
+
* เลือกวัน + เวลาในกล่องเดียว (ปฏิทิน + คอลัมน์ชั่วโมง/นาที) — นัดหมาย, กำหนดส่ง, เวลาเตือน
|
|
313
|
+
* ค่าเป็น `Date | null`; เลือกวันก่อนเวลา = ใช้ `defaultTime`; `name` ส่ง "YYYY-MM-DDTHH:mm" (เวลาท้องถิ่น) ใน FormData
|
|
314
|
+
*
|
|
315
|
+
* <DateTimePicker value={dueAt} onValueChange={setDueAt} minuteStep={15} />
|
|
316
|
+
*/
|
|
317
|
+
export function DateTimePicker({
|
|
318
|
+
value,
|
|
319
|
+
defaultValue = null,
|
|
320
|
+
onValueChange,
|
|
321
|
+
minuteStep = 5,
|
|
322
|
+
defaultTime = "09:00",
|
|
323
|
+
name,
|
|
324
|
+
id,
|
|
325
|
+
disabled,
|
|
326
|
+
placeholder,
|
|
327
|
+
className,
|
|
328
|
+
"aria-label": ariaLabel,
|
|
329
|
+
"aria-invalid": ariaInvalid,
|
|
330
|
+
}: {
|
|
331
|
+
value?: Date | null;
|
|
332
|
+
defaultValue?: Date | null;
|
|
333
|
+
onValueChange?: (value: Date | null) => void;
|
|
334
|
+
minuteStep?: number;
|
|
335
|
+
/** เวลาที่ใช้เมื่อเลือกวันครั้งแรก */
|
|
336
|
+
defaultTime?: TimeValue;
|
|
337
|
+
name?: string;
|
|
338
|
+
id?: string;
|
|
339
|
+
disabled?: boolean;
|
|
340
|
+
placeholder?: string;
|
|
341
|
+
className?: string;
|
|
342
|
+
"aria-label"?: string;
|
|
343
|
+
"aria-invalid"?: boolean;
|
|
344
|
+
}) {
|
|
345
|
+
const ui = useUiText();
|
|
346
|
+
const text = ui.time;
|
|
347
|
+
const [date, setDate] = useControllable<Date | null>(value, defaultValue, onValueChange);
|
|
348
|
+
const [open, setOpen] = useState(false);
|
|
349
|
+
const time = date ? `${pad(date.getHours())}:${pad(date.getMinutes())}` : null;
|
|
350
|
+
|
|
351
|
+
const withTime = (day: Date, t: TimeValue) => {
|
|
352
|
+
const [h, m] = splitTime(t) ?? [9, 0];
|
|
353
|
+
const d = new Date(day);
|
|
354
|
+
d.setHours(h, m, 0, 0);
|
|
355
|
+
return d;
|
|
356
|
+
};
|
|
357
|
+
|
|
358
|
+
const label = date
|
|
359
|
+
? date.toLocaleString("th-TH", { dateStyle: "medium", timeStyle: "short" })
|
|
360
|
+
: (placeholder ?? text?.dateTimePlaceholder ?? "เลือกวันและเวลา");
|
|
361
|
+
|
|
362
|
+
return (
|
|
363
|
+
<Popover open={open} onOpenChange={setOpen}>
|
|
364
|
+
<PopoverTrigger
|
|
365
|
+
render={
|
|
366
|
+
<Button
|
|
367
|
+
id={id}
|
|
368
|
+
type="button"
|
|
369
|
+
variant="outline"
|
|
370
|
+
disabled={disabled}
|
|
371
|
+
aria-label={ariaLabel}
|
|
372
|
+
aria-invalid={ariaInvalid || undefined}
|
|
373
|
+
data-slot="date-time-picker"
|
|
374
|
+
className={cn(
|
|
375
|
+
"w-full justify-start gap-2 font-normal",
|
|
376
|
+
!date && "text-muted-foreground",
|
|
377
|
+
className,
|
|
378
|
+
)}
|
|
379
|
+
/>
|
|
380
|
+
}
|
|
381
|
+
>
|
|
382
|
+
<CalendarClockIcon className="text-muted-foreground" />
|
|
383
|
+
<span className="truncate">{label}</span>
|
|
384
|
+
</PopoverTrigger>
|
|
385
|
+
{name && <input type="hidden" name={name} value={date ? toLocalDateTimeString(date) : ""} />}
|
|
386
|
+
<PopoverContent align="start" className="w-auto gap-0 p-0">
|
|
387
|
+
<div className="flex flex-col sm:flex-row">
|
|
388
|
+
<Calendar
|
|
389
|
+
mode="single"
|
|
390
|
+
locale={th}
|
|
391
|
+
selected={date ?? undefined}
|
|
392
|
+
defaultMonth={date ?? undefined}
|
|
393
|
+
onSelect={(day) => day && setDate(withTime(day, time ?? defaultTime))}
|
|
394
|
+
className="p-3"
|
|
395
|
+
/>
|
|
396
|
+
<div className="flex justify-center border-t p-2 sm:border-t-0 sm:border-l">
|
|
397
|
+
<TimeColumns
|
|
398
|
+
value={time}
|
|
399
|
+
minuteStep={minuteStep}
|
|
400
|
+
onValueChange={(t) => setDate(withTime(date ?? new Date(), t))}
|
|
401
|
+
/>
|
|
402
|
+
</div>
|
|
403
|
+
</div>
|
|
404
|
+
<div className="flex justify-between gap-2 border-t p-2">
|
|
405
|
+
<Button
|
|
406
|
+
type="button"
|
|
407
|
+
size="xs"
|
|
408
|
+
variant="ghost"
|
|
409
|
+
disabled={!date}
|
|
410
|
+
onClick={() => setDate(null)}
|
|
411
|
+
>
|
|
412
|
+
{text?.clear ?? "ล้าง"}
|
|
413
|
+
</Button>
|
|
414
|
+
<Button type="button" size="xs" onClick={() => setOpen(false)}>
|
|
415
|
+
{ui.done}
|
|
416
|
+
</Button>
|
|
417
|
+
</div>
|
|
418
|
+
</PopoverContent>
|
|
419
|
+
</Popover>
|
|
420
|
+
);
|
|
421
|
+
}
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { Avatar, AvatarFallback, AvatarImage } from "@suphark/ui/components/ui/avatar";
|
|
2
2
|
import { cn } from "@suphark/ui/lib/utils";
|
|
3
3
|
import type { ComponentProps } from "react";
|
|
4
|
+
import { type Presence, PresenceDot } from "./presence-dot";
|
|
4
5
|
|
|
5
6
|
/** ตัวอักษรย่อจากชื่อ (ไทย/อังกฤษ): "สมชาย ใจดี" → "สจ", "Suphark A." → "SA" */
|
|
6
7
|
export function initialsOf(name: string | null | undefined, fallback = "?"): string {
|
|
@@ -11,13 +12,20 @@ export function initialsOf(name: string | null | undefined, fallback = "?"): str
|
|
|
11
12
|
return (first + last).toUpperCase();
|
|
12
13
|
}
|
|
13
14
|
|
|
14
|
-
/** Avatar ของ shadcn + ตัวย่อชื่อ + สีพื้นคงที่ต่อชื่อ (ไม่ต้องมีรูป) */
|
|
15
|
+
/** Avatar ของ shadcn + ตัวย่อชื่อ + สีพื้นคงที่ต่อชื่อ (ไม่ต้องมีรูป) + จุดสถานะ `status` */
|
|
15
16
|
export function Initials({
|
|
16
17
|
name,
|
|
17
18
|
src,
|
|
19
|
+
status,
|
|
18
20
|
className,
|
|
21
|
+
children,
|
|
19
22
|
...props
|
|
20
|
-
}: ComponentProps<typeof Avatar> & {
|
|
23
|
+
}: ComponentProps<typeof Avatar> & {
|
|
24
|
+
name: string | null | undefined;
|
|
25
|
+
src?: string | null;
|
|
26
|
+
/** จุดสถานะมุมล่างขวา: online / away / busy / offline */
|
|
27
|
+
status?: Presence;
|
|
28
|
+
}) {
|
|
21
29
|
const hue = [...(name ?? "")].reduce((h, c) => (h * 31 + c.codePointAt(0)!) % 360, 7);
|
|
22
30
|
return (
|
|
23
31
|
<Avatar className={cn("shrink-0", className)} {...props}>
|
|
@@ -28,6 +36,8 @@ export function Initials({
|
|
|
28
36
|
>
|
|
29
37
|
{initialsOf(name)}
|
|
30
38
|
</AvatarFallback>
|
|
39
|
+
{status && <PresenceDot status={status} />}
|
|
40
|
+
{children}
|
|
31
41
|
</Avatar>
|
|
32
42
|
);
|
|
33
43
|
}
|
|
@@ -1,7 +1,12 @@
|
|
|
1
|
-
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
import { Alert, AlertAction, AlertDescription, AlertTitle } from "@suphark/ui/components/ui/alert";
|
|
4
|
+
import { Button } from "@suphark/ui/components/ui/button";
|
|
2
5
|
import { cn } from "@suphark/ui/lib/utils";
|
|
3
|
-
import
|
|
6
|
+
import { CircleAlertIcon, CircleCheckIcon, InfoIcon, TriangleAlertIcon, XIcon } from "lucide-react";
|
|
7
|
+
import { type ComponentProps, type ReactNode, useState } from "react";
|
|
4
8
|
import type { Tone } from "./status-badge";
|
|
9
|
+
import { useUiText } from "./ui-text";
|
|
5
10
|
|
|
6
11
|
export type NoticeTone = Exclude<Tone, "neutral">;
|
|
7
12
|
|
|
@@ -13,21 +18,66 @@ const TONE: Record<NoticeTone, string> = {
|
|
|
13
18
|
danger: "border-destructive-border bg-destructive-subtle text-destructive-subtle-foreground",
|
|
14
19
|
};
|
|
15
20
|
|
|
16
|
-
|
|
21
|
+
const TONE_ICON: Record<NoticeTone, ReactNode> = {
|
|
22
|
+
info: <InfoIcon />,
|
|
23
|
+
success: <CircleCheckIcon />,
|
|
24
|
+
warning: <TriangleAlertIcon />,
|
|
25
|
+
danger: <CircleAlertIcon />,
|
|
26
|
+
};
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* Alert ของ shadcn + สีตามความหมาย (info/success/warning/danger)
|
|
30
|
+
* `icon` = true ใช้ไอคอนตามโทน หรือส่งไอคอนเอง · `dismissible` = มีปุ่ม X ซ่อนเองเมื่อกด (+ `onDismiss` เช่นจำไว้ใน cookie)
|
|
31
|
+
*
|
|
32
|
+
* <Notice tone="warning" icon dismissible title="ยังไม่ได้ยืนยันอีเมล">ตรวจกล่องจดหมาย…</Notice>
|
|
33
|
+
*/
|
|
17
34
|
export function Notice({
|
|
18
35
|
tone = "info",
|
|
19
36
|
title,
|
|
37
|
+
icon,
|
|
38
|
+
dismissible = false,
|
|
39
|
+
onDismiss,
|
|
20
40
|
children,
|
|
21
41
|
className,
|
|
22
42
|
...props
|
|
23
43
|
}: Omit<ComponentProps<typeof Alert>, "variant" | "title"> & {
|
|
24
44
|
tone?: NoticeTone;
|
|
25
45
|
title?: ReactNode;
|
|
46
|
+
/** true = ไอคอนตามโทน, ReactNode = ไอคอนเอง */
|
|
47
|
+
icon?: ReactNode | boolean;
|
|
48
|
+
/** แสดงปุ่มปิด (UiText.close) — กดแล้วซ่อนกล่องนี้ */
|
|
49
|
+
dismissible?: boolean;
|
|
50
|
+
onDismiss?: () => void;
|
|
26
51
|
}) {
|
|
52
|
+
const closeLabel = useUiText().close ?? "ปิด";
|
|
53
|
+
const [hidden, setHidden] = useState(false);
|
|
54
|
+
if (hidden) return null;
|
|
55
|
+
const iconNode = icon === true ? TONE_ICON[tone] : icon || null;
|
|
27
56
|
return (
|
|
28
|
-
<Alert
|
|
57
|
+
<Alert
|
|
58
|
+
className={cn(TONE[tone], dismissible && "has-data-[slot=alert-action]:pr-9", className)}
|
|
59
|
+
{...props}
|
|
60
|
+
>
|
|
61
|
+
{iconNode}
|
|
29
62
|
{title && <AlertTitle>{title}</AlertTitle>}
|
|
30
63
|
<AlertDescription className="text-inherit">{children}</AlertDescription>
|
|
64
|
+
{dismissible && (
|
|
65
|
+
<AlertAction className="top-1.5 right-1.5">
|
|
66
|
+
<Button
|
|
67
|
+
type="button"
|
|
68
|
+
variant="ghost"
|
|
69
|
+
size="icon-xs"
|
|
70
|
+
aria-label={closeLabel}
|
|
71
|
+
className="text-current hover:bg-current/10 hover:text-current"
|
|
72
|
+
onClick={() => {
|
|
73
|
+
setHidden(true);
|
|
74
|
+
onDismiss?.();
|
|
75
|
+
}}
|
|
76
|
+
>
|
|
77
|
+
<XIcon />
|
|
78
|
+
</Button>
|
|
79
|
+
</AlertAction>
|
|
80
|
+
)}
|
|
31
81
|
</Alert>
|
|
32
82
|
);
|
|
33
83
|
}
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
import { AvatarBadge } from "@suphark/ui/components/ui/avatar";
|
|
4
|
+
import { cn } from "@suphark/ui/lib/utils";
|
|
5
|
+
import { useUiText } from "./ui-text";
|
|
6
|
+
|
|
7
|
+
export type Presence = "online" | "away" | "busy" | "offline";
|
|
8
|
+
|
|
9
|
+
const PRESENCE_CLASS: Record<Presence, string> = {
|
|
10
|
+
online: "bg-success",
|
|
11
|
+
away: "bg-warning",
|
|
12
|
+
busy: "bg-destructive",
|
|
13
|
+
offline: "bg-muted-foreground",
|
|
14
|
+
};
|
|
15
|
+
|
|
16
|
+
const PRESENCE_TH: Record<Presence, string> = {
|
|
17
|
+
online: "ออนไลน์",
|
|
18
|
+
away: "ไม่อยู่",
|
|
19
|
+
busy: "ไม่ว่าง",
|
|
20
|
+
offline: "ออฟไลน์",
|
|
21
|
+
};
|
|
22
|
+
|
|
23
|
+
/** จุดสถานะมุมล่างขวาของ Avatar (ใช้ใน `Initials status`) — ชื่อสถานะอ่านได้ด้วย screen reader */
|
|
24
|
+
export function PresenceDot({ status, className }: { status: Presence; className?: string }) {
|
|
25
|
+
const label = useUiText().presence?.[status] ?? PRESENCE_TH[status];
|
|
26
|
+
return (
|
|
27
|
+
<AvatarBadge data-presence={status} className={cn(PRESENCE_CLASS[status], className)}>
|
|
28
|
+
<span className="sr-only">{label}</span>
|
|
29
|
+
</AvatarBadge>
|
|
30
|
+
);
|
|
31
|
+
}
|