@goplusvn/core 0.1.90 → 0.1.92
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 +46 -0
- package/package.json +1 -1
- package/src/crud/__tests__/crud-performance.test.tsx +105 -0
- package/src/crud/components/crud-table.tsx +32 -48
- package/src/crud/lib/query-builder.ts +1 -1
- package/src/crud/server-service.test.ts +15 -0
- package/src/crud/server-service.ts +1 -1
- package/src/ui/data-display/data-table/data-table.tsx +31 -5
- package/src/ui/data-display/data-table/index.ts +1 -0
- package/src/ui/forms/multi-select.tsx +160 -186
- package/src/ui/primitives/__tests__/combobox-portal.test.tsx +91 -0
- package/src/ui/primitives/combobox.tsx +166 -189
|
@@ -7,7 +7,7 @@ import { Check, ChevronsUpDown, Search, X } from "lucide-react";
|
|
|
7
7
|
import { cn } from "../../utils";
|
|
8
8
|
import { Button } from "./button";
|
|
9
9
|
import { Input } from "./input";
|
|
10
|
-
import {
|
|
10
|
+
import { Popover, PopoverContent, PopoverTrigger } from "./popover";
|
|
11
11
|
|
|
12
12
|
export interface ComboboxOption {
|
|
13
13
|
value: string | number | boolean;
|
|
@@ -24,6 +24,11 @@ interface ComboboxProps {
|
|
|
24
24
|
disabled?: boolean;
|
|
25
25
|
className?: string;
|
|
26
26
|
id?: string;
|
|
27
|
+
/**
|
|
28
|
+
* Chiều cao tối đa của panel (mặc định 20rem). Radix vẫn cắt tiếp theo chỗ
|
|
29
|
+
* trống thực tế của màn hình, nên đây chỉ là trần trên.
|
|
30
|
+
*/
|
|
31
|
+
maxHeight?: string;
|
|
27
32
|
}
|
|
28
33
|
|
|
29
34
|
export function Combobox({
|
|
@@ -36,12 +41,11 @@ export function Combobox({
|
|
|
36
41
|
disabled = false,
|
|
37
42
|
className,
|
|
38
43
|
id,
|
|
44
|
+
maxHeight = "20rem",
|
|
39
45
|
}: ComboboxProps) {
|
|
40
46
|
const [open, setOpen] = useState(false);
|
|
41
47
|
const [searchValue, setSearchValue] = useState("");
|
|
42
|
-
const containerRef = useRef<HTMLDivElement>(null);
|
|
43
48
|
const searchInputRef = useRef<HTMLInputElement>(null);
|
|
44
|
-
const dropdownRef = useRef<HTMLDivElement>(null);
|
|
45
49
|
|
|
46
50
|
const selectedOption = options.find(
|
|
47
51
|
(option) => String(option.value) === String(value),
|
|
@@ -67,53 +71,6 @@ export function Combobox({
|
|
|
67
71
|
}
|
|
68
72
|
}, [open]);
|
|
69
73
|
|
|
70
|
-
// Focus search input when dropdown opens
|
|
71
|
-
useEffect(() => {
|
|
72
|
-
if (open && searchInputRef.current) {
|
|
73
|
-
// Small delay to ensure dropdown is rendered
|
|
74
|
-
setTimeout(() => {
|
|
75
|
-
searchInputRef.current?.focus();
|
|
76
|
-
}, 50);
|
|
77
|
-
}
|
|
78
|
-
}, [open]);
|
|
79
|
-
|
|
80
|
-
// Close dropdown when clicking outside
|
|
81
|
-
useEffect(() => {
|
|
82
|
-
if (!open) return;
|
|
83
|
-
|
|
84
|
-
const handleClickOutside = (event: MouseEvent) => {
|
|
85
|
-
const target = event.target as HTMLElement;
|
|
86
|
-
if (
|
|
87
|
-
containerRef.current &&
|
|
88
|
-
!containerRef.current.contains(target) &&
|
|
89
|
-
dropdownRef.current &&
|
|
90
|
-
!dropdownRef.current.contains(target)
|
|
91
|
-
) {
|
|
92
|
-
// Don't close if clicking inside dialog
|
|
93
|
-
if (target.closest('[data-slot="dialog-content"]')) {
|
|
94
|
-
return;
|
|
95
|
-
}
|
|
96
|
-
setOpen(false);
|
|
97
|
-
}
|
|
98
|
-
};
|
|
99
|
-
|
|
100
|
-
const handleEscape = (event: KeyboardEvent) => {
|
|
101
|
-
if (event.key === "Escape" && open) {
|
|
102
|
-
event.stopPropagation();
|
|
103
|
-
setOpen(false);
|
|
104
|
-
}
|
|
105
|
-
};
|
|
106
|
-
|
|
107
|
-
// Use capture phase to catch events before Dialog
|
|
108
|
-
document.addEventListener("mousedown", handleClickOutside, true);
|
|
109
|
-
document.addEventListener("keydown", handleEscape, true);
|
|
110
|
-
|
|
111
|
-
return () => {
|
|
112
|
-
document.removeEventListener("mousedown", handleClickOutside, true);
|
|
113
|
-
document.removeEventListener("keydown", handleEscape, true);
|
|
114
|
-
};
|
|
115
|
-
}, [open]);
|
|
116
|
-
|
|
117
74
|
const handleSelect = useCallback(
|
|
118
75
|
(optionValue: string | number | boolean) => {
|
|
119
76
|
if (onValueChange) {
|
|
@@ -137,154 +94,174 @@ export function Combobox({
|
|
|
137
94
|
);
|
|
138
95
|
|
|
139
96
|
return (
|
|
140
|
-
<
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
<div className="ml-2 flex items-center gap-1 shrink-0">
|
|
158
|
-
{selectedOption && (
|
|
159
|
-
<div
|
|
160
|
-
role="button"
|
|
161
|
-
tabIndex={0}
|
|
162
|
-
className="h-4 w-4 p-0 hover:bg-transparent cursor-pointer flex items-center justify-center"
|
|
163
|
-
onClick={handleClear}
|
|
164
|
-
onKeyDown={(e) => {
|
|
165
|
-
if (e.key === "Enter" || e.key === " ") {
|
|
166
|
-
e.preventDefault();
|
|
167
|
-
e.stopPropagation();
|
|
168
|
-
handleClear(e as any);
|
|
169
|
-
}
|
|
170
|
-
}}
|
|
171
|
-
>
|
|
172
|
-
<X className="h-3.5 w-3.5 text-red-500 hover:text-red-600" />
|
|
173
|
-
</div>
|
|
97
|
+
<Popover
|
|
98
|
+
open={open}
|
|
99
|
+
onOpenChange={(next) => {
|
|
100
|
+
if (disabled && next) return;
|
|
101
|
+
setOpen(next);
|
|
102
|
+
}}
|
|
103
|
+
>
|
|
104
|
+
<PopoverTrigger asChild>
|
|
105
|
+
<Button
|
|
106
|
+
type="button"
|
|
107
|
+
variant="outline"
|
|
108
|
+
role="combobox"
|
|
109
|
+
aria-expanded={open}
|
|
110
|
+
disabled={disabled}
|
|
111
|
+
className={cn(
|
|
112
|
+
"w-full justify-between h-auto min-h-9 text-left whitespace-normal gap-2",
|
|
113
|
+
className,
|
|
174
114
|
)}
|
|
175
|
-
|
|
176
|
-
</div>
|
|
177
|
-
</Button>
|
|
178
|
-
|
|
179
|
-
{open && (
|
|
180
|
-
<div
|
|
181
|
-
ref={dropdownRef}
|
|
182
|
-
className="absolute z-[100] mt-1 w-full rounded-md border bg-popover text-popover-foreground shadow-md"
|
|
183
|
-
style={{
|
|
184
|
-
top: "100%",
|
|
185
|
-
left: 0,
|
|
186
|
-
}}
|
|
115
|
+
id={id}
|
|
187
116
|
>
|
|
188
|
-
<
|
|
189
|
-
{
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
117
|
+
<span className="break-words min-w-0">
|
|
118
|
+
{selectedOption ? selectedOption.label : placeholder}
|
|
119
|
+
</span>
|
|
120
|
+
<div className="ml-2 flex items-center gap-1 shrink-0">
|
|
121
|
+
{selectedOption && (
|
|
122
|
+
<div
|
|
123
|
+
role="button"
|
|
124
|
+
tabIndex={0}
|
|
125
|
+
className="h-4 w-4 p-0 hover:bg-transparent cursor-pointer flex items-center justify-center"
|
|
126
|
+
onPointerDown={(e) => {
|
|
127
|
+
// Radix Trigger mở/đóng theo pointerdown ở một số nhánh —
|
|
128
|
+
// chặn sớm để nút xoá không kéo theo việc bật panel.
|
|
129
|
+
e.preventDefault();
|
|
197
130
|
e.stopPropagation();
|
|
198
|
-
setSearchValue(e.target.value);
|
|
199
131
|
}}
|
|
132
|
+
onClick={handleClear}
|
|
200
133
|
onKeyDown={(e) => {
|
|
201
|
-
|
|
202
|
-
e.stopPropagation();
|
|
203
|
-
// Close dropdown on Escape
|
|
204
|
-
if (e.key === "Escape") {
|
|
205
|
-
e.preventDefault();
|
|
206
|
-
setOpen(false);
|
|
207
|
-
}
|
|
208
|
-
// Select first filtered option on Enter
|
|
209
|
-
if (e.key === "Enter") {
|
|
134
|
+
if (e.key === "Enter" || e.key === " ") {
|
|
210
135
|
e.preventDefault();
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
}
|
|
136
|
+
e.stopPropagation();
|
|
137
|
+
handleClear(e as unknown as React.MouseEvent);
|
|
214
138
|
}
|
|
215
139
|
}}
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
}}
|
|
222
|
-
className="border-0 focus-visible:ring-0 focus-visible:ring-offset-0 h-9 bg-transparent px-0"
|
|
223
|
-
/>
|
|
224
|
-
{searchValue && (
|
|
225
|
-
<Button
|
|
226
|
-
type="button"
|
|
227
|
-
variant="ghost"
|
|
228
|
-
size="sm"
|
|
229
|
-
className="h-6 w-6 p-0"
|
|
230
|
-
onClick={(e) => {
|
|
231
|
-
e.stopPropagation();
|
|
232
|
-
setSearchValue("");
|
|
233
|
-
searchInputRef.current?.focus();
|
|
234
|
-
}}
|
|
235
|
-
>
|
|
236
|
-
<X className="h-3 w-3" />
|
|
237
|
-
</Button>
|
|
238
|
-
)}
|
|
239
|
-
</div>
|
|
240
|
-
|
|
241
|
-
{/* Options List */}
|
|
242
|
-
<ScrollArea className="max-h-[300px]">
|
|
243
|
-
{filteredOptions.length === 0 ? (
|
|
244
|
-
<div className="py-6 text-center text-sm text-muted-foreground">
|
|
245
|
-
{emptyText}
|
|
246
|
-
</div>
|
|
247
|
-
) : (
|
|
248
|
-
<div className="p-1">
|
|
249
|
-
{filteredOptions.map((option) => {
|
|
250
|
-
const isSelected = String(value) === String(option.value);
|
|
251
|
-
return (
|
|
252
|
-
<div
|
|
253
|
-
key={String(option.value)}
|
|
254
|
-
role="option"
|
|
255
|
-
aria-selected={isSelected}
|
|
256
|
-
className={cn(
|
|
257
|
-
"relative flex cursor-pointer select-none items-center rounded-sm px-2 py-1.5 text-sm outline-none",
|
|
258
|
-
"hover:bg-accent hover:text-accent-foreground",
|
|
259
|
-
isSelected && "bg-accent text-accent-foreground",
|
|
260
|
-
)}
|
|
261
|
-
onMouseDown={(e) => {
|
|
262
|
-
e.preventDefault();
|
|
263
|
-
e.stopPropagation();
|
|
264
|
-
handleSelect(option.value);
|
|
265
|
-
}}
|
|
266
|
-
onClick={(e) => {
|
|
267
|
-
e.preventDefault();
|
|
268
|
-
e.stopPropagation();
|
|
269
|
-
handleSelect(option.value);
|
|
270
|
-
}}
|
|
271
|
-
>
|
|
272
|
-
<Check
|
|
273
|
-
className={cn(
|
|
274
|
-
"mr-2 h-4 w-4 shrink-0",
|
|
275
|
-
isSelected ? "opacity-100" : "opacity-0",
|
|
276
|
-
)}
|
|
277
|
-
/>
|
|
278
|
-
<span className="flex-1 truncate">{option.label}</span>
|
|
279
|
-
</div>
|
|
280
|
-
);
|
|
281
|
-
})}
|
|
282
|
-
</div>
|
|
283
|
-
)}
|
|
284
|
-
</ScrollArea>
|
|
140
|
+
>
|
|
141
|
+
<X className="h-3.5 w-3.5 text-red-500 hover:text-red-600" />
|
|
142
|
+
</div>
|
|
143
|
+
)}
|
|
144
|
+
<ChevronsUpDown className="h-4 w-4 opacity-50" />
|
|
285
145
|
</div>
|
|
146
|
+
</Button>
|
|
147
|
+
</PopoverTrigger>
|
|
148
|
+
|
|
149
|
+
{/*
|
|
150
|
+
Panel đi qua Portal của Radix + tự lật lên trên khi thiếu chỗ bên dưới
|
|
151
|
+
(`avoidCollisions` mặc định). Trước đây panel là `absolute` nằm trong
|
|
152
|
+
thân dialog (`overflow-y-auto`) nên bị CẮT NGANG ở mép dialog — chọn
|
|
153
|
+
combo trong CrudDialog chỉ thấy 1-2 dòng đầu.
|
|
154
|
+
- `--radix-popover-trigger-width`: panel rộng đúng bằng ô trigger.
|
|
155
|
+
- `--radix-popover-content-available-height`: trần chiều cao theo chỗ
|
|
156
|
+
trống thực tế, KHÔNG bao giờ tràn khỏi màn hình.
|
|
157
|
+
*/}
|
|
158
|
+
<PopoverContent
|
|
159
|
+
align="start"
|
|
160
|
+
sideOffset={4}
|
|
161
|
+
collisionPadding={8}
|
|
162
|
+
onOpenAutoFocus={(e) => {
|
|
163
|
+
e.preventDefault();
|
|
164
|
+
searchInputRef.current?.focus();
|
|
165
|
+
}}
|
|
166
|
+
className="z-[100] w-[var(--radix-popover-trigger-width)] min-w-[12rem] p-0 flex flex-col overflow-hidden"
|
|
167
|
+
style={{
|
|
168
|
+
maxHeight: `min(${maxHeight}, var(--radix-popover-content-available-height, ${maxHeight}))`,
|
|
169
|
+
}}
|
|
170
|
+
>
|
|
171
|
+
{/* Search Input */}
|
|
172
|
+
<div className="flex items-center border-b px-3 py-2 shrink-0">
|
|
173
|
+
<Search className="mr-2 h-4 w-4 shrink-0 opacity-50" />
|
|
174
|
+
<Input
|
|
175
|
+
ref={searchInputRef}
|
|
176
|
+
placeholder={searchPlaceholder}
|
|
177
|
+
value={searchValue}
|
|
178
|
+
onChange={(e) => {
|
|
179
|
+
e.stopPropagation();
|
|
180
|
+
setSearchValue(e.target.value);
|
|
181
|
+
}}
|
|
182
|
+
onKeyDown={(e) => {
|
|
183
|
+
// Prevent Dialog from intercepting keyboard events
|
|
184
|
+
e.stopPropagation();
|
|
185
|
+
// Close dropdown on Escape
|
|
186
|
+
if (e.key === "Escape") {
|
|
187
|
+
e.preventDefault();
|
|
188
|
+
setOpen(false);
|
|
189
|
+
}
|
|
190
|
+
// Select first filtered option on Enter
|
|
191
|
+
if (e.key === "Enter") {
|
|
192
|
+
e.preventDefault();
|
|
193
|
+
if (filteredOptions.length > 0) {
|
|
194
|
+
handleSelect(filteredOptions[0].value);
|
|
195
|
+
}
|
|
196
|
+
}
|
|
197
|
+
}}
|
|
198
|
+
onClick={(e) => {
|
|
199
|
+
e.stopPropagation();
|
|
200
|
+
}}
|
|
201
|
+
className="border-0 focus-visible:ring-0 focus-visible:ring-offset-0 h-9 bg-transparent px-0"
|
|
202
|
+
/>
|
|
203
|
+
{searchValue && (
|
|
204
|
+
<Button
|
|
205
|
+
type="button"
|
|
206
|
+
variant="ghost"
|
|
207
|
+
size="sm"
|
|
208
|
+
className="h-6 w-6 p-0"
|
|
209
|
+
onClick={(e) => {
|
|
210
|
+
e.stopPropagation();
|
|
211
|
+
setSearchValue("");
|
|
212
|
+
searchInputRef.current?.focus();
|
|
213
|
+
}}
|
|
214
|
+
>
|
|
215
|
+
<X className="h-3 w-3" />
|
|
216
|
+
</Button>
|
|
217
|
+
)}
|
|
218
|
+
</div>
|
|
219
|
+
|
|
220
|
+
{/* Options List */}
|
|
221
|
+
<div className="min-h-0 flex-1 overflow-y-auto overscroll-contain">
|
|
222
|
+
{filteredOptions.length === 0 ? (
|
|
223
|
+
<div className="py-6 text-center text-sm text-muted-foreground">
|
|
224
|
+
{emptyText}
|
|
225
|
+
</div>
|
|
226
|
+
) : (
|
|
227
|
+
<div className="p-1">
|
|
228
|
+
{filteredOptions.map((option) => {
|
|
229
|
+
const isSelected = String(value) === String(option.value);
|
|
230
|
+
return (
|
|
231
|
+
<div
|
|
232
|
+
key={String(option.value)}
|
|
233
|
+
role="option"
|
|
234
|
+
aria-selected={isSelected}
|
|
235
|
+
className={cn(
|
|
236
|
+
"relative flex cursor-pointer select-none items-center rounded-sm px-2 py-1.5 text-sm outline-none",
|
|
237
|
+
"hover:bg-accent hover:text-accent-foreground",
|
|
238
|
+
isSelected && "bg-accent text-accent-foreground",
|
|
239
|
+
)}
|
|
240
|
+
onMouseDown={(e) => {
|
|
241
|
+
e.preventDefault();
|
|
242
|
+
e.stopPropagation();
|
|
243
|
+
handleSelect(option.value);
|
|
244
|
+
}}
|
|
245
|
+
onClick={(e) => {
|
|
246
|
+
e.preventDefault();
|
|
247
|
+
e.stopPropagation();
|
|
248
|
+
handleSelect(option.value);
|
|
249
|
+
}}
|
|
250
|
+
>
|
|
251
|
+
<Check
|
|
252
|
+
className={cn(
|
|
253
|
+
"mr-2 h-4 w-4 shrink-0",
|
|
254
|
+
isSelected ? "opacity-100" : "opacity-0",
|
|
255
|
+
)}
|
|
256
|
+
/>
|
|
257
|
+
<span className="flex-1 truncate">{option.label}</span>
|
|
258
|
+
</div>
|
|
259
|
+
);
|
|
260
|
+
})}
|
|
261
|
+
</div>
|
|
262
|
+
)}
|
|
286
263
|
</div>
|
|
287
|
-
|
|
288
|
-
</
|
|
264
|
+
</PopoverContent>
|
|
265
|
+
</Popover>
|
|
289
266
|
);
|
|
290
267
|
}
|