@suphark/ui 0.29.0 → 0.30.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 +12 -0
- package/package.json +1 -1
- package/scripts/hardcoded-text.d.mts +3 -0
- package/scripts/hardcoded-text.mjs +101 -0
- package/src/components/app-shell/app-shell.tsx +1 -1
- package/src/components/app-shell/sub-nav.tsx +8 -4
- package/src/components/app-shell/top-tabs.tsx +6 -1
- package/src/components/data-table/data-table-faceted-filter.tsx +3 -1
- package/src/components/data-table/data-table-toolbar.tsx +13 -6
- package/src/components/shared/form/form-field-combobox.tsx +9 -2
- package/src/components/shared/layout/error-state.tsx +8 -4
- package/src/components/shared/month-stepper.tsx +8 -6
- package/src/components/shared/multi-select-combobox.tsx +11 -4
- package/src/components/shared/resource-property-filter.tsx +33 -16
- package/src/components/shared/segmented-control.tsx +17 -1
- package/src/components/shared/ui-text.tsx +120 -0
- package/src/index.ts +5 -1
package/CHANGELOG.md
CHANGED
|
@@ -2,6 +2,18 @@
|
|
|
2
2
|
|
|
3
3
|
รูปแบบตาม [Keep a Changelog](https://keepachangelog.com/) · semver: patch = หน้าตา/บั๊ก, minor = component/prop ใหม่, major = เปลี่ยน/ลบ props
|
|
4
4
|
|
|
5
|
+
## [0.30.0] — 2026-10-05
|
|
6
|
+
|
|
7
|
+
### Added
|
|
8
|
+
|
|
9
|
+
- **ข้อความที่ผู้ใช้เห็นในแพ็กเกจเปลี่ยนภาษาได้ครบแล้ว** — เก็บข้อความไทยที่ฝังตายใน JSX อีก 34 จุดเข้า `UiText`: `DataTableToolbar` (ค้นหา/ล้าง/ตัวกรอง/คำอธิบาย/ปิด), `DataTableFacetedFilter` + `FormFieldCombobox` ("ไม่พบข้อมูล"), `MultiSelectCombobox` (ล้างทั้งหมด/ลบ/เพิ่ม), `ResourcePropertyFilter` (ทั้งแผง 14 จุด), `AppShell`/`SubNav`/`TopTabs` (เมนูหลัก/เมนูย่อย/เลือกหน้า/ไม่พบ/ล้างคำค้นหา), `ErrorState` (เซสชันหมดอายุ) — คีย์ใหม่: `UiText.combobox`, `UiText.filter`, `UiText.nav`, `UiText.sessionExpired` และ `UiText.table.{search,clearSearch,filters,filtersDescription,closeFilters,clearFilter}`
|
|
10
|
+
- **เทสต์กันข้อความฝังตาย** `no-hardcoded-text` (อยู่ใน `pnpm test`/CI): ไล่ JSX ของ `shared`/`app-shell`/`data-table`/`sortable` หาข้อความไทยที่ไม่มีทาง override — ยกเว้นได้แต่ต้องเขียนเหตุผลใน ALLOW; มีชุด "ปลูกการละเมิด" พิสูจน์ว่าตัวตรวจจับของจริงได้และไม่จับของที่ถูกต้อง (คอมเมนต์/JSDoc/`?? "…"`/พจนานุกรมของ component) — ไอเดียจาก Pi Config ที่เจอว่าเทสต์แบบนี้ล้มเหลวเงียบได้ง่าย
|
|
11
|
+
- `SegmentedControl`: `tone` ต่อตัวเลือก (`success` / `warning` / `destructive` / `info`) — ลงสีเฉพาะตัวที่ถูกเลือก ใช้กับตัวกรองที่ "ประเภท" มีความหมายทางสี เช่นรายรับ/รายจ่าย (คำขอจาก DueBell ที่ต้องใช้ไอคอนแทนไปก่อน)
|
|
12
|
+
|
|
13
|
+
### Fixed
|
|
14
|
+
|
|
15
|
+
- `MonthStepper`: ปุ่มกลางตอนอยู่เดือนปัจจุบันเคยเป็น `<Button disabled>` ซึ่ง screen reader อ่านว่า "ปุ่ม ปิดใช้งาน" ทั้งที่มันคือป้ายชื่อเดือน — ตอนนี้เรนเดอร์เป็นป้ายธรรมดาเมื่อไม่มีอะไรให้กด (รายงานจาก DueBell) และตอนกดได้ใช้ tooltip ของ `Button` แทน `title` (เลิก tooltip ซ้อนของเบราว์เซอร์)
|
|
16
|
+
|
|
5
17
|
## [0.29.0] — 2026-10-05
|
|
6
18
|
|
|
7
19
|
### Added
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@suphark/ui",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.30.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,101 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* หา "ข้อความที่ผู้ใช้เห็น" ซึ่งฝังภาษาไทยตายไว้ใน JSX ของแพ็กเกจ
|
|
3
|
+
* ข้อความแบบนั้นแอปสองภาษาแปลไม่ได้เลย — ต้องผ่าน `UiText` (`text.x ?? "ไทย"`) หรือ prop
|
|
4
|
+
*
|
|
5
|
+
* ตรวจเฉพาะ **ข้อความใน JSX** สองแบบ (จุดที่ override ไม่ได้จริง ๆ):
|
|
6
|
+
* 1. attribute ที่ผู้ใช้/screen reader เห็น: `aria-label` `placeholder` `title` `alt` ฯลฯ
|
|
7
|
+
* 2. ข้อความที่เป็นลูกของ element: `<CommandEmpty>ไม่พบข้อมูล</CommandEmpty>`
|
|
8
|
+
* ไม่ตรวจ: คอมเมนต์ (รวม JSDoc ตัวอย่างโค้ด), ค่าเริ่มต้นของ prop, พจนานุกรมของ component
|
|
9
|
+
* (`const TEXT = { th, en }`) และ `text.x ?? "ไทย"` — ทั้งหมดนั้นแอปเปลี่ยนได้อยู่แล้ว
|
|
10
|
+
*
|
|
11
|
+
* export เป็นฟังก์ชันล้วนเพื่อให้เทสต์ "ปลูกไฟล์ละเมิด" พิสูจน์ได้ว่าจับได้จริงทั้งสองทาง
|
|
12
|
+
* (เทสต์ประเภทนี้ล้มเหลวเงียบง่ายมาก — ผ่านเพราะไม่ได้ตรวจอะไร)
|
|
13
|
+
*/
|
|
14
|
+
|
|
15
|
+
const THAI = "[\\u0E00-\\u0E7F]";
|
|
16
|
+
const VISIBLE_ATTR = new RegExp(
|
|
17
|
+
`\\s(?:aria-label|aria-description|aria-roledescription|placeholder|title|alt|label)=\\{?["'\`][^"'\`]*${THAI}`,
|
|
18
|
+
);
|
|
19
|
+
const JSX_CHILD = new RegExp(`>\\s*[^<>{}\\n]*${THAI}`);
|
|
20
|
+
const THAI_ANY = new RegExp(THAI);
|
|
21
|
+
|
|
22
|
+
/** ตัดคอมเมนต์ออกทั้งไฟล์ (`//`, `/* *\/` ทั้งบรรทัดเดียวและหลายบรรทัด) โดยคงจำนวนบรรทัดไว้ */
|
|
23
|
+
export function stripComments(source) {
|
|
24
|
+
const out = [];
|
|
25
|
+
let inBlock = false;
|
|
26
|
+
for (const line of source.split("\n")) {
|
|
27
|
+
let rest = line;
|
|
28
|
+
let kept = "";
|
|
29
|
+
while (rest !== "") {
|
|
30
|
+
if (inBlock) {
|
|
31
|
+
const end = rest.indexOf("*/");
|
|
32
|
+
if (end === -1) {
|
|
33
|
+
rest = "";
|
|
34
|
+
break;
|
|
35
|
+
}
|
|
36
|
+
inBlock = false;
|
|
37
|
+
rest = rest.slice(end + 2);
|
|
38
|
+
continue;
|
|
39
|
+
}
|
|
40
|
+
const block = rest.indexOf("/*");
|
|
41
|
+
const lineComment = rest.indexOf("//");
|
|
42
|
+
const blockOutside = block !== -1 && balanced(rest.slice(0, block));
|
|
43
|
+
const lineOutside = lineComment !== -1 && balanced(rest.slice(0, lineComment));
|
|
44
|
+
if (lineOutside && (!blockOutside || lineComment < block)) {
|
|
45
|
+
kept += rest.slice(0, lineComment);
|
|
46
|
+
rest = "";
|
|
47
|
+
break;
|
|
48
|
+
}
|
|
49
|
+
if (!blockOutside) {
|
|
50
|
+
kept += rest;
|
|
51
|
+
rest = "";
|
|
52
|
+
break;
|
|
53
|
+
}
|
|
54
|
+
kept += rest.slice(0, block);
|
|
55
|
+
rest = rest.slice(block + 2);
|
|
56
|
+
inBlock = true;
|
|
57
|
+
}
|
|
58
|
+
out.push(kept);
|
|
59
|
+
}
|
|
60
|
+
return out;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/** quote เปิด-ปิดครบไหม (ใช้ตัดสินว่า `//` หรือ `/*` อยู่นอก string เช่น "https://…") */
|
|
64
|
+
function balanced(text) {
|
|
65
|
+
let quote = null;
|
|
66
|
+
for (let i = 0; i < text.length; i += 1) {
|
|
67
|
+
const ch = text[i];
|
|
68
|
+
if (ch === "\\") {
|
|
69
|
+
i += 1;
|
|
70
|
+
continue;
|
|
71
|
+
}
|
|
72
|
+
if (quote) {
|
|
73
|
+
if (ch === quote) quote = null;
|
|
74
|
+
} else if (ch === '"' || ch === "'" || ch === "`") {
|
|
75
|
+
quote = ch;
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
return quote === null;
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
/** บรรทัดนี้มีทางให้แอปเปลี่ยนข้อความอยู่แล้วไหม */
|
|
82
|
+
function hasEscapeHatch(line) {
|
|
83
|
+
// `text.x ?? "ไทย"` / `text?.x?.y ?? "ไทย"` (ค่าเริ่มต้นของ UiText)
|
|
84
|
+
if (/\?\?\s*["'`]/.test(line)) return true;
|
|
85
|
+
return false;
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
/** คืนรายการละเมิดของไฟล์เดียว: `{ line, text }` */
|
|
89
|
+
export function findHardcodedThai(source) {
|
|
90
|
+
const lines = stripComments(source);
|
|
91
|
+
const found = [];
|
|
92
|
+
lines.forEach((line, index) => {
|
|
93
|
+
if (!THAI_ANY.test(line)) return;
|
|
94
|
+
if (hasEscapeHatch(line)) return;
|
|
95
|
+
// บรรทัดที่ไม่มีแท็กเลยไม่ใช่ JSX (เช่น object ของข้อความ หรือ `throw new Error(...)`)
|
|
96
|
+
const looksJsx = line.includes("<");
|
|
97
|
+
if (!VISIBLE_ATTR.test(line) && !(looksJsx && JSX_CHILD.test(line))) return;
|
|
98
|
+
found.push({ line: index + 1, text: line.trim() });
|
|
99
|
+
});
|
|
100
|
+
return found;
|
|
101
|
+
}
|
|
@@ -436,7 +436,7 @@ function ShellSidebar({
|
|
|
436
436
|
<InputGroupAddon align="inline-end">
|
|
437
437
|
<InputGroupButton
|
|
438
438
|
onClick={() => setQuery("")}
|
|
439
|
-
aria-label="ล้างคำค้นหาเมนู"
|
|
439
|
+
aria-label={text.nav?.clearSearch ?? "ล้างคำค้นหาเมนู"}
|
|
440
440
|
className="size-6 p-0 hover:bg-transparent"
|
|
441
441
|
>
|
|
442
442
|
<X aria-hidden="true" />
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
"use client";
|
|
2
2
|
|
|
3
|
+
import { useUiText } from "@suphark/ui/components/shared/ui-text";
|
|
3
4
|
import {
|
|
4
5
|
InputGroup,
|
|
5
6
|
InputGroupAddon,
|
|
@@ -30,6 +31,7 @@ export function SubNav({
|
|
|
30
31
|
searchPlaceholder?: string;
|
|
31
32
|
className?: string;
|
|
32
33
|
}) {
|
|
34
|
+
const text = useUiText();
|
|
33
35
|
const pathname = usePathname();
|
|
34
36
|
const router = useRouter();
|
|
35
37
|
const [query, setQuery] = useState("");
|
|
@@ -66,9 +68,9 @@ export function SubNav({
|
|
|
66
68
|
className="h-9 rounded-md border bg-background px-2 text-foreground text-sm"
|
|
67
69
|
value={current?.href ?? ""}
|
|
68
70
|
onChange={(e) => e.target.value && router.push(e.target.value)}
|
|
69
|
-
aria-label={typeof title === "string" ? title : "เมนูย่อย"}
|
|
71
|
+
aria-label={typeof title === "string" ? title : (text.nav?.sub ?? "เมนูย่อย")}
|
|
70
72
|
>
|
|
71
|
-
{!current && <option value=""
|
|
73
|
+
{!current && <option value="">{text.nav?.choosePage ?? "เลือกหน้า…"}</option>}
|
|
72
74
|
{sections.map((s, si) => (
|
|
73
75
|
// biome-ignore lint/suspicious/noArrayIndexKey: กลุ่มคงที่
|
|
74
76
|
<optgroup key={si} label={s.title ?? ""}>
|
|
@@ -84,7 +86,7 @@ export function SubNav({
|
|
|
84
86
|
|
|
85
87
|
{/* เดสก์ท็อป */}
|
|
86
88
|
<nav
|
|
87
|
-
aria-label={typeof title === "string" ? title : "เมนูย่อย"}
|
|
89
|
+
aria-label={typeof title === "string" ? title : (text.nav?.sub ?? "เมนูย่อย")}
|
|
88
90
|
className={cn(
|
|
89
91
|
"sticky top-28 hidden w-56 shrink-0 flex-col gap-3 self-start lg:flex",
|
|
90
92
|
className,
|
|
@@ -142,7 +144,9 @@ export function SubNav({
|
|
|
142
144
|
</div>
|
|
143
145
|
))}
|
|
144
146
|
{sections.length === 0 && (
|
|
145
|
-
<p className="px-2 text-muted-foreground text-xs"
|
|
147
|
+
<p className="px-2 text-muted-foreground text-xs">
|
|
148
|
+
{(text.nav?.noResults ?? "ไม่พบ “{query}”").replace("{query}", query)}
|
|
149
|
+
</p>
|
|
146
150
|
)}
|
|
147
151
|
</nav>
|
|
148
152
|
</>
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
"use client";
|
|
2
2
|
|
|
3
|
+
import { useUiText } from "@suphark/ui/components/shared/ui-text";
|
|
3
4
|
import { cn } from "@suphark/ui/lib/utils";
|
|
4
5
|
import Link from "next/link";
|
|
5
6
|
import { usePathname } from "next/navigation";
|
|
@@ -34,11 +35,15 @@ export function isTabActive(pathname: string, tab: AppNavItem): boolean {
|
|
|
34
35
|
|
|
35
36
|
/** แท็บหลักแนวนอนใต้ header (แบบ Clerk: Overview · Users · …) — active มีเส้นใต้; จอแคบเลื่อนแนวนอน */
|
|
36
37
|
export function TopTabs({ nav, className }: { nav: AppNavGroup[]; className?: string }) {
|
|
38
|
+
const text = useUiText();
|
|
37
39
|
const pathname = usePathname();
|
|
38
40
|
const tabs = topLevelTabs(nav);
|
|
39
41
|
if (tabs.length === 0) return null;
|
|
40
42
|
return (
|
|
41
|
-
<nav
|
|
43
|
+
<nav
|
|
44
|
+
aria-label={text.nav?.main ?? "เมนูหลัก"}
|
|
45
|
+
className={cn("no-scrollbar -mb-px flex overflow-x-auto", className)}
|
|
46
|
+
>
|
|
42
47
|
{tabs.map((tab) => {
|
|
43
48
|
const active = isTabActive(pathname, tab);
|
|
44
49
|
return (
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
"use client";
|
|
2
2
|
|
|
3
3
|
import { Button } from "@suphark/ui/components/shared/button/button";
|
|
4
|
+
import { useUiText } from "@suphark/ui/components/shared/ui-text";
|
|
4
5
|
import { Badge } from "@suphark/ui/components/ui/badge";
|
|
5
6
|
import {
|
|
6
7
|
Command,
|
|
@@ -37,6 +38,7 @@ export function DataTableFacetedFilter<TData, TValue>({
|
|
|
37
38
|
compact = false,
|
|
38
39
|
fullWidth = false,
|
|
39
40
|
}: DataTableFacetedFilterProps<TData, TValue>) {
|
|
41
|
+
const text = useUiText();
|
|
40
42
|
const facets = column?.getFacetedUniqueValues();
|
|
41
43
|
const filterValue = column?.getFilterValue();
|
|
42
44
|
// Set + ลำดับ memo จากค่า filter จริง (เดิมสร้าง Set ใหม่ทุก render ทำให้ memo ไม่เคย hit)
|
|
@@ -109,7 +111,7 @@ export function DataTableFacetedFilter<TData, TValue>({
|
|
|
109
111
|
<Command>
|
|
110
112
|
{!compact ? <CommandInput placeholder={title} /> : null}
|
|
111
113
|
<CommandList>
|
|
112
|
-
<CommandEmpty
|
|
114
|
+
<CommandEmpty>{text.combobox?.empty ?? "ไม่พบข้อมูล"}</CommandEmpty>
|
|
113
115
|
<CommandGroup>
|
|
114
116
|
{sortedOptions.map((option) => {
|
|
115
117
|
const valueAsString = String(option.value);
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
"use client";
|
|
2
2
|
|
|
3
3
|
import { Button } from "@suphark/ui/components/shared/button/button";
|
|
4
|
+
import { useUiText } from "@suphark/ui/components/shared/ui-text";
|
|
4
5
|
import { Badge } from "@suphark/ui/components/ui/badge";
|
|
5
6
|
import {
|
|
6
7
|
InputGroup,
|
|
@@ -43,6 +44,7 @@ export function DataTableToolbar<TData>({
|
|
|
43
44
|
toolbarFilters,
|
|
44
45
|
preservedFilterIds = [],
|
|
45
46
|
}: DataTableToolbarProps<TData>) {
|
|
47
|
+
const text = useUiText();
|
|
46
48
|
const globalFilter = table.getState().globalFilter;
|
|
47
49
|
// ค้นหา: เก็บข้อความในช่องเอง แล้วส่งให้ตารางเมื่อหยุดพิมพ์ 250 ms — เดิมกรอง/เรียง/render ทุกแถวทุกตัวอักษร
|
|
48
50
|
// (และถ้าแอปผูก globalFilter กับ URL = router.replace ทุกตัวอักษร)
|
|
@@ -121,7 +123,7 @@ export function DataTableToolbar<TData>({
|
|
|
121
123
|
<Search aria-hidden="true" className="size-4" />
|
|
122
124
|
</InputGroupAddon>
|
|
123
125
|
<InputGroupInput
|
|
124
|
-
aria-label="ค้นหาข้อมูลในตาราง"
|
|
126
|
+
aria-label={text.table?.search ?? "ค้นหาข้อมูลในตาราง"}
|
|
125
127
|
name="table-search"
|
|
126
128
|
autoComplete="off"
|
|
127
129
|
placeholder={globalFilterPlaceholder}
|
|
@@ -135,7 +137,7 @@ export function DataTableToolbar<TData>({
|
|
|
135
137
|
<InputGroupAddon align="inline-end">
|
|
136
138
|
<InputGroupButton
|
|
137
139
|
variant="ghost"
|
|
138
|
-
aria-label="ล้างคำค้นหา"
|
|
140
|
+
aria-label={text.table?.clearSearch ?? "ล้างคำค้นหา"}
|
|
139
141
|
onClick={() => commitSearch("")}
|
|
140
142
|
>
|
|
141
143
|
<X aria-hidden="true" className="size-4" />
|
|
@@ -166,15 +168,17 @@ export function DataTableToolbar<TData>({
|
|
|
166
168
|
className="w-[min(24rem,calc(100vw-1rem))] sm:max-w-md"
|
|
167
169
|
>
|
|
168
170
|
<SheetHeader className="border-b pr-12">
|
|
169
|
-
<SheetTitle
|
|
170
|
-
<SheetDescription
|
|
171
|
+
<SheetTitle>{text.table?.filters ?? "ตัวกรองข้อมูล"}</SheetTitle>
|
|
172
|
+
<SheetDescription>
|
|
173
|
+
{text.table?.filtersDescription ?? "เลือกได้หลายเงื่อนไข ผลลัพธ์จะอัปเดตทันที"}
|
|
174
|
+
</SheetDescription>
|
|
171
175
|
<SheetClose
|
|
172
176
|
render={
|
|
173
177
|
<Button variant="ghost" size="icon-sm" className="absolute top-3 right-3" />
|
|
174
178
|
}
|
|
175
179
|
>
|
|
176
180
|
<X />
|
|
177
|
-
<span className="sr-only"
|
|
181
|
+
<span className="sr-only">{text.table?.closeFilters ?? "ปิดตัวกรอง"}</span>
|
|
178
182
|
</SheetClose>
|
|
179
183
|
</SheetHeader>
|
|
180
184
|
<div className="flex min-h-0 flex-1 flex-col gap-2 overflow-y-auto px-4">
|
|
@@ -231,7 +235,10 @@ export function DataTableToolbar<TData>({
|
|
|
231
235
|
<span className="font-medium">{displayedLabels}</span>
|
|
232
236
|
<button
|
|
233
237
|
type="button"
|
|
234
|
-
aria-label={
|
|
238
|
+
aria-label={`${(text.table?.clearFilter ?? "ล้างตัวกรอง {name}").replace(
|
|
239
|
+
"{name}",
|
|
240
|
+
config.title,
|
|
241
|
+
)}: ${displayedLabels}`}
|
|
235
242
|
onClick={() => table.getColumn(filter.id)?.setFilterValue(undefined)}
|
|
236
243
|
className="ml-1 rounded-full outline-none hover:bg-muted-foreground/20"
|
|
237
244
|
>
|
|
@@ -2,6 +2,7 @@
|
|
|
2
2
|
|
|
3
3
|
import { Button } from "@suphark/ui/components/shared/button/button";
|
|
4
4
|
import { SwitcherChevron } from "@suphark/ui/components/shared/switcher-chevron";
|
|
5
|
+
import { useUiText } from "@suphark/ui/components/shared/ui-text";
|
|
5
6
|
import {
|
|
6
7
|
Command,
|
|
7
8
|
CommandEmpty,
|
|
@@ -68,6 +69,7 @@ export function FormFieldCombobox<T extends FieldValues>({
|
|
|
68
69
|
autoFocus,
|
|
69
70
|
onSelect,
|
|
70
71
|
}: FormFieldComboboxProps<T>) {
|
|
72
|
+
const text = useUiText();
|
|
71
73
|
const cacheKey = fetcherKey ?? fetcher;
|
|
72
74
|
const [options, setOptions] = useState<{ label: string; value: string; subLabel?: string }[]>(
|
|
73
75
|
() => cacheGet(cacheKey) || [],
|
|
@@ -133,9 +135,14 @@ export function FormFieldCombobox<T extends FieldValues>({
|
|
|
133
135
|
</PopoverTrigger>
|
|
134
136
|
<PopoverContent className="w-[--anchor-width] p-0">
|
|
135
137
|
<Command>
|
|
136
|
-
<CommandInput
|
|
138
|
+
<CommandInput
|
|
139
|
+
placeholder={(text.combobox?.search ?? "ค้นหา {label}…").replace(
|
|
140
|
+
"{label}",
|
|
141
|
+
placeholder.toLowerCase(),
|
|
142
|
+
)}
|
|
143
|
+
/>
|
|
137
144
|
<CommandList>
|
|
138
|
-
<CommandEmpty
|
|
145
|
+
<CommandEmpty>{text.combobox?.empty ?? "ไม่พบข้อมูล"}</CommandEmpty>
|
|
139
146
|
<CommandGroup>
|
|
140
147
|
{options.map((option) => (
|
|
141
148
|
<CommandItem
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
"use client";
|
|
2
2
|
|
|
3
3
|
import { Button } from "@suphark/ui/components/shared/button/button";
|
|
4
|
+
import { useUiText } from "@suphark/ui/components/shared/ui-text";
|
|
4
5
|
import { buttonVariants } from "@suphark/ui/components/ui/button";
|
|
5
6
|
import { Card, CardContent, CardHeader, CardTitle } from "@suphark/ui/components/ui/card";
|
|
6
7
|
import { cn } from "@suphark/ui/lib/utils";
|
|
@@ -55,6 +56,7 @@ export function ErrorState({
|
|
|
55
56
|
signInHref = "/sign-in",
|
|
56
57
|
className,
|
|
57
58
|
}: ErrorStateProps) {
|
|
59
|
+
const text = useUiText();
|
|
58
60
|
const [sessionExpired, setSessionExpired] = useState(false);
|
|
59
61
|
|
|
60
62
|
useEffect(() => {
|
|
@@ -100,19 +102,21 @@ export function ErrorState({
|
|
|
100
102
|
<LogIn className="h-10 w-10 text-primary" />
|
|
101
103
|
</div>
|
|
102
104
|
</div>
|
|
103
|
-
<CardTitle className="text-2xl"
|
|
105
|
+
<CardTitle className="text-2xl">
|
|
106
|
+
{text.sessionExpired?.title ?? "เซสชันหมดอายุ"}
|
|
107
|
+
</CardTitle>
|
|
104
108
|
</CardHeader>
|
|
105
109
|
<CardContent className="space-y-4 text-center">
|
|
106
110
|
<p className="text-muted-foreground">
|
|
107
|
-
|
|
108
|
-
|
|
111
|
+
{text.sessionExpired?.description ??
|
|
112
|
+
"คุณถูกออกจากระบบระหว่างใช้งาน ข้อมูลที่ยังไม่ได้บันทึกจะหายไป กรุณาเข้าสู่ระบบอีกครั้ง แล้วระบบจะพากลับมาหน้านี้"}
|
|
109
113
|
</p>
|
|
110
114
|
<div className="flex justify-center pt-2">
|
|
111
115
|
{/* full page load เพื่อให้ proxy/เซสชันเริ่มใหม่สะอาด ๆ */}
|
|
112
116
|
{/* eslint-disable-next-line @next/next/no-html-link-for-pages */}
|
|
113
117
|
<a href={signInUrl} className={cn(buttonVariants())}>
|
|
114
118
|
<LogIn className="mr-2 h-4 w-4" />
|
|
115
|
-
เข้าสู่ระบบ
|
|
119
|
+
{text.sessionExpired?.signIn ?? "เข้าสู่ระบบ"}
|
|
116
120
|
</a>
|
|
117
121
|
</div>
|
|
118
122
|
</CardContent>
|
|
@@ -62,6 +62,8 @@ export function MonthStepper({
|
|
|
62
62
|
const nowInRange = (!min || compareMonth(now, min) >= 0) && (!max || compareMonth(now, max) <= 0);
|
|
63
63
|
const label = formatLabel ? formatLabel(value) : formatMonthKey(value, locale, format);
|
|
64
64
|
const isCurrent = value === now;
|
|
65
|
+
// กดชื่อเดือนเพื่อกลับเดือนนี้ได้ก็ต่อเมื่อยังไม่ได้อยู่เดือนนี้ และเดือนนี้อยู่ในช่วงที่เลือกได้
|
|
66
|
+
const canReset = resetToCurrent && !isCurrent && nowInRange;
|
|
65
67
|
|
|
66
68
|
return (
|
|
67
69
|
<div
|
|
@@ -78,23 +80,23 @@ export function MonthStepper({
|
|
|
78
80
|
>
|
|
79
81
|
<ChevronLeft />
|
|
80
82
|
</Button>
|
|
81
|
-
{
|
|
83
|
+
{canReset ? (
|
|
82
84
|
<Button
|
|
83
85
|
type="button"
|
|
84
86
|
variant="ghost"
|
|
85
|
-
|
|
86
|
-
// ปุ่มกลางแสดงชื่อเดือนเสมอ — ปิดเมื่ออยู่เดือนนี้แล้วแต่ยังต้องอ่านออก ไม่ให้จางแบบ disabled ปกติ
|
|
87
|
-
className="min-w-32 gap-2 tabular-nums disabled:opacity-100"
|
|
87
|
+
className="min-w-32 gap-2 tabular-nums"
|
|
88
88
|
onClick={() => onValueChange(now)}
|
|
89
|
-
|
|
89
|
+
tooltip={text?.current ?? "กลับไปเดือนนี้"}
|
|
90
90
|
aria-live="polite"
|
|
91
91
|
>
|
|
92
92
|
<CalendarDays className="text-muted-foreground" />
|
|
93
93
|
{label}
|
|
94
94
|
</Button>
|
|
95
95
|
) : (
|
|
96
|
+
// อยู่เดือนนี้แล้ว (หรือปิด resetToCurrent) = ไม่มีอะไรให้กด — เป็นป้ายชื่อเดือน ไม่ใช่ปุ่มที่ปิดอยู่
|
|
97
|
+
// (เดิมเป็น <Button disabled> ซึ่ง screen reader อ่านว่า "ปุ่ม, ปิดใช้งาน" ทั้งที่มันคือป้าย — DueBell รายงาน)
|
|
96
98
|
<span
|
|
97
|
-
className="flex min-w-32 items-center justify-center gap-2 px-2 font-medium text-sm tabular-nums"
|
|
99
|
+
className="flex h-8 min-w-32 items-center justify-center gap-2 px-2 font-medium text-sm tabular-nums"
|
|
98
100
|
aria-live="polite"
|
|
99
101
|
>
|
|
100
102
|
<CalendarDays className="size-4 text-muted-foreground" />
|
|
@@ -2,6 +2,7 @@
|
|
|
2
2
|
|
|
3
3
|
import { Button } from "@suphark/ui/components/shared/button/button";
|
|
4
4
|
import { SwitcherChevron } from "@suphark/ui/components/shared/switcher-chevron";
|
|
5
|
+
import { useUiText } from "@suphark/ui/components/shared/ui-text";
|
|
5
6
|
import { Badge } from "@suphark/ui/components/ui/badge";
|
|
6
7
|
import {
|
|
7
8
|
Command,
|
|
@@ -90,6 +91,7 @@ export function MultiSelectCombobox({
|
|
|
90
91
|
ariaLabel,
|
|
91
92
|
wrapText = false,
|
|
92
93
|
}: MultiSelectComboboxProps) {
|
|
94
|
+
const text = useUiText();
|
|
93
95
|
const [open, setOpen] = React.useState(false);
|
|
94
96
|
const [inputValue, setInputValue] = React.useState("");
|
|
95
97
|
|
|
@@ -213,7 +215,10 @@ export function MultiSelectCombobox({
|
|
|
213
215
|
<span
|
|
214
216
|
role="button"
|
|
215
217
|
tabIndex={0}
|
|
216
|
-
aria-label={
|
|
218
|
+
aria-label={(text.combobox?.remove ?? "ลบ {label}").replace(
|
|
219
|
+
"{label}",
|
|
220
|
+
getLabel(val),
|
|
221
|
+
)}
|
|
217
222
|
onKeyDown={(e) => {
|
|
218
223
|
if (e.key === "Enter" || e.key === " ") {
|
|
219
224
|
e.preventDefault();
|
|
@@ -246,8 +251,8 @@ export function MultiSelectCombobox({
|
|
|
246
251
|
<span
|
|
247
252
|
role="button"
|
|
248
253
|
tabIndex={0}
|
|
249
|
-
title="ล้างทั้งหมด"
|
|
250
|
-
aria-label="ล้างทั้งหมด"
|
|
254
|
+
title={text.combobox?.clearAll ?? "ล้างทั้งหมด"}
|
|
255
|
+
aria-label={text.combobox?.clearAll ?? "ล้างทั้งหมด"}
|
|
251
256
|
onClick={handleClearAll}
|
|
252
257
|
onKeyDown={(e) => {
|
|
253
258
|
if (e.key === "Enter" || e.key === " ") {
|
|
@@ -378,7 +383,9 @@ export function MultiSelectCombobox({
|
|
|
378
383
|
className="flex cursor-pointer items-center gap-2 text-primary"
|
|
379
384
|
>
|
|
380
385
|
<Plus className="size-4" />
|
|
381
|
-
<span
|
|
386
|
+
<span>
|
|
387
|
+
{(text.combobox?.add ?? "เพิ่ม “{value}”").replace("{value}", inputValue)}
|
|
388
|
+
</span>
|
|
382
389
|
</CommandItem>
|
|
383
390
|
</CommandGroup>
|
|
384
391
|
</>
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
"use client";
|
|
2
2
|
|
|
3
3
|
import { Button } from "@suphark/ui/components/shared/button/button";
|
|
4
|
+
import { useUiText } from "@suphark/ui/components/shared/ui-text";
|
|
4
5
|
import { Badge } from "@suphark/ui/components/ui/badge";
|
|
5
6
|
import { Checkbox } from "@suphark/ui/components/ui/checkbox";
|
|
6
7
|
import { Empty, EmptyHeader, EmptyTitle } from "@suphark/ui/components/ui/empty";
|
|
@@ -94,6 +95,7 @@ export function ResourcePropertyFilter({
|
|
|
94
95
|
triggerLabel = "คุณสมบัติ Resource",
|
|
95
96
|
triggerClassName,
|
|
96
97
|
}: ResourcePropertyFilterProps) {
|
|
98
|
+
const text = useUiText();
|
|
97
99
|
const idPrefix = useId();
|
|
98
100
|
const [propertySearch, setPropertySearch] = useState("");
|
|
99
101
|
const deferredPropertySearch = useDeferredValue(propertySearch);
|
|
@@ -136,8 +138,10 @@ export function ResourcePropertyFilter({
|
|
|
136
138
|
<PopoverHeader className="border-b p-4">
|
|
137
139
|
<div className="flex items-start justify-between gap-3">
|
|
138
140
|
<div className="flex flex-col gap-1">
|
|
139
|
-
<PopoverTitle
|
|
140
|
-
<PopoverDescription
|
|
141
|
+
<PopoverTitle>{text.filter?.title ?? "กรองตามคุณสมบัติ"}</PopoverTitle>
|
|
142
|
+
<PopoverDescription>
|
|
143
|
+
{text.filter?.description ?? "ค่าในคุณสมบัติเดียวกันเป็น OR และคนละคุณสมบัติเป็น AND"}
|
|
144
|
+
</PopoverDescription>
|
|
141
145
|
</div>
|
|
142
146
|
{activeCount > 0 ? (
|
|
143
147
|
<Button type="button" variant="ghost" size="xs" onClick={() => onChange({})}>
|
|
@@ -155,15 +159,15 @@ export function ResourcePropertyFilter({
|
|
|
155
159
|
<InputGroupInput
|
|
156
160
|
value={propertySearch}
|
|
157
161
|
onChange={(event) => setPropertySearch(event.target.value)}
|
|
158
|
-
placeholder=
|
|
159
|
-
aria-label="
|
|
162
|
+
placeholder={text.filter?.searchProperty ?? "ค้นหาคุณสมบัติ…"}
|
|
163
|
+
aria-label={text.filter?.searchProperty ?? "ค้นหาคุณสมบัติ…"}
|
|
160
164
|
/>
|
|
161
165
|
{propertySearch ? (
|
|
162
166
|
<InputGroupAddon align="inline-end">
|
|
163
167
|
<InputGroupButton
|
|
164
168
|
size="icon-xs"
|
|
165
169
|
onClick={() => setPropertySearch("")}
|
|
166
|
-
aria-label="ล้างคำค้นหา"
|
|
170
|
+
aria-label={text.filter?.clearSearch ?? "ล้างคำค้นหา"}
|
|
167
171
|
>
|
|
168
172
|
<X />
|
|
169
173
|
</InputGroupButton>
|
|
@@ -197,7 +201,10 @@ export function ResourcePropertyFilter({
|
|
|
197
201
|
variant="ghost"
|
|
198
202
|
size="icon-xs"
|
|
199
203
|
onClick={() => clearFilter(definition.propertyTemplateId)}
|
|
200
|
-
aria-label={
|
|
204
|
+
aria-label={(text.filter?.clearOne ?? "ล้างตัวกรอง {name}").replace(
|
|
205
|
+
"{name}",
|
|
206
|
+
definition.propertyName,
|
|
207
|
+
)}
|
|
201
208
|
>
|
|
202
209
|
<X />
|
|
203
210
|
</Button>
|
|
@@ -230,8 +237,11 @@ export function ResourcePropertyFilter({
|
|
|
230
237
|
query: event.target.value,
|
|
231
238
|
})
|
|
232
239
|
}
|
|
233
|
-
placeholder="
|
|
234
|
-
aria-label={
|
|
240
|
+
placeholder={text.filter?.searchValue ?? "ค้นหาข้อความ…"}
|
|
241
|
+
aria-label={(text.filter?.filterOne ?? "กรอง {name}").replace(
|
|
242
|
+
"{name}",
|
|
243
|
+
definition.propertyName,
|
|
244
|
+
)}
|
|
235
245
|
/>
|
|
236
246
|
)}
|
|
237
247
|
</Field>
|
|
@@ -241,7 +251,7 @@ export function ResourcePropertyFilter({
|
|
|
241
251
|
) : (
|
|
242
252
|
<Empty className="min-h-40 rounded-none border-0">
|
|
243
253
|
<EmptyHeader>
|
|
244
|
-
<EmptyTitle
|
|
254
|
+
<EmptyTitle>{text.filter?.noProperties ?? "ไม่พบคุณสมบัติ"}</EmptyTitle>
|
|
245
255
|
</EmptyHeader>
|
|
246
256
|
</Empty>
|
|
247
257
|
)}
|
|
@@ -250,7 +260,10 @@ export function ResourcePropertyFilter({
|
|
|
250
260
|
</Popover>
|
|
251
261
|
|
|
252
262
|
{activeCount > 0 ? (
|
|
253
|
-
<fieldset
|
|
263
|
+
<fieldset
|
|
264
|
+
className="flex flex-wrap gap-1 border-0 p-0"
|
|
265
|
+
aria-label={text.filter?.active ?? "ตัวกรองคุณสมบัติที่ใช้งาน"}
|
|
266
|
+
>
|
|
254
267
|
{definitions.map((definition) => {
|
|
255
268
|
const filter = filters[definition.propertyTemplateId];
|
|
256
269
|
if (!isResourcePropertyFilterActive(filter)) return null;
|
|
@@ -265,7 +278,10 @@ export function ResourcePropertyFilter({
|
|
|
265
278
|
variant="ghost"
|
|
266
279
|
size="icon-xs"
|
|
267
280
|
onClick={() => clearFilter(definition.propertyTemplateId)}
|
|
268
|
-
aria-label={
|
|
281
|
+
aria-label={(text.filter?.clearOne ?? "ล้างตัวกรอง {name}").replace(
|
|
282
|
+
"{name}",
|
|
283
|
+
definition.propertyName,
|
|
284
|
+
)}
|
|
269
285
|
>
|
|
270
286
|
<X />
|
|
271
287
|
</Button>
|
|
@@ -327,6 +343,7 @@ function NumberPropertyField({
|
|
|
327
343
|
filter?: Extract<ResourcePropertyFilterValue, { dataType: "NUMBER" }>;
|
|
328
344
|
onChange: (filter: Extract<ResourcePropertyFilterValue, { dataType: "NUMBER" }>) => void;
|
|
329
345
|
}) {
|
|
346
|
+
const text = useUiText();
|
|
330
347
|
const currentFilter = filter ?? {
|
|
331
348
|
dataType: "NUMBER" as const,
|
|
332
349
|
min: "",
|
|
@@ -342,8 +359,8 @@ function NumberPropertyField({
|
|
|
342
359
|
step="any"
|
|
343
360
|
value={currentFilter.min}
|
|
344
361
|
onChange={(event) => onChange({ ...currentFilter, min: event.target.value })}
|
|
345
|
-
placeholder="ต่ำสุด"
|
|
346
|
-
aria-label={`${definition.propertyName}
|
|
362
|
+
placeholder={text.filter?.min ?? "ต่ำสุด"}
|
|
363
|
+
aria-label={`${definition.propertyName} ${text.filter?.min ?? "ต่ำสุด"}`}
|
|
347
364
|
/>
|
|
348
365
|
<Input
|
|
349
366
|
type="number"
|
|
@@ -351,8 +368,8 @@ function NumberPropertyField({
|
|
|
351
368
|
step="any"
|
|
352
369
|
value={currentFilter.max}
|
|
353
370
|
onChange={(event) => onChange({ ...currentFilter, max: event.target.value })}
|
|
354
|
-
placeholder="สูงสุด"
|
|
355
|
-
aria-label={`${definition.propertyName}
|
|
371
|
+
placeholder={text.filter?.max ?? "สูงสุด"}
|
|
372
|
+
aria-label={`${definition.propertyName} ${text.filter?.max ?? "สูงสุด"}`}
|
|
356
373
|
/>
|
|
357
374
|
{definition.units.length > 0 ? (
|
|
358
375
|
<Select
|
|
@@ -360,7 +377,7 @@ function NumberPropertyField({
|
|
|
360
377
|
onValueChange={(unitId) => onChange({ ...currentFilter, unitId })}
|
|
361
378
|
>
|
|
362
379
|
<SelectTrigger className="col-span-2 w-full">
|
|
363
|
-
<SelectValue placeholder="เลือกหน่วย" />
|
|
380
|
+
<SelectValue placeholder={text.filter?.unit ?? "เลือกหน่วย"} />
|
|
364
381
|
</SelectTrigger>
|
|
365
382
|
<SelectContent>
|
|
366
383
|
<SelectGroup>
|
|
@@ -5,12 +5,28 @@ import { cn } from "@suphark/ui/lib/utils";
|
|
|
5
5
|
import type { ReactNode } from "react";
|
|
6
6
|
import { CountBadge } from "./count-badge";
|
|
7
7
|
|
|
8
|
+
export type SegmentedTone = "default" | "success" | "warning" | "destructive" | "info";
|
|
9
|
+
|
|
10
|
+
/** สีตอนถูกเลือกเท่านั้น — ตัวที่ไม่ได้เลือกคงหน้าตากลาง ๆ ไม่ให้แถบกลายเป็นรุ้ง */
|
|
11
|
+
const TONE: Record<SegmentedTone, string> = {
|
|
12
|
+
default: "",
|
|
13
|
+
success: "aria-pressed:bg-success-subtle aria-pressed:text-success-subtle-foreground",
|
|
14
|
+
warning: "aria-pressed:bg-warning-subtle aria-pressed:text-warning-subtle-foreground",
|
|
15
|
+
destructive: "aria-pressed:bg-destructive-subtle aria-pressed:text-destructive-subtle-foreground",
|
|
16
|
+
info: "aria-pressed:bg-info-subtle aria-pressed:text-info-subtle-foreground",
|
|
17
|
+
};
|
|
18
|
+
|
|
8
19
|
export interface SegmentedOption<T extends string> {
|
|
9
20
|
value: T;
|
|
10
21
|
label: ReactNode;
|
|
11
22
|
icon?: ReactNode;
|
|
12
23
|
/** จำนวนท้ายตัวเลือก เช่นจำนวนรายการในตัวกรองนั้น */
|
|
13
24
|
count?: number;
|
|
25
|
+
/**
|
|
26
|
+
* สีตามความหมายของตัวเลือกนั้นเมื่อถูกเลือก (รายรับ = success, รายจ่าย = destructive)
|
|
27
|
+
* ไม่ส่ง = พื้นเทาเหมือนเดิม — ใช้เมื่อ "ประเภท" มีความหมายทางสี ไม่ใช่ตกแต่ง
|
|
28
|
+
*/
|
|
29
|
+
tone?: SegmentedTone;
|
|
14
30
|
disabled?: boolean;
|
|
15
31
|
}
|
|
16
32
|
|
|
@@ -59,7 +75,7 @@ export function SegmentedControl<T extends string>({
|
|
|
59
75
|
key={o.value}
|
|
60
76
|
value={o.value}
|
|
61
77
|
disabled={o.disabled}
|
|
62
|
-
className={cn("gap-1.5", fullWidth && "flex-1")}
|
|
78
|
+
className={cn("gap-1.5", TONE[o.tone ?? "default"], fullWidth && "flex-1")}
|
|
63
79
|
>
|
|
64
80
|
{o.icon}
|
|
65
81
|
{o.label}
|
|
@@ -32,6 +32,39 @@ export interface UiText {
|
|
|
32
32
|
dropped: string;
|
|
33
33
|
cancelled: string;
|
|
34
34
|
}>;
|
|
35
|
+
/** MultiSelectCombobox / FormFieldCombobox (optional) — `search`/`remove` ใช้ `{label}`, `add` ใช้ `{value}` */
|
|
36
|
+
combobox?: Partial<{
|
|
37
|
+
search: string;
|
|
38
|
+
empty: string;
|
|
39
|
+
clearAll: string;
|
|
40
|
+
remove: string;
|
|
41
|
+
add: string;
|
|
42
|
+
}>;
|
|
43
|
+
/** ResourcePropertyFilter (optional) — ตัวที่ใช้ `{name}` คือชื่อคุณสมบัติ */
|
|
44
|
+
filter?: Partial<{
|
|
45
|
+
title: string;
|
|
46
|
+
description: string;
|
|
47
|
+
searchProperty: string;
|
|
48
|
+
clearSearch: string;
|
|
49
|
+
noProperties: string;
|
|
50
|
+
active: string;
|
|
51
|
+
searchValue: string;
|
|
52
|
+
filterOne: string;
|
|
53
|
+
clearOne: string;
|
|
54
|
+
min: string;
|
|
55
|
+
max: string;
|
|
56
|
+
unit: string;
|
|
57
|
+
}>;
|
|
58
|
+
/** AppShell เมนู (optional) — `noResults` ใช้ `{query}` */
|
|
59
|
+
nav?: Partial<{
|
|
60
|
+
main: string;
|
|
61
|
+
sub: string;
|
|
62
|
+
choosePage: string;
|
|
63
|
+
noResults: string;
|
|
64
|
+
clearSearch: string;
|
|
65
|
+
}>;
|
|
66
|
+
/** ErrorState เซสชันหมดอายุ (optional) */
|
|
67
|
+
sessionExpired?: Partial<{ title: string; description: string; signIn: string }>;
|
|
35
68
|
/** SelectionBar (optional) — `selected` ใช้ `{count}` */
|
|
36
69
|
selection?: Partial<{ selected: string; clear: string }>;
|
|
37
70
|
/** DataTable (optional) — `rowsSelected` ใช้ `{selected}`/`{total}`, `pageOf` ใช้ `{page}`/`{pages}` */
|
|
@@ -46,6 +79,13 @@ export interface UiText {
|
|
|
46
79
|
deleting: string;
|
|
47
80
|
deleted: string;
|
|
48
81
|
delete: string;
|
|
82
|
+
search: string;
|
|
83
|
+
clearSearch: string;
|
|
84
|
+
filters: string;
|
|
85
|
+
filtersDescription: string;
|
|
86
|
+
closeFilters: string;
|
|
87
|
+
/** ใช้ `{name}` */
|
|
88
|
+
clearFilter: string;
|
|
49
89
|
}>;
|
|
50
90
|
/** DialogsProvider: ปุ่มปิดของ alert (optional — ค่าเริ่มต้น "ตกลง") */
|
|
51
91
|
ok?: string;
|
|
@@ -163,6 +203,40 @@ export const UI_TEXT_TH: UiText = {
|
|
|
163
203
|
confirmTitle: "ยืนยันการทำรายการ",
|
|
164
204
|
actionFailed: "ดำเนินการไม่สำเร็จ",
|
|
165
205
|
edit: "แก้ไข",
|
|
206
|
+
combobox: {
|
|
207
|
+
search: "ค้นหา {label}…",
|
|
208
|
+
empty: "ไม่พบข้อมูล",
|
|
209
|
+
clearAll: "ล้างทั้งหมด",
|
|
210
|
+
remove: "ลบ {label}",
|
|
211
|
+
add: "เพิ่ม “{value}”",
|
|
212
|
+
},
|
|
213
|
+
filter: {
|
|
214
|
+
title: "กรองตามคุณสมบัติ",
|
|
215
|
+
description: "ค่าในคุณสมบัติเดียวกันเป็น OR และคนละคุณสมบัติเป็น AND",
|
|
216
|
+
searchProperty: "ค้นหาคุณสมบัติ…",
|
|
217
|
+
clearSearch: "ล้างคำค้นหา",
|
|
218
|
+
noProperties: "ไม่พบคุณสมบัติ",
|
|
219
|
+
active: "ตัวกรองคุณสมบัติที่ใช้งาน",
|
|
220
|
+
searchValue: "ค้นหาข้อความ…",
|
|
221
|
+
filterOne: "กรอง {name}",
|
|
222
|
+
clearOne: "ล้างตัวกรอง {name}",
|
|
223
|
+
min: "ต่ำสุด",
|
|
224
|
+
max: "สูงสุด",
|
|
225
|
+
unit: "เลือกหน่วย",
|
|
226
|
+
},
|
|
227
|
+
nav: {
|
|
228
|
+
main: "เมนูหลัก",
|
|
229
|
+
sub: "เมนูย่อย",
|
|
230
|
+
choosePage: "เลือกหน้า…",
|
|
231
|
+
noResults: "ไม่พบ “{query}”",
|
|
232
|
+
clearSearch: "ล้างคำค้นหาเมนู",
|
|
233
|
+
},
|
|
234
|
+
sessionExpired: {
|
|
235
|
+
title: "เซสชันหมดอายุ",
|
|
236
|
+
description:
|
|
237
|
+
"คุณถูกออกจากระบบระหว่างใช้งาน ข้อมูลที่ยังไม่ได้บันทึกจะหายไป กรุณาเข้าสู่ระบบอีกครั้ง แล้วระบบจะพากลับมาหน้านี้",
|
|
238
|
+
signIn: "เข้าสู่ระบบ",
|
|
239
|
+
},
|
|
166
240
|
selection: { selected: "เลือก {count} รายการ", clear: "ล้างการเลือก" },
|
|
167
241
|
sortable: {
|
|
168
242
|
handle: "ลากเพื่อจัดลำดับ",
|
|
@@ -182,6 +256,12 @@ export const UI_TEXT_TH: UiText = {
|
|
|
182
256
|
deleting: "กำลังลบ{resource} “{name}”…",
|
|
183
257
|
deleted: "ลบ{resource} “{name}” แล้ว",
|
|
184
258
|
delete: "ลบ",
|
|
259
|
+
search: "ค้นหาข้อมูลในตาราง",
|
|
260
|
+
clearSearch: "ล้างคำค้นหา",
|
|
261
|
+
filters: "ตัวกรองข้อมูล",
|
|
262
|
+
filtersDescription: "เลือกได้หลายเงื่อนไข ผลลัพธ์จะอัปเดตทันที",
|
|
263
|
+
closeFilters: "ปิดตัวกรอง",
|
|
264
|
+
clearFilter: "ล้างตัวกรอง {name}",
|
|
185
265
|
},
|
|
186
266
|
ok: "ตกลง",
|
|
187
267
|
alertTitle: "แจ้งให้ทราบ",
|
|
@@ -282,6 +362,40 @@ export const UI_TEXT_EN: UiText = {
|
|
|
282
362
|
confirmTitle: "Please confirm",
|
|
283
363
|
actionFailed: "Action failed",
|
|
284
364
|
edit: "Edit",
|
|
365
|
+
combobox: {
|
|
366
|
+
search: "Search {label}…",
|
|
367
|
+
empty: "No results",
|
|
368
|
+
clearAll: "Clear all",
|
|
369
|
+
remove: "Remove {label}",
|
|
370
|
+
add: "Add “{value}”",
|
|
371
|
+
},
|
|
372
|
+
filter: {
|
|
373
|
+
title: "Filter by property",
|
|
374
|
+
description: "Values in the same property are OR, different properties are AND",
|
|
375
|
+
searchProperty: "Search properties…",
|
|
376
|
+
clearSearch: "Clear search",
|
|
377
|
+
noProperties: "No properties found",
|
|
378
|
+
active: "Active property filters",
|
|
379
|
+
searchValue: "Search values…",
|
|
380
|
+
filterOne: "Filter {name}",
|
|
381
|
+
clearOne: "Clear {name} filter",
|
|
382
|
+
min: "Min",
|
|
383
|
+
max: "Max",
|
|
384
|
+
unit: "Select unit",
|
|
385
|
+
},
|
|
386
|
+
nav: {
|
|
387
|
+
main: "Main menu",
|
|
388
|
+
sub: "Section menu",
|
|
389
|
+
choosePage: "Choose a page…",
|
|
390
|
+
noResults: "No match for “{query}”",
|
|
391
|
+
clearSearch: "Clear menu search",
|
|
392
|
+
},
|
|
393
|
+
sessionExpired: {
|
|
394
|
+
title: "Session expired",
|
|
395
|
+
description:
|
|
396
|
+
"You were signed out while working. Unsaved changes are lost — sign in again and we will bring you back to this page.",
|
|
397
|
+
signIn: "Sign in",
|
|
398
|
+
},
|
|
285
399
|
selection: { selected: "{count} selected", clear: "Clear selection" },
|
|
286
400
|
sortable: {
|
|
287
401
|
handle: "Drag to reorder",
|
|
@@ -302,6 +416,12 @@ export const UI_TEXT_EN: UiText = {
|
|
|
302
416
|
deleting: "Deleting{resource} “{name}”…",
|
|
303
417
|
deleted: "“{name}”{resource} deleted",
|
|
304
418
|
delete: "Delete",
|
|
419
|
+
search: "Search the table",
|
|
420
|
+
clearSearch: "Clear search",
|
|
421
|
+
filters: "Filters",
|
|
422
|
+
filtersDescription: "Combine conditions — results update instantly",
|
|
423
|
+
closeFilters: "Close filters",
|
|
424
|
+
clearFilter: "Clear {name} filter",
|
|
305
425
|
},
|
|
306
426
|
ok: "OK",
|
|
307
427
|
alertTitle: "Notice",
|
package/src/index.ts
CHANGED
|
@@ -197,7 +197,11 @@ export {
|
|
|
197
197
|
export { type SaveState, SaveStatus, type SaveStatusText } from "./components/shared/save-status";
|
|
198
198
|
export { SecretReveal } from "./components/shared/secret-reveal";
|
|
199
199
|
export { SectionCard } from "./components/shared/section-card";
|
|
200
|
-
export {
|
|
200
|
+
export {
|
|
201
|
+
SegmentedControl,
|
|
202
|
+
type SegmentedOption,
|
|
203
|
+
type SegmentedTone,
|
|
204
|
+
} from "./components/shared/segmented-control";
|
|
201
205
|
export {
|
|
202
206
|
type SelectionAction,
|
|
203
207
|
SelectionBar,
|