diff --git a/packages/react/__tests__/__snapshots__/PublicAPI-test.js.snap b/packages/react/__tests__/__snapshots__/PublicAPI-test.js.snap index 57ad9ac6f903..dd260f938d5f 100644 --- a/packages/react/__tests__/__snapshots__/PublicAPI-test.js.snap +++ b/packages/react/__tests__/__snapshots__/PublicAPI-test.js.snap @@ -2824,6 +2824,9 @@ Map { "type": "string", }, "ariaLabel": [Function], + "autoAlign": Object { + "type": "bool", + }, "className": Object { "type": "string", }, diff --git a/packages/react/src/components/Dropdown/Dropdown-test.js b/packages/react/src/components/Dropdown/Dropdown-test.js index bfe9a8a8560e..e1cf0c25e604 100644 --- a/packages/react/src/components/Dropdown/Dropdown-test.js +++ b/packages/react/src/components/Dropdown/Dropdown-test.js @@ -14,6 +14,7 @@ import { openMenu, generateItems, generateGenericItem, + waitForPosition, } from '../ListBox/test-helpers'; import Dropdown from '../Dropdown'; import DropdownSkeleton from '../Dropdown/Dropdown.Skeleton'; @@ -35,8 +36,9 @@ describe('Dropdown', () => { }; }); - it('should initially render with the menu not open', () => { + it('should initially render with the menu not open', async () => { render(); + await waitForPosition(); assertMenuClosed(); }); @@ -65,7 +67,7 @@ describe('Dropdown', () => { expect(itemToElement).toHaveBeenCalled(); }); - it('should render selectedItem as an element', () => { + it('should render selectedItem as an element', async () => { render( { )} /> ); + await waitForPosition(); // custom element should be rendered for the selected item expect( // eslint-disable-next-line testing-library/no-node-access @@ -92,24 +95,27 @@ describe('Dropdown', () => { }); describe('title', () => { - it('renders a title', () => { + it('renders a title', async () => { render(); + await waitForPosition(); expect(screen.getByText('Email Input')).toBeInTheDocument(); }); - it('has the expected classes', () => { + it('has the expected classes', async () => { render(); + await waitForPosition(); expect(screen.getByText('Email Input')).toHaveClass(`${prefix}--label`); }); }); describe('helper', () => { - it('renders a helper', () => { + it('renders a helper', async () => { render(); + await waitForPosition(); expect(screen.getByText('Email Input')).toBeInTheDocument(); }); - it('renders children as expected', () => { + it('renders children as expected', async () => { render( { {...mockProps} /> ); + await waitForPosition(); expect(screen.getByRole('link')).toBeInTheDocument(); }); @@ -128,7 +135,6 @@ describe('Dropdown', () => { it('should let the user select an option by clicking on the option node', async () => { render(); await openMenu(); - await userEvent.click(screen.getByText('Item 0')); expect(mockProps.onChange).toHaveBeenCalledTimes(1); expect(mockProps.onChange).toHaveBeenCalledWith({ @@ -161,15 +167,16 @@ describe('Dropdown', () => { }); describe('should display initially selected item found in `initialSelectedItem`', () => { - it('using an object type for the `initialSelectedItem` prop', () => { + it('using an object type for the `initialSelectedItem` prop', async () => { render( ); + await waitForPosition(); expect(screen.getByText(mockProps.items[0].label)).toBeInTheDocument(); }); - it('using a string type for the `initialSelectedItem` prop', () => { + it('using a string type for the `initialSelectedItem` prop', async () => { // Replace the 'items' property in mockProps with a list of strings mockProps = { ...mockProps, @@ -179,20 +186,23 @@ describe('Dropdown', () => { render( ); + await waitForPosition(); expect(screen.getByText(mockProps.items[1])).toBeInTheDocument(); }); }); describe('Component API', () => { - it('should accept a `ref` for the underlying button element', () => { + it('should accept a `ref` for the underlying button element', async () => { const ref = React.createRef(); render(); + await waitForPosition(); expect(ref.current).toHaveAttribute('aria-haspopup', 'listbox'); }); - it('should respect slug prop', () => { + it('should respect slug prop', async () => { const { container } = render(} />); + await waitForPosition(); expect(container.firstChild).toHaveClass( `${prefix}--list-box__wrapper--slug` ); diff --git a/packages/react/src/components/Dropdown/Dropdown.stories.js b/packages/react/src/components/Dropdown/Dropdown.stories.js index e0347e877b51..394916ab7799 100644 --- a/packages/react/src/components/Dropdown/Dropdown.stories.js +++ b/packages/react/src/components/Dropdown/Dropdown.stories.js @@ -69,6 +69,24 @@ const items = [ }, ]; +export const ExperimentalAutoAlign = () => ( +
+
+ (item ? item.text : '')} + direction="top" + /> +
+
+); + export const Playground = (args) => (
*/ ariaLabel?: string; + /** + * **Experimental**: Will attempt to automatically align the floating element to avoid collisions with the viewport and being clipped by ancestor elements. + */ + autoAlign?: boolean; + /** * Specify the direction of the dropdown. Can be either top or bottom. */ @@ -238,6 +250,7 @@ export type DropdownTranslationKey = ListBoxMenuIconTranslationKey; const Dropdown = React.forwardRef( ( { + autoAlign = false, className: containerClassName, disabled = false, direction = 'bottom', @@ -270,6 +283,43 @@ const Dropdown = React.forwardRef( }: DropdownProps, ref: ForwardedRef ) => { + const { refs, floatingStyles } = useFloating( + autoAlign + ? { + placement: direction, + + // The floating element is positioned relative to its nearest + // containing block (usually the viewport). It will in many cases also + // “break” the floating element out of a clipping ancestor. + // https://floating-ui.com/docs/misc#clipping + strategy: 'fixed', + + // Middleware order matters, arrow should be last + middleware: [ + floatingSize({ + apply({ rects, elements }) { + Object.assign(elements.floating.style, { + width: `${rects.reference.width}px`, + }); + }, + }), + flip(), + ], + whileElementsMounted: autoUpdate, + } + : {} // When autoAlign is turned off, floating-ui will not be used + ); + + useEffect(() => { + if (autoAlign) { + Object.keys(floatingStyles).forEach((style) => { + if (refs.floating.current) { + refs.floating.current.style[style] = floatingStyles[style]; + } + }); + } + }, [floatingStyles, autoAlign, refs.floating]); + const prefix = usePrefix(); const { isFluid } = useContext(FormContext); @@ -340,6 +390,7 @@ const Dropdown = React.forwardRef( [`${prefix}--dropdown--readonly`]: readOnly, [`${prefix}--dropdown--${size}`]: size, [`${prefix}--list-box--up`]: direction === 'top', + [`${prefix}--dropdown--autoalign`]: autoAlign, }); const titleClasses = cx(`${prefix}--label`, { @@ -447,6 +498,7 @@ const Dropdown = React.forwardRef( }; const menuProps = getMenuProps(); + const menuRef = mergeRefs(menuProps.ref, refs.setFloating); // Slug is always size `mini` let normalizedSlug; @@ -475,6 +527,7 @@ const Dropdown = React.forwardRef( warnText={warnText} light={light} isOpen={isOpen} + ref={refs.setReference} id={id}> {invalid && ( @@ -514,7 +567,7 @@ const Dropdown = React.forwardRef( /> {normalizedSlug} - + {isOpen && items.map((item, index) => { const isObject = item !== null && typeof item === 'object'; @@ -592,6 +645,11 @@ Dropdown.propTypes = { 'This prop syntax has been deprecated. Please use the new `aria-label`.' ), + /** + * **Experimental**: Will attempt to automatically align the floating element to avoid collisions with the viewport and being clipped by ancestor elements. + */ + autoAlign: PropTypes.bool, + /** * Provide a custom className to be applied on the cds--dropdown node */ diff --git a/packages/react/src/components/FluidDropdown/__tests__/FluidDropdown-test.js b/packages/react/src/components/FluidDropdown/__tests__/FluidDropdown-test.js index 94998f0c8b3d..6edb4c8f2fb5 100644 --- a/packages/react/src/components/FluidDropdown/__tests__/FluidDropdown-test.js +++ b/packages/react/src/components/FluidDropdown/__tests__/FluidDropdown-test.js @@ -14,6 +14,7 @@ import { openMenu, generateItems, generateGenericItem, + waitForPosition, } from '../../ListBox/test-helpers'; import FluidDropdown from '../FluidDropdown'; @@ -33,22 +34,25 @@ describe('FluidDropdown', () => { }; }); - it('should render with fluid classes', () => { + it('should render with fluid classes', async () => { const { container } = render(); + await waitForPosition(); expect(container.firstChild).toHaveClass( `${prefix}--list-box__wrapper--fluid` ); }); - it('should render with condensed styles if isCondensed is provided', () => { + it('should render with condensed styles if isCondensed is provided', async () => { const { container } = render(); + await waitForPosition(); expect(container.firstChild).toHaveClass( `${prefix}--list-box__wrapper--fluid--condensed` ); }); - it('should initially render with the menu not open', () => { + it('should initially render with the menu not open', async () => { render(); + await waitForPosition(); assertMenuClosed(); }); @@ -77,7 +81,7 @@ describe('FluidDropdown', () => { expect(itemToElement).toHaveBeenCalled(); }); - it('should render selectedItem as an element', () => { + it('should render selectedItem as an element', async () => { render( { )} /> ); + await waitForPosition(); // custom element should be rendered for the selected item expect( // eslint-disable-next-line testing-library/no-node-access @@ -104,13 +109,15 @@ describe('FluidDropdown', () => { }); describe('title', () => { - it('renders a title', () => { + it('renders a title', async () => { render(); + await waitForPosition(); expect(screen.getByText('Email Input')).toBeInTheDocument(); }); - it('has the expected classes', () => { + it('has the expected classes', async () => { render(); + await waitForPosition(); expect(screen.getByText('Email Input')).toHaveClass(`${prefix}--label`); }); }); @@ -138,18 +145,19 @@ describe('FluidDropdown', () => { }); describe('should display initially selected item found in `initialSelectedItem`', () => { - it('using an object type for the `initialSelectedItem` prop', () => { + it('using an object type for the `initialSelectedItem` prop', async () => { render( ); + await waitForPosition(); expect(screen.getByText(mockProps.items[0].label)).toBeInTheDocument(); }); - it('using a string type for the `initialSelectedItem` prop', () => { + it('using a string type for the `initialSelectedItem` prop', async () => { // Replace the 'items' property in mockProps with a list of strings mockProps = { ...mockProps, @@ -162,15 +170,17 @@ describe('FluidDropdown', () => { initialSelectedItem={mockProps.items[1]} /> ); + await waitForPosition(); expect(screen.getByText(mockProps.items[1])).toBeInTheDocument(); }); }); describe('Component API', () => { - it('should accept a `ref` for the underlying button element', () => { + it('should accept a `ref` for the underlying button element', async () => { const ref = React.createRef(); render(); + await waitForPosition(); expect(ref.current).toHaveAttribute('aria-haspopup', 'listbox'); }); });