@suphark/ui 0.20.1 → 0.22.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,27 @@
2
2
 
3
3
  รูปแบบตาม [Keep a Changelog](https://keepachangelog.com/) · semver: patch = หน้าตา/บั๊ก, minor = component/prop ใหม่, major = เปลี่ยน/ลบ props
4
4
 
5
+ ## [0.22.0] — 2026-10-02
6
+
7
+ ### Added
8
+
9
+ - `ProviderButtons`: prop `text` ต่อ provider (ข้อความเต็มของปุ่มแทน "ดำเนินการต่อด้วย {label}" เช่น ชื่อ SSO ยาว "Estar (Microsoft 365)"); ปุ่มเต็มกว้าง (1 ตัว / `layout="list"`) **ขึ้นบรรทัดใหม่แทนตัดเป็น …** (badge ไม่หด); ตาราง 2 คอลัมน์ยังตัด แต่ `aria-label`/`title` เป็นข้อความเต็ม — ขอจาก Pi Auth
10
+ - showcase: demo `/components/auth-layout` ปุ่ม SSO ใช้ `text` ยาว
11
+
12
+ ## [0.21.0] — 2026-10-02
13
+
14
+ ตัวสำเร็จรูปของ primitive ใหม่จาก shadcn (0.20.0) — เพิ่มอย่างเดียว
15
+
16
+ ### Added
17
+
18
+ - **`ChatThread`** — หน้าแชตจาก array ข้อความ (แทน MessageScrollerProvider/MessageScroller/Viewport/Content/Item/Button + Message/MessageAvatar/MessageContent/MessageHeader/MessageFooter + Bubble/BubbleContent + Marker + InputGroup รวม 15+ ชิ้นตามตัวอย่างทางการของ shadcn): `messages: { id, from: "me" | "other", content, author?, avatar?, meta? }[]`, เลื่อนตามข้อความใหม่ (ข้อความของเรายึดเป็นจุดเลื่อน), ปุ่ม "ไปข้อความล่าสุด", `typing` (แถว "กำลังพิมพ์…" ด้วย Marker), `onSend` = มีช่องพิมพ์ (Enter ส่ง · Shift+Enter ขึ้นบรรทัด · **ไม่ส่งระหว่างประกอบคำด้วย IME** แบบที่ PDF ทำ `submitOnEnter`), คืน Promise = ปุ่มหมุนแล้วล้างช่องเมื่อสำเร็จ, `empty`, `disabled`
19
+ - **`SimpleQuestionnaire`** — แบบสอบถามทีละข้อจาก array คำถาม (แทน Questionnaire 15 ชิ้น): `{ name, title, description?, required?, multiple?, choices?: { value, label, description?, disabled? }[], input?: boolean | placeholder }`, คีย์ลัดตัวเลข/ตัวอักษร, ข้อบังคับไม่มีปุ่มข้าม, `onSubmit(values)` ได้ object (ข้อเดียว = string, หลายข้อ = string[], ข้อที่ข้ามไม่มี key) ไม่ต้องอ่าน FormData เอง
20
+ - **`AuthLayout`** — หน้าเข้าสู่ระบบ/สมัคร/ยืนยันตัวตนสำเร็จรูปแบบ Clerk (server component ได้): `logo`, `title` (h1), `description`, เนื้อ, `cardFooter` (แถบล่างการ์ดพื้นต่างสี เช่น "ยังไม่มีบัญชี? สมัคร"), `footer` (ใต้การ์ด เช่น "ยืนยันตัวตนโดย Pi Auth · ข้อกำหนด"), `actions` (มุมขวาบน ธีม/ภาษา), `variant` centered (พื้นลายจุดจาง) | split (ฝั่งซ้าย `aside` สีแบรนด์ แบบ shadcn login blocks — มือถือซ่อน), `brandColor`/`brandForeground` (สีแบรนด์ของ tenant), `fullScreen`
21
+ - **`ProviderButtons`** (Google/Microsoft/SSO: 1 ตัว = "ดำเนินการต่อด้วย …" เต็มกว้าง, 2+ ตัว = ตาราง 2 คอลัมน์, `badge` เช่น "ใช้ล่าสุด", `href` หรือ `onClick`, `pendingId` กันกดซ้ำ), **`AuthDivider`** ("หรือ"), **`PasswordInput`** (ปุ่มแสดง/ซ่อน + `aria-pressed`, `autoComplete` ค่าเริ่มต้น `current-password` ตามแนวทาง web.dev)
22
+ - showcase: หน้า `/login` เปลี่ยนเป็น `AuthLayout` (โลโก้, ปุ่ม "ดำเนินการต่อด้วย Pi Auth", แถบล่าง, "ยืนยันตัวตนโดย Pi Auth", ปุ่มธีม) + หน้า `/components/auth-layout` (ฟอร์มเต็มทั้งสองแบบ)
23
+ - `UiText` key ใหม่: `questionnaire` (ปุ่ม/ความคืบหน้า "ข้อ {current} จาก {total}"), `chat`, `auth`
24
+ - showcase: หน้า `/components/{chat-thread,simple-questionnaire,marker}` (122 หน้ารวม auth-layout); tests 7 กรณี (รวม 192)
25
+
5
26
  ## [0.20.1] — 2026-10-01
6
27
 
7
28
  ### Fixed
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@suphark/ui",
3
- "version": "0.20.1",
3
+ "version": "0.22.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 เอง",
@@ -0,0 +1,139 @@
1
+ import { cn } from "@suphark/ui/lib/utils";
2
+ import type { CSSProperties, ReactNode } from "react";
3
+
4
+ export interface AuthLayoutProps {
5
+ /** โลโก้เหนือหัวข้อ — element (`<img>`/ไอคอน) หรือไม่ส่ง = ไม่มี */
6
+ logo?: ReactNode;
7
+ /** หัวข้อ เช่น "เข้าสู่ระบบ Acme" */
8
+ title: ReactNode;
9
+ /** คำอธิบายใต้หัวข้อ เช่น "ยินดีต้อนรับกลับ — เข้าสู่ระบบเพื่อดำเนินการต่อ" */
10
+ description?: ReactNode;
11
+ /** เนื้อการ์ด: ปุ่ม provider, AuthDivider, ฟอร์ม, Notice */
12
+ children: ReactNode;
13
+ /** แถบล่างของการ์ด (พื้นต่างสีแบบ Clerk) เช่น "ยังไม่มีบัญชี? สมัคร" */
14
+ cardFooter?: ReactNode;
15
+ /** ใต้การ์ด เช่น "ยืนยันตัวตนโดย Pi Auth · ความเป็นส่วนตัว · ข้อกำหนด" */
16
+ footer?: ReactNode;
17
+ /** มุมขวาบนของหน้า เช่น `<LanguageSwitch />` `<ThemeSwitch />` */
18
+ actions?: ReactNode;
19
+ /**
20
+ * centered (ค่าเริ่มต้น) = การ์ดกลางจอแบบ Clerk
21
+ * split = แบ่งสองฝั่ง ฝั่งซ้ายเป็น `aside` (แบรนด์/ภาพ/คำรับรอง) แบบตัวอย่าง login ของ shadcn — มือถือซ่อนฝั่งซ้าย
22
+ */
23
+ variant?: "centered" | "split";
24
+ /** เนื้อฝั่งซ้ายของ variant split */
25
+ aside?: ReactNode;
26
+ /** สีแบรนด์ของ tenant/แอป (CSS color) → primary/ring ในหน้านี้เปลี่ยนตาม */
27
+ brandColor?: string | null;
28
+ brandForeground?: string | null;
29
+ /** เต็มความสูงจอ (ค่าเริ่มต้น true) — false ใช้ฝังในกรอบ เช่นหน้าเอกสาร */
30
+ fullScreen?: boolean;
31
+ className?: string;
32
+ }
33
+
34
+ /**
35
+ * โครงหน้าเข้าสู่ระบบ/สมัคร/ยืนยันตัวตนสำเร็จรูป (แบบ Clerk / shadcn login blocks):
36
+ * โลโก้ + หัวข้อ + คำอธิบาย + เนื้อ + แถบล่างของการ์ด + ลิงก์ใต้การ์ด + ปุ่มธีม/ภาษามุมขวาบน
37
+ * ใช้ได้จาก server component (ไม่มี state) — ใช้ร่วมกันทั้ง Pi Auth (sign-in/sign-up/2FA) และหน้า `/login` ของทุกแอป
38
+ *
39
+ * <AuthLayout logo={<Logo />} title="เข้าสู่ระบบ Acme" description="ยินดีต้อนรับกลับ"
40
+ * actions={<ThemeSwitch />} cardFooter={<>ยังไม่มีบัญชี? <a href="/sign-up">สมัคร</a></>}
41
+ * footer="ยืนยันตัวตนโดย Pi Auth">
42
+ * <ProviderButtons providers={[…]} />
43
+ * <AuthDivider />
44
+ * <form>…</form>
45
+ * </AuthLayout>
46
+ */
47
+ export function AuthLayout({
48
+ logo,
49
+ title,
50
+ description,
51
+ children,
52
+ cardFooter,
53
+ footer,
54
+ actions,
55
+ variant = "centered",
56
+ aside,
57
+ brandColor,
58
+ brandForeground,
59
+ fullScreen = true,
60
+ className,
61
+ }: AuthLayoutProps) {
62
+ const style = brandColor
63
+ ? ({
64
+ "--brand": brandColor,
65
+ ...(brandForeground ? { "--brand-foreground": brandForeground } : {}),
66
+ } as CSSProperties)
67
+ : undefined;
68
+
69
+ const card = (
70
+ <div className="flex w-full max-w-sm flex-col gap-6">
71
+ <div
72
+ data-slot="auth-card"
73
+ className="overflow-hidden rounded-2xl border bg-card text-card-foreground shadow-lg shadow-foreground/5"
74
+ >
75
+ <div className="flex flex-col gap-6 px-6 py-8 sm:px-8">
76
+ <header className="flex flex-col items-center gap-3 text-center">
77
+ {logo && (
78
+ <div className="flex size-11 items-center justify-center overflow-hidden rounded-xl [&_img]:size-full [&_img]:object-contain [&_svg]:size-6">
79
+ {logo}
80
+ </div>
81
+ )}
82
+ <div className="flex flex-col gap-1">
83
+ <h1 className="text-balance font-semibold text-title tracking-tight">{title}</h1>
84
+ {description && (
85
+ <p className="text-balance text-caption text-muted-foreground">{description}</p>
86
+ )}
87
+ </div>
88
+ </header>
89
+ <div className="flex flex-col gap-4">{children}</div>
90
+ </div>
91
+ {cardFooter && (
92
+ <div className="border-t bg-muted/50 px-6 py-4 text-center text-caption text-muted-foreground sm:px-8 [&_a]:font-medium [&_a]:text-foreground [&_a]:underline-offset-4 hover:[&_a]:underline">
93
+ {cardFooter}
94
+ </div>
95
+ )}
96
+ </div>
97
+ {footer && (
98
+ <div className="flex flex-wrap items-center justify-center gap-x-3 gap-y-1 text-center text-label text-muted-foreground [&_a]:underline-offset-4 hover:[&_a]:text-foreground hover:[&_a]:underline">
99
+ {footer}
100
+ </div>
101
+ )}
102
+ </div>
103
+ );
104
+
105
+ return (
106
+ <main
107
+ data-slot="auth-layout"
108
+ data-variant={variant}
109
+ style={style}
110
+ className={cn(
111
+ "relative flex w-full bg-muted/40",
112
+ fullScreen ? "min-h-svh" : "min-h-[36rem]",
113
+ variant === "split" && "lg:grid lg:grid-cols-2",
114
+ className,
115
+ )}
116
+ >
117
+ {variant === "split" && (
118
+ <aside className="relative hidden flex-col justify-between overflow-hidden bg-primary p-10 text-primary-foreground lg:flex">
119
+ {aside}
120
+ </aside>
121
+ )}
122
+ <div className="relative flex flex-1 flex-col items-center justify-center px-4 py-12 sm:py-16">
123
+ {/* ลายจุดจาง ๆ ด้านหลังการ์ด — ใช้ token สีเส้นขอบ จึงสลับโหมดมืดเอง */}
124
+ {variant === "centered" && (
125
+ <div
126
+ aria-hidden
127
+ className="pointer-events-none absolute inset-0 bg-[radial-gradient(var(--border)_1px,transparent_1px)] mask-[radial-gradient(ellipse_at_center,black_30%,transparent_75%)] bg-size-[20px_20px]"
128
+ />
129
+ )}
130
+ {actions && (
131
+ <div className="absolute top-4 right-4 flex items-center gap-1 rounded-full border bg-background/80 px-1.5 py-1 shadow-xs backdrop-blur">
132
+ {actions}
133
+ </div>
134
+ )}
135
+ <div className="relative flex w-full justify-center">{card}</div>
136
+ </div>
137
+ </main>
138
+ );
139
+ }
@@ -0,0 +1,149 @@
1
+ "use client";
2
+
3
+ import { Button } from "@suphark/ui/components/ui/button";
4
+ import {
5
+ InputGroup,
6
+ InputGroupAddon,
7
+ InputGroupButton,
8
+ InputGroupInput,
9
+ } from "@suphark/ui/components/ui/input-group";
10
+ import { cn } from "@suphark/ui/lib/utils";
11
+ import { EyeIcon, EyeOffIcon } from "lucide-react";
12
+ import { type ComponentProps, type ReactNode, useState } from "react";
13
+ import { useUiText } from "./ui-text";
14
+
15
+ /** เส้นคั่น "หรือ" ระหว่างปุ่ม provider กับฟอร์มอีเมล */
16
+ export function AuthDivider({ children, className }: { children?: ReactNode; className?: string }) {
17
+ const text = useUiText().auth;
18
+ return (
19
+ <div
20
+ data-slot="auth-divider"
21
+ className={cn("flex items-center gap-3 text-label text-muted-foreground", className)}
22
+ >
23
+ <span className="h-px flex-1 bg-border" />
24
+ {children ?? text?.or ?? "หรือ"}
25
+ <span className="h-px flex-1 bg-border" />
26
+ </div>
27
+ );
28
+ }
29
+
30
+ export interface AuthProvider {
31
+ id: string;
32
+ /** ชื่อ provider เช่น "Google" — ปุ่มแสดง "ดำเนินการต่อด้วย Google" (UiText.auth.continueWith) */
33
+ label: string;
34
+ /** ข้อความเต็มของปุ่มแทน "ดำเนินการต่อด้วย {label}" (ทุก layout) เช่น "Estar (Microsoft 365)" */
35
+ text?: ReactNode;
36
+ /** โลโก้ของ provider (svg) */
37
+ icon?: ReactNode;
38
+ onClick?: () => void;
39
+ /** ลิงก์แทน onClick (เช่น /api/auth/sign-in/google) */
40
+ href?: string;
41
+ /** ป้ายท้ายปุ่ม เช่น "ใช้ล่าสุด" */
42
+ badge?: ReactNode;
43
+ disabled?: boolean;
44
+ }
45
+
46
+ /**
47
+ * ปุ่มเข้าสู่ระบบด้วย provider (Google / Microsoft / SSO ขององค์กร)
48
+ * 1 ตัว = ปุ่มเต็มกว้างพร้อมข้อความ · 2 ตัวขึ้นไป = ตาราง 2 คอลัมน์ (`layout="list"` บังคับเรียงลงพร้อมข้อความเต็ม)
49
+ * `pendingId` = ตัวที่กำลังพาไป (ปุ่มนั้นหมุน ตัวอื่นปิด กันกดซ้ำ)
50
+ * ปุ่มเต็มกว้าง (1 ตัว / list) ข้อความยาวขึ้นบรรทัดใหม่ได้ ไม่ตัดทิ้ง · ตาราง 2 คอลัมน์ตัดด้วย … (ชื่อเต็มอยู่ใน aria-label/title)
51
+ */
52
+ export function ProviderButtons({
53
+ providers,
54
+ pendingId,
55
+ layout = "auto",
56
+ className,
57
+ }: {
58
+ providers: ReadonlyArray<AuthProvider>;
59
+ pendingId?: string | null;
60
+ layout?: "auto" | "list";
61
+ className?: string;
62
+ }) {
63
+ const text = useUiText().auth;
64
+ const grid = layout === "auto" && providers.length >= 2;
65
+ return (
66
+ <div className={cn("grid gap-2", grid ? "grid-cols-2" : "grid-cols-1", className)}>
67
+ {providers.map((p) => {
68
+ const full = (text?.continueWith ?? "ดำเนินการต่อด้วย {provider}").replace(
69
+ "{provider}",
70
+ p.label,
71
+ );
72
+ const label = p.text ?? (grid ? p.label : full);
73
+ const content = (
74
+ <>
75
+ {p.icon}
76
+ <span className={grid ? "truncate" : "min-w-0 text-balance"}>{label}</span>
77
+ {p.badge && (
78
+ <span className="ml-auto shrink-0 text-label text-muted-foreground">{p.badge}</span>
79
+ )}
80
+ </>
81
+ );
82
+ // ปุ่มเต็มกว้าง: ให้ข้อความยาวขึ้นบรรทัดใหม่ได้ (Button ตั้ง whitespace-nowrap + h-8)
83
+ const buttonClass = grid ? "w-full" : "h-auto min-h-8 w-full py-1.5 whitespace-normal";
84
+ const a11y = grid ? { "aria-label": full, title: full } : {};
85
+ const busy = pendingId === p.id;
86
+ const disabled = p.disabled || (pendingId != null && !busy);
87
+ return p.href ? (
88
+ <Button
89
+ key={p.id}
90
+ variant="outline"
91
+ className={buttonClass}
92
+ disabled={disabled}
93
+ nativeButton={false}
94
+ render={<a href={p.href} {...a11y} />}
95
+ >
96
+ {content}
97
+ </Button>
98
+ ) : (
99
+ <Button
100
+ key={p.id}
101
+ type="button"
102
+ variant="outline"
103
+ className={buttonClass}
104
+ disabled={disabled || busy}
105
+ {...a11y}
106
+ onClick={p.onClick}
107
+ >
108
+ {content}
109
+ </Button>
110
+ );
111
+ })}
112
+ </div>
113
+ );
114
+ }
115
+
116
+ /**
117
+ * ช่องรหัสผ่านพร้อมปุ่มแสดง/ซ่อน (แนวทาง web.dev: ให้ผู้ใช้ตรวจสิ่งที่พิมพ์ได้)
118
+ * `autoComplete` ค่าเริ่มต้น "current-password" — หน้าสมัคร/ตั้งรหัสใหม่ส่ง "new-password"
119
+ */
120
+ export function PasswordInput({
121
+ autoComplete = "current-password",
122
+ className,
123
+ ...props
124
+ }: Omit<ComponentProps<typeof InputGroupInput>, "type">) {
125
+ const text = useUiText().auth;
126
+ const [visible, setVisible] = useState(false);
127
+ return (
128
+ <InputGroup className={className}>
129
+ <InputGroupInput
130
+ type={visible ? "text" : "password"}
131
+ autoComplete={autoComplete}
132
+ {...props}
133
+ />
134
+ <InputGroupAddon align="inline-end">
135
+ <InputGroupButton
136
+ type="button"
137
+ size="icon-xs"
138
+ aria-label={
139
+ visible ? (text?.hidePassword ?? "ซ่อนรหัสผ่าน") : (text?.showPassword ?? "แสดงรหัสผ่าน")
140
+ }
141
+ aria-pressed={visible}
142
+ onClick={() => setVisible((v) => !v)}
143
+ >
144
+ {visible ? <EyeOffIcon /> : <EyeIcon />}
145
+ </InputGroupButton>
146
+ </InputGroupAddon>
147
+ </InputGroup>
148
+ );
149
+ }
@@ -0,0 +1,205 @@
1
+ "use client";
2
+
3
+ import { Bubble, BubbleContent } from "@suphark/ui/components/ui/bubble";
4
+ import {
5
+ InputGroup,
6
+ InputGroupAddon,
7
+ InputGroupButton,
8
+ InputGroupTextarea,
9
+ } from "@suphark/ui/components/ui/input-group";
10
+ import { Marker, MarkerContent, MarkerIcon } from "@suphark/ui/components/ui/marker";
11
+ import {
12
+ Message,
13
+ MessageAvatar,
14
+ MessageContent,
15
+ MessageFooter,
16
+ MessageHeader,
17
+ } from "@suphark/ui/components/ui/message";
18
+ import {
19
+ MessageScroller,
20
+ MessageScrollerButton,
21
+ MessageScrollerContent,
22
+ MessageScrollerItem,
23
+ MessageScrollerProvider,
24
+ MessageScrollerViewport,
25
+ } from "@suphark/ui/components/ui/message-scroller";
26
+ import { Spinner } from "@suphark/ui/components/ui/spinner";
27
+ import { cn } from "@suphark/ui/lib/utils";
28
+ import { ArrowDown, SendHorizontal } from "lucide-react";
29
+ import { type ReactNode, useState } from "react";
30
+ import { useUiText } from "./ui-text";
31
+
32
+ export interface ChatMessage {
33
+ id: string;
34
+ /** "me" = ข้อความของผู้ใช้ (ชิดขวา สี primary), อื่น ๆ ชิดซ้าย */
35
+ from: "me" | "other";
36
+ content: ReactNode;
37
+ /** ชื่อผู้ส่งเหนือข้อความ (ห้องที่มีหลายคน) */
38
+ author?: ReactNode;
39
+ /** รูป/อักษรย่อ เช่น `<Initials name="…" />` */
40
+ avatar?: ReactNode;
41
+ /** ใต้ข้อความ เช่นเวลา / "อ่านแล้ว" */
42
+ meta?: ReactNode;
43
+ }
44
+
45
+ /**
46
+ * หน้าแชตสำเร็จรูป (ความเห็นในงาน, แชตกับผู้ช่วย, ข้อความถึงฝ่ายสนับสนุน) จาก array ของข้อความ
47
+ * แทน MessageScroller + Message + Bubble + Marker + InputGroup 10+ ชิ้น:
48
+ * เลื่อนตามข้อความใหม่ให้เอง (ข้อความของเรายึดเป็นจุดเลื่อน), ปุ่ม "ไปข้อความล่าสุด" เมื่อเลื่อนขึ้นไปอ่าน,
49
+ * `typing` แสดงแถว "กำลังพิมพ์…", ส่ง `onSend` = มีช่องพิมพ์ (Enter ส่ง · Shift+Enter ขึ้นบรรทัด · ไม่ส่งระหว่างพิมพ์ไทยด้วย IME)
50
+ * ความสูงกำหนดที่ `className` (เช่น `h-96`) — เนื้อหาเลื่อนภายใน
51
+ *
52
+ * <ChatThread className="h-96" messages={messages} typing={waiting}
53
+ * onSend={async (text) => { await post(text) }} />
54
+ */
55
+ export function ChatThread({
56
+ messages,
57
+ typing = false,
58
+ onSend,
59
+ placeholder,
60
+ disabled = false,
61
+ empty,
62
+ className,
63
+ }: {
64
+ messages: ReadonlyArray<ChatMessage>;
65
+ /** true = แถว "กำลังพิมพ์…" ท้ายรายการ, ReactNode = ข้อความเอง */
66
+ typing?: boolean | ReactNode;
67
+ /** มีค่า = แสดงช่องพิมพ์; คืน Promise = ปุ่มส่งหมุนจนเสร็จ (ล้างช่องเมื่อสำเร็จ) */
68
+ onSend?: (text: string) => void | Promise<void>;
69
+ placeholder?: string;
70
+ disabled?: boolean;
71
+ /** ยังไม่มีข้อความ */
72
+ empty?: ReactNode;
73
+ className?: string;
74
+ }) {
75
+ const text = useUiText().chat;
76
+ return (
77
+ <div
78
+ data-slot="chat-thread"
79
+ className={cn("flex min-h-0 flex-col overflow-hidden rounded-xl border bg-card", className)}
80
+ >
81
+ <div className="relative min-h-0 flex-1">
82
+ <MessageScrollerProvider>
83
+ <MessageScroller className="h-full">
84
+ <MessageScrollerViewport>
85
+ <MessageScrollerContent className="p-4">
86
+ {messages.length === 0 && empty ? (
87
+ <div className="m-auto text-center text-muted-foreground text-sm">{empty}</div>
88
+ ) : null}
89
+ {messages.map((m) => {
90
+ const mine = m.from === "me";
91
+ return (
92
+ <MessageScrollerItem key={m.id} messageId={m.id} scrollAnchor={mine}>
93
+ <Message align={mine ? "end" : "start"}>
94
+ {m.avatar && <MessageAvatar>{m.avatar}</MessageAvatar>}
95
+ <MessageContent>
96
+ {m.author && <MessageHeader>{m.author}</MessageHeader>}
97
+ <Bubble variant={mine ? "default" : "muted"}>
98
+ <BubbleContent className="whitespace-pre-wrap">
99
+ {m.content}
100
+ </BubbleContent>
101
+ </Bubble>
102
+ {m.meta && <MessageFooter>{m.meta}</MessageFooter>}
103
+ </MessageContent>
104
+ </Message>
105
+ </MessageScrollerItem>
106
+ );
107
+ })}
108
+ {typing ? (
109
+ <MessageScrollerItem scrollAnchor={false}>
110
+ <Marker role="status">
111
+ <MarkerIcon>
112
+ <Spinner />
113
+ </MarkerIcon>
114
+ <MarkerContent>
115
+ {typing === true ? (text?.typing ?? "กำลังพิมพ์…") : typing}
116
+ </MarkerContent>
117
+ </Marker>
118
+ </MessageScrollerItem>
119
+ ) : null}
120
+ </MessageScrollerContent>
121
+ </MessageScrollerViewport>
122
+ <MessageScrollerButton aria-label={text?.scrollToLatest ?? "ไปข้อความล่าสุด"}>
123
+ <ArrowDown />
124
+ </MessageScrollerButton>
125
+ </MessageScroller>
126
+ </MessageScrollerProvider>
127
+ </div>
128
+ {onSend && (
129
+ <ChatComposer
130
+ onSend={onSend}
131
+ disabled={disabled}
132
+ placeholder={placeholder ?? text?.placeholder ?? "พิมพ์ข้อความ…"}
133
+ sendLabel={text?.send ?? "ส่ง"}
134
+ />
135
+ )}
136
+ </div>
137
+ );
138
+ }
139
+
140
+ function ChatComposer({
141
+ onSend,
142
+ disabled,
143
+ placeholder,
144
+ sendLabel,
145
+ }: {
146
+ onSend: (text: string) => void | Promise<void>;
147
+ disabled: boolean;
148
+ placeholder: string;
149
+ sendLabel: string;
150
+ }) {
151
+ const [value, setValue] = useState("");
152
+ const [pending, setPending] = useState(false);
153
+ const canSend = value.trim() !== "" && !disabled && !pending;
154
+
155
+ const send = async () => {
156
+ if (!canSend) return;
157
+ setPending(true);
158
+ try {
159
+ await onSend(value.trim());
160
+ setValue("");
161
+ } finally {
162
+ setPending(false);
163
+ }
164
+ };
165
+
166
+ return (
167
+ <form
168
+ className="border-t p-3"
169
+ onSubmit={(e) => {
170
+ e.preventDefault();
171
+ void send();
172
+ }}
173
+ >
174
+ <InputGroup>
175
+ <InputGroupTextarea
176
+ value={value}
177
+ onChange={(e) => setValue(e.target.value)}
178
+ onKeyDown={(e) => {
179
+ // Enter ส่ง, Shift+Enter ขึ้นบรรทัด — ระหว่างประกอบคำด้วย IME (พิมพ์ไทย/จีน) Enter คือยืนยันคำ ไม่ใช่ส่ง
180
+ if (e.key === "Enter" && !e.shiftKey && !e.nativeEvent.isComposing) {
181
+ e.preventDefault();
182
+ void send();
183
+ }
184
+ }}
185
+ placeholder={placeholder}
186
+ disabled={disabled}
187
+ rows={1}
188
+ className="max-h-40 min-h-10"
189
+ aria-label={placeholder}
190
+ />
191
+ <InputGroupAddon align="inline-end">
192
+ <InputGroupButton
193
+ type="submit"
194
+ size="icon-sm"
195
+ variant="default"
196
+ disabled={!canSend}
197
+ aria-label={sendLabel}
198
+ >
199
+ {pending ? <Spinner /> : <SendHorizontal />}
200
+ </InputGroupButton>
201
+ </InputGroupAddon>
202
+ </InputGroup>
203
+ </form>
204
+ );
205
+ }
@@ -0,0 +1,141 @@
1
+ "use client";
2
+
3
+ import {
4
+ Questionnaire,
5
+ QuestionnaireActions,
6
+ QuestionnaireChoice,
7
+ QuestionnaireChoiceDescription,
8
+ QuestionnaireChoices,
9
+ QuestionnaireDescription,
10
+ QuestionnaireError,
11
+ QuestionnaireInput,
12
+ QuestionnaireItem,
13
+ QuestionnaireNext,
14
+ QuestionnairePrevious,
15
+ QuestionnaireProgress,
16
+ QuestionnaireSkip,
17
+ QuestionnaireSubmit,
18
+ QuestionnaireTitle,
19
+ } from "@suphark/ui/components/ui/questionnaire";
20
+ import type { FormEvent, ReactNode } from "react";
21
+ import { useUiText } from "./ui-text";
22
+
23
+ export interface QuestionnaireQuestion {
24
+ /** ชื่อคำตอบ (key ใน values ที่ได้ตอน submit) */
25
+ name: string;
26
+ title: ReactNode;
27
+ description?: ReactNode;
28
+ /** ต้องตอบก่อนไปข้อถัดไป (ปุ่ม "ข้าม" ไม่แสดง) */
29
+ required?: boolean;
30
+ /** เลือกได้หลายข้อ (checkbox) — ไม่ส่ง = เลือกข้อเดียว */
31
+ multiple?: boolean;
32
+ choices?: ReadonlyArray<{
33
+ value: string;
34
+ label: ReactNode;
35
+ description?: ReactNode;
36
+ disabled?: boolean;
37
+ }>;
38
+ /** ช่องพิมพ์คำตอบเอง (ต่อท้ายตัวเลือก หรือเป็นคำตอบเดียวถ้าไม่มี choices) — string = placeholder */
39
+ input?: boolean | string;
40
+ }
41
+
42
+ export type QuestionnaireValues = Record<string, string | string[]>;
43
+
44
+ /**
45
+ * แบบสอบถามทีละข้อสำเร็จรูป (onboarding, สำรวจความพึงพอใจ, ถามความต้องการก่อนสร้าง) จาก array ของคำถาม
46
+ * แทน Questionnaire primitive 15 ชิ้น (Item/Title/Description/Choices/Choice/Input/Error/Progress/Actions/
47
+ * Previous/Skip/Next/Submit) — ตัวเลข/ตัวอักษรเป็นคีย์ลัดเลือกตัวเลือกได้ (`shortcuts`)
48
+ *
49
+ * <SimpleQuestionnaire
50
+ * questions={[{ name: "role", title: "คุณทำงานด้านไหน", required: true,
51
+ * choices: [{ value: "dev", label: "พัฒนา" }, { value: "ops", label: "ปฏิบัติการ" }], input: true }]}
52
+ * onSubmit={(values) => save(values)} />
53
+ */
54
+ export function SimpleQuestionnaire({
55
+ questions,
56
+ onSubmit,
57
+ shortcuts = "numbers",
58
+ submitLabel,
59
+ className,
60
+ }: {
61
+ questions: ReadonlyArray<QuestionnaireQuestion>;
62
+ /** ได้ค่าเป็น object: ข้อเดียว = string, หลายข้อ = string[] (ข้อที่ข้าม = ไม่มี key) */
63
+ onSubmit: (values: QuestionnaireValues) => void;
64
+ shortcuts?: "numbers" | "letters" | "none";
65
+ submitLabel?: string;
66
+ className?: string;
67
+ }) {
68
+ const text = useUiText().questionnaire;
69
+ const items = questions.map((q) => ({
70
+ name: q.name,
71
+ required: q.required,
72
+ choices: q.choices?.map((c) => ({ value: c.value, disabled: c.disabled })),
73
+ }));
74
+
75
+ const handleSubmit = (event: FormEvent<HTMLFormElement>) => {
76
+ event.preventDefault();
77
+ const fd = new FormData(event.currentTarget);
78
+ const values: QuestionnaireValues = {};
79
+ for (const q of questions) {
80
+ const all = fd
81
+ .getAll(q.name)
82
+ .map(String)
83
+ .filter((v) => v.trim() !== "");
84
+ if (all.length === 0) continue;
85
+ values[q.name] = q.multiple ? all : (all[0] as string);
86
+ }
87
+ onSubmit(values);
88
+ };
89
+
90
+ return (
91
+ <Questionnaire
92
+ className={className}
93
+ // base-ui-ok: ข้อแรกตอน mount — หลังจากนั้น primitive เดินข้อเอง
94
+ defaultItem={questions[0]?.name}
95
+ items={items}
96
+ shortcuts={shortcuts === "none" ? undefined : shortcuts}
97
+ onSubmit={handleSubmit}
98
+ >
99
+ <QuestionnaireProgress
100
+ render={(props, state) => (
101
+ <span {...props}>
102
+ {(text?.progress ?? "ข้อ {current} จาก {total}")
103
+ .replace("{current}", String(state.current))
104
+ .replace("{total}", String(state.total))}
105
+ </span>
106
+ )}
107
+ />
108
+ {questions.map((q) => (
109
+ <QuestionnaireItem key={q.name} name={q.name} required={q.required} multiple={q.multiple}>
110
+ <QuestionnaireTitle>{q.title}</QuestionnaireTitle>
111
+ {q.description && <QuestionnaireDescription>{q.description}</QuestionnaireDescription>}
112
+ {(q.choices?.length || q.input) && (
113
+ <QuestionnaireChoices>
114
+ {q.choices?.map((c) => (
115
+ <QuestionnaireChoice key={c.value} value={c.value} disabled={c.disabled}>
116
+ <span className="font-medium">{c.label}</span>
117
+ {c.description && (
118
+ <QuestionnaireChoiceDescription>{c.description}</QuestionnaireChoiceDescription>
119
+ )}
120
+ </QuestionnaireChoice>
121
+ ))}
122
+ {q.input && (
123
+ <QuestionnaireInput
124
+ aria-label={typeof q.title === "string" ? q.title : undefined}
125
+ placeholder={typeof q.input === "string" ? q.input : (text?.other ?? "คำตอบอื่น…")}
126
+ />
127
+ )}
128
+ </QuestionnaireChoices>
129
+ )}
130
+ <QuestionnaireError />
131
+ </QuestionnaireItem>
132
+ ))}
133
+ <QuestionnaireActions className="w-full">
134
+ <QuestionnairePrevious>{text?.previous ?? "ย้อนกลับ"}</QuestionnairePrevious>
135
+ <QuestionnaireSkip>{text?.skip ?? "ข้าม"}</QuestionnaireSkip>
136
+ <QuestionnaireNext>{text?.next ?? "ถัดไป"}</QuestionnaireNext>
137
+ <QuestionnaireSubmit>{submitLabel ?? text?.submit ?? "ส่งคำตอบ"}</QuestionnaireSubmit>
138
+ </QuestionnaireActions>
139
+ </Questionnaire>
140
+ );
141
+ }
@@ -68,6 +68,19 @@ export interface UiText {
68
68
  mobileTabs?: string;
69
69
  /** SimpleDialog/SimpleSheet: ปุ่มปิด (sr-only) (optional — "ปิด") */
70
70
  close?: string;
71
+ /** AuthDivider / ProviderButtons / PasswordInput (optional) — continueWith ใช้ {provider} */
72
+ auth?: Partial<{ or: string; continueWith: string; showPassword: string; hidePassword: string }>;
73
+ /** SimpleQuestionnaire (optional) — progress ใช้ {current}/{total} */
74
+ questionnaire?: Partial<{
75
+ previous: string;
76
+ next: string;
77
+ skip: string;
78
+ submit: string;
79
+ progress: string;
80
+ other: string;
81
+ }>;
82
+ /** ChatThread (optional) */
83
+ chat?: Partial<{ placeholder: string; send: string; typing: string; scrollToLatest: string }>;
71
84
  /** SimplePagination (optional) */
72
85
  pagination?: Partial<{ previous: string; next: string; label: string; page: string }>;
73
86
  }
@@ -113,6 +126,26 @@ export const UI_TEXT_TH: UiText = {
113
126
  settings: { back: "กลับ", empty: "ไม่พบหัวข้อที่ตรงกับคำค้น", label: "หัวข้อการตั้งค่า" },
114
127
  mobileTabs: "เมนูหลัก",
115
128
  close: "ปิด",
129
+ auth: {
130
+ or: "หรือ",
131
+ continueWith: "ดำเนินการต่อด้วย {provider}",
132
+ showPassword: "แสดงรหัสผ่าน",
133
+ hidePassword: "ซ่อนรหัสผ่าน",
134
+ },
135
+ questionnaire: {
136
+ previous: "ย้อนกลับ",
137
+ next: "ถัดไป",
138
+ skip: "ข้าม",
139
+ submit: "ส่งคำตอบ",
140
+ progress: "ข้อ {current} จาก {total}",
141
+ other: "คำตอบอื่น…",
142
+ },
143
+ chat: {
144
+ placeholder: "พิมพ์ข้อความ…",
145
+ send: "ส่ง",
146
+ typing: "กำลังพิมพ์…",
147
+ scrollToLatest: "ไปข้อความล่าสุด",
148
+ },
116
149
  pagination: { previous: "ก่อนหน้า", next: "ถัดไป", label: "แบ่งหน้า", page: "หน้า" },
117
150
  };
118
151
 
@@ -157,6 +190,26 @@ export const UI_TEXT_EN: UiText = {
157
190
  settings: { back: "Back", empty: "No settings match your search", label: "Settings sections" },
158
191
  mobileTabs: "Main menu",
159
192
  close: "Close",
193
+ auth: {
194
+ or: "or",
195
+ continueWith: "Continue with {provider}",
196
+ showPassword: "Show password",
197
+ hidePassword: "Hide password",
198
+ },
199
+ questionnaire: {
200
+ previous: "Back",
201
+ next: "Next",
202
+ skip: "Skip",
203
+ submit: "Submit",
204
+ progress: "Question {current} of {total}",
205
+ other: "Other answer…",
206
+ },
207
+ chat: {
208
+ placeholder: "Type a message…",
209
+ send: "Send",
210
+ typing: "Typing…",
211
+ scrollToLatest: "Jump to latest",
212
+ },
160
213
  pagination: { previous: "Previous", next: "Next", label: "Pagination", page: "Page" },
161
214
  };
162
215
 
package/src/index.ts CHANGED
@@ -19,6 +19,13 @@ export {
19
19
  type ActionMenuCommand,
20
20
  type ActionMenuItem,
21
21
  } from "./components/shared/action-menu";
22
+ export { AuthLayout, type AuthLayoutProps } from "./components/shared/auth-layout";
23
+ export {
24
+ AuthDivider,
25
+ type AuthProvider,
26
+ PasswordInput,
27
+ ProviderButtons,
28
+ } from "./components/shared/auth-parts";
22
29
  export * from "./components/shared/button";
23
30
  export {
24
31
  type CalendarDayContext,
@@ -28,6 +35,7 @@ export {
28
35
  WEEKDAYS_EN,
29
36
  WEEKDAYS_TH,
30
37
  } from "./components/shared/calendar-month-grid";
38
+ export { type ChatMessage, ChatThread } from "./components/shared/chat-thread";
31
39
  export { ClientRedirect } from "./components/shared/client-redirect";
32
40
  export {
33
41
  ColorPicker,
@@ -159,6 +167,11 @@ export { DangerZone, SettingsRow, SwitchRow } from "./components/shared/settings
159
167
  export { SetupChecklist, type SetupChecklistStep } from "./components/shared/setup-checklist";
160
168
  export { SimpleDialog, SimpleSheet } from "./components/shared/simple-dialog";
161
169
  export { paginationRange, SimplePagination } from "./components/shared/simple-pagination";
170
+ export {
171
+ type QuestionnaireQuestion,
172
+ type QuestionnaireValues,
173
+ SimpleQuestionnaire,
174
+ } from "./components/shared/simple-questionnaire";
162
175
  export { SimpleTable, type SimpleTableColumn } from "./components/shared/simple-table";
163
176
  export {
164
177
  SimpleAccordion,