Skip to content

Commit 5e363b1

Browse files
committed
Update Radix UI components and improve select behavior
- Added overrides for Radix UI components in package.json and pnpm-lock.yaml to ensure consistent versions. - Updated the Select and Popover components to handle pointer events more effectively, improving user interaction. - Enhanced FormSelectField to manage default values correctly. - Adjusted version specifications for several Radix UI components in the components package.
1 parent a108977 commit 5e363b1

10 files changed

Lines changed: 159 additions & 12 deletions

File tree

.changeset/fix-select-react19.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
"@pplethai/components": patch
3+
---
4+
5+
Fix Select and popover dropdowns closing immediately on React 19 and in mobile WebViews by upgrading Radix Select, aligning shared Radix layers, ignoring spurious empty value sync in forms, and using controlled values in FormSelectField.

package.json

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -23,5 +23,12 @@
2323
"typescript": "^5.7.2",
2424
"typescript-eslint": "^8.18.2"
2525
},
26-
"packageManager": "pnpm@9.15.1"
26+
"packageManager": "pnpm@9.15.1",
27+
"pnpm": {
28+
"overrides": {
29+
"@radix-ui/react-dismissable-layer": "1.1.11",
30+
"@radix-ui/react-focus-scope": "1.1.7",
31+
"@radix-ui/react-portal": "1.1.9"
32+
}
33+
}
2734
}

packages/components/package.json

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -58,14 +58,14 @@
5858
"@hookform/resolvers": "^3.9.1",
5959
"@radix-ui/react-accordion": "^1.2.2",
6060
"@radix-ui/react-checkbox": "^1.1.3",
61-
"@radix-ui/react-dialog": "^1.1.4",
62-
"@radix-ui/react-dropdown-menu": "^2.1.4",
61+
"@radix-ui/react-dialog": "^1.1.15",
62+
"@radix-ui/react-dropdown-menu": "^2.1.16",
6363
"@radix-ui/react-label": "^2.1.1",
6464
"@radix-ui/react-navigation-menu": "^1.2.3",
6565
"@radix-ui/react-popover": "^1.1.15",
6666
"@radix-ui/react-progress": "^1.1.1",
6767
"@radix-ui/react-radio-group": "^1.2.2",
68-
"@radix-ui/react-select": "^2.1.4",
68+
"@radix-ui/react-select": "^2.2.6",
6969
"@radix-ui/react-separator": "^1.1.1",
7070
"@radix-ui/react-slider": "^1.2.2",
7171
"@radix-ui/react-slot": "^1.1.1",

packages/components/src/components/ui/popover.tsx

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
import * as React from "react";
22
import * as PopoverPrimitive from "@radix-ui/react-popover";
3+
import { composePointerDownOutsideOnCombobox } from "../../lib/radix-outside-pointer";
34
import { cn } from "../../lib/utils";
45

