@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 +15 -0
- package/package.json +1 -1
- package/src/components/shared/auth-layout.tsx +6 -1
- package/src/components/shared/form/textarea-field.tsx +109 -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/ui-text.tsx +12 -0
- package/src/components/shared/user-avatars.tsx +68 -0
- package/src/components/shared/user-identity.tsx +5 -0
- package/src/index.ts +4 -0
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.
|
|
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
|
|
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> & {
|
|
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
|
+
}
|
|
@@ -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";
|