@suphark/ui 0.23.0 → 0.24.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 CHANGED
@@ -2,6 +2,21 @@
2
2
 
3
3
  รูปแบบตาม [Keep a Changelog](https://keepachangelog.com/) · semver: patch = หน้าตา/บั๊ก, minor = component/prop ใหม่, major = เปลี่ยน/ลบ props
4
4
 
5
+ ## [0.24.0] — 2026-10-02
6
+
7
+ เติมของเดิมตามที่สำรวจ shadcnstudio.com (หยิบเฉพาะ "หน้าที่" ที่ยังไม่มี ทำเป็นตัวเดียว + props) — เพิ่มอย่างเดียว ค่าเริ่มต้นหน้าตาเดิม
8
+
9
+ ### Added
10
+
11
+ - **`TextareaField`** — ช่องข้อความหลายบรรทัดพร้อม `label`/`hint`/`error` + **ตัวนับตัวอักษร** เมื่อมี `maxLength` (แดงเมื่อเต็ม, screen reader อ่าน "พิมพ์แล้ว x จาก y ตัวอักษร" ผ่าน `aria-describedby`) + `minRows`/`maxRows` (Textarea ขยายตามเนื้อหาอยู่แล้ว — `maxRows` จำกัดแล้วเลื่อนใน); controlled/uncontrolled; `textareaClassName`
12
+ - **`UserAvatars`** — รูปผู้ใช้ซ้อนกัน + "+N" จาก array (`users`, `max`, `size` sm|default|lg, `label` นำหน้ารายชื่อให้ screen reader) ชี้ที่ "+N" เห็นชื่อที่เหลือ; ใช้จาก server component ได้ — แทน AvatarGroup + Avatar×N + AvatarGroupCount
13
+ - **จุดสถานะ** `status?: "online" | "away" | "busy" | "offline"` ที่ `Initials`, `UserIdentity` และรายการของ `UserAvatars` (`PresenceDot` + `type Presence` export ด้วย) ใช้ token success/warning/destructive/muted
14
+ - **`TableSkeleton`** (`rows`, `columns`, `header`), **`ListSkeleton`** (`rows`, `avatar`, `lines`), **`CardSkeleton`** (`count`, `variant` stat|content) — โครงระหว่างโหลดสำเร็จรูปสำหรับ `loading.tsx` มี `role="status"` + "กำลังโหลด…" ความกว้างสลับแบบคงที่ (ไม่ random → ไม่ hydration mismatch)
15
+ - **`Notice`**: `icon` (true = ไอคอนตามโทน หรือส่งเอง) และ `dismissible` + `onDismiss` (ปุ่ม X อ่าน `UiText.close`) — ไม่ส่ง = หน้าตาเดิม; ไฟล์เป็น `"use client"` แล้ว (ใช้จาก server component ได้เหมือนเดิม)
16
+ - **`AuthLayout`**: `size?: "sm" | "md" | "lg"` ความกว้างการ์ด (ค่าเริ่มต้น sm = เดิม) — หน้า consent/เลือกองค์กรของ Pi Auth ใช้ md (ขอจาก Pi Auth)
17
+ - `UiText` key ใหม่: `loading`, `presence`, `characterCount`
18
+ - showcase: หน้า `/components/{textarea-field,user-avatars,loading-skeletons}` (125 หน้า), demo `notice` มี icon + dismissible; tests 10 กรณี
19
+
5
20
  ## [0.23.0] — 2026-10-02
6
21
 
7
22
  ### Added
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@suphark/ui",
3
- "version": "0.23.0",
3
+ "version": "0.24.0",
4
4
  "private": false,
5
5
  "type": "module",
6
6
  "description": "Design system กลางของ Suphark apps: shadcn/ui (base-nova, Base UI, Tailwind v4) + shared components + token/ฟอนต์ Noto Sans Thai Looped — ส่งเป็น source ให้แอป Next transpile เอง",
@@ -1,6 +1,8 @@
1
1
  import { cn } from "@suphark/ui/lib/utils";
2
2
  import type { CSSProperties, ReactNode } from "react";
3
3
 
4
+ const SIZE = { sm: "max-w-sm", md: "max-w-md", lg: "max-w-lg" } as const;
5
+
4
6
  export interface AuthLayoutProps {
5
7
  /** โลโก้เหนือหัวข้อ — element (`<img>`/ไอคอน) หรือไม่ส่ง = ไม่มี */
6
8
  logo?: ReactNode;
@@ -28,6 +30,8 @@ export interface AuthLayoutProps {
28
30
  brandForeground?: string | null;
29
31
  /** เต็มความสูงจอ (ค่าเริ่มต้น true) — false ใช้ฝังในกรอบ เช่นหน้าเอกสาร */
30
32
  fullScreen?: boolean;
33
+ /** ความกว้างการ์ด: sm = max-w-sm (ค่าเริ่มต้น ฟอร์มล็อกอิน), md = max-w-md (consent/เลือกองค์กร), lg = max-w-lg */
34
+ size?: "sm" | "md" | "lg";
31
35
  className?: string;
32
36
  }
33
37
 
@@ -57,6 +61,7 @@ export function AuthLayout({
57
61
  brandColor,
58
62
  brandForeground,
59
63
  fullScreen = true,
64
+ size = "sm",
60
65
  className,
61
66
  }: AuthLayoutProps) {
62
67
  const style = brandColor
@@ -67,7 +72,7 @@ export function AuthLayout({
67
72
  : undefined;
68
73
 
69
74
  const card = (
70
- <div className="flex w-full max-w-sm flex-col gap-6">
75
+ <div className={cn("flex w-full flex-col gap-6", SIZE[size])}>
71
76
  <div
72
77
  data-slot="auth-card"
73
78
  className="overflow-hidden rounded-2xl border bg-card text-card-foreground shadow-lg shadow-foreground/5"
@@ -0,0 +1,109 @@
1
+ "use client";
2
+
3
+ import { Field, FieldDescription, FieldError, FieldLabel } from "@suphark/ui/components/ui/field";
4
+ import { Textarea } from "@suphark/ui/components/ui/textarea";
5
+ import { cn } from "@suphark/ui/lib/utils";
6
+ import { type ComponentProps, type CSSProperties, type ReactNode, useId, useState } from "react";
7
+ import { useUiText } from "../ui-text";
8
+
9
+ /** ความสูงตามจำนวนบรรทัด: บรรทัด × line-height + py-2 (1rem) + เส้นขอบ 2px */
10
+ const rowsHeight = (rows: number) => `calc(${rows}lh + 1rem + 2px)`;
11
+
12
+ /**
13
+ * ช่องข้อความหลายบรรทัดพร้อม label/hint/error + ตัวนับตัวอักษร — แทน LabeledField + Textarea + state นับเอง
14
+ * Textarea ขยายตามเนื้อหาอยู่แล้ว (field-sizing) — `maxRows` จำกัดความสูงแล้วเลื่อนใน, `minRows` ความสูงเริ่มต้น
15
+ * ตัวนับโผล่เมื่อมี `maxLength` (ปิดด้วย `showCount={false}`) ใช้ได้ทั้ง controlled (`value`) และ uncontrolled (`defaultValue` + `name`)
16
+ *
17
+ * <TextareaField name="bio" label="แนะนำตัว" hint="แสดงในโปรไฟล์" maxLength={280} maxRows={8} />
18
+ */
19
+ export function TextareaField({
20
+ label,
21
+ hint,
22
+ error,
23
+ showCount,
24
+ minRows,
25
+ maxRows,
26
+ className,
27
+ textareaClassName,
28
+ id,
29
+ style,
30
+ onChange,
31
+ ...props
32
+ }: Omit<ComponentProps<typeof Textarea>, "children"> & {
33
+ label: ReactNode;
34
+ /** ข้อความช่วยใต้ช่อง — ซ่อนเมื่อมี error */
35
+ hint?: ReactNode;
36
+ error?: ReactNode;
37
+ /** ค่าเริ่มต้น = true เมื่อมี maxLength */
38
+ showCount?: boolean;
39
+ minRows?: number;
40
+ maxRows?: number;
41
+ /** className ของ textarea (`className` อยู่ที่กล่อง Field) */
42
+ textareaClassName?: string;
43
+ }) {
44
+ const text = useUiText();
45
+ const autoId = useId();
46
+ const inputId = id ?? autoId;
47
+ const countId = `${inputId}-count`;
48
+ const [uncontrolledLength, setUncontrolledLength] = useState(
49
+ () => String(props.defaultValue ?? "").length,
50
+ );
51
+ const length = props.value != null ? String(props.value).length : uncontrolledLength;
52
+ const max = props.maxLength;
53
+ const counting = (showCount ?? max != null) && max != null;
54
+ const sizeStyle: CSSProperties = {
55
+ ...(minRows ? { minHeight: rowsHeight(minRows) } : null),
56
+ ...(maxRows ? { maxHeight: rowsHeight(maxRows) } : null),
57
+ ...style,
58
+ };
59
+
60
+ return (
61
+ <Field
62
+ data-slot="textarea-field"
63
+ className={cn("gap-1.5", className)}
64
+ data-invalid={error ? true : undefined}
65
+ >
66
+ <FieldLabel htmlFor={inputId} className="font-medium text-sm">
67
+ {label}
68
+ </FieldLabel>
69
+ <Textarea
70
+ id={inputId}
71
+ aria-invalid={error ? true : undefined}
72
+ aria-describedby={counting ? countId : undefined}
73
+ className={textareaClassName}
74
+ style={sizeStyle}
75
+ onChange={(e) => {
76
+ setUncontrolledLength(e.currentTarget.value.length);
77
+ onChange?.(e);
78
+ }}
79
+ {...props}
80
+ />
81
+ {(error || hint || counting) && (
82
+ <div className="flex items-start gap-2">
83
+ {error ? (
84
+ <FieldError className="text-xs">{error}</FieldError>
85
+ ) : (
86
+ hint && <FieldDescription className="text-xs">{hint}</FieldDescription>
87
+ )}
88
+ {counting && (
89
+ <span
90
+ className={cn(
91
+ "ml-auto shrink-0 text-xs tabular-nums",
92
+ length >= max ? "text-destructive" : "text-muted-foreground",
93
+ )}
94
+ >
95
+ <span aria-hidden>
96
+ {length}/{max}
97
+ </span>
98
+ <span id={countId} className="sr-only">
99
+ {(text.characterCount ?? "พิมพ์แล้ว {count} จาก {max} ตัวอักษร")
100
+ .replace("{count}", String(length))
101
+ .replace("{max}", String(max))}
102
+ </span>
103
+ </span>
104
+ )}
105
+ </div>
106
+ )}
107
+ </Field>
108
+ );
109
+ }
@@ -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
+ }
@@ -0,0 +1,176 @@
1
+ "use client";
2
+
3
+ import { Skeleton } from "@suphark/ui/components/ui/skeleton";
4
+ import { cn } from "@suphark/ui/lib/utils";
5
+ import type { ReactNode } from "react";
6
+ import { useUiText } from "./ui-text";
7
+
8
+ /** ความกว้างสลับกันให้ดูเป็นข้อความจริง (คงที่ทุก render — ไม่ใช้ random กัน hydration mismatch) */
9
+ const WIDTHS = ["w-3/4", "w-1/2", "w-2/3", "w-5/6", "w-2/5"] as const;
10
+ const widthAt = (i: number) => WIDTHS[i % WIDTHS.length];
11
+
12
+ const GRID_COLS: Record<number, string> = {
13
+ 1: "",
14
+ 2: "sm:grid-cols-2",
15
+ 3: "sm:grid-cols-2 lg:grid-cols-3",
16
+ 4: "sm:grid-cols-2 lg:grid-cols-4",
17
+ };
18
+
19
+ /** กล่องนอกของ skeleton: role=status + ข้อความ "กำลังโหลด…" สำหรับ screen reader */
20
+ function LoadingRegion({
21
+ slot,
22
+ label,
23
+ className,
24
+ children,
25
+ }: {
26
+ slot: string;
27
+ label?: string;
28
+ className?: string;
29
+ children: ReactNode;
30
+ }) {
31
+ const text = useUiText().loading ?? "กำลังโหลด…";
32
+ return (
33
+ <div data-slot={slot} role="status" aria-busy="true" className={className}>
34
+ <span className="sr-only">{label ?? text}</span>
35
+ {children}
36
+ </div>
37
+ );
38
+ }
39
+
40
+ /**
41
+ * โครงตารางระหว่างโหลด (loading.tsx ของหน้ารายการ) — หัวตาราง + แถวตามจำนวน คอลัมน์แรกกว้างกว่า
42
+ *
43
+ * export default function Loading() { return <TableSkeleton rows={8} columns={5} /> }
44
+ */
45
+ export function TableSkeleton({
46
+ rows = 5,
47
+ columns = 4,
48
+ header = true,
49
+ label,
50
+ className,
51
+ }: {
52
+ rows?: number;
53
+ columns?: number;
54
+ /** แสดงแถวหัวตาราง */
55
+ header?: boolean;
56
+ /** ข้อความ screen reader (ค่าเริ่มต้น UiText.loading) */
57
+ label?: string;
58
+ className?: string;
59
+ }) {
60
+ const template = { gridTemplateColumns: `2fr repeat(${Math.max(columns - 1, 0)}, 1fr)` };
61
+ const cols = Array.from({ length: columns }, (_, c) => c);
62
+ return (
63
+ <LoadingRegion
64
+ slot="table-skeleton"
65
+ label={label}
66
+ className={cn("overflow-hidden rounded-lg border", className)}
67
+ >
68
+ {header && (
69
+ <div className="grid items-center gap-4 border-b bg-muted/50 px-3 py-2.5" style={template}>
70
+ {cols.map((c) => (
71
+ <Skeleton key={c} className="h-3.5 w-1/2" />
72
+ ))}
73
+ </div>
74
+ )}
75
+ {Array.from({ length: rows }, (_, r) => (
76
+ <div
77
+ // biome-ignore lint/suspicious/noArrayIndexKey: แถวโครงคงที่ ไม่มีการเรียงใหม่
78
+ key={r}
79
+ className="grid items-center gap-4 border-b px-3 py-3 last:border-b-0"
80
+ style={template}
81
+ >
82
+ {cols.map((c) => (
83
+ <Skeleton key={c} className={cn("h-4", widthAt(r + c))} />
84
+ ))}
85
+ </div>
86
+ ))}
87
+ </LoadingRegion>
88
+ );
89
+ }
90
+
91
+ /**
92
+ * โครงรายการระหว่างโหลด — avatar (เลือกได้) + ข้อความ 1–2 บรรทัดต่อแถว
93
+ *
94
+ * <ListSkeleton rows={4} avatar lines={2} />
95
+ */
96
+ export function ListSkeleton({
97
+ rows = 5,
98
+ avatar = true,
99
+ lines = 2,
100
+ label,
101
+ className,
102
+ }: {
103
+ rows?: number;
104
+ avatar?: boolean;
105
+ /** จำนวนบรรทัดข้อความต่อแถว (1 หรือ 2) */
106
+ lines?: 1 | 2;
107
+ label?: string;
108
+ className?: string;
109
+ }) {
110
+ return (
111
+ <LoadingRegion
112
+ slot="list-skeleton"
113
+ label={label}
114
+ className={cn("flex flex-col gap-4", className)}
115
+ >
116
+ {Array.from({ length: rows }, (_, r) => (
117
+ // biome-ignore lint/suspicious/noArrayIndexKey: แถวโครงคงที่ ไม่มีการเรียงใหม่
118
+ <div key={r} className="flex items-center gap-3">
119
+ {avatar && <Skeleton className="size-9 shrink-0 rounded-full" />}
120
+ <div className="flex min-w-0 flex-1 flex-col gap-2">
121
+ <Skeleton className={cn("h-4", widthAt(r))} />
122
+ {lines === 2 && <Skeleton className={cn("h-3", widthAt(r + 2))} />}
123
+ </div>
124
+ </div>
125
+ ))}
126
+ </LoadingRegion>
127
+ );
128
+ }
129
+
130
+ /**
131
+ * โครงการ์ดระหว่างโหลด — การ์ดตัวเลข (`variant="stat"` ค่าเริ่มต้น แบบ StatGrid) หรือการ์ดเนื้อหา (`variant="content"`)
132
+ *
133
+ * <CardSkeleton count={4} /> <CardSkeleton count={3} variant="content" />
134
+ */
135
+ export function CardSkeleton({
136
+ count = 3,
137
+ variant = "stat",
138
+ label,
139
+ className,
140
+ }: {
141
+ count?: number;
142
+ variant?: "stat" | "content";
143
+ label?: string;
144
+ className?: string;
145
+ }) {
146
+ return (
147
+ <LoadingRegion
148
+ slot="card-skeleton"
149
+ label={label}
150
+ className={cn("grid gap-4", GRID_COLS[Math.min(Math.max(count, 1), 4)], className)}
151
+ >
152
+ {Array.from({ length: count }, (_, i) => (
153
+ <div
154
+ // biome-ignore lint/suspicious/noArrayIndexKey: การ์ดโครงคงที่ ไม่มีการเรียงใหม่
155
+ key={i}
156
+ className="flex flex-col gap-3 rounded-xl border bg-card p-4"
157
+ >
158
+ {variant === "stat" ? (
159
+ <>
160
+ <Skeleton className="h-3.5 w-1/2" />
161
+ <Skeleton className="h-7 w-2/3" />
162
+ <Skeleton className="h-3 w-1/3" />
163
+ </>
164
+ ) : (
165
+ <>
166
+ <Skeleton className="h-5 w-2/3" />
167
+ <Skeleton className="h-3.5 w-full" />
168
+ <Skeleton className={cn("h-3.5", widthAt(i))} />
169
+ <Skeleton className="mt-2 h-8 w-24" />
170
+ </>
171
+ )}
172
+ </div>
173
+ ))}
174
+ </LoadingRegion>
175
+ );
176
+ }
@@ -83,6 +83,12 @@ export interface UiText {
83
83
  chat?: Partial<{ placeholder: string; send: string; typing: string; scrollToLatest: string }>;
84
84
  /** SimplePagination (optional) */
85
85
  pagination?: Partial<{ previous: string; next: string; label: string; page: string }>;
86
+ /** TableSkeleton/ListSkeleton/CardSkeleton (optional) — ข้อความสำหรับ screen reader */
87
+ loading?: string;
88
+ /** สถานะออนไลน์ของ UserIdentity/Initials (optional) */
89
+ presence?: Partial<{ online: string; away: string; busy: string; offline: string }>;
90
+ /** TextareaField (optional) — ข้อความ screen reader ของตัวนับ ใช้ {count} {max} */
91
+ characterCount?: string;
86
92
  }
87
93
 
88
94
  export const UI_TEXT_TH: UiText = {
@@ -147,6 +153,9 @@ export const UI_TEXT_TH: UiText = {
147
153
  scrollToLatest: "ไปข้อความล่าสุด",
148
154
  },
149
155
  pagination: { previous: "ก่อนหน้า", next: "ถัดไป", label: "แบ่งหน้า", page: "หน้า" },
156
+ loading: "กำลังโหลด…",
157
+ presence: { online: "ออนไลน์", away: "ไม่อยู่", busy: "ไม่ว่าง", offline: "ออฟไลน์" },
158
+ characterCount: "พิมพ์แล้ว {count} จาก {max} ตัวอักษร",
150
159
  };
151
160
 
152
161
  export const UI_TEXT_EN: UiText = {
@@ -211,6 +220,9 @@ export const UI_TEXT_EN: UiText = {
211
220
  scrollToLatest: "Jump to latest",
212
221
  },
213
222
  pagination: { previous: "Previous", next: "Next", label: "Pagination", page: "Page" },
223
+ loading: "Loading…",
224
+ presence: { online: "Online", away: "Away", busy: "Busy", offline: "Offline" },
225
+ characterCount: "{count} of {max} characters",
214
226
  };
215
227
 
216
228
  const UiTextContext = createContext<UiText>(UI_TEXT_TH);
@@ -0,0 +1,68 @@
1
+ import { AvatarGroup, AvatarGroupCount } from "@suphark/ui/components/ui/avatar";
2
+ import { Initials } from "./initials";
3
+ import type { Presence } from "./presence-dot";
4
+
5
+ export interface UserAvatarItem {
6
+ /** key ของ React (ไม่ส่ง = ใช้ชื่อ) */
7
+ id?: string;
8
+ name: string | null | undefined;
9
+ src?: string | null;
10
+ status?: Presence;
11
+ }
12
+
13
+ /**
14
+ * รูปผู้ใช้ซ้อนกัน + "+N" — ผู้รับผิดชอบงาน, สมาชิกทีม, คนที่ดูอยู่ (แทน AvatarGroup + Avatar + AvatarImage + AvatarFallback + AvatarGroupCount)
15
+ * ไม่มีรูป = ตัวย่อชื่อสีคงที่ต่อชื่อ; ชี้ที่รูปเห็นชื่อ, ที่ "+N" เห็นชื่อที่เหลือ; screen reader อ่านชื่อทั้งหมด
16
+ * ใช้จาก server component ได้
17
+ *
18
+ * <UserAvatars users={task.assignees} max={3} size="sm" />
19
+ */
20
+ export function UserAvatars({
21
+ users,
22
+ max = 4,
23
+ size = "default",
24
+ label,
25
+ className,
26
+ }: {
27
+ users: ReadonlyArray<UserAvatarItem>;
28
+ /** จำนวนรูปที่แสดงก่อนรวบเป็น "+N" (นับรวมช่อง +N) */
29
+ max?: number;
30
+ size?: "sm" | "default" | "lg";
31
+ /** ข้อความนำหน้ารายชื่อสำหรับ screen reader เช่น "ผู้รับผิดชอบ" */
32
+ label?: string;
33
+ className?: string;
34
+ }) {
35
+ const overflow = users.length > max;
36
+ const shown = overflow ? users.slice(0, Math.max(max - 1, 1)) : users;
37
+ const rest = users.slice(shown.length);
38
+ const names = users.map((u) => u.name?.trim() || "—").join(", ");
39
+ return (
40
+ <AvatarGroup
41
+ data-slot="user-avatars"
42
+ role="group"
43
+ aria-label={label ? `${label}: ${names}` : names}
44
+ className={className}
45
+ >
46
+ {shown.map((u, i) => (
47
+ <Initials
48
+ key={u.id ?? `${u.name ?? ""}-${i}`}
49
+ name={u.name}
50
+ src={u.src}
51
+ status={u.status}
52
+ size={size}
53
+ title={u.name ?? undefined}
54
+ aria-hidden
55
+ />
56
+ ))}
57
+ {rest.length > 0 && (
58
+ <AvatarGroupCount
59
+ aria-hidden
60
+ title={rest.map((u) => u.name?.trim() || "—").join(", ")}
61
+ className="text-label font-medium"
62
+ >
63
+ +{rest.length}
64
+ </AvatarGroupCount>
65
+ )}
66
+ </AvatarGroup>
67
+ );
68
+ }
@@ -1,6 +1,7 @@
1
1
  import { Initials } from "@suphark/ui/components/shared/initials";
2
2
  import { cn } from "@suphark/ui/lib/utils";
3
3
  import type { ReactNode } from "react";
4
+ import type { Presence } from "./presence-dot";
4
5
 
5
6
  export interface UserIdentityProps {
6
7
  name: string | null | undefined;
@@ -14,6 +15,8 @@ export interface UserIdentityProps {
14
15
  hint?: ReactNode;
15
16
  /** sm = ใช้ในแถวตาราง (avatar size-7), default = size-9 */
16
17
  size?: "sm" | "default";
18
+ /** จุดสถานะบน avatar: online / away / busy / offline */
19
+ status?: Presence;
17
20
  /** ซ่อน avatar (เมื่อแถวมีรูปอยู่แล้ว) */
18
21
  hideAvatar?: boolean;
19
22
  className?: string;
@@ -30,6 +33,7 @@ export function UserIdentity({
30
33
  badge,
31
34
  hint,
32
35
  size = "default",
36
+ status,
33
37
  hideAvatar = false,
34
38
  className,
35
39
  }: UserIdentityProps) {
@@ -43,6 +47,7 @@ export function UserIdentity({
43
47
  <Initials
44
48
  name={displayName ?? (typeof email === "string" ? email : "")}
45
49
  src={src}
50
+ status={status}
46
51
  className={size === "sm" ? "size-7" : "size-9"}
47
52
  />
48
53
  )}
package/src/index.ts CHANGED
@@ -100,6 +100,7 @@ export {
100
100
  type SimpleSelectProps,
101
101
  } from "./components/shared/form/simple-select";
102
102
  export { SliderField, type SliderFieldProps } from "./components/shared/form/slider-field";
103
+ export { TextareaField } from "./components/shared/form/textarea-field";
103
104
  export {
104
105
  type FormAction,
105
106
  FormMessage,
@@ -142,6 +143,7 @@ export { Notice, type NoticeTone } from "./components/shared/notice";
142
143
  export { type DecimalLike, FormattedNumber } from "./components/shared/number/formatted-number";
143
144
  export { PageHeader } from "./components/shared/page-header";
144
145
  export { PageSkeleton } from "./components/shared/page-skeleton";
146
+ export { type Presence, PresenceDot } from "./components/shared/presence-dot";
145
147
  export { ProgressBar, type ProgressTone } from "./components/shared/progress-bar";
146
148
  export {
147
149
  describeCondition,
@@ -180,6 +182,7 @@ export {
180
182
  SimpleTabs,
181
183
  } from "./components/shared/simple-tabs";
182
184
  export { SimplePopover, SimpleTooltip } from "./components/shared/simple-tooltip";
185
+ export { CardSkeleton, ListSkeleton, TableSkeleton } from "./components/shared/skeletons";
183
186
  export { SkipLink } from "./components/shared/skip-link";
184
187
  export { Stat, type StatDelta, StatGrid, type StatTone } from "./components/shared/stat";
185
188
  export { StatusBadge, statusTone, type Tone } from "./components/shared/status-badge";
@@ -206,6 +209,7 @@ export {
206
209
  type UploadListText,
207
210
  type UploadStatus,
208
211
  } from "./components/shared/upload-list";
212
+ export { type UserAvatarItem, UserAvatars } from "./components/shared/user-avatars";
209
213
  export { UserIdentity, type UserIdentityProps } from "./components/shared/user-identity";
210
214
  // ---------- shadcn/ui primitives ----------
211
215
  export * from "./components/ui/accordion";