@suphark/ui 0.23.0 → 0.25.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -2,6 +2,42 @@
2
2
 
3
3
  รูปแบบตาม [Keep a Changelog](https://keepachangelog.com/) · semver: patch = หน้าตา/บั๊ก, minor = component/prop ใหม่, major = เปลี่ยน/ลบ props
4
4
 
5
+ ## [0.25.0] — 2026-10-02
6
+
7
+ input ใหม่ตามแผนจาก shadcnstudio.com — เพิ่มอย่างเดียว
8
+
9
+ ### Added
10
+
11
+ - **`NumberInput`** — ช่องตัวเลขพร้อมปุ่ม −/+ (Base UI NumberField): `min`/`max`/`step`/`largeStep` (Shift+ลูกศร), `format` (Intl เช่น ทศนิยม 2 ตำแหน่ง/สกุลเงิน), `locale` (ค่าเริ่มต้น th-TH), `prefix`/`suffix` (฿, %, ชิ้น), `hideButtons`, `size` sm|default, `name` ส่งค่าดิบใน FormData — แทน `<Input type="number">` + InputGroup + state
12
+ - **`TimePicker`** — ช่องเวลา 24 ชม. ค่า `"HH:mm"`: พิมพ์ได้เลย (`"1430"`, `"9.15"`, `"14:30 น."` → แปลงตอน blur/Enter, ผิดรูปแบบ = `aria-invalid`) หรือกดไอคอนนาฬิกาเลือกจากคอลัมน์ชั่วโมง/นาที (`minuteStep`, ปุ่ม "ตอนนี้"); `name` ส่งใน FormData
13
+ - **`DateTimePicker`** — ปฏิทิน (ภาษาไทย) + คอลัมน์เวลาในกล่องเดียว ค่า `Date | null`, `defaultTime` เมื่อเลือกวันครั้งแรก, ปุ่มล้าง; `name` ส่ง `"YYYY-MM-DDTHH:mm"` (เวลาท้องถิ่น แบบ datetime-local)
14
+ - `TimeColumns` (คอลัมน์ listbox ใช้ลูกศร/Home/End), `parseTimeInput`, `toLocalDateTimeString`, `type TimeValue`
15
+ - **`TagsInput`** — พิมพ์หลายค่าเป็นป้าย: Enter/จุลภาค (+ เว้นวรรคถ้า `splitOnSpace`) เพิ่ม, วางหลายค่าพร้อมกัน, Backspace ลบตัวสุดท้าย, ไม่รับค่าซ้ำ (`allowDuplicates`), `max`, `transform`, `validate` คืนข้อความ error (ค่าที่ไม่ผ่านคงอยู่ในช่องให้แก้), ปลอดภัยกับ IME ไทย; `name` ส่งทุกค่า → `formData.getAll(name)` — redirect URI/โดเมน/อีเมล
16
+ - **`StepForm`** — ฟอร์มหลายขั้น (wizard) จาก array `steps` (`title`, `description`, `content`, `validate(formData)`): แถบขั้นตอน (ขั้นที่ผ่านแล้วกดย้อนได้, จอแคบแสดง "ขั้นที่ x จาก y"), ปุ่มย้อนกลับ/ถัดไป/ส่ง, "ถัดไป" ตรวจ required/pattern + `validate` ของขั้นนั้น, ส่ง = ตรวจทุกขั้นอีกครั้งแล้วพากลับไปขั้นที่ผิด, input ตัวแทนที่ซ่อนของ Base UI ผิด = แสดงข้อความใต้ฟอร์ม; ทุกขั้นอยู่ใน form เดียว → `onSubmit(formData)` ได้ค่าครบ (ส่ง server action ต่อได้), throw = แสดงข้อความ, ปุ่มหมุนระหว่างรอ, โฟกัสย้ายไปหัวข้อขั้นใหม่
17
+ - `UiText` key ใหม่: `number`, `time`, `tags`, `stepForm`
18
+ - showcase: หน้า `/components/{number-input,time-picker,tags-input,step-form}` (129 หน้า); tests 11 กรณี
19
+
20
+ ### Performance
21
+
22
+ - **barrel ไม่ใช้ `export *` แล้ว** (`index.ts` 66 บรรทัด, `next.ts`, `data-table.ts`, `components/data-table/index.ts` → รายชื่อ export ชัด ๆ แยก `type`; รายชื่อ export เท่าเดิมทุกตัว ตรวจด้วย TypeScript แล้ว) — วัดบน showcase (Next 16.3 Turbopack): server component ที่ import จาก `@suphark/ui` แบบ `export *` ทำให้ client component แทบทั้งแพ็กเกจ (ปฏิทิน, date-fns, cmdk, input-otp, accordion, questionnaire …) ถูกส่งไปทุกหน้า — **root layout ที่ใช้แค่ `ThemeProvider`/`Toaster` ลดจาก 988 kB (gzip 311 kB) เหลือ 61 kB (gzip 18 kB)**, หน้า `/login` 991 → 114 kB; แอปไม่ต้องแก้โค้ด แค่ bump (`optimizePackageImports` / `turbopackRemoveUnused*` ไม่ช่วย — ลองแล้ว)
23
+ - `scripts/barrel-exports.mjs`: `pnpm check:barrel` (ใน verify + CI — เจอ `export *` ใน `packages/ui/src` = ล้ม) และ `pnpm fix:barrel` (แปลงให้หลัง `shadcn add`)
24
+ - หมายเหตุ: ตัวเลข date-fns 824 kB บน bundlephobia คือทั้งไลบรารี — ใน build จริงมีเฉพาะฟังก์ชันที่ใช้ + locale ไทย/อังกฤษ
25
+
26
+ ## [0.24.0] — 2026-10-02
27
+
28
+ เติมของเดิมตามที่สำรวจ shadcnstudio.com (หยิบเฉพาะ "หน้าที่" ที่ยังไม่มี ทำเป็นตัวเดียว + props) — เพิ่มอย่างเดียว ค่าเริ่มต้นหน้าตาเดิม
29
+
30
+ ### Added
31
+
32
+ - **`TextareaField`** — ช่องข้อความหลายบรรทัดพร้อม `label`/`hint`/`error` + **ตัวนับตัวอักษร** เมื่อมี `maxLength` (แดงเมื่อเต็ม, screen reader อ่าน "พิมพ์แล้ว x จาก y ตัวอักษร" ผ่าน `aria-describedby`) + `minRows`/`maxRows` (Textarea ขยายตามเนื้อหาอยู่แล้ว — `maxRows` จำกัดแล้วเลื่อนใน); controlled/uncontrolled; `textareaClassName`
33
+ - **`UserAvatars`** — รูปผู้ใช้ซ้อนกัน + "+N" จาก array (`users`, `max`, `size` sm|default|lg, `label` นำหน้ารายชื่อให้ screen reader) ชี้ที่ "+N" เห็นชื่อที่เหลือ; ใช้จาก server component ได้ — แทน AvatarGroup + Avatar×N + AvatarGroupCount
34
+ - **จุดสถานะ** `status?: "online" | "away" | "busy" | "offline"` ที่ `Initials`, `UserIdentity` และรายการของ `UserAvatars` (`PresenceDot` + `type Presence` export ด้วย) ใช้ token success/warning/destructive/muted
35
+ - **`TableSkeleton`** (`rows`, `columns`, `header`), **`ListSkeleton`** (`rows`, `avatar`, `lines`), **`CardSkeleton`** (`count`, `variant` stat|content) — โครงระหว่างโหลดสำเร็จรูปสำหรับ `loading.tsx` มี `role="status"` + "กำลังโหลด…" ความกว้างสลับแบบคงที่ (ไม่ random → ไม่ hydration mismatch)
36
+ - **`Notice`**: `icon` (true = ไอคอนตามโทน หรือส่งเอง) และ `dismissible` + `onDismiss` (ปุ่ม X อ่าน `UiText.close`) — ไม่ส่ง = หน้าตาเดิม; ไฟล์เป็น `"use client"` แล้ว (ใช้จาก server component ได้เหมือนเดิม)
37
+ - **`AuthLayout`**: `size?: "sm" | "md" | "lg"` ความกว้างการ์ด (ค่าเริ่มต้น sm = เดิม) — หน้า consent/เลือกองค์กรของ Pi Auth ใช้ md (ขอจาก Pi Auth)
38
+ - `UiText` key ใหม่: `loading`, `presence`, `characterCount`
39
+ - showcase: หน้า `/components/{textarea-field,user-avatars,loading-skeletons}` (125 หน้า), demo `notice` มี icon + dismissible; tests 10 กรณี
40
+
5
41
  ## [0.23.0] — 2026-10-02
6
42
 
7
43
  ### Added
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@suphark/ui",
3
- "version": "0.23.0",
3
+ "version": "0.25.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,9 +1,9 @@
1
- export * from "./data-table";
2
- export * from "./data-table-column-header";
3
- export * from "./data-table-date-range-filter";
4
- export * from "./data-table-faceted-filter";
5
- export * from "./data-table-pagination";
6
- export * from "./data-table-toolbar";
7
- export * from "./data-table-view-option";
8
- export * from "./types";
9
- export * from "./use-data-table";
1
+ export { DataTable } from "./data-table";
2
+ export { DataTableColumnHeader } from "./data-table-column-header";
3
+ export { DataTableDateRangeFilter } from "./data-table-date-range-filter";
4
+ export { DataTableFacetedFilter } from "./data-table-faceted-filter";
5
+ export { DataTablePagination } from "./data-table-pagination";
6
+ export { DataTableToolbar } from "./data-table-toolbar";
7
+ export { DataTableViewOptions } from "./data-table-view-option";
8
+ export type { DataTableProps, FacetedFilterConfig } from "./types";
9
+ export { useDataTable } from "./use-data-table";
@@ -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,144 @@
1
+ "use client";
2
+
3
+ import { NumberField } from "@base-ui/react/number-field";
4
+ import { cn } from "@suphark/ui/lib/utils";
5
+ import { MinusIcon, PlusIcon } from "lucide-react";
6
+ import type { ReactNode } from "react";
7
+ import { useUiText } from "../ui-text";
8
+
9
+ const SIZE = {
10
+ sm: { group: "h-7", button: "w-7 [&_svg]:size-3.5", text: "text-sm" },
11
+ default: { group: "h-8", button: "w-8 [&_svg]:size-4", text: "text-base md:text-sm" },
12
+ } as const;
13
+
14
+ /**
15
+ * ช่องตัวเลขพร้อมปุ่ม −/+ (Base UI NumberField) — แทน `<Input type="number">` + InputGroup + state เอง
16
+ * ลูกศรขึ้น/ลง (Shift = `largeStep`), จัดรูปแบบตาม locale (`format` เช่น ทศนิยม/สกุลเงิน), `prefix`/`suffix` เช่น ฿ หรือ %
17
+ * `name` ส่งค่าใน FormData ของ server action ได้ (ค่าดิบไม่มีจุลภาค)
18
+ *
19
+ * <NumberInput name="amount" prefix="฿" min={0} step={100} format={{ maximumFractionDigits: 2 }} />
20
+ * <NumberInput value={qty} onValueChange={setQty} min={1} max={99} aria-label="จำนวน" />
21
+ */
22
+ export function NumberInput({
23
+ value,
24
+ defaultValue,
25
+ onValueChange,
26
+ min,
27
+ max,
28
+ step = 1,
29
+ largeStep,
30
+ format,
31
+ locale,
32
+ prefix,
33
+ suffix,
34
+ hideButtons = false,
35
+ size = "default",
36
+ name,
37
+ id,
38
+ required,
39
+ disabled,
40
+ readOnly,
41
+ placeholder,
42
+ className,
43
+ inputClassName,
44
+ "aria-label": ariaLabel,
45
+ "aria-describedby": ariaDescribedBy,
46
+ "aria-invalid": ariaInvalid,
47
+ }: {
48
+ value?: number | null;
49
+ defaultValue?: number;
50
+ onValueChange?: (value: number | null) => void;
51
+ min?: number;
52
+ max?: number;
53
+ step?: number;
54
+ /** ก้าวเมื่อกด Shift + ลูกศร / PageUp-PageDown (ค่าเริ่มต้น step × 10) */
55
+ largeStep?: number;
56
+ /** Intl.NumberFormatOptions เช่น `{ maximumFractionDigits: 2 }` หรือ `{ style: "percent" }` */
57
+ format?: Intl.NumberFormatOptions;
58
+ /** ค่าเริ่มต้น th-TH */
59
+ locale?: Intl.LocalesArgument;
60
+ /** ข้อความ/ไอคอนหน้าตัวเลข เช่น "฿" */
61
+ prefix?: ReactNode;
62
+ /** ข้อความ/ไอคอนหลังตัวเลข เช่น "%", "ชิ้น" */
63
+ suffix?: ReactNode;
64
+ /** ซ่อนปุ่ม −/+ (ยังใช้ลูกศรคีย์บอร์ดได้) */
65
+ hideButtons?: boolean;
66
+ size?: "sm" | "default";
67
+ name?: string;
68
+ id?: string;
69
+ required?: boolean;
70
+ disabled?: boolean;
71
+ readOnly?: boolean;
72
+ placeholder?: string;
73
+ className?: string;
74
+ inputClassName?: string;
75
+ "aria-label"?: string;
76
+ "aria-describedby"?: string;
77
+ "aria-invalid"?: boolean | "true" | "false";
78
+ }) {
79
+ const text = useUiText().number;
80
+ const s = SIZE[size];
81
+ const buttonClass = cn(
82
+ "flex shrink-0 items-center justify-center text-muted-foreground transition-colors outline-none hover:bg-muted hover:text-foreground focus-visible:bg-muted disabled:pointer-events-none disabled:opacity-50",
83
+ s.button,
84
+ );
85
+ const affix = "flex shrink-0 items-center px-2 text-muted-foreground text-sm select-none";
86
+ return (
87
+ <NumberField.Root
88
+ id={id}
89
+ name={name}
90
+ value={value}
91
+ defaultValue={defaultValue}
92
+ onValueChange={(v) => onValueChange?.(v)}
93
+ min={min}
94
+ max={max}
95
+ step={step}
96
+ largeStep={largeStep ?? step * 10}
97
+ format={format}
98
+ locale={locale ?? "th-TH"}
99
+ required={required}
100
+ disabled={disabled}
101
+ readOnly={readOnly}
102
+ className={cn("w-full", className)}
103
+ >
104
+ <NumberField.Group
105
+ data-slot="number-input"
106
+ className={cn(
107
+ "flex w-full min-w-0 items-stretch overflow-hidden rounded-lg border border-input bg-transparent transition-colors focus-within:border-ring focus-within:ring-3 focus-within:ring-ring/50 has-aria-invalid:border-destructive has-aria-invalid:ring-3 has-aria-invalid:ring-destructive/20 has-disabled:opacity-50 dark:bg-input/30",
108
+ s.group,
109
+ )}
110
+ >
111
+ {!hideButtons && (
112
+ <NumberField.Decrement
113
+ aria-label={text?.decrement ?? "ลด"}
114
+ className={cn(buttonClass, "border-r border-input")}
115
+ >
116
+ <MinusIcon />
117
+ </NumberField.Decrement>
118
+ )}
119
+ {prefix && <span className={cn(affix, !hideButtons && "pr-0")}>{prefix}</span>}
120
+ <NumberField.Input
121
+ placeholder={placeholder}
122
+ aria-label={ariaLabel}
123
+ aria-describedby={ariaDescribedBy}
124
+ aria-invalid={ariaInvalid}
125
+ className={cn(
126
+ "min-w-0 flex-1 bg-transparent px-2.5 tabular-nums outline-none placeholder:text-muted-foreground",
127
+ hideButtons ? "text-left" : "text-center",
128
+ s.text,
129
+ inputClassName,
130
+ )}
131
+ />
132
+ {suffix && <span className={cn(affix, !hideButtons && "pl-0")}>{suffix}</span>}
133
+ {!hideButtons && (
134
+ <NumberField.Increment
135
+ aria-label={text?.increment ?? "เพิ่ม"}
136
+ className={cn(buttonClass, "border-l border-input")}
137
+ >
138
+ <PlusIcon />
139
+ </NumberField.Increment>
140
+ )}
141
+ </NumberField.Group>
142
+ </NumberField.Root>
143
+ );
144
+ }
@@ -0,0 +1,192 @@
1
+ "use client";
2
+
3
+ import { cn } from "@suphark/ui/lib/utils";
4
+ import { XIcon } from "lucide-react";
5
+ import { type ClipboardEvent, type KeyboardEvent, useId, useRef, useState } from "react";
6
+ import { useUiText } from "../ui-text";
7
+
8
+ /**
9
+ * ช่องพิมพ์หลายค่าเป็นป้าย (chip) — พิมพ์แล้วกด Enter หรือจุลภาค, วางหลายค่าพร้อมกันได้ (แยกด้วย , ; เว้นวรรค หรือขึ้นบรรทัด)
10
+ * Backspace ในช่องว่างลบป้ายสุดท้าย; ไม่รับค่าซ้ำ; `validate` คืนข้อความ error เพื่อปฏิเสธค่า (แสดงใต้ช่อง)
11
+ * `name` ส่งทุกค่าใน FormData (`formData.getAll(name)`) — แทน input + state array + map ป้าย + ปุ่มลบเอง
12
+ *
13
+ * <TagsInput name="redirectUris" placeholder="https://…"
14
+ * validate={(v) => (URL.canParse(v) ? null : "ต้องเป็น URL")} />
15
+ */
16
+ export function TagsInput({
17
+ value,
18
+ defaultValue = [],
19
+ onValueChange,
20
+ validate,
21
+ transform = (v) => v.trim(),
22
+ max,
23
+ allowDuplicates = false,
24
+ splitOnSpace = false,
25
+ name,
26
+ id,
27
+ disabled,
28
+ placeholder,
29
+ className,
30
+ "aria-label": ariaLabel,
31
+ "aria-describedby": ariaDescribedBy,
32
+ "aria-invalid": ariaInvalid,
33
+ }: {
34
+ value?: string[];
35
+ defaultValue?: string[];
36
+ onValueChange?: (value: string[]) => void;
37
+ /** คืน string = ข้อความ error (ไม่เพิ่มค่า), null/undefined = ผ่าน */
38
+ validate?: (tag: string, current: string[]) => string | null | undefined;
39
+ /** แปลงค่าก่อนเพิ่ม (ค่าเริ่มต้น trim) เช่น `(v) => v.trim().toLowerCase()` */
40
+ transform?: (raw: string) => string;
41
+ /** จำนวนป้ายสูงสุด */
42
+ max?: number;
43
+ allowDuplicates?: boolean;
44
+ /** เว้นวรรคก็ตัดเป็นป้ายใหม่ (เหมาะกับอีเมล/โดเมน) */
45
+ splitOnSpace?: boolean;
46
+ name?: string;
47
+ id?: string;
48
+ disabled?: boolean;
49
+ placeholder?: string;
50
+ className?: string;
51
+ "aria-label"?: string;
52
+ "aria-describedby"?: string;
53
+ "aria-invalid"?: boolean;
54
+ }) {
55
+ const text = useUiText().tags;
56
+ const [inner, setInner] = useState(defaultValue);
57
+ const tags = value ?? inner;
58
+ const [draft, setDraft] = useState("");
59
+ const [error, setError] = useState<string | null>(null);
60
+ const inputRef = useRef<HTMLInputElement>(null);
61
+ const errorId = useId();
62
+ const full = max != null && tags.length >= max;
63
+
64
+ const commit = (next: string[]) => {
65
+ if (value === undefined) setInner(next);
66
+ onValueChange?.(next);
67
+ };
68
+
69
+ /** เพิ่มหลายค่า — คืนค่าที่ไม่ผ่าน (ให้คงไว้ในช่องพิมพ์) */
70
+ const add = (raws: string[]): string => {
71
+ const next = [...tags];
72
+ let firstError: string | null = null;
73
+ const rejected: string[] = [];
74
+ for (const raw of raws) {
75
+ const tag = transform(raw);
76
+ if (!tag) continue;
77
+ let message: string | null | undefined = null;
78
+ if (max != null && next.length >= max) {
79
+ message = (text?.max ?? "เพิ่มได้สูงสุด {max} รายการ").replace("{max}", String(max));
80
+ } else if (!allowDuplicates && next.includes(tag)) {
81
+ message = (text?.duplicate ?? "มี “{tag}” แล้ว").replace("{tag}", tag);
82
+ } else {
83
+ message = validate?.(tag, next);
84
+ }
85
+ if (message) {
86
+ firstError ??= message;
87
+ rejected.push(raw.trim());
88
+ } else {
89
+ next.push(tag);
90
+ }
91
+ }
92
+ if (next.length !== tags.length) commit(next);
93
+ setError(firstError);
94
+ return rejected.join(", ");
95
+ };
96
+
97
+ const separator = splitOnSpace ? /[,;\n\s]+/ : /[,;\n]+/;
98
+
99
+ const onKeyDown = (e: KeyboardEvent<HTMLInputElement>) => {
100
+ if (e.nativeEvent.isComposing) return; // กำลังประกอบคำด้วย IME
101
+ if (e.key === "Enter" || e.key === "," || (splitOnSpace && e.key === " ")) {
102
+ if (!draft.trim()) {
103
+ if (e.key !== "Enter") e.preventDefault();
104
+ return;
105
+ }
106
+ e.preventDefault();
107
+ setDraft(add([draft]));
108
+ } else if (e.key === "Backspace" && draft === "" && tags.length > 0) {
109
+ e.preventDefault();
110
+ commit(tags.slice(0, -1));
111
+ setError(null);
112
+ }
113
+ };
114
+
115
+ const onPaste = (e: ClipboardEvent<HTMLInputElement>) => {
116
+ const pasted = e.clipboardData.getData("text");
117
+ if (!separator.test(pasted)) return;
118
+ e.preventDefault();
119
+ setDraft(add(pasted.split(separator)));
120
+ };
121
+
122
+ const remove = (index: number) => {
123
+ commit(tags.filter((_, i) => i !== index));
124
+ setError(null);
125
+ inputRef.current?.focus();
126
+ };
127
+
128
+ const describedBy =
129
+ [ariaDescribedBy, error ? errorId : null].filter(Boolean).join(" ") || undefined;
130
+
131
+ return (
132
+ <div data-slot="tags-input" className={cn("flex w-full flex-col gap-1.5", className)}>
133
+ <div
134
+ onClick={() => inputRef.current?.focus()}
135
+ className={cn(
136
+ "flex min-h-8 w-full flex-wrap items-center gap-1 rounded-lg border border-input bg-transparent px-1.5 py-1 transition-colors focus-within:border-ring focus-within:ring-3 focus-within:ring-ring/50 dark:bg-input/30",
137
+ (error || ariaInvalid) &&
138
+ "border-destructive ring-3 ring-destructive/20 focus-within:border-destructive focus-within:ring-destructive/20",
139
+ disabled && "pointer-events-none opacity-50",
140
+ )}
141
+ >
142
+ {tags.map((tag, i) => (
143
+ <span
144
+ // biome-ignore lint/suspicious/noArrayIndexKey: allowDuplicates ทำให้ค่าซ้ำได้ ใช้ตำแหน่งร่วม
145
+ key={`${tag}-${i}`}
146
+ className="inline-flex h-6 max-w-full items-center gap-0.5 rounded-md bg-secondary pr-0.5 pl-2 text-secondary-foreground text-sm"
147
+ >
148
+ <span className="truncate">{tag}</span>
149
+ <button
150
+ type="button"
151
+ disabled={disabled}
152
+ aria-label={(text?.remove ?? "ลบ {tag}").replace("{tag}", tag)}
153
+ onClick={(e) => {
154
+ e.stopPropagation();
155
+ remove(i);
156
+ }}
157
+ className="flex size-5 shrink-0 items-center justify-center rounded-sm text-muted-foreground outline-none hover:bg-background/60 hover:text-foreground focus-visible:ring-2 focus-visible:ring-ring/50 [&_svg]:size-3.5"
158
+ >
159
+ <XIcon />
160
+ </button>
161
+ {name && <input type="hidden" name={name} value={tag} />}
162
+ </span>
163
+ ))}
164
+ <input
165
+ ref={inputRef}
166
+ id={id}
167
+ value={draft}
168
+ disabled={disabled || full}
169
+ placeholder={full ? undefined : (placeholder ?? text?.placeholder ?? "พิมพ์แล้วกด Enter")}
170
+ aria-label={ariaLabel}
171
+ aria-describedby={describedBy}
172
+ aria-invalid={error || ariaInvalid ? true : undefined}
173
+ onChange={(e) => {
174
+ setDraft(e.currentTarget.value);
175
+ if (error) setError(null);
176
+ }}
177
+ onKeyDown={onKeyDown}
178
+ onPaste={onPaste}
179
+ onBlur={() => {
180
+ if (draft.trim()) setDraft(add([draft]));
181
+ }}
182
+ className="h-6 min-w-24 flex-1 bg-transparent px-1 text-base outline-none placeholder:text-muted-foreground md:text-sm"
183
+ />
184
+ </div>
185
+ {error && (
186
+ <p id={errorId} role="alert" className="text-destructive text-xs">
187
+ {error}
188
+ </p>
189
+ )}
190
+ </div>
191
+ );
192
+ }
@@ -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
+ }