@suphark/ui 0.24.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 +21 -0
- package/package.json +1 -1
- package/src/components/data-table/index.ts +9 -9
- package/src/components/shared/form/number-input.tsx +144 -0
- package/src/components/shared/form/tags-input.tsx +192 -0
- package/src/components/shared/form/time-picker.tsx +421 -0
- package/src/components/shared/step-form.tsx +283 -0
- package/src/components/shared/ui-text.tsx +65 -0
- package/src/data-table.ts +12 -1
- package/src/index.ts +430 -66
- package/src/next.ts +38 -1
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.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
|
+
|
|
5
26
|
## [0.24.0] — 2026-10-02
|
|
6
27
|
|
|
7
28
|
เติมของเดิมตามที่สำรวจ shadcnstudio.com (หยิบเฉพาะ "หน้าที่" ที่ยังไม่มี ทำเป็นตัวเดียว + props) — เพิ่มอย่างเดียว ค่าเริ่มต้นหน้าตาเดิม
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@suphark/ui",
|
|
3
|
-
"version": "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
|
|
2
|
-
export
|
|
3
|
-
export
|
|
4
|
-
export
|
|
5
|
-
export
|
|
6
|
-
export
|
|
7
|
-
export
|
|
8
|
-
export
|
|
9
|
-
export
|
|
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";
|
|
@@ -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
|
+
}
|