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.
Files changed (26) hide show
  1. package/dist/bundle.cjs +1 -1
  2. package/dist/bundle.css +1 -1
  3. package/dist/components/Autocomplete/Autocomplete.js +14 -6
  4. package/dist/components/Autocomplete/Autocomplete.types.d.ts +1 -0
  5. package/dist/components/DropdownMenu/DropdownMenu.context.js +3 -2
  6. package/dist/components/DropdownMenu/DropdownMenu.js +7 -6
  7. package/dist/components/DropdownMenu/DropdownMenu.types.d.ts +9 -2
  8. package/dist/components/DropdownMenu/tests/DropdownMenu.stories.d.ts +1 -0
  9. package/dist/components/DropdownMenu/tests/DropdownMenu.stories.js +98 -0
  10. package/dist/components/Flyout/Flyout.module.css +1 -1
  11. package/dist/components/Flyout/Flyout.types.d.ts +2 -1
  12. package/dist/components/Flyout/FlyoutContent.js +2 -2
  13. package/dist/components/Flyout/FlyoutControlled.js +64 -31
  14. package/dist/components/Flyout/utilities/cooldown.d.ts +5 -0
  15. package/dist/components/Flyout/utilities/cooldown.js +11 -0
  16. package/dist/components/Image/tests/Image.stories.js +4 -1
  17. package/dist/components/Modal/Modal.module.css +1 -1
  18. package/dist/components/Overlay/Overlay.js +32 -10
  19. package/dist/components/Overlay/Overlay.module.css +1 -1
  20. package/dist/components/Popover/tests/Popover.stories.d.ts +3 -0
  21. package/dist/components/Popover/tests/Popover.stories.js +23 -1
  22. package/dist/components/Tooltip/tests/Tooltip.stories.d.ts +3 -0
  23. package/dist/components/Tooltip/tests/Tooltip.stories.js +36 -0
  24. package/dist/hooks/useOnClickOutside.d.ts +5 -0
  25. package/dist/hooks/useOnClickOutside.js +4 -4
  26. 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 isDropdownActive = hasChildren && (active ?? internalActive);
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 { onItemClick, highlightedId } = React.useContext(AutocompleteContext);
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 value = React.useMemo(() => ({ size: resolvedSize }), [resolvedSize]);
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
- * Stop event propagation to make sure outside click doesn't get triggered
41
- * after the content is closed
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);pointer-events:all}.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)}
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 (triggerType !== "hover")
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, triggerType]);
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 (triggerType === "hover" && isRendered) {
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, triggerType, isRendered, flyout.position]);
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
- remove();
247
- onAfterCloseRef.current?.();
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
- remove();
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
- if (flyoutElRef.current?.firstElementChild?.getAnimations?.().length)
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
- remove();
305
- onAfterCloseRef.current?.();
306
- }, [status, remove, onAfterCloseRef]);
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
- render: (args) => <Image src={imgUrl} alt="photo" onLoad={args.handleLoad}/>,
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");