@suphark/ui 0.1.0 → 0.2.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.
Files changed (30) hide show
  1. package/CHANGELOG.md +26 -0
  2. package/README.md +5 -1
  3. package/package.json +16 -2
  4. package/src/components/data-table/data-table-column-header.tsx +71 -0
  5. package/src/components/data-table/data-table-date-range-filter.tsx +25 -0
  6. package/src/components/data-table/data-table-faceted-filter.tsx +181 -0
  7. package/src/components/data-table/data-table-pagination.tsx +114 -0
  8. package/src/components/data-table/data-table-row-actions.tsx +166 -0
  9. package/src/components/data-table/data-table-toolbar.tsx +207 -0
  10. package/src/components/data-table/data-table-view-option.tsx +59 -0
  11. package/src/components/data-table/data-table.tsx +399 -0
  12. package/src/components/data-table/index.ts +9 -0
  13. package/src/components/data-table/types.ts +106 -0
  14. package/src/components/data-table/use-data-table.ts +229 -0
  15. package/src/components/design-system/sections/showcase-data-display.tsx +71 -0
  16. package/src/components/design-system/sections/showcase-guidelines-recipes.tsx +8 -8
  17. package/src/components/shared/button/delete-many-button.tsx +85 -0
  18. package/src/components/shared/resource-property-filter.tsx +399 -0
  19. package/src/components/ui/native-select.tsx +54 -0
  20. package/src/data-table.ts +21 -0
  21. package/src/hooks/use-table-search-params.ts +147 -0
  22. package/src/index.ts +1 -0
  23. package/src/next.ts +5 -0
  24. package/src/components/shared/__tests__/multi-select-combobox.test.tsx +0 -180
  25. package/src/components/shared/__tests__/name-id-fields.test.ts +0 -24
  26. package/src/components/shared/form/__tests__/simple-select.test.tsx +0 -72
  27. package/src/components/ui/__tests__/combobox.test.tsx +0 -40
  28. package/src/components/ui/__tests__/date-picker.test.tsx +0 -53
  29. package/src/components/ui/__tests__/slider.test.tsx +0 -18
  30. package/src/components/ui/__tests__/tabs.test.tsx +0 -33
