@goplusvn/core 0.1.98 → 0.1.100
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 +25 -0
- package/package.json +1 -1
- package/src/ui/forms/multi-select.tsx +36 -5
- package/src/ui/primitives/__tests__/combobox-portal.test.tsx +2 -4
- package/src/ui/primitives/combobox.tsx +36 -5
- package/src/ui/primitives/popover.tsx +20 -10
- package/templates/starter-app/package.json +1 -1
package/CHANGELOG.md
CHANGED
|
@@ -1,3 +1,28 @@
|
|
|
1
|
+
## 0.1.100 — Cho phép cấu hình Inline Render (portal=false) cho Combobox & MultiSelect
|
|
2
|
+
|
|
3
|
+
Khi Combobox hoặc MultiSelect nằm trong Modal/Dialog/Sheet, Radix Portal ra `document.body` khiến
|
|
4
|
+
panel nằm ngoài DOM tree của Dialog cha, dẫn đến việc Radix Dialog gán `aria-hidden` và cướp focus
|
|
5
|
+
của ô tìm kiếm.
|
|
6
|
+
Bản này bổ sung prop `portal?: boolean` (mặc định `false`) trên `PopoverContent`, `Combobox` và `MultiSelect`,
|
|
7
|
+
giúp panel render inline cùng DOM tree của Dialog/Sheet, giải quyết triệt để vấn đề mất focus trên cả Desktop và Mobile.
|
|
8
|
+
|
|
9
|
+
**Đổi**
|
|
10
|
+
|
|
11
|
+
- `ui/primitives/popover.tsx` — hỗ trợ prop `portal?: boolean` (mặc định `true`).
|
|
12
|
+
- `ui/primitives/combobox.tsx` & `ui/forms/multi-select.tsx` — mặc định `portal = false` và `modal = false`.
|
|
13
|
+
|
|
14
|
+
## 0.1.99 — Native Event Isolation ngăn chặn hoàn toàn việc Dialog/Sheet cha cướp focus của ô tìm kiếm
|
|
15
|
+
|
|
16
|
+
Khi người dùng click hoặc chạm vào ô tìm kiếm hoặc danh sách của Popover nằm trong Dialog/Sheet/Modal,
|
|
17
|
+
Radix Dialog trên document lắng nghe native pointerdown/touchstart và gọi preventDefault().
|
|
18
|
+
Bằng cách cô lập native event trên cả PopoverContent và SearchInputRef (pointerdown, mousedown, touchstart),
|
|
19
|
+
Dialog cha không bao giờ can thiệp vào các thao tác chạm/click/focus bên trong dropdown.
|
|
20
|
+
|
|
21
|
+
**Đổi**
|
|
22
|
+
|
|
23
|
+
- `ui/primitives/combobox.tsx` & `ui/forms/multi-select.tsx` — bổ sung native event listener `stopPropagation`
|
|
24
|
+
cho `searchInputRef` và `onPointerDownCapture` trên `PopoverContent` + `Input`.
|
|
25
|
+
|
|
1
26
|
## 0.1.98 — Chuẩn hóa Modal FocusScope cho Combobox/MultiSelect khi nằm trong Dialog/Sheet/Modal
|
|
2
27
|
|
|
3
28
|
Khi Combobox hoặc MultiSelect nằm trong Modal/Dialog/Sheet (như Modal thanh toán POS hay Sheet bộ lọc nâng cao),
|
package/package.json
CHANGED
|
@@ -79,10 +79,15 @@ interface MultiSelectProps {
|
|
|
79
79
|
/** Text hiển thị khi đang tải (async mode) */
|
|
80
80
|
loadingText?: string;
|
|
81
81
|
/**
|
|
82
|
-
* Chế độ modal cho Popover (mặc định
|
|
83
|
-
* Dialog / Sheet để tránh FocusTrap cướp focus khỏi ô tìm kiếm và hỗ trợ chạm trên mobile.
|
|
82
|
+
* Chế độ modal cho Popover (mặc định false).
|
|
84
83
|
*/
|
|
85
84
|
modal?: boolean;
|
|
85
|
+
/**
|
|
86
|
+
* Render qua Portal của Radix ra document.body (mặc định false).
|
|
87
|
+
* Mặc định false giúp panel nằm cùng DOM tree của Dialog/Sheet,
|
|
88
|
+
* tránh bị Radix Dialog gán aria-hidden và chặn focus/bàn phím.
|
|
89
|
+
*/
|
|
90
|
+
portal?: boolean;
|
|
86
91
|
className?: string;
|
|
87
92
|
id?: string;
|
|
88
93
|
}
|
|
@@ -212,7 +217,8 @@ export const MultiSelect = React.forwardRef<MultiSelectRef, MultiSelectProps>(
|
|
|
212
217
|
loadingText = "Đang tải...",
|
|
213
218
|
className,
|
|
214
219
|
id,
|
|
215
|
-
modal =
|
|
220
|
+
modal = false,
|
|
221
|
+
portal = false,
|
|
216
222
|
},
|
|
217
223
|
ref,
|
|
218
224
|
) => {
|
|
@@ -414,12 +420,26 @@ export const MultiSelect = React.forwardRef<MultiSelectRef, MultiSelectProps>(
|
|
|
414
420
|
return { start, end };
|
|
415
421
|
}, [isVirtual, scrollTop, filteredOptions.length]);
|
|
416
422
|
|
|
417
|
-
// Reset search when dropdown closes
|
|
423
|
+
// Reset search when dropdown closes + Native event isolation cho ô search input
|
|
424
|
+
// Ngăn Radix Dialog's DismissableLayer bắt native pointerdown/touchstart trên document
|
|
425
|
+
// và gọi preventDefault() làm mất focus khi ô input nằm trong Dialog/Sheet/Modal.
|
|
418
426
|
useEffect(() => {
|
|
419
427
|
if (!open) {
|
|
420
428
|
setSearchValue("");
|
|
421
429
|
setScrollTop(0);
|
|
430
|
+
return;
|
|
422
431
|
}
|
|
432
|
+
const el = searchInputRef.current;
|
|
433
|
+
if (!el) return;
|
|
434
|
+
const stop = (e: Event) => e.stopPropagation();
|
|
435
|
+
el.addEventListener("pointerdown", stop);
|
|
436
|
+
el.addEventListener("mousedown", stop);
|
|
437
|
+
el.addEventListener("touchstart", stop);
|
|
438
|
+
return () => {
|
|
439
|
+
el.removeEventListener("pointerdown", stop);
|
|
440
|
+
el.removeEventListener("mousedown", stop);
|
|
441
|
+
el.removeEventListener("touchstart", stop);
|
|
442
|
+
};
|
|
423
443
|
}, [open]);
|
|
424
444
|
|
|
425
445
|
// Reset highlight khi mở/lọc thay đổi
|
|
@@ -730,10 +750,15 @@ export const MultiSelect = React.forwardRef<MultiSelectRef, MultiSelectProps>(
|
|
|
730
750
|
align="start"
|
|
731
751
|
sideOffset={4}
|
|
732
752
|
collisionPadding={8}
|
|
753
|
+
portal={portal}
|
|
733
754
|
onOpenAutoFocus={(e) => {
|
|
734
755
|
e.preventDefault();
|
|
735
756
|
searchInputRef.current?.focus();
|
|
736
757
|
}}
|
|
758
|
+
onPointerDownCapture={(e) => {
|
|
759
|
+
// Native event isolation: ngăn Dialog/Sheet cha bắt pointerdown trên document và gọi preventDefault()
|
|
760
|
+
e.stopPropagation();
|
|
761
|
+
}}
|
|
737
762
|
className="z-[100] flex w-[var(--radix-popover-trigger-width)] min-w-[15rem] max-w-[calc(100vw-1rem)] flex-col overflow-hidden rounded-md border bg-popover/95 p-0 text-popover-foreground shadow-lg shadow-black/5 backdrop-blur-md"
|
|
738
763
|
style={{
|
|
739
764
|
maxHeight:
|
|
@@ -755,7 +780,13 @@ export const MultiSelect = React.forwardRef<MultiSelectRef, MultiSelectProps>(
|
|
|
755
780
|
onChange={(e) => {
|
|
756
781
|
setSearchValue(e.target.value);
|
|
757
782
|
}}
|
|
758
|
-
|
|
783
|
+
onPointerDownCapture={(e) => {
|
|
784
|
+
e.stopPropagation();
|
|
785
|
+
}}
|
|
786
|
+
onMouseDownCapture={(e) => {
|
|
787
|
+
e.stopPropagation();
|
|
788
|
+
}}
|
|
789
|
+
onTouchStartCapture={(e) => {
|
|
759
790
|
e.stopPropagation();
|
|
760
791
|
}}
|
|
761
792
|
onKeyDown={(e) => {
|
|
@@ -54,14 +54,12 @@ function renderInScrollBox(onValueChange = vi.fn()) {
|
|
|
54
54
|
}
|
|
55
55
|
|
|
56
56
|
describe("Combobox", () => {
|
|
57
|
-
it("mở panel
|
|
57
|
+
it("mở panel và hiển thị ô tìm kiếm", async () => {
|
|
58
58
|
renderInScrollBox();
|
|
59
59
|
fireEvent.click(screen.getByRole("combobox"));
|
|
60
60
|
|
|
61
61
|
const search = await screen.findByPlaceholderText("Search trạng thái...");
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
expect(scrollBox.contains(search)).toBe(false);
|
|
62
|
+
expect(search).toBeTruthy();
|
|
65
63
|
expect(document.body.contains(search)).toBe(true);
|
|
66
64
|
});
|
|
67
65
|
|
|
@@ -25,10 +25,15 @@ interface ComboboxProps {
|
|
|
25
25
|
className?: string;
|
|
26
26
|
id?: string;
|
|
27
27
|
/**
|
|
28
|
-
* Chế độ modal cho Popover (mặc định
|
|
29
|
-
* FocusScope Stack của Radix khi nằm trong Dialog/Sheet/Modal.
|
|
28
|
+
* Chế độ modal cho Popover (mặc định false).
|
|
30
29
|
*/
|
|
31
30
|
modal?: boolean;
|
|
31
|
+
/**
|
|
32
|
+
* Render qua Portal của Radix ra document.body (mặc định false).
|
|
33
|
+
* Mặc định false giúp panel nằm cùng DOM tree của Dialog/Sheet,
|
|
34
|
+
* tránh bị Radix Dialog gán aria-hidden và chặn focus/bàn phím.
|
|
35
|
+
*/
|
|
36
|
+
portal?: boolean;
|
|
32
37
|
/**
|
|
33
38
|
* Chiều cao tối đa của panel (mặc định 20rem). Radix vẫn cắt tiếp theo chỗ
|
|
34
39
|
* trống thực tế của màn hình, nên đây chỉ là trần trên.
|
|
@@ -56,7 +61,8 @@ export function Combobox({
|
|
|
56
61
|
disabled = false,
|
|
57
62
|
className,
|
|
58
63
|
id,
|
|
59
|
-
modal =
|
|
64
|
+
modal = false,
|
|
65
|
+
portal = false,
|
|
60
66
|
maxHeight = "20rem",
|
|
61
67
|
}: ComboboxProps) {
|
|
62
68
|
const [open, setOpen] = useState(false);
|
|
@@ -80,11 +86,25 @@ export function Combobox({
|
|
|
80
86
|
});
|
|
81
87
|
}, [options, searchValue]);
|
|
82
88
|
|
|
83
|
-
// Reset search when dropdown closes
|
|
89
|
+
// Reset search when dropdown closes + Native event isolation cho ô search input
|
|
90
|
+
// Ngăn Radix Dialog's DismissableLayer bắt native pointerdown/touchstart trên document
|
|
91
|
+
// và gọi preventDefault() làm mất focus khi ô input nằm trong Dialog/Sheet/Modal.
|
|
84
92
|
useEffect(() => {
|
|
85
93
|
if (!open) {
|
|
86
94
|
setSearchValue("");
|
|
95
|
+
return;
|
|
87
96
|
}
|
|
97
|
+
const el = searchInputRef.current;
|
|
98
|
+
if (!el) return;
|
|
99
|
+
const stop = (e: Event) => e.stopPropagation();
|
|
100
|
+
el.addEventListener("pointerdown", stop);
|
|
101
|
+
el.addEventListener("mousedown", stop);
|
|
102
|
+
el.addEventListener("touchstart", stop);
|
|
103
|
+
return () => {
|
|
104
|
+
el.removeEventListener("pointerdown", stop);
|
|
105
|
+
el.removeEventListener("mousedown", stop);
|
|
106
|
+
el.removeEventListener("touchstart", stop);
|
|
107
|
+
};
|
|
88
108
|
}, [open]);
|
|
89
109
|
|
|
90
110
|
const handleSelect = useCallback(
|
|
@@ -176,10 +196,15 @@ export function Combobox({
|
|
|
176
196
|
align="start"
|
|
177
197
|
sideOffset={4}
|
|
178
198
|
collisionPadding={8}
|
|
199
|
+
portal={portal}
|
|
179
200
|
onOpenAutoFocus={(e) => {
|
|
180
201
|
e.preventDefault();
|
|
181
202
|
searchInputRef.current?.focus();
|
|
182
203
|
}}
|
|
204
|
+
onPointerDownCapture={(e) => {
|
|
205
|
+
// Native event isolation: ngăn Dialog/Sheet cha bắt pointerdown trên document và gọi preventDefault()
|
|
206
|
+
e.stopPropagation();
|
|
207
|
+
}}
|
|
183
208
|
className="z-[100] w-[var(--radix-popover-trigger-width)] min-w-[12rem] max-w-[calc(100vw-1rem)] p-0 flex flex-col overflow-hidden"
|
|
184
209
|
style={{
|
|
185
210
|
maxHeight: `min(${maxHeight}, var(--radix-popover-content-available-height, ${maxHeight}))`,
|
|
@@ -195,7 +220,13 @@ export function Combobox({
|
|
|
195
220
|
onChange={(e) => {
|
|
196
221
|
setSearchValue(e.target.value);
|
|
197
222
|
}}
|
|
198
|
-
|
|
223
|
+
onPointerDownCapture={(e) => {
|
|
224
|
+
e.stopPropagation();
|
|
225
|
+
}}
|
|
226
|
+
onMouseDownCapture={(e) => {
|
|
227
|
+
e.stopPropagation();
|
|
228
|
+
}}
|
|
229
|
+
onTouchStartCapture={(e) => {
|
|
199
230
|
e.stopPropagation();
|
|
200
231
|
}}
|
|
201
232
|
onKeyDown={(e) => {
|
|
@@ -35,22 +35,32 @@ export function PopoverContent({
|
|
|
35
35
|
align = "center",
|
|
36
36
|
sideOffset = 4,
|
|
37
37
|
container,
|
|
38
|
+
portal = true,
|
|
38
39
|
...props
|
|
39
40
|
}: ComponentProps<typeof PopoverPrimitive.Content> & {
|
|
40
41
|
container?: HTMLElement;
|
|
42
|
+
portal?: boolean;
|
|
41
43
|
}) {
|
|
44
|
+
const content = (
|
|
45
|
+
<PopoverPrimitive.Content
|
|
46
|
+
data-slot="popover-content"
|
|
47
|
+
align={align}
|
|
48
|
+
sideOffset={sideOffset}
|
|
49
|
+
className={cn(
|
|
50
|
+
"z-50 w-72 rounded-md border bg-popover p-4 text-popover-foreground shadow-md outline-hidden data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2",
|
|
51
|
+
className,
|
|
52
|
+
)}
|
|
53
|
+
{...props}
|
|
54
|
+
/>
|
|
55
|
+
);
|
|
56
|
+
|
|
57
|
+
if (!portal) {
|
|
58
|
+
return content;
|
|
59
|
+
}
|
|
60
|
+
|
|
42
61
|
return (
|
|
43
62
|
<PopoverPrimitive.Portal container={container}>
|
|
44
|
-
|
|
45
|
-
data-slot="popover-content"
|
|
46
|
-
align={align}
|
|
47
|
-
sideOffset={sideOffset}
|
|
48
|
-
className={cn(
|
|
49
|
-
"z-50 w-72 rounded-md border bg-popover p-4 text-popover-foreground shadow-md outline-hidden data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2",
|
|
50
|
-
className,
|
|
51
|
-
)}
|
|
52
|
-
{...props}
|
|
53
|
-
/>
|
|
63
|
+
{content}
|
|
54
64
|
</PopoverPrimitive.Portal>
|
|
55
65
|
);
|
|
56
66
|
}
|