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) => [ + close()}> + Option #1 + , + close()}> + Option #2 + , + close()} disabled> + Option #3 + , + ]; + + 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 ( - <> - { - if (e.defaultPrevented) { - return; - } - if (e.key === 'Tab') { - setVisible(false); - } - })} - role={role} - ref={forwardedRef} - portal={portal} - popoverProps={{ + { + if (e.defaultPrevented) { + return; + } + if (e.key === 'Tab') { + setVisible(false); + } + })} + role={role} + ref={forwardedRef} + portal={portal} + popoverProps={React.useMemo( + () => ({ placement, matchWidth, visible, onVisibleChange: setVisible, - }} - {...rest} - > - {menuContent} - - + middleware, + }), + [matchWidth, middleware, placement, setVisible, visible], + )} + {...rest} + > + {menuContent} + ); }) 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) => [ + close()}> + Option #1 + , + close()}> + Option #2 + , + close()} disabled> + Option #3 + , + ]; + + 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(); + }); }); // ----------------------------------------------------------------------------