56
export const Popover = PopoverPrimitive.Root;
@@ -9,12 +10,13 @@ export const PopoverAnchor = PopoverPrimitive.Anchor;
910
export const PopoverContent = React.forwardRef<
1011
React.ElementRef<typeof PopoverPrimitive.Content>,
1112
React.ComponentPropsWithoutRef<typeof PopoverPrimitive.Content>
12-
>(({ className, align = "center", sideOffset = 4, ...props }, ref) => (
13+
>(({ className, align = "center", sideOffset = 4, onPointerDownOutside, ...props }, ref) => (
1314
<PopoverPrimitive.Portal>
1415
<PopoverPrimitive.Content
1516
ref={ref}
1617
align={align}
1718
sideOffset={sideOffset}
19+
onPointerDownOutside={composePointerDownOutsideOnCombobox(onPointerDownOutside)}
1820
className={cn(
1921
"z-50 w-72 rounded-md border bg-popover p-4 text-popover-foreground shadow-md outline-none 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",
2022
className,

packages/components/src/components/ui/select.tsx

Lines changed: 31 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -2,9 +2,37 @@ import * as React from "react";
22
import * as SelectPrimitive from "@radix-ui/react-select";
33
import { Check, ChevronDown, ChevronUp } from "lucide-react";
44
import { dropdownFieldStyles } from "../../lib/dropdown-field-styles";
5+
import {
6+
composePointerDownOutsideOnCombobox,
7+
shouldIgnoreSpuriousSelectValueChange,
8+
} from "../../lib/radix-outside-pointer";
59
import { cn } from "../../lib/utils";
610

7-
export const Select = SelectPrimitive.Root;
11+
export const Select = ({
12+
onValueChange,
13+
value,
14+
...props
15+
}: React.ComponentPropsWithoutRef<typeof SelectPrimitive.Root>) => {
16+
const handleValueChange = React.useCallback(
17+
(nextValue: string) => {
18+
if (shouldIgnoreSpuriousSelectValueChange(nextValue, value)) {
19+
return;
20+
}
21+
onValueChange?.(nextValue);
22+
},
23+
[onValueChange, value],
24+
);
25+
26+
return (
27+
<SelectPrimitive.Root
28+
value={value}
29+
onValueChange={onValueChange ? handleValueChange : undefined}
30+
{...props}
31+
/>
32+
);
33+
};
34+
Select.displayName = SelectPrimitive.Root.displayName;
35+
836
export const SelectGroup = SelectPrimitive.Group;
937
export const SelectValue = SelectPrimitive.Value;
1038

@@ -59,7 +87,7 @@ SelectScrollDownButton.displayName = SelectPrimitive.ScrollDownButton.displayNam
5987
export const SelectContent = React.forwardRef<
6088
React.ElementRef<typeof SelectPrimitive.Content>,
6189
React.ComponentPropsWithoutRef<typeof SelectPrimitive.Content>
62-
>(({ className, children, position = "popper", ...props }, ref) => (
90+
>(({ className, children, position = "popper", onPointerDownOutside, ...props }, ref) => (
6391
<SelectPrimitive.Portal>
6492
<SelectPrimitive.Content
6593
ref={ref}
@@ -70,6 +98,7 @@ export const SelectContent = React.forwardRef<
7098
className,
7199
)}
72100
position={position}
101+
onPointerDownOutside={composePointerDownOutsideOnCombobox(onPointerDownOutside)}
73102
{...props}
74103
>
75104
<SelectScrollUpButton />

packages/components/src/form/fields.tsx

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -73,7 +73,10 @@ export function FormSelectField<T extends FieldValues>({
7373
render={({ field }) => (
7474
<FormItem>
7575
<FormLabel>{label}</FormLabel>
76-
<Select onValueChange={field.onChange} defaultValue={field.value}>
76+
<Select
77+
value={field.value === undefined || field.value === null ? "" : String(field.value)}
78+
onValueChange={field.onChange}
79+
>
7780
<FormControl>
7881
<SelectTrigger>
7982
<SelectValue placeholder={placeholder} />
Lines changed: 63 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,63 @@
1+
import { describe, expect, it, vi } from "vitest";
2+
import {
3+
composePointerDownOutsideOnCombobox,
4+
isComboboxTriggerTarget,
5+
shouldIgnoreSpuriousSelectValueChange,
6+
} from "./radix-outside-pointer";
7+
8+
describe("shouldIgnoreSpuriousSelectValueChange", () => {
9+
it("ignores empty string when a controlled value is set", () => {
10+
expect(shouldIgnoreSpuriousSelectValueChange("", "bkk")).toBe(true);
11+
});
12+
13+
it("allows empty string when value is already empty", () => {
14+
expect(shouldIgnoreSpuriousSelectValueChange("", "")).toBe(false);
15+
});
16+
17+
it("allows empty string when uncontrolled", () => {
18+
expect(shouldIgnoreSpuriousSelectValueChange("", undefined)).toBe(false);
19+
});
20+
21+
it("allows non-empty updates", () => {
22+
expect(shouldIgnoreSpuriousSelectValueChange("cnx", "bkk")).toBe(false);
23+
});
24+
});
25+
26+
describe("composePointerDownOutsideOnCombobox", () => {
27+
it("prevents default when target is inside a combobox trigger", () => {
28+
const trigger = document.createElement("button");
29+
trigger.setAttribute("role", "combobox");
30+
const inner = document.createElement("span");
31+
trigger.appendChild(inner);
32+
33+
const handler = composePointerDownOutsideOnCombobox();
34+
const event = new CustomEvent("pointerdownOutside", {
35+
detail: { originalEvent: {} as PointerEvent },
36+
});
37+
Object.defineProperty(event, "target", { value: inner });
38+
const preventDefault = vi.spyOn(event, "preventDefault");
39+
40+
handler(event);
41+
42+
expect(preventDefault).toHaveBeenCalled();
43+
});
44+
45+
it("forwards to user handler", () => {
46+
const userHandler = vi.fn();
47+
const handler = composePointerDownOutsideOnCombobox(userHandler);
48+
const event = new CustomEvent("pointerdownOutside", {
49+
detail: { originalEvent: {} as PointerEvent },
50+
});
51+
Object.defineProperty(event, "target", { value: document.body });
52+
53+
handler(event);
54+
55+
expect(userHandler).toHaveBeenCalledWith(event);
56+
});
57+
});
58+
59+
describe("isComboboxTriggerTarget", () => {
60+
it("returns false for non-element targets", () => {
61+
expect(isComboboxTriggerTarget(null)).toBe(false);
62+
});
63+
});
Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,33 @@
1+
type PointerDownOutsideEvent = CustomEvent<{ originalEvent: PointerEvent }>;
2+
3+
/** True when the event target is a select/combobox trigger (not the portaled content). */
4+
export function isComboboxTriggerTarget(target: EventTarget | null): boolean {
5+
return target instanceof Element && target.closest('[role="combobox"]') !== null;
6+
}
7+
8+
/**
9+
* Radix Select opens on pointerdown; the same gesture can be treated as an outside
10+
* pointerdown on the newly mounted content (React 19 / mobile WebViews). Ignore those.
11+
*/
12+
export function composePointerDownOutsideOnCombobox(
13+
onPointerDownOutside?: (event: PointerDownOutsideEvent) => void,
14+
): (event: PointerDownOutsideEvent) => void {
15+
return (event) => {
16+
if (isComboboxTriggerTarget(event.target)) {
17+
event.preventDefault();
18+
}
19+
onPointerDownOutside?.(event);
20+
};
21+
}
22+
23+
/**
24+
* Radix can call onValueChange('') when the hidden native select syncs before options mount
25+
* (common with React 19 + forms). Skip empty updates when a real controlled value exists.
26+
* @see https://github.com/radix-ui/primitives/issues/3693
27+
*/
28+
export function shouldIgnoreSpuriousSelectValueChange(
29+
nextValue: string,
30+
controlledValue: string | undefined,
31+
): boolean {
32+
return nextValue === "" && controlledValue !== undefined && controlledValue !== "";
33+
}
Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1 +1 @@
1-
{"root":["./src/index.ts","./src/components/icon.tsx","./src/components/logo.tsx","./src/components/nav-link-class-name.ts","./src/components/navbar.tsx","./src/components/layout/container.tsx","./src/components/layout/index.ts","./src/components/layout/inline.tsx","./src/components/layout/stack.tsx","./src/components/ui/accordion.tsx","./src/components/ui/alert.tsx","./src/components/ui/autocomplete.tsx","./src/components/ui/badge.tsx","./src/components/ui/breadcrumb.tsx","./src/components/ui/button.tsx","./src/components/ui/card.tsx","./src/components/ui/checkbox.tsx","./src/components/ui/dialog.tsx","./src/components/ui/dropdown-menu.tsx","./src/components/ui/form.tsx","./src/components/ui/input.tsx","./src/components/ui/label.tsx","./src/components/ui/multi-select.tsx","./src/components/ui/navigation-menu.tsx","./src/components/ui/popover.tsx","./src/components/ui/progress.tsx","./src/components/ui/radio-group.tsx","./src/components/ui/select.tsx","./src/components/ui/separator.tsx","./src/components/ui/sheet.tsx","./src/components/ui/skeleton.tsx","./src/components/ui/slider.tsx","./src/components/ui/sonner.tsx","./src/components/ui/spinner.tsx","./src/components/ui/stepper.tsx","./src/components/ui/switch.tsx","./src/components/ui/tabs.tsx","./src/components/ui/textarea.tsx","./src/components/ui/toast.ts","./src/form/fields.tsx","./src/form/index.ts","./src/lib/dropdown-field-styles.ts","./src/lib/gradients.ts","./src/lib/utils.ts","./src/lib/variants.ts","./src/test/setup.ts"],"version":"5.9.3"}
1+
{"root":["./src/index.ts","./src/components/icon.tsx","./src/components/logo.tsx","./src/components/nav-link-class-name.ts","./src/components/navbar.tsx","./src/components/layout/container.tsx","./src/components/layout/index.ts","./src/components/layout/inline.tsx","./src/components/layout/stack.tsx","./src/components/ui/accordion.tsx","./src/components/ui/alert.tsx","./src/components/ui/autocomplete.tsx","./src/components/ui/badge.tsx","./src/components/ui/breadcrumb.tsx","./src/components/ui/button.tsx","./src/components/ui/card.tsx","./src/components/ui/checkbox.tsx","./src/components/ui/dialog.tsx","./src/components/ui/dropdown-menu.tsx","./src/components/ui/form.tsx","./src/components/ui/input.tsx","./src/components/ui/label.tsx","./src/components/ui/multi-select.tsx","./src/components/ui/navigation-menu.tsx","./src/components/ui/popover.tsx","./src/components/ui/progress.tsx","./src/components/ui/radio-group.tsx","./src/components/ui/select.tsx","./src/components/ui/separator.tsx","./src/components/ui/sheet.tsx","./src/components/ui/skeleton.tsx","./src/components/ui/slider.tsx","./src/components/ui/sonner.tsx","./src/components/ui/spinner.tsx","./src/components/ui/stepper.tsx","./src/components/ui/switch.tsx","./src/components/ui/tabs.tsx","./src/components/ui/textarea.tsx","./src/components/ui/toast.ts","./src/form/fields.tsx","./src/form/index.ts","./src/lib/dropdown-field-styles.ts","./src/lib/gradients.ts","./src/lib/radix-outside-pointer.ts","./src/lib/utils.ts","./src/lib/variants.ts","./src/test/setup.ts"],"version":"5.9.3"}

pnpm-lock.yaml

Lines changed: 8 additions & 3 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

0 commit comments

Comments
 (0)