@suphark/ui 0.27.0 → 0.28.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 +30 -0
- package/README.md +2 -1
- package/package.json +1 -1
- package/src/components/app-shell/app-shell.tsx +11 -2
- package/src/components/app-shell/organization-switcher.tsx +1 -1
- package/src/components/app-shell/user-menu.tsx +1 -1
- package/src/components/data-table/data-table-column-header.tsx +1 -1
- package/src/components/data-table/data-table-faceted-filter.tsx +1 -1
- package/src/components/data-table/data-table-pagination.tsx +9 -4
- package/src/components/data-table/data-table-row-actions.tsx +24 -12
- package/src/components/data-table/data-table-view-option.tsx +1 -1
- package/src/components/data-table/data-table.tsx +3 -1
- package/src/components/design-system/sections/hero-specimen.tsx +1 -1
- package/src/components/design-system/sections/showcase-data-display.tsx +1 -1
- package/src/components/design-system/sections/showcase-feedback.tsx +1 -1
- package/src/components/design-system/sections/showcase-forms.tsx +1 -1
- package/src/components/design-system/sections/showcase-guidelines-recipes.tsx +1 -1
- package/src/components/design-system/sections/showcase-overlays-navigation.tsx +1 -1
- package/src/components/design-system/sections/showcase-overview.tsx +1 -1
- package/src/components/design-system/sections/showcase-primitives.tsx +1 -1
- package/src/components/pwa/pwa-install-card.tsx +1 -1
- package/src/components/shared/action-form.tsx +1 -1
- package/src/components/shared/activity-feed.tsx +114 -0
- package/src/components/shared/button/base/action-button.tsx +8 -3
- package/src/components/shared/button/base/link-button.tsx +3 -1
- package/src/components/shared/button/button.tsx +8 -1
- package/src/components/shared/button/copy-button.tsx +1 -1
- package/src/components/shared/button/floating-save-button.tsx +1 -1
- package/src/components/shared/combobox.tsx +1 -1
- package/src/components/shared/date/date-range-picker.tsx +1 -1
- package/src/components/shared/dropzone.tsx +1 -1
- package/src/components/shared/env-snippet.tsx +1 -1
- package/src/components/shared/filter-bar.tsx +1 -1
- package/src/components/shared/form/date-time-picker.tsx +1 -1
- package/src/components/shared/form/form-field-combobox.tsx +1 -1
- package/src/components/shared/form/form-field-select.tsx +1 -1
- package/src/components/shared/form-dialog.tsx +1 -1
- package/src/components/shared/inline-edit.tsx +175 -0
- package/src/components/shared/language/language-switch.tsx +1 -1
- package/src/components/shared/layout/error-state.tsx +2 -1
- package/src/components/shared/multi-select-combobox.tsx +1 -1
- package/src/components/shared/selection-bar.tsx +94 -0
- package/src/components/shared/selection-button.tsx +1 -1
- package/src/components/shared/simple-table.tsx +51 -2
- package/src/components/shared/simple-tabs.tsx +54 -13
- package/src/components/shared/step-form.tsx +1 -1
- package/src/components/shared/ui-text.tsx +47 -0
- package/src/index.ts +11 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
"use client";
|
|
2
2
|
|
|
3
|
-
import { Button } from "@suphark/ui/components/
|
|
3
|
+
import { Button } from "@suphark/ui/components/shared/button/button";
|
|
4
4
|
import { cn } from "@suphark/ui/lib/utils";
|
|
5
5
|
import { Check, Loader2, Save, X } from "lucide-react";
|
|
6
6
|
import { useEffect, useRef, useState } from "react";
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
"use client";
|
|
2
2
|
|
|
3
|
+
import { Button } from "@suphark/ui/components/shared/button/button";
|
|
3
4
|
import { SwitcherChevron } from "@suphark/ui/components/shared/switcher-chevron";
|
|
4
|
-
import { Button } from "@suphark/ui/components/ui/button";
|
|
5
5
|
import {
|
|
6
6
|
Command,
|
|
7
7
|
CommandEmpty,
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
"use client";
|
|
2
2
|
|
|
3
|
+
import { Button } from "@suphark/ui/components/shared/button/button";
|
|
3
4
|
import { Badge } from "@suphark/ui/components/ui/badge";
|
|
4
|
-
import { Button } from "@suphark/ui/components/ui/button";
|
|
5
5
|
import { Calendar } from "@suphark/ui/components/ui/calendar";
|
|
6
6
|
import { Popover, PopoverContent, PopoverTrigger } from "@suphark/ui/components/ui/popover";
|
|
7
7
|
import { Separator } from "@suphark/ui/components/ui/separator";
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
"use client";
|
|
2
2
|
|
|
3
|
-
import { Button } from "@suphark/ui/components/
|
|
3
|
+
import { Button } from "@suphark/ui/components/shared/button/button";
|
|
4
4
|
import { formatBytes } from "@suphark/ui/lib/formatters";
|
|
5
5
|
import { cn } from "@suphark/ui/lib/utils";
|
|
6
6
|
import { UploadCloud } from "lucide-react";
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
"use client";
|
|
2
2
|
|
|
3
|
-
import { Button } from "@suphark/ui/components/
|
|
3
|
+
import { Button } from "@suphark/ui/components/shared/button/button";
|
|
4
4
|
import { copyText, selectContents } from "@suphark/ui/lib/copy-text";
|
|
5
5
|
import { CheckIcon, CopyIcon } from "lucide-react";
|
|
6
6
|
import { useRef, useState } from "react";
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
"use client";
|
|
2
2
|
|
|
3
|
-
import { Button } from "@suphark/ui/components/
|
|
3
|
+
import { Button } from "@suphark/ui/components/shared/button/button";
|
|
4
4
|
import { NativeSelect, NativeSelectOption } from "@suphark/ui/components/ui/native-select";
|
|
5
5
|
import { cn } from "@suphark/ui/lib/utils";
|
|
6
6
|
import { XIcon } from "lucide-react";
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
"use client";
|
|
2
2
|
|
|
3
|
-
import { Button } from "@suphark/ui/components/
|
|
3
|
+
import { Button } from "@suphark/ui/components/shared/button/button";
|
|
4
4
|
import { Calendar } from "@suphark/ui/components/ui/calendar";
|
|
5
5
|
import { Popover, PopoverContent, PopoverTrigger } from "@suphark/ui/components/ui/popover";
|
|
6
6
|
import { useMounted } from "@suphark/ui/hooks/use-mounted";
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
"use client";
|
|
2
2
|
|
|
3
|
+
import { Button } from "@suphark/ui/components/shared/button/button";
|
|
3
4
|
import { SwitcherChevron } from "@suphark/ui/components/shared/switcher-chevron";
|
|
4
|
-
import { Button } from "@suphark/ui/components/ui/button";
|
|
5
5
|
import {
|
|
6
6
|
Command,
|
|
7
7
|
CommandEmpty,
|
|
@@ -1,8 +1,8 @@
|
|
|
1
1
|
"use client";
|
|
2
2
|
|
|
3
|
+
import { Button } from "@suphark/ui/components/shared/button/button";
|
|
3
4
|
import { MultiSelectCombobox } from "@suphark/ui/components/shared/multi-select-combobox";
|
|
4
5
|
import { SwitcherChevron } from "@suphark/ui/components/shared/switcher-chevron";
|
|
5
|
-
import { Button } from "@suphark/ui/components/ui/button";
|
|
6
6
|
import {
|
|
7
7
|
Command,
|
|
8
8
|
CommandEmpty,
|
|
@@ -0,0 +1,175 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
import { Button } from "@suphark/ui/components/shared/button/button";
|
|
4
|
+
import { useUiText } from "@suphark/ui/components/shared/ui-text";
|
|
5
|
+
import { Input } from "@suphark/ui/components/ui/input";
|
|
6
|
+
import { errorMessage, isNavigationError } from "@suphark/ui/lib/action-result";
|
|
7
|
+
import { cn } from "@suphark/ui/lib/utils";
|
|
8
|
+
import { CheckIcon, Loader2Icon, PencilIcon, XIcon } from "lucide-react";
|
|
9
|
+
import { type ReactNode, useRef, useState } from "react";
|
|
10
|
+
import { toast } from "sonner";
|
|
11
|
+
|
|
12
|
+
export interface InlineEditProps {
|
|
13
|
+
value: string;
|
|
14
|
+
/** บันทึกค่าใหม่ — throw หรือคืน `{ ok: false, error }` = ล้มเหลว (คงโหมดแก้ไขไว้ให้ลองใหม่) */
|
|
15
|
+
onSave: (value: string) => void | Promise<unknown>;
|
|
16
|
+
/** ป้ายสำหรับ screen reader ของปุ่มแก้ไข/ช่องกรอก เช่น "ชื่อโปรเจกต์" */
|
|
17
|
+
label: string;
|
|
18
|
+
placeholder?: string;
|
|
19
|
+
/** แสดงแทนค่าว่าง (ค่าเริ่มต้น "—") */
|
|
20
|
+
emptyLabel?: ReactNode;
|
|
21
|
+
/** ตรวจก่อนบันทึก — คืนข้อความ = ไม่ผ่าน */
|
|
22
|
+
validate?: (value: string) => string | null | undefined;
|
|
23
|
+
/** ค่าเดิมกับค่าใหม่เท่ากัน = ปิดโหมดแก้ไขโดยไม่เรียก `onSave` (ค่าเริ่มต้น true) */
|
|
24
|
+
skipWhenUnchanged?: boolean;
|
|
25
|
+
disabled?: boolean;
|
|
26
|
+
/** sm = ใช้ในแถวตาราง */
|
|
27
|
+
size?: "sm" | "default";
|
|
28
|
+
className?: string;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* แก้ค่าในที่ — คลิกข้อความ (หรือปุ่มดินสอ) → ช่องกรอก → Enter/✓ บันทึก, Esc/✕ ยกเลิก
|
|
33
|
+
* ใช้กับค่าสั้น ๆ ที่ไม่คุ้มเปิด dialog (ชื่อโปรเจกต์, ชื่อเล่นของคีย์, หมายเหตุหนึ่งบรรทัด)
|
|
34
|
+
* ล้มเหลว = ขึ้น toast และคงโหมดแก้ไขไว้ให้ลองใหม่ · ค่ายาว/หลายช่องใช้ `FormDialog`
|
|
35
|
+
*
|
|
36
|
+
* <InlineEdit label="ชื่อโปรเจกต์" value={p.name} onSave={(name) => renameProject(p.id, name)} />
|
|
37
|
+
*/
|
|
38
|
+
export function InlineEdit({
|
|
39
|
+
value,
|
|
40
|
+
onSave,
|
|
41
|
+
label,
|
|
42
|
+
placeholder,
|
|
43
|
+
emptyLabel = "—",
|
|
44
|
+
validate,
|
|
45
|
+
skipWhenUnchanged = true,
|
|
46
|
+
disabled = false,
|
|
47
|
+
size = "default",
|
|
48
|
+
className,
|
|
49
|
+
}: InlineEditProps) {
|
|
50
|
+
const text = useUiText();
|
|
51
|
+
const [editing, setEditing] = useState(false);
|
|
52
|
+
const [draft, setDraft] = useState(value);
|
|
53
|
+
const [pending, setPending] = useState(false);
|
|
54
|
+
const inputRef = useRef<HTMLInputElement>(null);
|
|
55
|
+
// เลือกข้อความทั้งหมดครั้งแรกที่เข้าโหมดแก้ไข — พิมพ์ทับได้ทันที (โฟกัสรอบหลังไม่เลือกซ้ำ)
|
|
56
|
+
const selectedOnOpen = useRef(false);
|
|
57
|
+
|
|
58
|
+
function start() {
|
|
59
|
+
setDraft(value);
|
|
60
|
+
selectedOnOpen.current = false;
|
|
61
|
+
setEditing(true);
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
async function commit() {
|
|
65
|
+
const next = draft.trim();
|
|
66
|
+
if (skipWhenUnchanged && next === value.trim()) {
|
|
67
|
+
setEditing(false);
|
|
68
|
+
return;
|
|
69
|
+
}
|
|
70
|
+
const invalid = validate?.(next);
|
|
71
|
+
if (invalid) {
|
|
72
|
+
toast.error(invalid);
|
|
73
|
+
inputRef.current?.focus();
|
|
74
|
+
return;
|
|
75
|
+
}
|
|
76
|
+
setPending(true);
|
|
77
|
+
try {
|
|
78
|
+
const result = await onSave(next);
|
|
79
|
+
// server action ที่คืน { ok: false, error } ถือว่าล้มเหลวเหมือน throw
|
|
80
|
+
if (result && typeof result === "object" && "ok" in result && result.ok === false) {
|
|
81
|
+
const message = "error" in result ? String(result.error) : undefined;
|
|
82
|
+
toast.error(message || (text.actionFailed ?? "ดำเนินการไม่สำเร็จ"));
|
|
83
|
+
return;
|
|
84
|
+
}
|
|
85
|
+
setEditing(false);
|
|
86
|
+
} catch (err) {
|
|
87
|
+
// action ที่ redirect() = สำเร็จ (Next นำทางให้แล้ว)
|
|
88
|
+
if (isNavigationError(err)) {
|
|
89
|
+
setEditing(false);
|
|
90
|
+
return;
|
|
91
|
+
}
|
|
92
|
+
toast.error(errorMessage(err, text.actionFailed ?? "ดำเนินการไม่สำเร็จ"));
|
|
93
|
+
} finally {
|
|
94
|
+
setPending(false);
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
const compact = size === "sm";
|
|
99
|
+
|
|
100
|
+
if (!editing) {
|
|
101
|
+
return (
|
|
102
|
+
<span
|
|
103
|
+
data-slot="inline-edit"
|
|
104
|
+
className={cn("group/inline inline-flex min-w-0 items-center gap-1", className)}
|
|
105
|
+
>
|
|
106
|
+
<span
|
|
107
|
+
className={cn("truncate", compact ? "text-sm" : "", !value && "text-muted-foreground")}
|
|
108
|
+
>
|
|
109
|
+
{value || emptyLabel}
|
|
110
|
+
</span>
|
|
111
|
+
<Button
|
|
112
|
+
variant="ghost"
|
|
113
|
+
size={compact ? "icon-xs" : "icon-sm"}
|
|
114
|
+
aria-label={`${text.edit ?? "แก้ไข"}: ${label}`}
|
|
115
|
+
disabled={disabled}
|
|
116
|
+
onClick={start}
|
|
117
|
+
className="opacity-0 transition-opacity focus-visible:opacity-100 group-hover/inline:opacity-100"
|
|
118
|
+
>
|
|
119
|
+
<PencilIcon />
|
|
120
|
+
</Button>
|
|
121
|
+
</span>
|
|
122
|
+
);
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
return (
|
|
126
|
+
<span
|
|
127
|
+
data-slot="inline-edit"
|
|
128
|
+
className={cn("inline-flex min-w-0 items-center gap-1", className)}
|
|
129
|
+
>
|
|
130
|
+
<Input
|
|
131
|
+
ref={inputRef}
|
|
132
|
+
// biome-ignore lint/a11y/noAutofocus: เพิ่งกดปุ่มแก้ไข — โฟกัสต่อเนื่องคือสิ่งที่ผู้ใช้คาดหวัง
|
|
133
|
+
autoFocus
|
|
134
|
+
aria-label={label}
|
|
135
|
+
value={draft}
|
|
136
|
+
placeholder={placeholder}
|
|
137
|
+
disabled={pending}
|
|
138
|
+
className={cn("min-w-0", compact && "h-7")}
|
|
139
|
+
onFocus={(e) => {
|
|
140
|
+
if (selectedOnOpen.current) return;
|
|
141
|
+
selectedOnOpen.current = true;
|
|
142
|
+
e.currentTarget.select();
|
|
143
|
+
}}
|
|
144
|
+
onChange={(e) => setDraft(e.target.value)}
|
|
145
|
+
onKeyDown={(e) => {
|
|
146
|
+
if (e.key === "Enter") {
|
|
147
|
+
e.preventDefault();
|
|
148
|
+
void commit();
|
|
149
|
+
} else if (e.key === "Escape") {
|
|
150
|
+
e.preventDefault();
|
|
151
|
+
setEditing(false);
|
|
152
|
+
}
|
|
153
|
+
}}
|
|
154
|
+
/>
|
|
155
|
+
<Button
|
|
156
|
+
variant="ghost"
|
|
157
|
+
size={compact ? "icon-xs" : "icon-sm"}
|
|
158
|
+
aria-label={text.save}
|
|
159
|
+
disabled={pending}
|
|
160
|
+
onClick={() => void commit()}
|
|
161
|
+
>
|
|
162
|
+
{pending ? <Loader2Icon className="animate-spin" /> : <CheckIcon />}
|
|
163
|
+
</Button>
|
|
164
|
+
<Button
|
|
165
|
+
variant="ghost"
|
|
166
|
+
size={compact ? "icon-xs" : "icon-sm"}
|
|
167
|
+
aria-label={text.cancel}
|
|
168
|
+
disabled={pending}
|
|
169
|
+
onClick={() => setEditing(false)}
|
|
170
|
+
>
|
|
171
|
+
<XIcon />
|
|
172
|
+
</Button>
|
|
173
|
+
</span>
|
|
174
|
+
);
|
|
175
|
+
}
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
"use client";
|
|
2
2
|
|
|
3
|
+
import { Button } from "@suphark/ui/components/shared/button/button";
|
|
3
4
|
import { useUiText } from "@suphark/ui/components/shared/ui-text";
|
|
4
|
-
import { Button } from "@suphark/ui/components/ui/button";
|
|
5
5
|
import {
|
|
6
6
|
DropdownMenu,
|
|
7
7
|
DropdownMenuContent,
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
"use client";
|
|
2
2
|
|
|
3
|
-
import { Button
|
|
3
|
+
import { Button } from "@suphark/ui/components/shared/button/button";
|
|
4
|
+
import { buttonVariants } from "@suphark/ui/components/ui/button";
|
|
4
5
|
import { Card, CardContent, CardHeader, CardTitle } from "@suphark/ui/components/ui/card";
|
|
5
6
|
import { cn } from "@suphark/ui/lib/utils";
|
|
6
7
|
import { AlertTriangle, Home, LogIn, RotateCcw } from "lucide-react";
|
|
@@ -1,8 +1,8 @@
|
|
|
1
1
|
"use client";
|
|
2
2
|
|
|
3
|
+
import { Button } from "@suphark/ui/components/shared/button/button";
|
|
3
4
|
import { SwitcherChevron } from "@suphark/ui/components/shared/switcher-chevron";
|
|
4
5
|
import { Badge } from "@suphark/ui/components/ui/badge";
|
|
5
|
-
import { Button } from "@suphark/ui/components/ui/button";
|
|
6
6
|
import {
|
|
7
7
|
Command,
|
|
8
8
|
CommandEmpty,
|
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
import { Button } from "@suphark/ui/components/shared/button/button";
|
|
4
|
+
import { useUiText } from "@suphark/ui/components/shared/ui-text";
|
|
5
|
+
import { cn } from "@suphark/ui/lib/utils";
|
|
6
|
+
import { XIcon } from "lucide-react";
|
|
7
|
+
import type { ReactNode } from "react";
|
|
8
|
+
|
|
9
|
+
export interface SelectionAction {
|
|
10
|
+
label: string;
|
|
11
|
+
onClick: () => void;
|
|
12
|
+
icon?: ReactNode;
|
|
13
|
+
destructive?: boolean;
|
|
14
|
+
disabled?: boolean;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
export interface SelectionBarProps {
|
|
18
|
+
/** จำนวนที่เลือก — 0 = ไม่แสดงอะไรเลย */
|
|
19
|
+
count: number;
|
|
20
|
+
/** ล้างการเลือก (เช่น `table.resetRowSelection`) */
|
|
21
|
+
onClear: () => void;
|
|
22
|
+
/** ปุ่มคำสั่งหมู่ — ใส่เป็น array หรือจะส่ง `children` เป็น element เองก็ได้ */
|
|
23
|
+
actions?: ReadonlyArray<SelectionAction>;
|
|
24
|
+
children?: ReactNode;
|
|
25
|
+
/** ข้อความนับ ใช้ `{count}` เช่น "เลือก {count} รายการ" (ไม่ส่ง = `UiText.selection.selected`) */
|
|
26
|
+
label?: string;
|
|
27
|
+
/** floating = แถบลอยกลางล่างจอ (ค่าเริ่มต้น) · inline = แถบในหน้า เหนือตาราง */
|
|
28
|
+
variant?: "floating" | "inline";
|
|
29
|
+
className?: string;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* แถบคำสั่งหมู่ที่โผล่เมื่อเลือกหลายแถว — "เลือก N รายการ" + ปุ่มคำสั่ง + ปุ่มล้าง
|
|
34
|
+
* ใช้คู่ `DataTable` (`table.getFilteredSelectedRowModel().rows`) หรือ `SimpleTable` ที่แอปคุม state เอง
|
|
35
|
+
* คำสั่งที่ย้อนกลับไม่ได้ให้ส่ง element ของ `ConfirmActionButton`/`DeleteManyButton` ผ่าน `children`
|
|
36
|
+
*
|
|
37
|
+
* <SelectionBar count={selected.length} onClear={() => table.resetRowSelection()}
|
|
38
|
+
* actions={[{ label: "ส่งออก", onClick: exportRows }, { label: "ลบ", destructive: true, onClick: removeRows }]} />
|
|
39
|
+
*/
|
|
40
|
+
export function SelectionBar({
|
|
41
|
+
count,
|
|
42
|
+
onClear,
|
|
43
|
+
actions,
|
|
44
|
+
children,
|
|
45
|
+
label,
|
|
46
|
+
variant = "floating",
|
|
47
|
+
className,
|
|
48
|
+
}: SelectionBarProps) {
|
|
49
|
+
const text = useUiText();
|
|
50
|
+
if (count <= 0) return null;
|
|
51
|
+
|
|
52
|
+
const template = label ?? text.selection?.selected ?? "เลือก {count} รายการ";
|
|
53
|
+
const message = template.replace("{count}", String(count));
|
|
54
|
+
|
|
55
|
+
return (
|
|
56
|
+
<div
|
|
57
|
+
data-slot="selection-bar"
|
|
58
|
+
// อ่านออกเสียงจำนวนที่เลือกให้ screen reader โดยไม่ขัดจังหวะ
|
|
59
|
+
aria-live="polite"
|
|
60
|
+
className={cn(
|
|
61
|
+
"flex items-center gap-2 rounded-xl border bg-card px-3 py-2 shadow-lg",
|
|
62
|
+
variant === "floating" &&
|
|
63
|
+
"-translate-x-1/2 fixed bottom-[calc(1rem+env(safe-area-inset-bottom))] left-1/2 z-40 max-w-[calc(100vw-2rem)]",
|
|
64
|
+
className,
|
|
65
|
+
)}
|
|
66
|
+
>
|
|
67
|
+
<span className="shrink-0 font-medium text-sm tabular-nums">{message}</span>
|
|
68
|
+
<div className="flex min-w-0 flex-wrap items-center gap-1.5">
|
|
69
|
+
{actions?.map((action) => (
|
|
70
|
+
<Button
|
|
71
|
+
key={action.label}
|
|
72
|
+
size="sm"
|
|
73
|
+
variant={action.destructive ? "destructive" : "outline"}
|
|
74
|
+
disabled={action.disabled}
|
|
75
|
+
onClick={action.onClick}
|
|
76
|
+
>
|
|
77
|
+
{action.icon}
|
|
78
|
+
{action.label}
|
|
79
|
+
</Button>
|
|
80
|
+
))}
|
|
81
|
+
{children}
|
|
82
|
+
</div>
|
|
83
|
+
<Button
|
|
84
|
+
variant="ghost"
|
|
85
|
+
size="icon-sm"
|
|
86
|
+
aria-label={text.selection?.clear ?? "ล้างการเลือก"}
|
|
87
|
+
onClick={onClear}
|
|
88
|
+
className="ml-auto shrink-0"
|
|
89
|
+
>
|
|
90
|
+
<XIcon />
|
|
91
|
+
</Button>
|
|
92
|
+
</div>
|
|
93
|
+
);
|
|
94
|
+
}
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
"use client";
|
|
2
2
|
|
|
3
|
+
import { Checkbox } from "@suphark/ui/components/ui/checkbox";
|
|
3
4
|
import {
|
|
4
5
|
Table,
|
|
5
6
|
TableBody,
|
|
@@ -38,6 +39,10 @@ export function SimpleTable<T>({
|
|
|
38
39
|
getRowKey,
|
|
39
40
|
empty = "ไม่มีข้อมูล",
|
|
40
41
|
onRowClick,
|
|
42
|
+
selected,
|
|
43
|
+
onSelectedChange,
|
|
44
|
+
selectLabel = "เลือกแถว",
|
|
45
|
+
selectAllLabel = "เลือกทั้งหมด",
|
|
41
46
|
caption,
|
|
42
47
|
className,
|
|
43
48
|
}: {
|
|
@@ -45,6 +50,13 @@ export function SimpleTable<T>({
|
|
|
45
50
|
rows: ReadonlyArray<T>;
|
|
46
51
|
getRowKey: (row: T, index: number) => string | number;
|
|
47
52
|
empty?: ReactNode;
|
|
53
|
+
/** คีย์ของแถวที่เลือก — ส่งคู่กับ `onSelectedChange` เพื่อเปิดช่องติ๊กหน้าแถว (ใช้คู่ `SelectionBar`) */
|
|
54
|
+
selected?: ReadonlyArray<string | number>;
|
|
55
|
+
onSelectedChange?: (selected: Array<string | number>) => void;
|
|
56
|
+
/** aria-label ของช่องติ๊กแต่ละแถว (ค่าเริ่มต้น "เลือกแถว") */
|
|
57
|
+
selectLabel?: string;
|
|
58
|
+
/** aria-label ของช่องติ๊กบนหัวตาราง (ค่าเริ่มต้น "เลือกทั้งหมด") */
|
|
59
|
+
selectAllLabel?: string;
|
|
48
60
|
/** คลิกทั้งแถว (เช่นเปิดรายละเอียด) — ปุ่มในแถวควร stopPropagation เอง */
|
|
49
61
|
onRowClick?: (row: T) => void;
|
|
50
62
|
/** คำอธิบายตารางสำหรับ screen reader (sr-only) */
|
|
@@ -53,12 +65,37 @@ export function SimpleTable<T>({
|
|
|
53
65
|
}) {
|
|
54
66
|
const alignClass = (a?: "left" | "center" | "right") =>
|
|
55
67
|
a === "right" ? "text-right tabular-nums" : a === "center" ? "text-center" : undefined;
|
|
68
|
+
|
|
69
|
+
const selectable = Boolean(onSelectedChange);
|
|
70
|
+
const selectedKeys = new Set(selected ?? []);
|
|
71
|
+
const allKeys = rows.map((row, i) => getRowKey(row, i));
|
|
72
|
+
const allSelected = allKeys.length > 0 && allKeys.every((k) => selectedKeys.has(k));
|
|
73
|
+
const someSelected = !allSelected && allKeys.some((k) => selectedKeys.has(k));
|
|
74
|
+
|
|
75
|
+
function toggleRow(key: string | number, checked: boolean) {
|
|
76
|
+
const next = new Set(selectedKeys);
|
|
77
|
+
if (checked) next.add(key);
|
|
78
|
+
else next.delete(key);
|
|
79
|
+
onSelectedChange?.([...next]);
|
|
80
|
+
}
|
|
56
81
|
return (
|
|
57
82
|
<div className={cn("overflow-hidden rounded-xl border bg-card", className)}>
|
|
58
83
|
<Table>
|
|
59
84
|
{caption && <caption className="sr-only">{caption}</caption>}
|
|
60
85
|
<TableHeader>
|
|
61
86
|
<TableRow>
|
|
87
|
+
{selectable && (
|
|
88
|
+
<TableHead className="w-10">
|
|
89
|
+
<Checkbox
|
|
90
|
+
aria-label={selectAllLabel}
|
|
91
|
+
checked={allSelected}
|
|
92
|
+
indeterminate={someSelected}
|
|
93
|
+
// ครึ่งหนึ่ง: ซ่อนเครื่องหมายถูก แสดงขีดแทน (ไฟล์ shadcn ไม่มีไอคอนขีด)
|
|
94
|
+
className="data-indeterminate:border-primary data-indeterminate:bg-primary data-indeterminate:text-primary-foreground data-indeterminate:before:absolute data-indeterminate:before:h-0.5 data-indeterminate:before:w-2 data-indeterminate:before:rounded-full data-indeterminate:before:bg-current [&[data-indeterminate]_svg]:hidden"
|
|
95
|
+
onCheckedChange={(checked) => onSelectedChange?.(checked ? allKeys : [])}
|
|
96
|
+
/>
|
|
97
|
+
</TableHead>
|
|
98
|
+
)}
|
|
62
99
|
{columns.map((c) => (
|
|
63
100
|
<TableHead key={c.key} className={cn(alignClass(c.align), c.className)}>
|
|
64
101
|
{c.header}
|
|
@@ -68,14 +105,26 @@ export function SimpleTable<T>({
|
|
|
68
105
|
</TableHeader>
|
|
69
106
|
<TableBody>
|
|
70
107
|
{rows.length === 0 ? (
|
|
71
|
-
<EmptyRow colSpan={columns.length}>{empty}</EmptyRow>
|
|
108
|
+
<EmptyRow colSpan={columns.length + (selectable ? 1 : 0)}>{empty}</EmptyRow>
|
|
72
109
|
) : (
|
|
73
110
|
rows.map((row, i) => (
|
|
74
111
|
<TableRow
|
|
75
112
|
key={getRowKey(row, i)}
|
|
76
113
|
onClick={onRowClick ? () => onRowClick(row) : undefined}
|
|
77
|
-
|
|
114
|
+
data-selected={selectedKeys.has(getRowKey(row, i)) || undefined}
|
|
115
|
+
className={cn(onRowClick && "cursor-pointer", "data-selected:bg-muted/50")}
|
|
78
116
|
>
|
|
117
|
+
{selectable && (
|
|
118
|
+
<TableCell className="w-10">
|
|
119
|
+
<Checkbox
|
|
120
|
+
aria-label={selectLabel}
|
|
121
|
+
checked={selectedKeys.has(getRowKey(row, i))}
|
|
122
|
+
onCheckedChange={(checked) => toggleRow(getRowKey(row, i), checked === true)}
|
|
123
|
+
// คลิกช่องติ๊กไม่ให้ไปโดน onRowClick ของทั้งแถว
|
|
124
|
+
onClick={(event) => event.stopPropagation()}
|
|
125
|
+
/>
|
|
126
|
+
</TableCell>
|
|
127
|
+
)}
|
|
79
128
|
{columns.map((c) => (
|
|
80
129
|
<TableCell key={c.key} className={cn(alignClass(c.align), c.className)}>
|
|
81
130
|
{c.cell
|
|
@@ -8,7 +8,7 @@ import {
|
|
|
8
8
|
} from "@suphark/ui/components/ui/accordion";
|
|
9
9
|
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@suphark/ui/components/ui/tabs";
|
|
10
10
|
import { cn } from "@suphark/ui/lib/utils";
|
|
11
|
-
import type
|
|
11
|
+
import { type ReactNode, useCallback, useEffect, useRef, useState } from "react";
|
|
12
12
|
|
|
13
13
|
export interface SimpleTabItem {
|
|
14
14
|
value: string;
|
|
@@ -49,6 +49,26 @@ export function SimpleTabs({
|
|
|
49
49
|
className?: string;
|
|
50
50
|
listClassName?: string;
|
|
51
51
|
}) {
|
|
52
|
+
const listRef = useRef<HTMLDivElement>(null);
|
|
53
|
+
const [edges, setEdges] = useState({ start: false, end: false });
|
|
54
|
+
|
|
55
|
+
const updateEdges = useCallback(() => {
|
|
56
|
+
const el = listRef.current;
|
|
57
|
+
if (!el) return;
|
|
58
|
+
const max = el.scrollWidth - el.clientWidth;
|
|
59
|
+
// 4px กันเศษจากการปัดทศนิยม
|
|
60
|
+
setEdges({ start: el.scrollLeft > 4, end: el.scrollLeft < max - 4 });
|
|
61
|
+
}, []);
|
|
62
|
+
|
|
63
|
+
useEffect(() => {
|
|
64
|
+
updateEdges();
|
|
65
|
+
const el = listRef.current;
|
|
66
|
+
if (!el || typeof ResizeObserver === "undefined") return;
|
|
67
|
+
const observer = new ResizeObserver(updateEdges);
|
|
68
|
+
observer.observe(el);
|
|
69
|
+
return () => observer.disconnect();
|
|
70
|
+
}, [updateEdges]);
|
|
71
|
+
|
|
52
72
|
return (
|
|
53
73
|
<Tabs
|
|
54
74
|
value={value}
|
|
@@ -57,18 +77,39 @@ export function SimpleTabs({
|
|
|
57
77
|
onValueChange={(v) => onValueChange?.(String(v))}
|
|
58
78
|
className={className}
|
|
59
79
|
>
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
80
|
+
{/* แท็บเยอะเกินความกว้าง = เลื่อนแนวนอนได้ + เงาบอกว่ายังมีต่อ (ซ่อนแถบเลื่อน) */}
|
|
81
|
+
<div className="relative">
|
|
82
|
+
<TabsList
|
|
83
|
+
ref={listRef}
|
|
84
|
+
onScroll={updateEdges}
|
|
85
|
+
variant={variant}
|
|
86
|
+
className={cn(
|
|
87
|
+
fullWidth && "w-full",
|
|
88
|
+
"no-scrollbar max-w-full overflow-x-auto scroll-smooth",
|
|
89
|
+
listClassName,
|
|
90
|
+
)}
|
|
91
|
+
>
|
|
92
|
+
{items.map((item) => (
|
|
93
|
+
<TabsTrigger key={item.value} value={item.value} disabled={item.disabled}>
|
|
94
|
+
{item.icon}
|
|
95
|
+
{item.label}
|
|
96
|
+
{item.badge}
|
|
97
|
+
</TabsTrigger>
|
|
98
|
+
))}
|
|
99
|
+
</TabsList>
|
|
100
|
+
{edges.start && (
|
|
101
|
+
<span
|
|
102
|
+
aria-hidden="true"
|
|
103
|
+
className="pointer-events-none absolute inset-y-0 left-0 w-6 rounded-l-lg bg-gradient-to-r from-background to-transparent"
|
|
104
|
+
/>
|
|
105
|
+
)}
|
|
106
|
+
{edges.end && (
|
|
107
|
+
<span
|
|
108
|
+
aria-hidden="true"
|
|
109
|
+
className="pointer-events-none absolute inset-y-0 right-0 w-6 rounded-r-lg bg-gradient-to-l from-background to-transparent"
|
|
110
|
+
/>
|
|
111
|
+
)}
|
|
112
|
+
</div>
|
|
72
113
|
{items.map((item) => (
|
|
73
114
|
<TabsContent key={item.value} value={item.value}>
|
|
74
115
|
{item.content}
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
"use client";
|
|
2
2
|
|
|
3
|
-
import { Button } from "@suphark/ui/components/
|
|
3
|
+
import { Button } from "@suphark/ui/components/shared/button/button";
|
|
4
4
|
import { Spinner } from "@suphark/ui/components/ui/spinner";
|
|
5
5
|
import { errorMessage, failedResult, isNavigationError } from "@suphark/ui/lib/action-result";
|
|
6
6
|
import { cn } from "@suphark/ui/lib/utils";
|