Skip to content

Commit 88c467c

Browse files
authored
fix: make all radix components uncontrolled (#5227)
Here improved handling radix states by making all components uncontrolled yet still synchronizing external state with local one.
1 parent 0c9b138 commit 88c467c

File tree

17 files changed

+136
-97
lines changed

17 files changed

+136
-97
lines changed

apps/builder/app/shared/content-model.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -424,7 +424,7 @@ export const richTextPlaceholders: Map<undefined | string, string> = new Map([
424424
["blockquote", "Blockquote"],
425425
["li", "List item"],
426426
["a", "Link"],
427-
["span", "Span"],
427+
["span", ""],
428428
]);
429429

430430
const findContentTags = ({

packages/sdk-components-react-radix/package.json

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -58,7 +58,6 @@
5858
"@radix-ui/react-switch": "^1.2.2",
5959
"@radix-ui/react-tabs": "^1.1.9",
6060
"@radix-ui/react-tooltip": "^1.2.4",
61-
"@radix-ui/react-use-controllable-state": "^1.2.2",
6261
"@webstudio-is/css-engine": "workspace:*",
6362
"@webstudio-is/icons": "workspace:*",
6463
"@webstudio-is/react-sdk": "workspace:*",

packages/sdk-components-react-radix/src/__generated__/tabs.props.ts

Lines changed: 1 addition & 1 deletion
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

packages/sdk-components-react-radix/src/accordion.template.tsx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -98,7 +98,7 @@ export const meta: TemplateMeta = {
9898
"A vertically stacked set of interactive headings that each reveal an associated section of content. Clicking on the heading will open the item and close other items.",
9999
order: 3,
100100
template: (
101-
<radix.Accordion collapsible={true} defaultValue="0">
101+
<radix.Accordion collapsible={true} value="0">
102102
{createAccordionItem(
103103
"Is it accessible?",
104104
"Yes. It adheres to the WAI-ARIA design pattern."

packages/sdk-components-react-radix/src/accordion.tsx

Lines changed: 16 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,8 @@ import {
44
forwardRef,
55
type ComponentProps,
66
type RefAttributes,
7+
useState,
8+
useEffect,
79
} from "react";
810
import {
911
Root,
@@ -21,8 +23,20 @@ export const Accordion = forwardRef<
2123
Extract<ComponentPropsWithoutRef<typeof Root>, { type: "single" }>,
2224
"type" | "asChild"
2325
>
24-
>((props, ref) => {
25-
return <Root ref={ref} type="single" {...props} />;
26+
>(({ defaultValue, ...props }, ref) => {
27+
const currentValue = props.value ?? defaultValue ?? "";
28+
const [value, setValue] = useState(currentValue);
29+
// synchronize external value with local one when changed
30+
useEffect(() => setValue(currentValue), [currentValue]);
31+
return (
32+
<Root
33+
{...props}
34+
ref={ref}
35+
type="single"
36+
value={value}
37+
onValueChange={setValue}
38+
/>
39+
);
2640
});
2741

2842
export const AccordionItem = forwardRef<

packages/sdk-components-react-radix/src/checkbox.tsx

Lines changed: 7 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -3,9 +3,10 @@ import {
33
type ComponentPropsWithRef,
44
forwardRef,
55
type ComponentProps,
6+
useState,
7+
useEffect,
68
} from "react";
79
import { Root, Indicator } from "@radix-ui/react-checkbox";
8-
import { useControllableState } from "@radix-ui/react-use-controllable-state";
910

1011
export const Checkbox = forwardRef<
1112
HTMLButtonElement,
@@ -16,16 +17,16 @@ export const Checkbox = forwardRef<
1617
defaultChecked?: boolean;
1718
}
1819
>(({ defaultChecked, ...props }, ref) => {
19-
const [checked, onCheckedChange] = useControllableState({
20-
prop: props.checked ?? defaultChecked ?? false,
21-
defaultProp: false,
22-
});
20+
const currentChecked = props.checked ?? defaultChecked ?? false;
21+
const [checked, setChecked] = useState(currentChecked);
22+
// synchronize external value with local one when changed
23+
useEffect(() => setChecked(currentChecked), [currentChecked]);
2324
return (
2425
<Root
2526
{...props}
2627
ref={ref}
2728
checked={checked}
28-
onCheckedChange={(open) => onCheckedChange(open === true)}
29+
onCheckedChange={(open) => setChecked(open === true)}
2930
/>
3031
);
3132
});

packages/sdk-components-react-radix/src/collapsible.tsx

Lines changed: 12 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -5,14 +5,22 @@ import {
55
Children,
66
type ComponentProps,
77
type RefAttributes,
8+
useState,
9+
useEffect,
810
} from "react";
911
import { Root, Trigger, Content } from "@radix-ui/react-collapsible";
1012
import { type Hook, getClosestInstance } from "@webstudio-is/react-sdk/runtime";
1113

12-
export const Collapsible: ForwardRefExoticComponent<
13-
Omit<ComponentProps<typeof Root>, "defaultOpen" | "asChild"> &
14-
RefAttributes<HTMLDivElement>
15-
> = Root;
14+
export const Collapsible = forwardRef<
15+
HTMLDivElement,
16+
Omit<ComponentProps<typeof Root>, "defaultOpen" | "asChild">
17+
>((props, ref) => {
18+
const currentOpen = props.open ?? false;
19+
const [open, setOpen] = useState(currentOpen);
20+
// synchronize external value with local one when changed
21+
useEffect(() => setOpen(currentOpen), [currentOpen]);
22+
return <Root {...props} ref={ref} open={open} onOpenChange={setOpen} />;
23+
});
1624

1725
/**
1826
* We're not exposing the 'asChild' property for the Trigger.

packages/sdk-components-react-radix/src/dialog.tsx

Lines changed: 15 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -1,22 +1,21 @@
1+
import interactionResponse from "await-interaction-response";
12
import {
2-
type ComponentPropsWithoutRef,
33
type ReactNode,
4+
type ComponentProps,
45
forwardRef,
56
Children,
6-
type ComponentProps,
77
useEffect,
88
useRef,
99
useContext,
1010
useCallback,
11+
useState,
1112
} from "react";
1213
import * as DialogPrimitive from "@radix-ui/react-dialog";
1314
import {
1415
ReactSdkContext,
1516
getClosestInstance,
1617
type Hook,
1718
} from "@webstudio-is/react-sdk/runtime";
18-
import { useControllableState } from "@radix-ui/react-use-controllable-state";
19-
import interactionResponse from "await-interaction-response";
2019

2120
/**
2221
* Naive heuristic to determine if a click event will cause navigate
@@ -50,23 +49,19 @@ const willNavigate = (event: MouseEvent) => {
5049
// wrap in forwardRef because Root is functional component without ref
5150
export const Dialog = forwardRef<
5251
HTMLDivElement,
53-
Omit<ComponentPropsWithoutRef<typeof DialogPrimitive.Root>, "defaultOpen">
52+
Omit<ComponentProps<typeof DialogPrimitive.Root>, "defaultOpen">
5453
>((props, _ref) => {
5554
const { renderer } = useContext(ReactSdkContext);
5655

57-
const [open, onOpenChange] = useControllableState({
58-
prop: props.open,
59-
defaultProp: false,
60-
onChange: props.onOpenChange,
61-
});
62-
63-
const onOpenChangeHandler = useCallback(
64-
async (open: boolean) => {
65-
await interactionResponse();
66-
onOpenChange(open);
67-
},
68-
[onOpenChange]
69-
);
56+
const currentOpen = props.open ?? false;
57+
const [open, setOpen] = useState(currentOpen);
58+
// synchronize external value with local one when changed
59+
useEffect(() => setOpen(currentOpen), [currentOpen]);
60+
61+
const onOpenChangeHandler = useCallback(async (open: boolean) => {
62+
await interactionResponse();
63+
setOpen(open);
64+
}, []);
7065

7166
/**
7267
* Close the dialog when a navigable link within it is clicked.
@@ -130,7 +125,7 @@ export const DialogTrigger = forwardRef<
130125

131126
export const DialogOverlay = forwardRef<
132127
HTMLDivElement,
133-
ComponentPropsWithoutRef<typeof DialogPrimitive.Overlay>
128+
ComponentProps<typeof DialogPrimitive.Overlay>
134129
>((props, ref) => {
135130
return (
136131
<DialogPrimitive.DialogPortal>
@@ -141,7 +136,7 @@ export const DialogOverlay = forwardRef<
141136

142137
export const DialogContent = forwardRef<
143138
HTMLDivElement,
144-
ComponentPropsWithoutRef<typeof DialogPrimitive.Content>
139+
ComponentProps<typeof DialogPrimitive.Content>
145140
>((props, ref) => {
146141
const preventAutoFocusOnClose = useRef(false);
147142
const { renderer } = useContext(ReactSdkContext);

packages/sdk-components-react-radix/src/dialog.ws.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -98,5 +98,6 @@ export const metaDialog: WsComponentMeta = {
9898
children: ["instance"],
9999
descendants: [radix.DialogTrigger, radix.DialogOverlay],
100100
},
101+
initialProps: ["open"],
101102
props: propsDialog,
102103
};

packages/sdk-components-react-radix/src/popover.tsx

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,8 @@ import {
33
type ReactNode,
44
forwardRef,
55
Children,
6+
useState,
7+
useEffect,
68
} from "react";
79
import * as PopoverPrimitive from "@radix-ui/react-popover";
810
import { getClosestInstance, type Hook } from "@webstudio-is/react-sdk/runtime";
@@ -12,7 +14,13 @@ export const Popover = forwardRef<
1214
HTMLDivElement,
1315
Omit<ComponentPropsWithoutRef<typeof PopoverPrimitive.Root>, "defaultOpen">
1416
>((props, _ref) => {
15-
return <PopoverPrimitive.Root {...props} />;
17+
const currentOpen = props.open ?? false;
18+
const [open, setOpen] = useState(currentOpen);
19+
// synchronize external value with local one when changed
20+
useEffect(() => setOpen(currentOpen), [currentOpen]);
21+
return (
22+
<PopoverPrimitive.Root {...props} open={open} onOpenChange={setOpen} />
23+
);
1624
});
1725

1826
/**

0 commit comments

Comments
 (0)