reshaped 4.3.0 → 4.4.0-canary.1
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/dist/bundle.cjs +1 -1
- package/dist/bundle.css +1 -1
- package/dist/components/Autocomplete/Autocomplete.js +14 -6
- package/dist/components/Autocomplete/Autocomplete.types.d.ts +1 -0
- package/dist/components/DropdownMenu/DropdownMenu.context.js +3 -2
- package/dist/components/DropdownMenu/DropdownMenu.js +7 -6
- package/dist/components/DropdownMenu/DropdownMenu.types.d.ts +9 -2
- package/dist/components/DropdownMenu/tests/DropdownMenu.stories.d.ts +1 -0
- package/dist/components/DropdownMenu/tests/DropdownMenu.stories.js +98 -0
- package/dist/components/Flyout/Flyout.module.css +1 -1
- package/dist/components/Flyout/Flyout.types.d.ts +2 -1
- package/dist/components/Flyout/FlyoutContent.js +2 -2
- package/dist/components/Flyout/FlyoutControlled.js +64 -31
- package/dist/components/Flyout/utilities/cooldown.d.ts +5 -0
- package/dist/components/Flyout/utilities/cooldown.js +11 -0
- package/dist/components/Image/tests/Image.stories.js +4 -1
- package/dist/components/Modal/Modal.module.css +1 -1
- package/dist/components/Overlay/Overlay.js +32 -10
- package/dist/components/Overlay/Overlay.module.css +1 -1
- package/dist/components/Popover/tests/Popover.stories.d.ts +3 -0
- package/dist/components/Popover/tests/Popover.stories.js +23 -1
- package/dist/components/Tooltip/tests/Tooltip.stories.d.ts +3 -0
- package/dist/components/Tooltip/tests/Tooltip.stories.js +36 -0
- package/dist/hooks/useOnClickOutside.d.ts +5 -0
- package/dist/hooks/useOnClickOutside.js +4 -4
- package/package.json +3 -3
|
@@ -2,6 +2,7 @@
|
|
|
2
2
|
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
3
3
|
import React from "react";
|
|
4
4
|
import DropdownMenu from "../../components/DropdownMenu/index.js";
|
|
5
|
+
import { useFormControl } from "../../components/FormControl/index.js";
|
|
5
6
|
import TextField from "../../components/TextField/index.js";
|
|
6
7
|
import useElementId from "../../hooks/useElementId.js";
|
|
7
8
|
import useHandlerRef from "../../hooks/useHandlerRef.js";
|
|
@@ -28,7 +29,9 @@ const Autocomplete = (props) => {
|
|
|
28
29
|
const onChangeRef = useHandlerRef(onChange);
|
|
29
30
|
const onItemSelectRef = useHandlerRef(onItemSelect);
|
|
30
31
|
const onEnterRef = useHandlerRef(onEnter);
|
|
31
|
-
const
|
|
32
|
+
const formControl = useFormControl();
|
|
33
|
+
const disabled = formControl.disabled || textFieldProps.disabled || false;
|
|
34
|
+
const isDropdownActive = !disabled && hasChildren && (active ?? internalActive);
|
|
32
35
|
const lockDropdown = React.useCallback(() => {
|
|
33
36
|
// Prevent dropdown from re-opening when clicked on item with mouse
|
|
34
37
|
// and focus moves to the item and back to the input
|
|
@@ -38,11 +41,11 @@ const Autocomplete = (props) => {
|
|
|
38
41
|
}, 100);
|
|
39
42
|
}, []);
|
|
40
43
|
const handleOpen = React.useCallback(() => {
|
|
41
|
-
if (lockedRef.current)
|
|
44
|
+
if (disabled || lockedRef.current)
|
|
42
45
|
return;
|
|
43
46
|
setInternalActive(true);
|
|
44
47
|
onOpenRef.current?.();
|
|
45
|
-
}, [onOpenRef]);
|
|
48
|
+
}, [disabled, onOpenRef]);
|
|
46
49
|
const handleClose = (args) => {
|
|
47
50
|
setInternalActive(false);
|
|
48
51
|
onCloseRef.current?.(args);
|
|
@@ -126,10 +129,11 @@ const Autocomplete = (props) => {
|
|
|
126
129
|
return () => mutationObserver.disconnect();
|
|
127
130
|
}, [isDropdownActive]);
|
|
128
131
|
const contextValue = React.useMemo(() => ({
|
|
132
|
+
disabled,
|
|
129
133
|
onItemClick: handleItemClick,
|
|
130
134
|
highlightedId,
|
|
131
135
|
setHighlightedId,
|
|
132
|
-
}), [highlightedId, handleItemClick]);
|
|
136
|
+
}), [disabled, highlightedId, handleItemClick]);
|
|
133
137
|
return (_jsx(AutocompleteContext.Provider, { value: contextValue, children: _jsxs(DropdownMenu, { position: "bottom", width: "trigger", triggerType: "focus", trapFocusMode: false, active: isDropdownActive, onClose: handleClose, onOpen: handleOpen, onAfterOpen: onAfterOpen, onAfterClose: onAfterClose, containerRef: containerRef, fallbackAdjustLayout: fallbackAdjustLayout, fallbackMinHeight: fallbackMinHeight, contentMaxHeight: contentMaxHeight, contentZIndex: contentZIndex, instanceRef: instanceRef, children: [_jsx(DropdownMenu.Trigger, { children: ({ ref, ...attributes }) => (_jsx(TextField, { ...textFieldProps, name: name, onChange: handleChange, focused: isDropdownActive, attributes: {
|
|
134
138
|
...textFieldProps.attributes,
|
|
135
139
|
// Ignoring the type check since TS can't infer the correct html element type
|
|
@@ -154,11 +158,15 @@ const Autocomplete = (props) => {
|
|
|
154
158
|
} })) }), _jsx(DropdownMenu.Content, { attributes: { onClick: handleContentClick, role: "listbox", ref: contentRef }, children: children })] }) }));
|
|
155
159
|
};
|
|
156
160
|
export const AutocompleteItem = (props) => {
|
|
157
|
-
const { value, data, onClick, disabled, ...menuItemProps } = props;
|
|
158
|
-
const
|
|
161
|
+
const { value, data, onClick, disabled: disabledProp, ...menuItemProps } = props;
|
|
162
|
+
const context = React.useContext(AutocompleteContext);
|
|
163
|
+
const { onItemClick, highlightedId } = context;
|
|
164
|
+
const disabled = disabledProp || context.disabled;
|
|
159
165
|
const id = useElementId();
|
|
160
166
|
const highlighted = highlightedId === id;
|
|
161
167
|
const handleClick = (e) => {
|
|
168
|
+
if (disabled)
|
|
169
|
+
return;
|
|
162
170
|
onClick?.(e);
|
|
163
171
|
onItemClick({ value, data });
|
|
164
172
|
};
|
|
@@ -22,6 +22,7 @@ export type Props = TextFieldProps & Pick<DropdownMenuProps, "containerRef" | "i
|
|
|
22
22
|
};
|
|
23
23
|
export type ItemProps = Omit<MenuItemProps, "focusIndicator"> & SelectArgs;
|
|
24
24
|
export type Context = {
|
|
25
|
+
disabled: boolean;
|
|
25
26
|
onItemClick: (args: SelectArgs) => void;
|
|
26
27
|
highlightedId?: string;
|
|
27
28
|
setHighlightedId: (value?: string) => void;
|
|
@@ -9,10 +9,11 @@ export const useDropdownMenu = () => React.useContext(DropdownMenuContext);
|
|
|
9
9
|
* define themselves are inherited from the menu they're nested in.
|
|
10
10
|
*/
|
|
11
11
|
export const DropdownMenuProvider = (props) => {
|
|
12
|
-
const { size, children } = props;
|
|
12
|
+
const { size, submenuTriggerType, children } = props;
|
|
13
13
|
const parentContext = useDropdownMenu();
|
|
14
14
|
const resolvedSize = size ?? parentContext.size;
|
|
15
|
-
const
|
|
15
|
+
const resolvedSubmenuTriggerType = submenuTriggerType ?? parentContext.submenuTriggerType;
|
|
16
|
+
const value = React.useMemo(() => ({ size: resolvedSize, submenuTriggerType: resolvedSubmenuTriggerType }), [resolvedSize, resolvedSubmenuTriggerType]);
|
|
16
17
|
return _jsx(DropdownMenuContext.Provider, { value: value, children: children });
|
|
17
18
|
};
|
|
18
19
|
DropdownMenuProvider.displayName = "DropdownMenu.Provider";
|
|
@@ -19,8 +19,8 @@ const DropdownMenuSubTriggerContext = React.createContext(false);
|
|
|
19
19
|
*/
|
|
20
20
|
export const DropdownMenuKeepOpenContext = React.createContext(false);
|
|
21
21
|
const DropdownMenu = (props) => {
|
|
22
|
-
const { children, position = "bottom-start", triggerType = "click", trapFocusMode = "action-menu", size, ...popoverProps } = props;
|
|
23
|
-
return (_jsx(DropdownMenuProvider, { size: size, children: _jsx(Popover, { ...popoverProps, position: position, padding: 1, trapFocusMode: trapFocusMode, triggerType: triggerType, children: children }) }));
|
|
22
|
+
const { children, position = "bottom-start", triggerType = "click", trapFocusMode = "action-menu", size, submenuTriggerType, ...popoverProps } = props;
|
|
23
|
+
return (_jsx(DropdownMenuProvider, { size: size, submenuTriggerType: submenuTriggerType, children: _jsx(Popover, { ...popoverProps, position: position, padding: 1, trapFocusMode: trapFocusMode, triggerType: triggerType, children: children }) }));
|
|
24
24
|
};
|
|
25
25
|
export const DropdownMenuContent = (props) => {
|
|
26
26
|
const { children, attributes, className } = props;
|
|
@@ -37,8 +37,8 @@ export const DropdownMenuContent = (props) => {
|
|
|
37
37
|
});
|
|
38
38
|
const handleClick = (e) => {
|
|
39
39
|
/**
|
|
40
|
-
*
|
|
41
|
-
*
|
|
40
|
+
* React events bubble through the portal, so item clicks would reach the components
|
|
41
|
+
* the menu is rendered in, like a clickable card. Wrappers inside the content still get them
|
|
42
42
|
*/
|
|
43
43
|
e.stopPropagation();
|
|
44
44
|
attributes?.onClick?.(e);
|
|
@@ -68,9 +68,10 @@ export const DropdownMenuItem = (props) => {
|
|
|
68
68
|
return (_jsx(MenuItem, { ...props, size: size ?? menuSize, focusIndicator: "highlight", roundedCorners: true, className: [s.item, props.className], attributes: { role: "menuitem", ...props.attributes }, onClick: handleClick }));
|
|
69
69
|
};
|
|
70
70
|
export const DropdownMenuSubMenu = (props) => {
|
|
71
|
-
const { children, position = "end-top", contentGap = 0.5, ...dropdownMenuProps } = props;
|
|
71
|
+
const { children, position = "end-top", contentGap = 0.5, triggerType, ...dropdownMenuProps } = props;
|
|
72
|
+
const { submenuTriggerType } = useDropdownMenu();
|
|
72
73
|
const dropdownMenuRef = React.useRef(null);
|
|
73
|
-
return (_jsx(DropdownMenuSubContext.Provider, { value: dropdownMenuRef, children: _jsx(DropdownMenu, { ...dropdownMenuProps, triggerType: "hover", position: position, contentGap: contentGap, instanceRef: dropdownMenuRef, children: children }) }));
|
|
74
|
+
return (_jsx(DropdownMenuSubContext.Provider, { value: dropdownMenuRef, children: _jsx(DropdownMenu, { ...dropdownMenuProps, triggerType: triggerType ?? submenuTriggerType ?? "hover", position: position, contentGap: contentGap, instanceRef: dropdownMenuRef, children: children }) }));
|
|
74
75
|
};
|
|
75
76
|
export const DropdownMenuSubTriggerItem = (props) => {
|
|
76
77
|
const { children, attributes, ...menuItemProps } = props;
|
|
@@ -3,18 +3,23 @@ import type { FlyoutContentProps } from "../../components/Flyout/index.js";
|
|
|
3
3
|
import type { MenuItemProps } from "../../components/MenuItem/index.js";
|
|
4
4
|
import type { PopoverInstance, PopoverProps } from "../../components/Popover/index.js";
|
|
5
5
|
export type Instance = PopoverInstance;
|
|
6
|
+
export type SubMenuTriggerType = Extract<PopoverProps["triggerType"], "hover" | "click">;
|
|
6
7
|
export type Context = {
|
|
7
8
|
size?: MenuItemProps["size"];
|
|
9
|
+
submenuTriggerType?: SubMenuTriggerType;
|
|
8
10
|
};
|
|
9
|
-
export type ProviderProps = {
|
|
11
|
+
export type ProviderProps = Context & {
|
|
10
12
|
children: React.ReactNode;
|
|
11
|
-
size?: MenuItemProps["size"];
|
|
12
13
|
};
|
|
13
14
|
export type Props = Pick<PopoverProps, "children" | "position" | "fallbackPositions" | "fallbackAdjustLayout" | "fallbackMinHeight" | "triggerType" | "contentGap" | "contentShift" | "contentMaxHeight" | "contentZIndex" | "onOpen" | "onClose" | "onAfterOpen" | "onAfterClose" | "active" | "defaultActive" | "width" | "disableHideAnimation" | "disableCloseOnOutsideClick" | "instanceRef" | "containerRef" | "positionRef" | "originCoordinates" | "elevation" | "initialFocusRef"> & {
|
|
14
15
|
/** Change component trap focus keyboard behavior and shortcuts */
|
|
15
16
|
trapFocusMode?: Extract<PopoverProps["trapFocusMode"], "action-menu" | "selection-menu"> | false;
|
|
16
17
|
/** Size used by the menu items */
|
|
17
18
|
size?: MenuItemProps["size"];
|
|
19
|
+
/** Interaction used to open all submenus of the menu, keyboard navigation works for both of them
|
|
20
|
+
* @default "hover"
|
|
21
|
+
*/
|
|
22
|
+
submenuTriggerType?: SubMenuTriggerType;
|
|
18
23
|
};
|
|
19
24
|
export type ContentProps = Pick<FlyoutContentProps, "attributes" | "children" | "className">;
|
|
20
25
|
export type ItemProps = Omit<MenuItemProps, "roundedCorners" | "focusIndicator">;
|
|
@@ -25,5 +30,7 @@ export type SectionProps = {
|
|
|
25
30
|
export type SubMenuProps = {
|
|
26
31
|
/** Node for inserting children */
|
|
27
32
|
children: React.ReactNode;
|
|
33
|
+
/** Interaction used to open the submenu, overrides the submenuTriggerType of the menu */
|
|
34
|
+
triggerType?: SubMenuTriggerType;
|
|
28
35
|
} & Pick<PopoverProps, "position" | "fallbackPositions" | "fallbackAdjustLayout" | "fallbackMinHeight" | "contentGap" | "contentShift" | "width" | "containerRef">;
|
|
29
36
|
export type SubTriggerProps = Omit<MenuItemProps, "endSlot" | "roundedCorners" | "focusIndicator">;
|
|
@@ -28,6 +28,7 @@ export declare const sections: {
|
|
|
28
28
|
};
|
|
29
29
|
export declare const size: StoryObj;
|
|
30
30
|
export declare const submenu: StoryObj;
|
|
31
|
+
export declare const submenuTriggerType: StoryObj;
|
|
31
32
|
export declare const defaultActive: StoryObj<{
|
|
32
33
|
handleOpen: ReturnType<typeof fn>;
|
|
33
34
|
handleClose: ReturnType<typeof fn>;
|
|
@@ -158,6 +158,104 @@ export const submenu = {
|
|
|
158
158
|
});
|
|
159
159
|
},
|
|
160
160
|
};
|
|
161
|
+
const expectNotOpenedOnHover = async (trigger, contentText) => {
|
|
162
|
+
await userEvent.hover(trigger);
|
|
163
|
+
await sleep(100);
|
|
164
|
+
expect(within(document.body).queryByText(contentText)).not.toBeInTheDocument();
|
|
165
|
+
};
|
|
166
|
+
export const submenuTriggerType = {
|
|
167
|
+
name: "submenuTriggerType",
|
|
168
|
+
render: () => (<Example>
|
|
169
|
+
<Example.Item title="submenuTriggerType: click, inherited by nested submenus">
|
|
170
|
+
<DropdownMenu submenuTriggerType="click">
|
|
171
|
+
<DropdownMenu.Trigger>
|
|
172
|
+
{(attributes) => <Button attributes={attributes}>Open</Button>}
|
|
173
|
+
</DropdownMenu.Trigger>
|
|
174
|
+
<DropdownMenu.Content>
|
|
175
|
+
<DropdownMenu.Item onClick={() => { }}>Item 1</DropdownMenu.Item>
|
|
176
|
+
<DropdownMenu.SubMenu>
|
|
177
|
+
<DropdownMenu.SubTrigger>Item 2</DropdownMenu.SubTrigger>
|
|
178
|
+
<DropdownMenu.Content>
|
|
179
|
+
<DropdownMenu.Item onClick={() => { }}>SubItem 1</DropdownMenu.Item>
|
|
180
|
+
<DropdownMenu.Item onClick={() => { }}>SubItem 2</DropdownMenu.Item>
|
|
181
|
+
</DropdownMenu.Content>
|
|
182
|
+
</DropdownMenu.SubMenu>
|
|
183
|
+
<DropdownMenu.SubMenu>
|
|
184
|
+
<DropdownMenu.SubTrigger>Item 3</DropdownMenu.SubTrigger>
|
|
185
|
+
<DropdownMenu.Content>
|
|
186
|
+
<DropdownMenu.Item onClick={() => { }}>SubItem 2-1</DropdownMenu.Item>
|
|
187
|
+
<DropdownMenu.SubMenu>
|
|
188
|
+
<DropdownMenu.SubTrigger>SubItem 2-2</DropdownMenu.SubTrigger>
|
|
189
|
+
<DropdownMenu.Content>
|
|
190
|
+
<DropdownMenu.Item onClick={() => { }}>SubItem 2-2-1</DropdownMenu.Item>
|
|
191
|
+
<DropdownMenu.Item onClick={() => { }}>SubItem 2-2-2</DropdownMenu.Item>
|
|
192
|
+
</DropdownMenu.Content>
|
|
193
|
+
</DropdownMenu.SubMenu>
|
|
194
|
+
</DropdownMenu.Content>
|
|
195
|
+
</DropdownMenu.SubMenu>
|
|
196
|
+
</DropdownMenu.Content>
|
|
197
|
+
</DropdownMenu>
|
|
198
|
+
</Example.Item>
|
|
199
|
+
|
|
200
|
+
<Example.Item title="submenuTriggerType: click, SubMenu triggerType: hover">
|
|
201
|
+
<DropdownMenu submenuTriggerType="click">
|
|
202
|
+
<DropdownMenu.Trigger>
|
|
203
|
+
{(attributes) => <Button attributes={attributes}>Open with override</Button>}
|
|
204
|
+
</DropdownMenu.Trigger>
|
|
205
|
+
<DropdownMenu.Content>
|
|
206
|
+
<DropdownMenu.SubMenu>
|
|
207
|
+
<DropdownMenu.SubTrigger>Click item</DropdownMenu.SubTrigger>
|
|
208
|
+
<DropdownMenu.Content>
|
|
209
|
+
<DropdownMenu.Item onClick={() => { }}>Click subitem</DropdownMenu.Item>
|
|
210
|
+
</DropdownMenu.Content>
|
|
211
|
+
</DropdownMenu.SubMenu>
|
|
212
|
+
<DropdownMenu.SubMenu triggerType="hover">
|
|
213
|
+
<DropdownMenu.SubTrigger>Hover item</DropdownMenu.SubTrigger>
|
|
214
|
+
<DropdownMenu.Content>
|
|
215
|
+
<DropdownMenu.Item onClick={() => { }}>Hover subitem</DropdownMenu.Item>
|
|
216
|
+
</DropdownMenu.Content>
|
|
217
|
+
</DropdownMenu.SubMenu>
|
|
218
|
+
</DropdownMenu.Content>
|
|
219
|
+
</DropdownMenu>
|
|
220
|
+
</Example.Item>
|
|
221
|
+
</Example>),
|
|
222
|
+
play: async ({ canvasElement }) => {
|
|
223
|
+
const canvas = within(canvasElement.ownerDocument.body);
|
|
224
|
+
await userEvent.click(canvas.getByRole("button", { name: "Open" }));
|
|
225
|
+
const rootItem = await canvas.findByText("Item 1");
|
|
226
|
+
const subTrigger = canvas.getByText("Item 2");
|
|
227
|
+
// Hover doesn't open the submenu
|
|
228
|
+
await expectNotOpenedOnHover(subTrigger, "SubItem 1");
|
|
229
|
+
await userEvent.click(subTrigger);
|
|
230
|
+
const subItem = await canvas.findByText("SubItem 1");
|
|
231
|
+
// Moving the mouse away doesn't close the submenu
|
|
232
|
+
await userEvent.unhover(subTrigger);
|
|
233
|
+
await userEvent.hover(rootItem);
|
|
234
|
+
await sleep(100);
|
|
235
|
+
expect(subItem).toBeInTheDocument();
|
|
236
|
+
// Opening another submenu closes the previous one
|
|
237
|
+
await userEvent.click(canvas.getByText("Item 3"));
|
|
238
|
+
const nestedSubTrigger = await canvas.findByText("SubItem 2-2");
|
|
239
|
+
await waitFor(() => {
|
|
240
|
+
expect(subItem).not.toBeInTheDocument();
|
|
241
|
+
});
|
|
242
|
+
// Nested submenus inherit the trigger type
|
|
243
|
+
await expectNotOpenedOnHover(nestedSubTrigger, "SubItem 2-2-1");
|
|
244
|
+
await userEvent.click(nestedSubTrigger);
|
|
245
|
+
const nestedItem = await canvas.findByText("SubItem 2-2-1");
|
|
246
|
+
await userEvent.click(nestedItem);
|
|
247
|
+
await waitFor(() => {
|
|
248
|
+
expect(rootItem).not.toBeInTheDocument();
|
|
249
|
+
expect(nestedItem).not.toBeInTheDocument();
|
|
250
|
+
});
|
|
251
|
+
// SubMenu triggerType overrides the menu submenuTriggerType
|
|
252
|
+
await userEvent.click(canvas.getByRole("button", { name: "Open with override" }));
|
|
253
|
+
const clickTrigger = await canvas.findByText("Click item");
|
|
254
|
+
await expectNotOpenedOnHover(clickTrigger, "Click subitem");
|
|
255
|
+
await userEvent.hover(canvas.getByText("Hover item"));
|
|
256
|
+
expect(await canvas.findByText("Hover subitem")).toBeInTheDocument();
|
|
257
|
+
},
|
|
258
|
+
};
|
|
161
259
|
export const defaultActive = {
|
|
162
260
|
name: "defaultActive, uncontrolled",
|
|
163
261
|
args: {
|
|
@@ -1 +1 @@
|
|
|
1
|
-
.positioner{--rs-flyout-max-h:100%;--rs-flyout-max-w:100%;--rs-flyout-z-index:var(--rs-z-index-absolute);--rs-flyout-origin-x:50%;--rs-flyout-origin-y:50%;position:absolute;isolation:isolate;z-index:var(--rs-flyout-z-index);display:flex;flex-direction:column;pointer-events:none}.positioner.--hover{pointer-events:all}.positioner.--hover-disabled,.positioner.--hover-disabled .content{pointer-events:none}.content{pointer-events:all;opacity:0;transform:scale(.92) translateY(0);transform-origin:var(--rs-flyout-origin-x) var(--rs-flyout-origin-y);outline:none;height:100%;max-width:var(--rs-flyout-max-w);max-height:var(--rs-flyout-max-h);backface-visibility:hidden;flex-grow:1;display:flex;flex-direction:column;transition:1ms var(--rs-easing-accelerate)}[data-rs-keyboard] .content:focus{box-shadow:var(--rs-outline-shadow)}.positioner.--width-trigger .content{transform:scale(1) translateY(var(--rs-unit-x2))}.positioner.--position-top,.positioner.--position-top-end,.positioner.--position-top-start{--rs-flyout-origin-y:100%}.positioner.--position-bottom,.positioner.--position-bottom-end,.positioner.--position-bottom-start{--rs-flyout-origin-y:0%}.positioner.--position-bottom-start,.positioner.--position-top-start{--rs-flyout-origin-x:0%}.positioner.--position-bottom-end,.positioner.--position-start,.positioner.--position-start-bottom,.positioner.--position-start-top,.positioner.--position-top-end{--rs-flyout-origin-x:100%}.positioner.--position-end,.positioner.--position-end-bottom,.positioner.--position-end-top{--rs-flyout-origin-x:0%}.positioner.--position-end-top,.positioner.--position-start-top{--rs-flyout-origin-y:0%}.positioner.--position-end-bottom,.positioner.--position-start-bottom{--rs-flyout-origin-y:100%}.positioner.--visible .content{opacity:1;transform:scale(1) translateY(0)
|
|
1
|
+
.positioner{--rs-flyout-max-h:100%;--rs-flyout-max-w:100%;--rs-flyout-z-index:var(--rs-z-index-absolute);--rs-flyout-origin-x:50%;--rs-flyout-origin-y:50%;position:absolute;isolation:isolate;z-index:var(--rs-flyout-z-index);display:flex;flex-direction:column;pointer-events:none}.positioner.--hover{pointer-events:all}.positioner.--hover-disabled,.positioner.--hover-disabled .content{pointer-events:none}.content{pointer-events:all;opacity:0;transform:scale(.92) translateY(0);transform-origin:var(--rs-flyout-origin-x) var(--rs-flyout-origin-y);outline:none;height:100%;max-width:var(--rs-flyout-max-w);max-height:var(--rs-flyout-max-h);backface-visibility:hidden;flex-grow:1;display:flex;flex-direction:column;transition:1ms var(--rs-easing-accelerate)}[data-rs-keyboard] .content:focus{box-shadow:var(--rs-outline-shadow)}.positioner.--width-trigger .content{transform:scale(1) translateY(var(--rs-unit-x2))}.positioner.--position-top,.positioner.--position-top-end,.positioner.--position-top-start{--rs-flyout-origin-y:100%}.positioner.--position-bottom,.positioner.--position-bottom-end,.positioner.--position-bottom-start{--rs-flyout-origin-y:0%}.positioner.--position-bottom-start,.positioner.--position-top-start{--rs-flyout-origin-x:0%}.positioner.--position-bottom-end,.positioner.--position-start,.positioner.--position-start-bottom,.positioner.--position-start-top,.positioner.--position-top-end{--rs-flyout-origin-x:100%}.positioner.--position-end,.positioner.--position-end-bottom,.positioner.--position-end-top{--rs-flyout-origin-x:0%}.positioner.--position-end-top,.positioner.--position-start-top{--rs-flyout-origin-y:0%}.positioner.--position-end-bottom,.positioner.--position-start-bottom{--rs-flyout-origin-y:100%}.positioner.--visible .content{opacity:1;transform:scale(1) translateY(0)}.positioner.--animated .content{transition-duration:var(--rs-duration-rapid);transition-property:opacity,transform}.positioner.--animated.--visible .content{transition-duration:var(--rs-duration-fast);transition-timing-function:var(--rs-easing-decelerate)}
|
|
@@ -102,7 +102,7 @@ type BaseProps = {
|
|
|
102
102
|
disabled?: boolean;
|
|
103
103
|
/** Disable the flyout content hide animation */
|
|
104
104
|
disableHideAnimation?: boolean;
|
|
105
|
-
/** Ignore the content hover events and hide it if the triggerType is hover */
|
|
105
|
+
/** Ignore the content hover events and hide it as soon as the pointer leaves the trigger if the triggerType is hover */
|
|
106
106
|
disableContentHover?: boolean;
|
|
107
107
|
/** Disable the flyout content close on outside click */
|
|
108
108
|
disableCloseOnOutsideClick?: boolean;
|
|
@@ -202,6 +202,7 @@ export type ContextProps = {
|
|
|
202
202
|
handleTouchStart: () => void;
|
|
203
203
|
handleContentMouseDown: () => void;
|
|
204
204
|
handleContentMouseUp: () => void;
|
|
205
|
+
handleContentKeyDown: () => void;
|
|
205
206
|
isSubmenu: boolean;
|
|
206
207
|
} & Pick<Props, "triggerType" | "contentClassName" | "contentAttributes" | "scrollableClassName" | "scrollableAttributes" | "contentGap" | "contentMaxHeight" | "contentMaxWidth" | "contentZIndex" | "trapFocusMode" | "containerRef" | "disableContentHover" | "autoFocus">;
|
|
207
208
|
export type TriggerContextProps = {
|
|
@@ -11,7 +11,7 @@ import cooldown from "./utilities/cooldown.js";
|
|
|
11
11
|
import s from "./Flyout.module.css";
|
|
12
12
|
const FlyoutContent = (props) => {
|
|
13
13
|
const { children, className, attributes, colorMode } = props;
|
|
14
|
-
const { active, flyout, id, triggerElRef, flyoutElRef, handleTransitionEnd, triggerType, handleContentMouseEnter, handleMouseLeave, handleContentMouseDown, handleContentMouseUp, contentClassName, contentAttributes, scrollableClassName, scrollableAttributes, contentMaxHeight, contentMaxWidth, contentZIndex, trapFocusMode, disableContentHover, autoFocus, width, isSubmenu, } = useFlyoutContext();
|
|
14
|
+
const { active, flyout, id, triggerElRef, flyoutElRef, handleTransitionEnd, triggerType, handleContentMouseEnter, handleMouseLeave, handleContentMouseDown, handleContentMouseUp, handleContentKeyDown, contentClassName, contentAttributes, scrollableClassName, scrollableAttributes, contentMaxHeight, contentMaxWidth, contentZIndex, trapFocusMode, disableContentHover, autoFocus, width, isSubmenu, } = useFlyoutContext();
|
|
15
15
|
const { status, position } = flyout;
|
|
16
16
|
const [mounted, setMounted] = React.useState(false);
|
|
17
17
|
const shadowRootRef = React.useRef(null);
|
|
@@ -51,7 +51,7 @@ const FlyoutContent = (props) => {
|
|
|
51
51
|
"--rs-flyout-max-h": contentMaxHeight,
|
|
52
52
|
"--rs-flyout-max-w": contentMaxWidth,
|
|
53
53
|
"--rs-flyout-z-index": contentZIndex,
|
|
54
|
-
}, ref: flyoutElRef, onTransitionEnd: handleTransitionEnd, onMouseEnter: triggerType === "hover" ? handleContentMouseEnter : undefined, onMouseLeave: triggerType === "hover" ? handleMouseLeave : undefined, onMouseDown: handleContentMouseDown, onTouchStart: handleContentMouseDown, onMouseUp: handleContentMouseUp, onTouchEnd: handleContentMouseUp, children: _jsx("div", { role: role, ...contentAttributes, ...attributes, id: id, tabIndex: !autoFocus ? -1 : undefined, "aria-modal": role === "dialog" ? true : undefined, style: { ...contentAttributes?.style, ...attributes?.style }, className: contentClassNames, children: _jsx(ScrollArea, { overscrollBehavior: "contain", scrollableAttributes: scrollableAttributes, scrollableClassName: scrollableClassName, children: _jsx(Freeze, { frozen: !active, children: children }) }) }) }) }));
|
|
54
|
+
}, ref: flyoutElRef, onTransitionEnd: handleTransitionEnd, onMouseEnter: triggerType === "hover" ? handleContentMouseEnter : undefined, onMouseLeave: triggerType === "hover" ? handleMouseLeave : undefined, onMouseDown: handleContentMouseDown, onTouchStart: handleContentMouseDown, onMouseUp: handleContentMouseUp, onTouchEnd: handleContentMouseUp, onKeyDown: handleContentKeyDown, children: _jsx("div", { role: role, ...contentAttributes, ...attributes, id: id, tabIndex: !autoFocus ? -1 : undefined, "aria-modal": role === "dialog" ? true : undefined, style: { ...contentAttributes?.style, ...attributes?.style }, className: contentClassNames, children: _jsx(ScrollArea, { overscrollBehavior: "contain", scrollableAttributes: scrollableAttributes, scrollableClassName: scrollableClassName, children: _jsx(Freeze, { frozen: !active, children: children }) }) }) }) }));
|
|
55
55
|
return (_jsx(Portal, { targetRef: shadowRootRef.current ? shadowRootRef : undefined, colorMode: colorMode, children: content }));
|
|
56
56
|
};
|
|
57
57
|
FlyoutContent.displayName = "Flyout.Content";
|
|
@@ -46,6 +46,8 @@ const FlyoutControlled = (props) => {
|
|
|
46
46
|
const lockedTimerRef = React.useRef(null);
|
|
47
47
|
const trapFocusRef = React.useRef(null);
|
|
48
48
|
const lockedRef = React.useRef(false);
|
|
49
|
+
// Open was requested but the content might not be rendered yet, so it still has to be closable
|
|
50
|
+
const openRequestedRef = React.useRef(false);
|
|
49
51
|
// Lock blur event while pressing anywhere inside the flyout content
|
|
50
52
|
const lockedBlurEffects = React.useRef(false);
|
|
51
53
|
// Focus shouldn't return back to the trigger when user intentionally clicks outside the flyout
|
|
@@ -57,6 +59,8 @@ const FlyoutControlled = (props) => {
|
|
|
57
59
|
const ignoreTouchMouseEnterRef = React.useRef(false);
|
|
58
60
|
// Other triggers got pointer-events disabled by this flyout and still have to be restored
|
|
59
61
|
const triggersDisabledRef = React.useRef(false);
|
|
62
|
+
// Interaction started inside the content React tree, including nested flyouts portaled outside of the content element
|
|
63
|
+
const interactionInsideContentRef = React.useRef(false);
|
|
60
64
|
// Cleanup function for safe area tracking
|
|
61
65
|
const safeAreaRef = React.useRef(null);
|
|
62
66
|
const originCoordinatesRef = React.useRef(originCoordinates ?? null);
|
|
@@ -78,6 +82,8 @@ const FlyoutControlled = (props) => {
|
|
|
78
82
|
});
|
|
79
83
|
const { status, updatePosition, render, hide, remove, show } = flyout;
|
|
80
84
|
const isRendered = status !== "idle";
|
|
85
|
+
// Hover content that can be reached by the pointer, which keeps it open while moving towards it
|
|
86
|
+
const hasSafeArea = triggerType === "hover" && !disableContentHover;
|
|
81
87
|
const isDismissible = useIsDismissible({
|
|
82
88
|
active: isRendered,
|
|
83
89
|
contentRef: flyoutElRef,
|
|
@@ -91,7 +97,7 @@ const FlyoutControlled = (props) => {
|
|
|
91
97
|
* Disable all triggers while mouse is moving over the safe area
|
|
92
98
|
*/
|
|
93
99
|
const disableTriggers = React.useCallback(() => {
|
|
94
|
-
if (
|
|
100
|
+
if (!hasSafeArea)
|
|
95
101
|
return;
|
|
96
102
|
triggersDisabledRef.current = true;
|
|
97
103
|
document.querySelectorAll("[data-rs-flyout-active=true]").forEach((el) => {
|
|
@@ -99,7 +105,7 @@ const FlyoutControlled = (props) => {
|
|
|
99
105
|
return;
|
|
100
106
|
el.style.pointerEvents = "none";
|
|
101
107
|
});
|
|
102
|
-
}, [triggerElRef,
|
|
108
|
+
}, [triggerElRef, hasSafeArea]);
|
|
103
109
|
const enableTriggers = React.useCallback(() => {
|
|
104
110
|
if (triggerType !== "hover")
|
|
105
111
|
return;
|
|
@@ -117,14 +123,16 @@ const FlyoutControlled = (props) => {
|
|
|
117
123
|
return;
|
|
118
124
|
if (isRendered && triggerType !== "hover")
|
|
119
125
|
return;
|
|
126
|
+
openRequestedRef.current = true;
|
|
120
127
|
onOpenRef.current?.();
|
|
121
128
|
disableTriggers();
|
|
122
129
|
}, [onOpenRef, isRendered, triggerType, disableTriggers]);
|
|
123
130
|
const handleClose = React.useCallback((options) => {
|
|
124
131
|
const isLocked = triggerType === "click" && !options.closeParents && !isDismissible();
|
|
125
|
-
const canClose = !isLocked && (isRendered || disabled);
|
|
132
|
+
const canClose = !isLocked && (isRendered || openRequestedRef.current || disabled);
|
|
126
133
|
if (!canClose)
|
|
127
134
|
return;
|
|
135
|
+
openRequestedRef.current = false;
|
|
128
136
|
onCloseRef.current?.({ reason: options.reason });
|
|
129
137
|
enableTriggers();
|
|
130
138
|
if (options?.closeParents) {
|
|
@@ -203,7 +211,7 @@ const FlyoutControlled = (props) => {
|
|
|
203
211
|
cooldown.cool();
|
|
204
212
|
clearTimer();
|
|
205
213
|
safeAreaRef.current?.cleanup();
|
|
206
|
-
if (
|
|
214
|
+
if (hasSafeArea && isRendered) {
|
|
207
215
|
// Safe area coordinates are defined based on the trigger mouse out, even when returning mouse from content to trigger
|
|
208
216
|
const origin = e.currentTarget === flyoutElRef.current && safeAreaRef.current?.origin
|
|
209
217
|
? safeAreaRef.current.origin
|
|
@@ -220,7 +228,7 @@ const FlyoutControlled = (props) => {
|
|
|
220
228
|
else {
|
|
221
229
|
handleClose({});
|
|
222
230
|
}
|
|
223
|
-
}, [clearTimer, handleClose, triggerElRef, flyoutElRef,
|
|
231
|
+
}, [clearTimer, handleClose, triggerElRef, flyoutElRef, hasSafeArea, isRendered, flyout.position]);
|
|
224
232
|
const handleTriggerClick = React.useCallback(() => {
|
|
225
233
|
if (!isRendered) {
|
|
226
234
|
handleOpen();
|
|
@@ -232,25 +240,32 @@ const FlyoutControlled = (props) => {
|
|
|
232
240
|
const handleContentMouseDown = () => {
|
|
233
241
|
lockedBlurEffects.current = true;
|
|
234
242
|
hoverTriggeredWithTouchEventRef.current = true;
|
|
243
|
+
interactionInsideContentRef.current = true;
|
|
235
244
|
};
|
|
236
245
|
const handleContentMouseUp = () => {
|
|
237
246
|
lockedBlurEffects.current = false;
|
|
238
247
|
};
|
|
248
|
+
const handleContentKeyDown = () => {
|
|
249
|
+
interactionInsideContentRef.current = true;
|
|
250
|
+
};
|
|
251
|
+
const finishHide = React.useCallback(() => {
|
|
252
|
+
remove();
|
|
253
|
+
onAfterCloseRef.current?.();
|
|
254
|
+
}, [remove, onAfterCloseRef]);
|
|
239
255
|
const handleTransitionEnd = React.useCallback((e) => {
|
|
240
256
|
if (flyoutElRef.current !== e.currentTarget || e.propertyName !== "transform")
|
|
241
257
|
return;
|
|
242
258
|
if (status === "visible") {
|
|
243
259
|
onAfterOpenRef.current?.();
|
|
244
260
|
}
|
|
245
|
-
if (status === "hidden")
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
}
|
|
249
|
-
}, [remove, status, onAfterOpenRef, onAfterCloseRef]);
|
|
261
|
+
if (status === "hidden")
|
|
262
|
+
finishHide();
|
|
263
|
+
}, [finishHide, status, onAfterOpenRef]);
|
|
250
264
|
/**
|
|
251
265
|
* Control the display based on the props
|
|
252
266
|
*/
|
|
253
267
|
useIsomorphicLayoutEffect(() => {
|
|
268
|
+
openRequestedRef.current = !!active;
|
|
254
269
|
if (active) {
|
|
255
270
|
render();
|
|
256
271
|
return;
|
|
@@ -267,20 +282,9 @@ const FlyoutControlled = (props) => {
|
|
|
267
282
|
}
|
|
268
283
|
else {
|
|
269
284
|
// In case transitions are disabled globally - remove from the DOM immediately
|
|
270
|
-
|
|
271
|
-
onAfterCloseRef.current?.();
|
|
285
|
+
finishHide();
|
|
272
286
|
}
|
|
273
|
-
}, [
|
|
274
|
-
active,
|
|
275
|
-
prevActive,
|
|
276
|
-
render,
|
|
277
|
-
hide,
|
|
278
|
-
remove,
|
|
279
|
-
disableHideAnimation,
|
|
280
|
-
disabled,
|
|
281
|
-
groupTimeouts,
|
|
282
|
-
onAfterCloseRef,
|
|
283
|
-
]);
|
|
287
|
+
}, [active, prevActive, render, hide, finishHide, disableHideAnimation, disabled, groupTimeouts]);
|
|
284
288
|
useIsomorphicLayoutEffect(() => {
|
|
285
289
|
if (status === "rendered")
|
|
286
290
|
show();
|
|
@@ -292,18 +296,20 @@ const FlyoutControlled = (props) => {
|
|
|
292
296
|
updatePosition();
|
|
293
297
|
// oxlint-disable-next-line react-hooks/exhaustive-deps
|
|
294
298
|
}, [originCoordinates?.x, originCoordinates?.y]);
|
|
295
|
-
/**
|
|
296
|
-
* Closing before the open transition starts cancels it instead of reversing,
|
|
297
|
-
* so there is no transitionend to wait for
|
|
298
|
-
*/
|
|
299
299
|
useIsomorphicLayoutEffect(() => {
|
|
300
300
|
if (status !== "hidden")
|
|
301
301
|
return;
|
|
302
|
-
|
|
302
|
+
// Closing before the open transition starts cancels it instead of reversing,
|
|
303
|
+
// so there is no transitionend to wait for
|
|
304
|
+
if (!flyoutElRef.current?.firstElementChild?.getAnimations?.().length) {
|
|
305
|
+
finishHide();
|
|
303
306
|
return;
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
307
|
+
}
|
|
308
|
+
// Grouped flyouts switch without animations, so the hide animation is cut short
|
|
309
|
+
// once another flyout from the group gets hovered
|
|
310
|
+
if (groupTimeouts)
|
|
311
|
+
return cooldown.onRewarm(finishHide);
|
|
312
|
+
}, [status, groupTimeouts, finishHide]);
|
|
307
313
|
/**
|
|
308
314
|
* Handle focus trap
|
|
309
315
|
*
|
|
@@ -351,6 +357,27 @@ const FlyoutControlled = (props) => {
|
|
|
351
357
|
shouldReturnFocusRef.current = true;
|
|
352
358
|
}
|
|
353
359
|
}, [status, isRendered, triggerType]);
|
|
360
|
+
/**
|
|
361
|
+
* Nested flyouts are portaled outside of the parent content element but are still rendered inside its React tree.
|
|
362
|
+
* React events bubble through portals, so content handlers mark the interactions coming from the nested flyouts too
|
|
363
|
+
* and the flag gets reset in the capture phase before every new interaction
|
|
364
|
+
*/
|
|
365
|
+
React.useEffect(() => {
|
|
366
|
+
if (!isRendered || disableCloseOnOutsideClick)
|
|
367
|
+
return;
|
|
368
|
+
const events = ["mousedown", "touchstart", "keydown"];
|
|
369
|
+
const resetInteraction = () => {
|
|
370
|
+
interactionInsideContentRef.current = false;
|
|
371
|
+
};
|
|
372
|
+
events.forEach((event) => {
|
|
373
|
+
document.addEventListener(event, resetInteraction, { capture: true, passive: true });
|
|
374
|
+
});
|
|
375
|
+
return () => {
|
|
376
|
+
events.forEach((event) => {
|
|
377
|
+
document.removeEventListener(event, resetInteraction, { capture: true });
|
|
378
|
+
});
|
|
379
|
+
};
|
|
380
|
+
}, [isRendered, disableCloseOnOutsideClick]);
|
|
354
381
|
/**
|
|
355
382
|
* Clean up safe polygon tracking on unmount or when flyout closes
|
|
356
383
|
*/
|
|
@@ -405,11 +432,16 @@ const FlyoutControlled = (props) => {
|
|
|
405
432
|
},
|
|
406
433
|
}, [handleClose, isDismissible]);
|
|
407
434
|
useOnClickOutside([flyoutElRef, triggerElRef], () => {
|
|
435
|
+
// Clicks inside nested flyouts are not considered outside clicks
|
|
436
|
+
if (interactionInsideContentRef.current)
|
|
437
|
+
return;
|
|
408
438
|
// Clicking outside changes focused element so we don't need to set it back ourselves
|
|
409
439
|
shouldReturnFocusRef.current = false;
|
|
410
440
|
handleClose({ reason: "outside-click" });
|
|
411
441
|
}, {
|
|
412
442
|
disabled: !isRendered || disableCloseOnOutsideClick,
|
|
443
|
+
// Content of components like DropdownMenu can stop the click propagation
|
|
444
|
+
capture: true,
|
|
413
445
|
});
|
|
414
446
|
return (_jsx(Provider, { value: {
|
|
415
447
|
id,
|
|
@@ -430,6 +462,7 @@ const FlyoutControlled = (props) => {
|
|
|
430
462
|
handleClick: handleTriggerClick,
|
|
431
463
|
handleContentMouseDown,
|
|
432
464
|
handleContentMouseUp,
|
|
465
|
+
handleContentKeyDown,
|
|
433
466
|
triggerType,
|
|
434
467
|
trapFocusMode,
|
|
435
468
|
contentZIndex,
|
|
@@ -1,6 +1,11 @@
|
|
|
1
1
|
declare class Cooldown {
|
|
2
2
|
status: "warming" | "warm" | "cooling" | "cold";
|
|
3
3
|
timer?: ReturnType<typeof setTimeout>;
|
|
4
|
+
private rewarmListeners;
|
|
5
|
+
/**
|
|
6
|
+
* Called when another flyout gets hovered while the group is still cooling down
|
|
7
|
+
*/
|
|
8
|
+
onRewarm: (listener: () => void) => () => void;
|
|
4
9
|
warm: () => void;
|
|
5
10
|
cool: () => void;
|
|
6
11
|
}
|
|
@@ -1,10 +1,21 @@
|
|
|
1
1
|
class Cooldown {
|
|
2
2
|
status = "cold";
|
|
3
3
|
timer;
|
|
4
|
+
rewarmListeners = new Set();
|
|
5
|
+
/**
|
|
6
|
+
* Called when another flyout gets hovered while the group is still cooling down
|
|
7
|
+
*/
|
|
8
|
+
onRewarm = (listener) => {
|
|
9
|
+
this.rewarmListeners.add(listener);
|
|
10
|
+
return () => {
|
|
11
|
+
this.rewarmListeners.delete(listener);
|
|
12
|
+
};
|
|
13
|
+
};
|
|
4
14
|
warm = () => {
|
|
5
15
|
clearTimeout(this.timer);
|
|
6
16
|
if (this.status === "cooling") {
|
|
7
17
|
this.status = "warm";
|
|
18
|
+
this.rewarmListeners.forEach((listener) => listener());
|
|
8
19
|
return;
|
|
9
20
|
}
|
|
10
21
|
this.status = "warming";
|
|
@@ -120,12 +120,15 @@ export const outline = {
|
|
|
120
120
|
expect(imgRect.height).toBe(36);
|
|
121
121
|
},
|
|
122
122
|
};
|
|
123
|
+
const localImage = "data:image/svg+xml," +
|
|
124
|
+
encodeURIComponent('<svg xmlns="http://www.w3.org/2000/svg" width="100" height="100"/>');
|
|
123
125
|
export const onLoad = {
|
|
124
126
|
name: "onLoad",
|
|
125
127
|
args: {
|
|
126
128
|
handleLoad: fn(),
|
|
127
129
|
},
|
|
128
|
-
|
|
130
|
+
// Local image keeps the test independent from the network
|
|
131
|
+
render: (args) => <Image src={localImage} alt="photo" onLoad={args.handleLoad}/>,
|
|
129
132
|
play: async ({ canvas, args }) => {
|
|
130
133
|
const { handleLoad } = args;
|
|
131
134
|
const img = canvas.getByRole("img");
|