@suphark/ui 0.28.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.
@@ -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}
@@ -22,6 +22,49 @@ export interface UiText {
22
22
  actionFailed?: string;
23
23
  /** InlineEdit: ปุ่มดินสอ (optional — "แก้ไข") */
24
24
  edit?: string;
25
+ /** SortableList (optional) — ที่จับลาก + คำอธิบายสำหรับ screen reader */
26
+ sortable?: Partial<{
27
+ handle: string;
28
+ instructions: string;
29
+ /** ใช้ `{index}` / `{total}` */
30
+ started: string;
31
+ moved: string;
32
+ dropped: string;
33
+ cancelled: string;
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 }>;
25
68
  /** SelectionBar (optional) — `selected` ใช้ `{count}` */
26
69
  selection?: Partial<{ selected: string; clear: string }>;
27
70
  /** DataTable (optional) — `rowsSelected` ใช้ `{selected}`/`{total}`, `pageOf` ใช้ `{page}`/`{pages}` */
@@ -36,6 +79,13 @@ export interface UiText {
36
79
  deleting: string;
37
80
  deleted: string;
38
81
  delete: string;
82
+ search: string;
83
+ clearSearch: string;
84
+ filters: string;
85
+ filtersDescription: string;
86
+ closeFilters: string;
87
+ /** ใช้ `{name}` */
88
+ clearFilter: string;
39
89
  }>;
40
90
  /** DialogsProvider: ปุ่มปิดของ alert (optional — ค่าเริ่มต้น "ตกลง") */
41
91
  ok?: string;
@@ -153,7 +203,49 @@ export const UI_TEXT_TH: UiText = {
153
203
  confirmTitle: "ยืนยันการทำรายการ",
154
204
  actionFailed: "ดำเนินการไม่สำเร็จ",
155
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
+ },
156
240
  selection: { selected: "เลือก {count} รายการ", clear: "ล้างการเลือก" },
241
+ sortable: {
242
+ handle: "ลากเพื่อจัดลำดับ",
243
+ instructions: "กด Space เพื่อเริ่มลาก ใช้ปุ่มลูกศรเลื่อน แล้วกด Space อีกครั้งเพื่อวาง (Esc = ยกเลิก)",
244
+ started: "เริ่มลากรายการที่ {index} จาก {total}",
245
+ moved: "ย้ายไปตำแหน่งที่ {index} จาก {total}",
246
+ dropped: "วางที่ตำแหน่งที่ {index} จาก {total}",
247
+ cancelled: "ยกเลิกการลาก",
248
+ },
157
249
  table: {
158
250
  rowsPerPage: "แถวต่อหน้า",
159
251
  pageOf: "หน้า {page} จาก {pages}",
@@ -164,6 +256,12 @@ export const UI_TEXT_TH: UiText = {
164
256
  deleting: "กำลังลบ{resource} “{name}”…",
165
257
  deleted: "ลบ{resource} “{name}” แล้ว",
166
258
  delete: "ลบ",
259
+ search: "ค้นหาข้อมูลในตาราง",
260
+ clearSearch: "ล้างคำค้นหา",
261
+ filters: "ตัวกรองข้อมูล",
262
+ filtersDescription: "เลือกได้หลายเงื่อนไข ผลลัพธ์จะอัปเดตทันที",
263
+ closeFilters: "ปิดตัวกรอง",
264
+ clearFilter: "ล้างตัวกรอง {name}",
167
265
  },
168
266
  ok: "ตกลง",
169
267
  alertTitle: "แจ้งให้ทราบ",
@@ -264,7 +362,50 @@ export const UI_TEXT_EN: UiText = {
264
362
  confirmTitle: "Please confirm",
265
363
  actionFailed: "Action failed",
266
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
+ },
267
399
  selection: { selected: "{count} selected", clear: "Clear selection" },
400
+ sortable: {
401
+ handle: "Drag to reorder",
402
+ instructions:
403
+ "Press Space to start dragging, use arrow keys to move, Space again to drop (Esc to cancel)",
404
+ started: "Picked up item {index} of {total}",
405
+ moved: "Moved to position {index} of {total}",
406
+ dropped: "Dropped at position {index} of {total}",
407
+ cancelled: "Dragging cancelled",
408
+ },
268
409
  table: {
269
410
  rowsPerPage: "Rows per page",
270
411
  pageOf: "Page {page} of {pages}",
@@ -275,6 +416,12 @@ export const UI_TEXT_EN: UiText = {
275
416
  deleting: "Deleting{resource} “{name}”…",
276
417
  deleted: "“{name}”{resource} deleted",
277
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",
278
425
  },
279
426
  ok: "OK",
280
427
  alertTitle: "Notice",