@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 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.29.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,3 @@
1
+ /** ชนิดของ `hardcoded-text.mjs` (สคริปต์เป็น .mjs เพราะ CI/เทสต์เรียกได้ทั้งคู่) */
2
+ export declare function stripComments(source: string): string[];
3
+ export declare function findHardcodedThai(source: string): Array<{ line: number; text: string }>;
@@ -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="">เลือกหน้า…</option>}
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">ไม่พบ “{query}”</p>
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 aria-label="เมนูหลัก" className={cn("no-scrollbar -mb-px flex overflow-x-auto", className)}>
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>ไม่พบข้อมูล</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>ตัวกรองข้อมูล</SheetTitle>
170
- <SheetDescription>เลือกได้หลายเงื่อนไข ผลลัพธ์จะอัปเดตทันที</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">ปิดตัวกรอง</span>
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={`ล้างตัวกรอง ${config.title}: ${displayedLabels}`}
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 placeholder={`ค้นหา ${placeholder.toLowerCase()}...`} />
138
+ <CommandInput
139
+ placeholder={(text.combobox?.search ?? "ค้นหา {label}…").replace(
140
+ "{label}",
141
+ placeholder.toLowerCase(),
142
+ )}
143
+ />
137
144
  <CommandList>
138
- <CommandEmpty>ไม่พบข้อมูล</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">เซสชันหมดอายุ</CardTitle>
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
- {resetToCurrent ? (
83
+ {canReset ? (
82
84
  <Button
83
85
  type="button"
84
86
  variant="ghost"
85
- disabled={isCurrent || !nowInRange}
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
- title={isCurrent ? undefined : (text?.current ?? "กลับไปเดือนนี้")}
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={`ลบ ${getLabel(val)}`}
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>เพิ่ม "{inputValue}"</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>กรองตามคุณสมบัติ</PopoverTitle>
140
- <PopoverDescription>ค่าในคุณสมบัติเดียวกันเป็น OR และคนละคุณสมบัติเป็น AND</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="ค้นหาชื่อ Property..."
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={`ล้างตัวกรอง ${definition.propertyName}`}
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={`กรอง ${definition.propertyName}`}
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>ไม่พบคุณสมบัติ</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 className="flex flex-wrap gap-1 border-0 p-0" aria-label="ตัวกรองคุณสมบัติที่ใช้งาน">
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={`ล้างตัวกรอง ${definition.propertyName}`}
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 { SegmentedControl, type SegmentedOption } from "./components/shared/segmented-control";
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,