@@ -0,0 +1,399 @@
1
+ "use client";
2
+
3
+ import { Badge } from "@suphark/ui/components/ui/badge";
4
+ import { Button } from "@suphark/ui/components/ui/button";
5
+ import { Checkbox } from "@suphark/ui/components/ui/checkbox";
6
+ import { Empty, EmptyHeader, EmptyTitle } from "@suphark/ui/components/ui/empty";
7
+ import { Field, FieldGroup, FieldLabel } from "@suphark/ui/components/ui/field";
8
+ import { Input } from "@suphark/ui/components/ui/input";
9
+ import {
10
+ InputGroup,
11
+ InputGroupAddon,
12
+ InputGroupButton,
13
+ InputGroupInput,
14
+ } from "@suphark/ui/components/ui/input-group";
15
+ import {
16
+ Popover,
17
+ PopoverContent,
18
+ PopoverDescription,
19
+ PopoverHeader,
20
+ PopoverTitle,
21
+ PopoverTrigger,
22
+ } from "@suphark/ui/components/ui/popover";
23
+ import { ScrollArea } from "@suphark/ui/components/ui/scroll-area";
24
+ import {
25
+ Select,
26
+ SelectContent,
27
+ SelectGroup,
28
+ SelectItem,
29
+ SelectTrigger,
30
+ SelectValue,
31
+ } from "@suphark/ui/components/ui/select";
32
+ import { cn } from "@suphark/ui/lib/utils";
33
+ import { ListFilter, RotateCcw, Search, X } from "lucide-react";
34
+ import { useDeferredValue, useId, useState } from "react";
35
+
36
+ export type ResourcePropertyFilterValue =
37
+ | { dataType: "DROPDOWN"; values: string[] }
38
+ | { dataType: "NUMBER"; min: string; max: string; unitId: string | null }
39
+ | { dataType: "TEXT"; query: string };
40
+
41
+ export type ResourcePropertyFilterState = Record<string, ResourcePropertyFilterValue>;
42
+
43
+ export interface ResourcePropertyFilterDefinition {
44
+ propertyTemplateId: string;
45
+ propertyName: string;
46
+ dataType: "TEXT" | "NUMBER" | "DROPDOWN" | "DATABASE";
47
+ workItemName: string;
48
+ options: Array<{ value: string; label: string }>;
49
+ units: Array<{ id: string; label: string; factor: number }>;
50
+ defaultUnitId: string | null;
51
+ }
52
+
53
+ interface ResourcePropertyFilterProps {
54
+ definitions: ResourcePropertyFilterDefinition[];
55
+ filters: ResourcePropertyFilterState;
56
+ onChange: (filters: ResourcePropertyFilterState) => void;
57
+ showWorkItemName?: boolean;
58
+ triggerLabel?: string;
59
+ triggerClassName?: string;
60
+ }
61
+
62
+ export function isResourcePropertyFilterActive(
63
+ filter: ResourcePropertyFilterValue | undefined,
64
+ ): boolean {
65
+ if (!filter) return false;
66
+ if (filter.dataType === "DROPDOWN") return filter.values.length > 0;
67
+ if (filter.dataType === "TEXT") return filter.query.trim().length > 0;
68
+ return isFiniteInput(filter.min) || isFiniteInput(filter.max);
69
+ }
70
+
71
+ export function countActiveResourcePropertyFilters(filters: ResourcePropertyFilterState): number {
72
+ return Object.values(filters).filter(isResourcePropertyFilterActive).length;
73
+ }
74
+
75
+ export function filterResourcePropertyDefinitions(
76
+ definitions: ResourcePropertyFilterDefinition[],
77
+ search: string,
78
+ ): ResourcePropertyFilterDefinition[] {
79
+ const normalizedSearch = search.trim().toLocaleLowerCase("th");
80
+ if (!normalizedSearch) return definitions;
81
+
82
+ return definitions.filter((definition) =>
83
+ `${definition.propertyName} ${definition.workItemName}`
84
+ .toLocaleLowerCase("th")
85
+ .includes(normalizedSearch),
86
+ );
87
+ }
88
+
89
+ export function ResourcePropertyFilter({
90
+ definitions,
91
+ filters,
92
+ onChange,
93
+ showWorkItemName = false,
94
+ triggerLabel = "คุณสมบัติ Resource",
95
+ triggerClassName,
96
+ }: ResourcePropertyFilterProps) {
97
+ const idPrefix = useId();
98
+ const [propertySearch, setPropertySearch] = useState("");
99
+ const deferredPropertySearch = useDeferredValue(propertySearch);
100
+ const activeCount = countActiveResourcePropertyFilters(filters);
101
+ const visibleDefinitions = filterResourcePropertyDefinitions(definitions, deferredPropertySearch);
102
+
103
+ const updateFilter = (propertyTemplateId: string, filter: ResourcePropertyFilterValue) => {
104
+ onChange({ ...filters, [propertyTemplateId]: filter });
105
+ };
106
+
107
+ const clearFilter = (propertyTemplateId: string) => {
108
+ const nextFilters = { ...filters };
109
+ delete nextFilters[propertyTemplateId];
110
+ onChange(nextFilters);
111
+ };
112
+
113
+ return (
114
+ <div className="flex flex-col gap-2">
115
+ <Popover>
116
+ <PopoverTrigger
117
+ render={
118
+ <Button
119
+ type="button"
120
+ variant="outline"
121
+ size="sm"
122
+ className={cn("w-full justify-start border-dashed sm:w-auto", triggerClassName)}
123
+ disabled={definitions.length === 0}
124
+ />
125
+ }
126
+ >
127
+ <ListFilter data-icon="inline-start" />
128
+ {triggerLabel}
129
+ {activeCount > 0 ? (
130
+ <Badge variant="secondary" className="ml-auto sm:ml-1">
131
+ {activeCount}
132
+ </Badge>
133
+ ) : null}
134
+ </PopoverTrigger>
135
+ <PopoverContent align="start" className="w-[min(24rem,calc(100vw-2rem))] p-0">
136
+ <PopoverHeader className="border-b p-4">
137
+ <div className="flex items-start justify-between gap-3">
138
+ <div className="flex flex-col gap-1">
139
+ <PopoverTitle>กรองตามคุณสมบัติ</PopoverTitle>
140
+ <PopoverDescription>ค่าในคุณสมบัติเดียวกันเป็น OR และคนละคุณสมบัติเป็น AND</PopoverDescription>
141
+ </div>
142
+ {activeCount > 0 ? (
143
+ <Button type="button" variant="ghost" size="xs" onClick={() => onChange({})}>
144
+ <RotateCcw data-icon="inline-start" />
145
+ ล้าง
146
+ </Button>
147
+ ) : null}
148
+ </div>
149
+ </PopoverHeader>
150
+ <div className="border-b p-3">
151
+ <InputGroup>
152
+ <InputGroupAddon align="inline-start">
153
+ <Search aria-hidden="true" />
154
+ </InputGroupAddon>
155
+ <InputGroupInput
156
+ value={propertySearch}
157
+ onChange={(event) => setPropertySearch(event.target.value)}
158
+ placeholder="ค้นหาชื่อ Property..."
159
+ aria-label="ค้นหาคุณสมบัติ"
160
+ />
161
+ {propertySearch ? (
162
+ <InputGroupAddon align="inline-end">
163
+ <InputGroupButton
164
+ size="icon-xs"
165
+ onClick={() => setPropertySearch("")}
166
+ aria-label="ล้างคำค้นหา"
167
+ >
168
+ <X />
169
+ </InputGroupButton>
170
+ </InputGroupAddon>
171
+ ) : null}
172
+ </InputGroup>
173
+ </div>
174
+ <ScrollArea className="h-[min(60vh,520px)]">
175
+ {visibleDefinitions.length > 0 ? (
176
+ <FieldGroup className="gap-4 p-4">
177
+ {visibleDefinitions.map((definition) => {
178
+ const filter = filters[definition.propertyTemplateId];
179
+
180
+ return (
181
+ <Field
182
+ key={definition.propertyTemplateId}
183
+ className="gap-2 rounded-md border p-3"
184
+ >
185
+ <div className="flex items-start justify-between gap-2">
186
+ <div className="flex min-w-0 flex-col gap-0.5">
187
+ <FieldLabel className="font-medium">{definition.propertyName}</FieldLabel>
188
+ {showWorkItemName ? (
189
+ <span className="truncate text-muted-foreground text-xs">
190
+ {definition.workItemName}
191
+ </span>
192
+ ) : null}
193
+ </div>
194
+ {isResourcePropertyFilterActive(filter) ? (
195
+ <Button
196
+ type="button"
197
+ variant="ghost"
198
+ size="icon-xs"
199
+ onClick={() => clearFilter(definition.propertyTemplateId)}
200
+ aria-label={`ล้างตัวกรอง ${definition.propertyName}`}
201
+ >
202
+ <X />
203
+ </Button>
204
+ ) : null}
205
+ </div>
206
+
207
+ {definition.dataType === "DROPDOWN" || definition.dataType === "DATABASE" ? (
208
+ <DropdownPropertyField
209
+ idPrefix={idPrefix}
210
+ definition={definition}
211
+ filter={filter?.dataType === "DROPDOWN" ? filter : undefined}
212
+ onChange={(nextFilter) =>
213
+ updateFilter(definition.propertyTemplateId, nextFilter)
214
+ }
215
+ />
216
+ ) : definition.dataType === "NUMBER" ? (
217
+ <NumberPropertyField
218
+ definition={definition}
219
+ filter={filter?.dataType === "NUMBER" ? filter : undefined}
220
+ onChange={(nextFilter) =>
221
+ updateFilter(definition.propertyTemplateId, nextFilter)
222
+ }
223
+ />
224
+ ) : (
225
+ <Input
226
+ value={filter?.dataType === "TEXT" ? filter.query : ""}
227
+ onChange={(event) =>
228
+ updateFilter(definition.propertyTemplateId, {
229
+ dataType: "TEXT",
230
+ query: event.target.value,
231
+ })
232
+ }
233
+ placeholder="ค้นหาข้อความ..."
234
+ aria-label={`กรอง ${definition.propertyName}`}
235
+ />
236
+ )}
237
+ </Field>
238
+ );
239
+ })}
240
+ </FieldGroup>
241
+ ) : (
242
+ <Empty className="min-h-40 rounded-none border-0">
243
+ <EmptyHeader>
244
+ <EmptyTitle>ไม่พบคุณสมบัติ</EmptyTitle>
245
+ </EmptyHeader>
246
+ </Empty>
247
+ )}
248
+ </ScrollArea>
249
+ </PopoverContent>
250
+ </Popover>
251
+
252
+ {activeCount > 0 ? (
253
+ <fieldset className="flex flex-wrap gap-1 border-0 p-0" aria-label="ตัวกรองคุณสมบัติที่ใช้งาน">
254
+ {definitions.map((definition) => {
255
+ const filter = filters[definition.propertyTemplateId];
256
+ if (!isResourcePropertyFilterActive(filter)) return null;
257
+
258
+ return (
259
+ <Badge key={definition.propertyTemplateId} variant="secondary" className="gap-1 pr-0">
260
+ <span className="max-w-56 truncate">
261
+ {definition.propertyName}: {describeFilter(definition, filter)}
262
+ </span>
263
+ <Button
264
+ type="button"
265
+ variant="ghost"
266
+ size="icon-xs"
267
+ onClick={() => clearFilter(definition.propertyTemplateId)}
268
+ aria-label={`ล้างตัวกรอง ${definition.propertyName}`}
269
+ >
270
+ <X />
271
+ </Button>
272
+ </Badge>
273
+ );
274
+ })}
275
+ </fieldset>
276
+ ) : null}
277
+ </div>
278
+ );
279
+ }
280
+
281
+ function DropdownPropertyField({
282
+ idPrefix,
283
+ definition,
284
+ filter,
285
+ onChange,
286
+ }: {
287
+ idPrefix: string;
288
+ definition: ResourcePropertyFilterDefinition;
289
+ filter?: Extract<ResourcePropertyFilterValue, { dataType: "DROPDOWN" }>;
290
+ onChange: (filter: Extract<ResourcePropertyFilterValue, { dataType: "DROPDOWN" }>) => void;
291
+ }) {
292
+ const selectedValues = new Set(filter?.values ?? []);
293
+
294
+ return (
295
+ <FieldGroup data-slot="checkbox-group" className="gap-2">
296
+ {definition.options.map((option) => {
297
+ const id = `${idPrefix}-resource-property-${definition.propertyTemplateId}-${option.value}`;
298
+
299
+ return (
300
+ <Field key={option.value} orientation="horizontal" className="gap-2">
301
+ <Checkbox
302
+ id={id}
303
+ checked={selectedValues.has(option.value)}
304
+ onCheckedChange={(checked) => {
305
+ const nextValues = new Set(selectedValues);
306
+ if (checked === true) nextValues.add(option.value);
307
+ else nextValues.delete(option.value);
308
+ onChange({ dataType: "DROPDOWN", values: Array.from(nextValues) });
309
+ }}
310
+ />
311
+ <FieldLabel htmlFor={id} className="cursor-pointer font-normal">
312
+ {option.label}
313
+ </FieldLabel>
314
+ </Field>
315
+ );
316
+ })}
317
+ </FieldGroup>
318
+ );
319
+ }
320
+
321
+ function NumberPropertyField({
322
+ definition,
323
+ filter,
324
+ onChange,
325
+ }: {
326
+ definition: ResourcePropertyFilterDefinition;
327
+ filter?: Extract<ResourcePropertyFilterValue, { dataType: "NUMBER" }>;
328
+ onChange: (filter: Extract<ResourcePropertyFilterValue, { dataType: "NUMBER" }>) => void;
329
+ }) {
330
+ const currentFilter = filter ?? {
331
+ dataType: "NUMBER" as const,
332
+ min: "",
333
+ max: "",
334
+ unitId: definition.defaultUnitId,
335
+ };
336
+
337
+ return (
338
+ <div className="grid grid-cols-2 gap-2">
339
+ <Input
340
+ type="number"
341
+ inputMode="decimal"
342
+ step="any"
343
+ value={currentFilter.min}
344
+ onChange={(event) => onChange({ ...currentFilter, min: event.target.value })}
345
+ placeholder="ต่ำสุด"
346
+ aria-label={`${definition.propertyName} ต่ำสุด`}
347
+ />
348
+ <Input
349
+ type="number"
350
+ inputMode="decimal"
351
+ step="any"
352
+ value={currentFilter.max}
353
+ onChange={(event) => onChange({ ...currentFilter, max: event.target.value })}
354
+ placeholder="สูงสุด"
355
+ aria-label={`${definition.propertyName} สูงสุด`}
356
+ />
357
+ {definition.units.length > 0 ? (
358
+ <Select
359
+ value={currentFilter.unitId ?? undefined}
360
+ onValueChange={(unitId) => onChange({ ...currentFilter, unitId })}
361
+ >
362
+ <SelectTrigger className="col-span-2 w-full">
363
+ <SelectValue placeholder="เลือกหน่วย" />
364
+ </SelectTrigger>
365
+ <SelectContent>
366
+ <SelectGroup>
367
+ {definition.units.map((unit) => (
368
+ <SelectItem key={unit.id} value={unit.id}>
369
+ {unit.label}
370
+ </SelectItem>
371
+ ))}
372
+ </SelectGroup>
373
+ </SelectContent>
374
+ </Select>
375
+ ) : null}
376
+ </div>
377
+ );
378
+ }
379
+
380
+ function describeFilter(
381
+ definition: ResourcePropertyFilterDefinition,
382
+ filter: ResourcePropertyFilterValue | undefined,
383
+ ): string {
384
+ if (!filter) return "";
385
+ if (filter.dataType === "TEXT") return filter.query.trim();
386
+ if (filter.dataType === "DROPDOWN") {
387
+ const labels = new Map(definition.options.map((option) => [option.value, option.label]));
388
+ return filter.values.map((value) => labels.get(value) ?? value).join(" หรือ ");
389
+ }
390
+
391
+ const unitLabel = definition.units.find((unit) => unit.id === filter.unitId)?.label ?? "";
392
+ if (filter.min && filter.max) return `${filter.min}–${filter.max} ${unitLabel}`.trim();
393
+ if (filter.min) return `≥ ${filter.min} ${unitLabel}`.trim();
394
+ return `≤ ${filter.max} ${unitLabel}`.trim();
395
+ }
396
+
397
+ function isFiniteInput(value: string): boolean {
398
+ return value.trim() !== "" && Number.isFinite(Number(value));
399
+ }
@@ -0,0 +1,54 @@
1
+ import { cn } from "cn";
2
+ import { ChevronDownIcon } from "lucide-react";
3
+ import type * as React from "react";
4
+
5
+ type NativeSelectProps = Omit<React.ComponentProps<"select">, "size"> & {
6
+ size?: "sm" | "default";
7
+ };
8
+
9
+ function NativeSelect({ className, size = "default", ...props }: NativeSelectProps) {
10
+ return (
11
+ <div
12
+ className={cn(
13
+ "group/native-select relative w-fit has-[select:disabled]:opacity-50",
14
+ className,
15
+ )}
16
+ data-slot="native-select-wrapper"
17
+ data-size={size}
18
+ >
19
+ <select
20
+ data-slot="native-select"
21
+ data-size={size}
22
+ className="h-8 w-full min-w-0 appearance-none rounded-lg border border-input bg-transparent py-1 pr-8 pl-2.5 text-sm transition-colors outline-none select-none selection:bg-primary selection:text-primary-foreground placeholder:text-muted-foreground focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-ring/50 disabled:pointer-events-none disabled:cursor-not-allowed aria-invalid:border-destructive aria-invalid:ring-3 aria-invalid:ring-destructive/20 data-[size=sm]:h-7 data-[size=sm]:rounded-[min(var(--radius-md),10px)] data-[size=sm]:py-0.5 dark:bg-input/30 dark:hover:bg-input/50 dark:aria-invalid:border-destructive/50 dark:aria-invalid:ring-destructive/40"
23
+ {...props}
24
+ />
25
+ <ChevronDownIcon
26
+ className="pointer-events-none absolute top-1/2 right-2.5 size-4 -translate-y-1/2 text-muted-foreground select-none"
27
+ aria-hidden="true"
28
+ data-slot="native-select-icon"
29
+ />
30
+ </div>
31
+ );
32
+ }
33
+
34
+ function NativeSelectOption({ className, ...props }: React.ComponentProps<"option">) {
35
+ return (
36
+ <option
37
+ data-slot="native-select-option"
38
+ className={cn("bg-[Canvas] text-[CanvasText]", className)}
39
+ {...props}
40
+ />
41
+ );
42
+ }
43
+
44
+ function NativeSelectOptGroup({ className, ...props }: React.ComponentProps<"optgroup">) {
45
+ return (
46
+ <optgroup
47
+ data-slot="native-select-optgroup"
48
+ className={cn("bg-[Canvas] text-[CanvasText]", className)}
49
+ {...props}
50
+ />
51
+ );
52
+ }
53
+
54
+ export { NativeSelect, NativeSelectOptGroup, NativeSelectOption };
@@ -0,0 +1,21 @@
1
+ /**
2
+ * @suphark/ui/data-table — ตารางข้อมูลบน @tanstack/react-table (ยกจาก supplychain `components/data-table`)
3
+ * แยก entry เพราะต้องมี peer `@tanstack/react-table` — แอปที่ไม่ใช้ตารางไม่ต้องติดตั้ง
4
+ *
5
+ * ```ts
6
+ * import { DataTable, DataTableColumnHeader, useDataTable } from "@suphark/ui/data-table";
7
+ * ```
8
+ * state ของตารางใน URL (`useTableSearchParams`) ต้องใช้ next/navigation → อยู่ที่ `@suphark/ui/next`
9
+ */
10
+ export * from "./components/data-table";
11
+ export { DataTableRowActions } from "./components/data-table/data-table-row-actions";
12
+ export { DeleteManyButton } from "./components/shared/button/delete-many-button";
13
+ export {
14
+ countActiveResourcePropertyFilters,
15
+ filterResourcePropertyDefinitions,
16
+ isResourcePropertyFilterActive,
17
+ ResourcePropertyFilter,
18
+ type ResourcePropertyFilterDefinition,
19
+ type ResourcePropertyFilterState,
20
+ type ResourcePropertyFilterValue,
21
+ } from "./components/shared/resource-property-filter";
@@ -0,0 +1,147 @@
1
+ "use client";
2
+
3
+ import { usePathname, useRouter, useSearchParams } from "next/navigation";
4
+ import { useCallback, useMemo, useTransition } from "react";
5
+
6
+ /**
7
+ * เก็บ state ของตาราง (หน้า, ขนาดหน้า, การเรียง, คำค้น, ตัวกรอง) ไว้ใน URL
8
+ *
9
+ * เก็บใน URL ไม่ใช่ useState เพราะ:
10
+ * - server component อ่าน searchParams แล้ว query เฉพาะหน้าที่ขอได้
11
+ * - ก๊อป URL ส่งต่อ หรือ refresh แล้วเห็นตารางหน้าเดิม
12
+ *
13
+ * ใช้ router.replace ไม่ใช่ push โดยตั้งใจ — การกดตัวกรองทีละครั้งไม่ควรถมประวัติ
14
+ * เบราว์เซอร์จนกดย้อนกลับออกจากหน้าไม่ได้ ผลคือปุ่ม back จะพาออกจากหน้านี้เลย
15
+ * ไม่ได้ย้อนทีละสถานะของตาราง
16
+ *
17
+ * ทุกการเปลี่ยนค่าห่อด้วย startTransition เพื่อให้ React แสดงข้อมูลเดิมค้างไว้
18
+ * ระหว่างรอ server ตอบ แทนที่จะกระพริบเป็นหน้าว่าง
19
+ */
20
+
21
+ export const TABLE_PARAM = {
22
+ page: "page",
23
+ pageSize: "pageSize",
24
+ sort: "sort",
25
+ order: "order",
26
+ search: "q",
27
+ } as const;
28
+
29
+ /** ชื่อ param ที่สงวนไว้ ที่เหลือถือเป็น faceted filter */
30
+ const RESERVED = new Set<string>(Object.values(TABLE_PARAM));
31
+
32
+ export type TableSearchState = {
33
+ page: number;
34
+ pageSize: number;
35
+ sort: string | null;
36
+ order: "asc" | "desc";
37
+ search: string;
38
+ filters: Record<string, string[]>;
39
+ };
40
+
41
+ export function useTableSearchParams(defaults?: { pageSize?: number }) {
42
+ const router = useRouter();
43
+ const pathname = usePathname();
44
+ const searchParams = useSearchParams();
45
+ const [isPending, startTransition] = useTransition();
46
+
47
+ const state = useMemo<TableSearchState>(() => {
48
+ const filters: Record<string, string[]> = {};
49
+ for (const key of new Set(searchParams.keys())) {
50
+ if (RESERVED.has(key)) continue;
51
+ const values = searchParams
52
+ .getAll(key)
53
+ .flatMap((v) => v.split(","))
54
+ .filter(Boolean);
55
+ if (values.length > 0) filters[key] = values;
56
+ }
57
+
58
+ const page = Number.parseInt(searchParams.get(TABLE_PARAM.page) ?? "1", 10);
59
+ const pageSize = Number.parseInt(
60
+ searchParams.get(TABLE_PARAM.pageSize) ?? String(defaults?.pageSize ?? 50),
61
+ 10,
62
+ );
63
+
64
+ return {
65
+ page: Number.isFinite(page) && page > 0 ? page : 1,
66
+ pageSize: Number.isFinite(pageSize) && pageSize > 0 ? pageSize : (defaults?.pageSize ?? 50),
67
+ sort: searchParams.get(TABLE_PARAM.sort),
68
+ order: searchParams.get(TABLE_PARAM.order) === "asc" ? "asc" : "desc",
69
+ search: searchParams.get(TABLE_PARAM.search) ?? "",
70
+ filters,
71
+ };
72
+ }, [searchParams, defaults?.pageSize]);
73
+
74
+ const push = useCallback(
75
+ (mutate: (params: URLSearchParams) => void) => {
76
+ const params = new URLSearchParams(searchParams.toString());
77
+ mutate(params);
78
+ startTransition(() => {
79
+ router.replace(`${pathname}?${params.toString()}`, { scroll: false });
80
+ });
81
+ },
82
+ [router, pathname, searchParams],
83
+ );
84
+
85
+ /** ตั้งค่าแล้วลบ param ทิ้งเมื่อค่าว่าง เพื่อไม่ให้ URL รก */
86
+ const setParam = useCallback((params: URLSearchParams, key: string, value: string | null) => {
87
+ if (value === null || value === "") params.delete(key);
88
+ else params.set(key, value);
89
+ }, []);
90
+
91
+ const setPage = useCallback(
92
+ (page: number) => push((p) => setParam(p, TABLE_PARAM.page, page > 1 ? String(page) : null)),
93
+ [push, setParam],
94
+ );
95
+
96
+ const setPageSize = useCallback(
97
+ (pageSize: number) =>
98
+ push((p) => {
99
+ setParam(p, TABLE_PARAM.pageSize, String(pageSize));
100
+ // เปลี่ยนขนาดหน้าแล้วหน้าเดิมอาจไม่มีอยู่จริง กลับไปหน้าแรกเสมอ
101
+ p.delete(TABLE_PARAM.page);
102
+ }),
103
+ [push, setParam],
104
+ );
105
+
106
+ const setSort = useCallback(
107
+ (field: string | null, order: "asc" | "desc" = "desc") =>
108
+ push((p) => {
109
+ setParam(p, TABLE_PARAM.sort, field);
110
+ setParam(p, TABLE_PARAM.order, field ? order : null);
111
+ p.delete(TABLE_PARAM.page);
112
+ }),
113
+ [push, setParam],
114
+ );
115
+
116
+ const setSearch = useCallback(
117
+ (search: string) =>
118
+ push((p) => {
119
+ setParam(p, TABLE_PARAM.search, search.trim());
120
+ p.delete(TABLE_PARAM.page);
121
+ }),
122
+ [push, setParam],
123
+ );
124
+
125
+ const setFilter = useCallback(
126
+ (key: string, values: string[]) =>
127
+ push((p) => {
128
+ setParam(p, key, values.length > 0 ? values.join(",") : null);
129
+ p.delete(TABLE_PARAM.page);
130
+ }),
131
+ [push, setParam],
132
+ );
133
+
134
+ const resetFilters = useCallback(
135
+ () =>
136
+ push((p) => {
137
+ for (const key of [...p.keys()]) {
138
+ if (!RESERVED.has(key)) p.delete(key);
139
+ }
140
+ p.delete(TABLE_PARAM.search);
141
+ p.delete(TABLE_PARAM.page);
142
+ }),
143
+ [push],
144
+ );
145
+
146
+ return { state, isPending, setPage, setPageSize, setSort, setSearch, setFilter, resetFilters };
147
+ }
package/src/index.ts CHANGED
@@ -106,6 +106,7 @@ export * from "./components/ui/item";
106
106
  export * from "./components/ui/kbd";
107
107
  export * from "./components/ui/label";
108
108
  export * from "./components/ui/menubar";
109
+ export * from "./components/ui/native-select";
109
110
  export * from "./components/ui/navigation-menu";
110
111
  export * from "./components/ui/pagination";
111
112
  export * from "./components/ui/popover";
package/src/next.ts CHANGED
@@ -13,3 +13,8 @@ export {
13
13
  } from "./components/shared/navigation/page-breadcrumb";
14
14
  export { type RowAction, RowActions } from "./components/shared/row-actions";
15
15
  export { RowMenu, type RowMenuDialog, type RowMenuItem } from "./components/shared/row-menu";
16
+ export {
17
+ TABLE_PARAM,
18
+ type TableSearchState,
19
+ useTableSearchParams,
20
+ } from "./hooks/use-table-search-params";