diff --git a/.changeset/eight-kids-train.md b/.changeset/eight-kids-train.md
new file mode 100644
index 00000000000..9a6bd1f2cec
--- /dev/null
+++ b/.changeset/eight-kids-train.md
@@ -0,0 +1,5 @@
+---
+'@itwin/itwinui-react': minor
+---
+
+Added a new `dropdownMenuProps` prop to `SplitButton` for additional control over the menu (e.g. disable the [`hide` middleware](https://floating-ui.com/docs/hide)).
diff --git a/.changeset/green-dogs-marry.md b/.changeset/green-dogs-marry.md
new file mode 100644
index 00000000000..b3726fd2bd1
--- /dev/null
+++ b/.changeset/green-dogs-marry.md
@@ -0,0 +1,5 @@
+---
+'@itwin/itwinui-react': patch
+---
+
+`Popover`'s `middleware.hide` prop is now respected.
diff --git a/.changeset/slimy-vans-lick.md b/.changeset/slimy-vans-lick.md
new file mode 100644
index 00000000000..b7e624bed09
--- /dev/null
+++ b/.changeset/slimy-vans-lick.md
@@ -0,0 +1,7 @@
+---
+'@itwin/itwinui-react': minor
+---
+
+`Popover` now enables the [`hide` middleware](https://floating-ui.com/docs/hide) to hide the floating content when the trigger is hidden.
+* This also affects other popover-like components (e.g. `Select`, `ComboBox`, `DropdownMenu`, `SplitButton`).
+* If the floating content gets hidden even when it shouldn't (e.g. due to some custom styles interfering with the trigger's hide detection), consider disabling the `hide` middleware.
diff --git a/apps/website/src/content/docs/dropdownmenu.mdx b/apps/website/src/content/docs/dropdownmenu.mdx
index 3bb39293279..379e123eb64 100644
--- a/apps/website/src/content/docs/dropdownmenu.mdx
+++ b/apps/website/src/content/docs/dropdownmenu.mdx
@@ -74,6 +74,27 @@ The menu can contain extra content, including both text and additional selectabl
+### `hide` middleware
+
+By default, the menu is hidden when the trigger is hidden (e.g. out of the scrollport/viewport). This is useful when the trigger element is scrolled out of view.
+
+
+
+
+
+If the menu gets hidden even when it shouldn't (e.g. due to some custom styles interfering with the trigger's hide detection), consider disabling the `hide` middleware.
+
+```jsx {4}
+
+ …
+
+```
+
## Props
diff --git a/apps/website/src/content/docs/popover.mdx b/apps/website/src/content/docs/popover.mdx
index 5fe2c7b76d6..767b061d295 100644
--- a/apps/website/src/content/docs/popover.mdx
+++ b/apps/website/src/content/docs/popover.mdx
@@ -36,7 +36,8 @@ Popover handles positioning using an external library called [Floating UI](float
There are some advanced positioning options available.
- The `positionReference` prop can be used to position the popover relative to a different element than the trigger. This can be useful, for example, when the trigger is part of a larger group of elements that should all be considered together.
-- The `middleware` prop exposes more granular control over the positioning logic. By default, `Popover` enables the [`flip`](https://floating-ui.com/docs/flip), [`shift`](https://floating-ui.com/docs/shift) and [`size`](https://floating-ui.com/docs/size) middlewares. You might also find the [`offset`](https://floating-ui.com/docs/offset) middleware useful for adding a gap between the trigger and the popover.
+- The `middleware` prop exposes more granular control over the positioning logic. By default, `Popover` enables the [`flip`](https://floating-ui.com/docs/flip), [`shift`](https://floating-ui.com/docs/shift), [`size`](https://floating-ui.com/docs/size), and [`hide`](https://floating-ui.com/docs/hide) middlewares. You might also find the [`offset`](https://floating-ui.com/docs/offset) middleware useful for adding a gap between the trigger and the popover.
+- If the floating content gets hidden even when it shouldn't (e.g. due to some custom styles interfering with the trigger's hide detection), consider disabling the `hide` middleware.
### Portals
diff --git a/examples/DropdownMenu.hidemiddleware.css b/examples/DropdownMenu.hidemiddleware.css
new file mode 100644
index 00000000000..e1878b09a25
--- /dev/null
+++ b/examples/DropdownMenu.hidemiddleware.css
@@ -0,0 +1,8 @@
+.demo-container {
+ width: 50%;
+}
+
+.list {
+ overflow-y: auto;
+ max-height: 200px;
+}
diff --git a/examples/DropdownMenu.hidemiddleware.jsx b/examples/DropdownMenu.hidemiddleware.jsx
new file mode 100644
index 00000000000..13d387c7071
--- /dev/null
+++ b/examples/DropdownMenu.hidemiddleware.jsx
@@ -0,0 +1,51 @@
+/*---------------------------------------------------------------------------------------------
+ * Copyright (c) Bentley Systems, Incorporated. All rights reserved.
+ * See LICENSE.md in the project root for license terms and full copyright notice.
+ *--------------------------------------------------------------------------------------------*/
+import * as React from 'react';
+import {
+ Surface,
+ List,
+ ListItem,
+ DropdownMenu,
+ IconButton,
+ MenuItem,
+} from '@itwin/itwinui-react';
+import { SvgMore } from '@itwin/itwinui-icons-react';
+
+export default () => {
+ const dropdownMenuItems = (close) => [
+ ,
+ ,
+ ,
+ ];
+
+ const items = new Array(30).fill(0);
+
+ return (
+
+
+ {items.map((_, i) => (
+
+ Item {i}
+
+
+
+
+
+
+ ))}
+
+
+ );
+};
diff --git a/examples/index.tsx b/examples/index.tsx
index 0d88f7ff9f5..d6ee488c09c 100644
--- a/examples/index.tsx
+++ b/examples/index.tsx
@@ -499,6 +499,12 @@ const DropdownMenuContentExample = withThemeProvider(
);
export { DropdownMenuContentExample };
+import { default as DropdownMenuHideMiddlewareExampleRaw } from './DropdownMenu.hidemiddleware';
+const DropdownMenuHideMiddlewareExample = withThemeProvider(
+ DropdownMenuHideMiddlewareExampleRaw,
+);
+export { DropdownMenuHideMiddlewareExample };
+
// ----------------------------------------------------------------------------
import { default as ExpandableBlockMainExampleRaw } from './ExpandableBlock.main';
diff --git a/packages/itwinui-react/src/core/Buttons/SplitButton.tsx b/packages/itwinui-react/src/core/Buttons/SplitButton.tsx
index 5dae92c5239..fbd950f7499 100644
--- a/packages/itwinui-react/src/core/Buttons/SplitButton.tsx
+++ b/packages/itwinui-react/src/core/Buttons/SplitButton.tsx
@@ -19,6 +19,7 @@ import type {
} from '../../utils/index.js';
import type { Placement } from '@floating-ui/react';
import { Menu } from '../Menu/Menu.js';
+import type { usePopover } from '../Popover/Popover.js';
export type SplitButtonProps = ButtonProps & {
/**
@@ -47,6 +48,10 @@ export type SplitButtonProps = ButtonProps & {
React.ComponentProps,
'label' | 'size'
>;
+ /**
+ * Props to customize menu behavior.
+ */
+ dropdownMenuProps?: Pick[0], 'middleware'>;
} & Pick;
/**
diff --git a/packages/itwinui-react/src/core/ComboBox/ComboBox.tsx b/packages/itwinui-react/src/core/ComboBox/ComboBox.tsx
index 68c2fb13967..fc57b18b1c0 100644
--- a/packages/itwinui-react/src/core/ComboBox/ComboBox.tsx
+++ b/packages/itwinui-react/src/core/ComboBox/ComboBox.tsx
@@ -110,7 +110,8 @@ export type ComboBoxProps = {
/**
* Props to customize dropdown menu behavior.
*/
- dropdownMenuProps?: React.ComponentProps<'div'>;
+ dropdownMenuProps?: React.ComponentProps<'div'> &
+ Pick['0'], 'middleware'>;
/**
* End icon props.
*/
@@ -197,7 +198,7 @@ export const ComboBox = React.forwardRef(
filterFunction = defaultFilterFunction,
inputProps,
endIconProps,
- dropdownMenuProps,
+ dropdownMenuProps: { middleware, ...dropdownMenuProps } = {},
emptyStateMessage = 'No options found',
itemRenderer,
enableVirtualization = false,
@@ -572,7 +573,10 @@ export const ComboBox = React.forwardRef(
visible: isOpen,
onVisibleChange: (open) => (open ? show() : hide()),
matchWidth: true,
- middleware: { size: { maxHeight: 'var(--iui-menu-max-height)' } },
+ middleware: {
+ size: { maxHeight: 'var(--iui-menu-max-height)' },
+ ...middleware,
+ },
closeOnOutsideClick: true,
interactions: { click: false, focus: true },
});
diff --git a/packages/itwinui-react/src/core/DropdownMenu/DropdownMenu.tsx b/packages/itwinui-react/src/core/DropdownMenu/DropdownMenu.tsx
index d6a69d489f6..45250fcf2e0 100644
--- a/packages/itwinui-react/src/core/DropdownMenu/DropdownMenu.tsx
+++ b/packages/itwinui-react/src/core/DropdownMenu/DropdownMenu.tsx
@@ -30,6 +30,17 @@ export type DropdownMenuProps = {
* Child element to wrap dropdown with.
*/
children: React.ReactNode;
+ /**
+ * Middleware options.
+ *
+ * By default, `hide` is enabled. If the menu gets hidden even when it shouldn't (e.g. some custom styles interfering
+ * with the trigger's hide detection) consider disabling the `hide` middleware.
+ *
+ * @see https://floating-ui.com/docs/middleware
+ */
+ middleware?: {
+ hide?: boolean;
+ };
} & Pick<
Parameters[0],
'visible' | 'onVisibleChange' | 'placement' | 'matchWidth'
@@ -78,6 +89,7 @@ const DropdownMenuContent = React.forwardRef((props, forwardedRef) => {
matchWidth = false,
onVisibleChange,
portal = true,
+ middleware,
...rest
} = props;
@@ -95,30 +107,32 @@ const DropdownMenuContent = React.forwardRef((props, forwardedRef) => {
}, [menuItems, setVisible]);
return (
- <>
-
);
}) as PolymorphicForwardRefComponent<'div', DropdownMenuProps>;
diff --git a/packages/itwinui-react/src/core/Popover/Popover.tsx b/packages/itwinui-react/src/core/Popover/Popover.tsx
index 9d736d2a00e..81024fd7e86 100644
--- a/packages/itwinui-react/src/core/Popover/Popover.tsx
+++ b/packages/itwinui-react/src/core/Popover/Popover.tsx
@@ -39,6 +39,7 @@ import {
Box,
ShadowRoot,
cloneElementWithRef,
+ isUnitTest,
mergeEventHandlers,
useControlledState,
useId,
@@ -73,7 +74,10 @@ type PopoverOptions = {
/**
* Middleware options.
*
- * By default, `flip`, `shift` and `size` are enabled.
+ * By default, `flip`, `shift`, `size`, and `hide` are enabled.
+ *
+ * If the floating content gets hidden even when it shouldn't (e.g. some custom styles interfering with the trigger's
+ * hide detection) consider disabling the `hide` middleware.
*
* @see https://floating-ui.com/docs/middleware
*/
@@ -170,13 +174,13 @@ export const usePopover = (options: PopoverOptions & PopoverInternalProps) => {
const mergedInteractions = React.useMemo(
() => ({
+ ...interactionsProp,
...{
- click: true,
- dismiss: true,
- hover: false,
- focus: false,
+ click: interactionsProp?.click ?? true,
+ dismiss: interactionsProp?.dismiss ?? true,
+ hover: interactionsProp?.hover ?? false,
+ focus: interactionsProp?.focus ?? false,
},
- ...interactionsProp,
}),
[interactionsProp],
);
@@ -184,7 +188,13 @@ export const usePopover = (options: PopoverOptions & PopoverInternalProps) => {
const tree = useFloatingTree();
const middleware = React.useMemo(
- () => ({ flip: true, shift: true, size: true, ...options.middleware }),
+ () => ({
+ ...options.middleware,
+ flip: options.middleware?.flip ?? true,
+ shift: options.middleware?.shift ?? true,
+ size: options.middleware?.size ?? true,
+ hide: options.middleware?.hide || !isUnitTest, // default is true, except in fake DOM environments
+ }),
[options.middleware],
);
@@ -287,17 +297,23 @@ export const usePopover = (options: PopoverOptions & PopoverInternalProps) => {
maxInlineSize: `min(${referenceWidth * 2}px, 90vw)`,
}
: {}),
+ ...(middleware.hide &&
+ floating.middlewareData.hide?.referenceHidden && {
+ visibility: 'hidden',
+ }),
...userProps?.style,
},
}),
[
- floating.floatingStyles,
interactions,
- matchWidth,
- referenceWidth,
+ floating.floatingStyles,
+ floating.middlewareData.hide?.referenceHidden,
middleware.size,
+ middleware.hide,
availableHeight,
maxHeight,
+ matchWidth,
+ referenceWidth,
],
);
diff --git a/packages/itwinui-react/src/core/Select/Select.tsx b/packages/itwinui-react/src/core/Select/Select.tsx
index bbccca9e824..0ed813f7fa2 100644
--- a/packages/itwinui-react/src/core/Select/Select.tsx
+++ b/packages/itwinui-react/src/core/Select/Select.tsx
@@ -550,7 +550,19 @@ export type CustomSelectProps = SelectCommonProps & {
| 'placement'
| 'matchWidth'
| 'closeOnOutsideClick'
- >;
+ > & {
+ /**
+ * Middleware options.
+ *
+ * By default, `hide` is enabled. If the floating options get hidden even when they shouldn't (e.g. some custom
+ * styles interfering with the trigger's hide detection) consider disabling the `hide` middleware.
+ *
+ * @see https://floating-ui.com/docs/middleware
+ */
+ middleware?: {
+ hide?: boolean;
+ };
+ };
/**
* Props to pass to the select button (trigger) element.
*/
diff --git a/testing/e2e/app/routes/DropdownMenu/route.tsx b/testing/e2e/app/routes/DropdownMenu/route.tsx
index 6fceb177c0b..5b57e01231c 100644
--- a/testing/e2e/app/routes/DropdownMenu/route.tsx
+++ b/testing/e2e/app/routes/DropdownMenu/route.tsx
@@ -2,23 +2,33 @@ import {
Button,
Checkbox,
DropdownMenu,
+ IconButton,
+ List,
+ ListItem,
MenuDivider,
MenuExtraContent,
MenuItem,
+ Surface,
} from '@itwin/itwinui-react';
import { useSearchParams } from '@remix-run/react';
+import { SvgMore } from '@itwin/itwinui-icons-react';
export default () => {
const [searchParams] = useSearchParams();
const menuType = (searchParams.get('menuType') || 'withSubmenu') as
| 'withSubmenu'
+ | 'withHideMiddleware'
| 'withExtraContent';
+ const hideMiddleware =
+ searchParams.get('hideMiddleware') === 'false' ? false : undefined;
return (
<>
{menuType === 'withExtraContent' ? (
+ ) : menuType === 'withHideMiddleware' ? (
+
) : (
)}
@@ -132,3 +142,50 @@ const DropdownMenuWithExtraContent = () => {
);
};
+
+const DropdownMenuHideMiddleware = ({
+ hideMiddleware,
+}: {
+ hideMiddleware?: boolean;
+}) => {
+ const dropdownMenuItems = (close: () => void) => [
+ ,
+ ,
+ ,
+ ];
+
+ const items = new Array(30).fill(0);
+
+ return (
+
+
+ {items.map((_, i) => (
+
+ Item {i}
+
+
+
+
+
+
+ ))}
+
+
+ );
+};
diff --git a/testing/e2e/app/routes/DropdownMenu/spec.ts b/testing/e2e/app/routes/DropdownMenu/spec.ts
index c655e8606ad..9ee0974679c 100644
--- a/testing/e2e/app/routes/DropdownMenu/spec.ts
+++ b/testing/e2e/app/routes/DropdownMenu/spec.ts
@@ -291,6 +291,26 @@ test.describe('DropdownMenu', () => {
// Close the menu with Tab
});
+
+ test('should hide menu when trigger hidden', async ({ page }) => {
+ await page.goto('/DropdownMenu?menuType=withHideMiddleware');
+
+ await page.locator('button').first().click();
+ await page.locator('button').nth(10).scrollIntoViewIfNeeded();
+
+ await expect(page.getByRole('menu').first()).not.toBeVisible();
+ });
+
+ test('should allow opting out of hide middleware', async ({ page }) => {
+ await page.goto(
+ '/DropdownMenu?menuType=withHideMiddleware&hideMiddleware=false',
+ );
+
+ await page.locator('button').first().click();
+ await page.locator('button').nth(10).scrollIntoViewIfNeeded();
+
+ await expect(page.getByRole('menu')).toBeVisible();
+ });
});
// ----------------------------------------------------------------------------