@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.
@@ -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> & { name: string | null | undefined; src?: string | null }) {
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
- import { Alert, AlertDescription, AlertTitle } from "@suphark/ui/components/ui/alert";
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 type { ComponentProps, ReactNode } from "react";
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
- /** Alert ของ shadcn + สีตามความหมาย (info/success/warning/danger) */
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 className={cn(TONE[tone], className)} {...props}>
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
+ }