@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.
@@ -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 { ScrollArea } from "./scroll-area";
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
- <div ref={containerRef} className="relative w-full">
141
- <Button
142
- type="button"
143
- variant="outline"
144
- role="combobox"
145
- aria-expanded={open}
146
- disabled={disabled}
147
- className={cn("w-full justify-between h-auto min-h-9 text-left whitespace-normal gap-2", className)}
148
- id={id}
149
- onClick={(e) => {
150
- e.stopPropagation();
151
- setOpen(!open);
152
- }}
153
- >
154
- <span className="break-words min-w-0">
155
- {selectedOption ? selectedOption.label : placeholder}
156
- </span>
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
- <ChevronsUpDown className="h-4 w-4 opacity-50" />
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
- <div className="flex flex-col">
189
- {/* Search Input */}
190
- <div className="flex items-center border-b px-3 py-2">
191
- <Search className="mr-2 h-4 w-4 shrink-0 opacity-50" />
192
- <Input
193
- ref={searchInputRef}
194
- placeholder={searchPlaceholder}
195
- value={searchValue}
196
- onChange={(e) => {
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
- // Prevent Dialog from intercepting keyboard events
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
- if (filteredOptions.length > 0) {
212
- handleSelect(filteredOptions[0].value);
213
- }
136
+ e.stopPropagation();
137
+ handleClear(e as unknown as React.MouseEvent);
214
138
  }
215
139
  }}
216
- onClick={(e) => {
217
- e.stopPropagation();
218
- }}
219
- onFocus={(e) => {
220
- e.stopPropagation();
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
- </div>
264
+ </PopoverContent>
265
+ </Popover>
289
266
  );
290
267
  }