diff --git a/packages/react/__tests__/__snapshots__/PublicAPI-test.js.snap b/packages/react/__tests__/__snapshots__/PublicAPI-test.js.snap index dd260f938d5f..9e8d3a3d8a2f 100644 --- a/packages/react/__tests__/__snapshots__/PublicAPI-test.js.snap +++ b/packages/react/__tests__/__snapshots__/PublicAPI-test.js.snap @@ -3418,6 +3418,9 @@ Map { "propTypes": Object { "aria-label": [Function], "ariaLabel": [Function], + "autoAlign": Object { + "type": "bool", + }, "clearSelectionDescription": Object { "type": "string", }, @@ -5091,6 +5094,9 @@ Map { "propTypes": Object { "aria-label": [Function], "ariaLabel": [Function], + "autoAlign": Object { + "type": "bool", + }, "clearSelectionDescription": Object { "type": "string", }, @@ -5339,6 +5345,9 @@ Map { "render": [Function], }, "propTypes": Object { + "autoAlign": Object { + "type": "bool", + }, "className": Object { "type": "string", }, diff --git a/packages/react/src/components/ListBox/test-helpers.js b/packages/react/src/components/ListBox/test-helpers.js index f9958e7f3e32..d64954a48693 100644 --- a/packages/react/src/components/ListBox/test-helpers.js +++ b/packages/react/src/components/ListBox/test-helpers.js @@ -7,7 +7,7 @@ const prefix = 'cds'; import userEvent from '@testing-library/user-event'; -import { act } from '@testing-library/react'; +import { act } from 'react'; // Finding nodes in a ListBox export const findListBoxNode = () => { diff --git a/packages/react/src/components/MultiSelect/FilterableMultiSelect.tsx b/packages/react/src/components/MultiSelect/FilterableMultiSelect.tsx index e9e0a04a782c..66d2ebebeafe 100644 --- a/packages/react/src/components/MultiSelect/FilterableMultiSelect.tsx +++ b/packages/react/src/components/MultiSelect/FilterableMultiSelect.tsx @@ -29,6 +29,7 @@ import React, { type FocusEvent, type KeyboardEvent, ReactElement, + useLayoutEffect, } from 'react'; import { defaultFilterItems } from '../ComboBox/tools/filter'; import { @@ -46,6 +47,12 @@ import { defaultSortItems, defaultCompareItems } from './tools/sorting'; import { usePrefix } from '../../internal/usePrefix'; import { FormContext } from '../FluidForm'; import { useSelection } from '../../internal/Selection'; +import { + useFloating, + flip, + size as floatingSize, + autoUpdate, +} from '@floating-ui/react'; const { InputBlur, @@ -83,6 +90,13 @@ export interface FilterableMultiSelectProps /** @deprecated */ 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; + className?: string; /** @@ -274,6 +288,7 @@ const FilterableMultiSelect = React.forwardRef(function FilterableMultiSelect< ItemType >( { + autoAlign = false, className: containerClassName, clearSelectionDescription = 'Total items selected: ', clearSelectionText = 'To clear selection, press Delete or Backspace', @@ -333,6 +348,43 @@ const FilterableMultiSelect = React.forwardRef(function FilterableMultiSelect< selectedItems: selected, }); + const { refs, floatingStyles, middlewareData } = 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: [ + flip({ crossAxis: false }), + floatingSize({ + apply({ rects, elements }) { + Object.assign(elements.floating.style, { + width: `${rects.reference.width}px`, + }); + }, + }), + ], + whileElementsMounted: autoUpdate, + } + : {} + ); + + useLayoutEffect(() => { + if (autoAlign) { + Object.keys(floatingStyles).forEach((style) => { + if (refs.floating.current) { + refs.floating.current.style[style] = floatingStyles[style]; + } + }); + } + }, [autoAlign, floatingStyles, refs.floating, middlewareData, open]); + const textInput = useRef(null); const filterableMultiSelectInstanceId = useId(); @@ -712,7 +764,7 @@ const FilterableMultiSelect = React.forwardRef(function FilterableMultiSelect< warnText={warnText} isOpen={isOpen} size={size}> -
+
{controlledSelectedItems.length > 0 && ( // @ts-expect-error: It is expecting a non-required prop called: "onClearSelection" {normalizedSlug} - + {isOpen ? sortedItems.map((item, index) => { const isChecked = @@ -846,6 +898,13 @@ FilterableMultiSelect.propTypes = { 'ariaLabel / aria-label props are no longer required for FilterableMultiSelect' ), + /** + * **Experimental**: Will attempt to automatically align the floating + * element to avoid collisions with the viewport and being clipped by + * ancestor elements. + */ + autoAlign: PropTypes.bool, + /** * Specify the text that should be read for screen readers that describes total items selected */ diff --git a/packages/react/src/components/MultiSelect/MultiSelect.stories.js b/packages/react/src/components/MultiSelect/MultiSelect.stories.js index 9e259abaeb97..aee3090af0c5 100644 --- a/packages/react/src/components/MultiSelect/MultiSelect.stories.js +++ b/packages/react/src/components/MultiSelect/MultiSelect.stories.js @@ -5,7 +5,7 @@ * LICENSE file in the root directory of this source tree. */ -import React, { useState } from 'react'; +import React, { useEffect, useRef, useState } from 'react'; import { action } from '@storybook/addon-actions'; import { WithLayer } from '../../../.storybook/templates/WithLayer'; @@ -115,24 +115,38 @@ const items = [ ]; export const Playground = (args) => { + const ref = useRef(); + useEffect(() => { + ref?.current?.scrollIntoView({ block: 'center', inline: 'center' }); + }); return ( -
- (item ? item.text : '')} - selectionFeedback="top-after-reopen" - {...args} - /> +
+
+ (item ? item.text : '')} + selectionFeedback="top-after-reopen" + ref={ref} + {...args} + /> +
); }; Playground.args = { size: 'md', + autoAlign: false, type: 'default', titleText: 'This is a MultiSelect Title', disabled: false, @@ -227,7 +241,10 @@ Playground.argTypes = { export const Default = () => { return ( -
+
{ export const WithInitialSelectedItems = () => { return ( -
+
{ export const Filterable = (args) => { return ( -
+
{
); }; + +export const ExperimentalAutoAlign = () => { + const ref = useRef(); + useEffect(() => { + ref?.current?.scrollIntoView({ block: 'center', inline: 'center' }); + }); + return ( +
+
+ (item ? item.text : '')} + selectionFeedback="top-after-reopen" + ref={ref} + autoAlign + /> +
+
+ ); +}; diff --git a/packages/react/src/components/MultiSelect/MultiSelect.tsx b/packages/react/src/components/MultiSelect/MultiSelect.tsx index 7ecb6abfabc8..2a87e50eb2eb 100644 --- a/packages/react/src/components/MultiSelect/MultiSelect.tsx +++ b/packages/react/src/components/MultiSelect/MultiSelect.tsx @@ -22,6 +22,7 @@ import React, { useState, useMemo, ReactNode, + useLayoutEffect, } from 'react'; import ListBox, { ListBoxSize, @@ -44,6 +45,12 @@ import { FormContext } from '../FluidForm'; import { ListBoxProps } from '../ListBox/ListBox'; import type { InternationalProps } from '../../types/common'; import { noopFn } from '../../internal/noopFn'; +import { + useFloating, + flip, + size as floatingSize, + autoUpdate, +} from '@floating-ui/react'; const getInstanceId = setupGetInstanceId(); const { @@ -95,6 +102,13 @@ export interface MultiSelectProps InternationalProps< 'close.menu' | 'open.menu' | 'clear.all' | 'clear.selection' > { + /** + * **Experimental**: Will attempt to automatically align the floating + * element to avoid collisions with the viewport and being clipped by + * ancestor elements. + */ + autoAlign?: boolean; + className?: string; /** @@ -273,6 +287,7 @@ export interface MultiSelectProps const MultiSelect = React.forwardRef( ( { + autoAlign = false, className: containerClassName, id, items, @@ -331,6 +346,43 @@ const MultiSelect = React.forwardRef( selectedItems: selected, }); + const { refs, floatingStyles, middlewareData } = 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: [ + flip({ crossAxis: false }), + floatingSize({ + apply({ rects, elements }) { + Object.assign(elements.floating.style, { + width: `${rects.reference.width}px`, + }); + }, + }), + ], + whileElementsMounted: autoUpdate, + } + : {} + ); + + useLayoutEffect(() => { + if (autoAlign) { + Object.keys(floatingStyles).forEach((style) => { + if (refs.floating.current) { + refs.floating.current.style[style] = floatingStyles[style]; + } + }); + } + }, [autoAlign, floatingStyles, refs.floating, middlewareData, open]); + // Filter out items with an object having undefined values const filteredItems = useMemo(() => { return items.filter((item) => { @@ -634,7 +686,9 @@ const MultiSelect = React.forwardRef( className={`${prefix}--list-box__invalid-icon ${prefix}--list-box__invalid-icon--warning`} /> )} -
+
{selectedItems.length > 0 && ( {normalizedSlug}
- + {isOpen && // eslint-disable-next-line @typescript-eslint/no-non-null-assertion sortItems!( @@ -744,6 +801,13 @@ MultiSelect.displayName = 'MultiSelect'; MultiSelect.propTypes = { ...sortingPropTypes, + /** + * **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 class name to be added to the outermost node in the * component diff --git a/packages/react/src/components/MultiSelect/__tests__/FilterableMultiSelect-test.js b/packages/react/src/components/MultiSelect/__tests__/FilterableMultiSelect-test.js index 9a4185a207cc..433debc08b1d 100644 --- a/packages/react/src/components/MultiSelect/__tests__/FilterableMultiSelect-test.js +++ b/packages/react/src/components/MultiSelect/__tests__/FilterableMultiSelect-test.js @@ -15,6 +15,7 @@ import { findMenuIconNode, generateItems, generateGenericItem, + waitForPosition, } from '../../ListBox/test-helpers'; import { Slug } from '../../Slug'; @@ -41,17 +42,23 @@ describe('FilterableMultiSelect', () => { it('should display all items when the menu is open', async () => { render(); + await waitForPosition(); + await openMenu(); expect(screen.getAllByRole('option').length).toBe(mockProps.items.length); }); - it('should initially have the menu open when open prop is provided', () => { + it('should initially have the menu open when open prop is provided', async () => { render(); + await waitForPosition(); + assertMenuOpen(mockProps); }); it('should open the menu with a down arrow', async () => { render(); + await waitForPosition(); + const menuIconNode = findMenuIconNode(); await userEvent.type(menuIconNode, '{arrowdown}'); @@ -60,6 +67,8 @@ describe('FilterableMultiSelect', () => { it('should let the user toggle the menu by the menu icon', async () => { render(); + await waitForPosition(); + await userEvent.click(findMenuIconNode()); assertMenuOpen(mockProps); @@ -70,6 +79,8 @@ describe('FilterableMultiSelect', () => { it('should not close the menu after a user makes a selection', async () => { render(); + await waitForPosition(); + await openMenu(); await userEvent.click(screen.getAllByRole('option')[0]); @@ -79,6 +90,8 @@ describe('FilterableMultiSelect', () => { it('should filter a list of items by the input value', async () => { render(); + await waitForPosition(); + await openMenu(); expect(screen.getAllByRole('option').length).toBe(mockProps.items.length); @@ -89,6 +102,8 @@ describe('FilterableMultiSelect', () => { it('should call `onChange` with each update to selected items', async () => { render(); + await waitForPosition(); + await openMenu(); // Select the first two items @@ -122,6 +137,8 @@ describe('FilterableMultiSelect', () => { it('should let items stay at their position after selecting', async () => { render(); + await waitForPosition(); + await openMenu(); // Select the first two items @@ -142,6 +159,8 @@ describe('FilterableMultiSelect', () => { it('should not clear input value after a user makes a selection', async () => { render(); + await waitForPosition(); + await openMenu(); await userEvent.type(screen.getByPlaceholderText('test'), '3'); @@ -151,10 +170,12 @@ describe('FilterableMultiSelect', () => { expect(screen.getByPlaceholderText('test')).toHaveDisplayValue(3); }); - 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/MultiSelect/__tests__/MultiSelect-test.js b/packages/react/src/components/MultiSelect/__tests__/MultiSelect-test.js index 075f85ff1873..62d20b703a21 100644 --- a/packages/react/src/components/MultiSelect/__tests__/MultiSelect-test.js +++ b/packages/react/src/components/MultiSelect/__tests__/MultiSelect-test.js @@ -9,7 +9,11 @@ import { getByText, isElementVisible } from '@carbon/test-utils/dom'; import { act, render, screen } from '@testing-library/react'; import React from 'react'; import MultiSelect from '../'; -import { generateItems, generateGenericItem } from '../../ListBox/test-helpers'; +import { + generateItems, + generateGenericItem, + waitForPosition, +} from '../../ListBox/test-helpers'; import userEvent from '@testing-library/user-event'; import { Slug } from '../../Slug'; @@ -26,6 +30,8 @@ describe('MultiSelect', () => { const { container } = render( ); + await waitForPosition(); + await expect(container).toHaveNoAxeViolations(); }); @@ -34,6 +40,8 @@ describe('MultiSelect', () => { const { container } = render( ); + await waitForPosition(); + await expect(container).toHaveNoACViolations('MultiSelect'); }); }); @@ -48,6 +56,7 @@ describe('MultiSelect', () => { itemToString={(item) => (item ? item.text : '')} /> ); + await waitForPosition(); const labelNode = screen.getByRole('combobox'); await userEvent.click(labelNode); @@ -59,12 +68,13 @@ describe('MultiSelect', () => { ).not.toBeInTheDocument(); }); - it('should initially render with a given label', () => { + it('should initially render with a given label', async () => { const items = generateItems(4, generateGenericItem); const label = 'test-label'; const { container } = render( ); + await waitForPosition(); // eslint-disable-next-line testing-library/prefer-screen-queries const labelNode = getByText(container, label); @@ -82,6 +92,7 @@ describe('MultiSelect', () => { const { container } = render( ); + await waitForPosition(); // eslint-disable-next-line testing-library/prefer-screen-queries const labelNode = getByText(container, label); @@ -100,6 +111,7 @@ describe('MultiSelect', () => { it('should open the menu when a user hits space while the field is focused', async () => { const items = generateItems(4, generateGenericItem); render(); + await waitForPosition(); await userEvent.tab(); await userEvent.keyboard('[Space]'); @@ -117,6 +129,7 @@ describe('MultiSelect', () => { it('should open the menu when a user hits enter while the field is focused', async () => { const items = generateItems(4, generateGenericItem); render(); + await waitForPosition(); await userEvent.tab(); await userEvent.keyboard('[Enter]'); @@ -137,6 +150,7 @@ describe('MultiSelect', () => { const { container } = render( ); + await waitForPosition(); // eslint-disable-next-line testing-library/prefer-screen-queries const labelNode = getByText(container, label); @@ -188,6 +202,8 @@ describe('MultiSelect', () => { const { container } = render( ); + await waitForPosition(); + // eslint-disable-next-line testing-library/prefer-screen-queries const labelNode = getByText(container, label); @@ -217,6 +233,7 @@ describe('MultiSelect', () => { const { container } = render( ); + await waitForPosition(); await userEvent.tab(); await userEvent.keyboard('[Space]'); @@ -238,6 +255,8 @@ describe('MultiSelect', () => { const { container } = render( ); + await waitForPosition(); + // eslint-disable-next-line testing-library/prefer-screen-queries const labelNode = getByText(container, label); await userEvent.click(labelNode); @@ -269,6 +288,8 @@ describe('MultiSelect', () => { const { container } = render( ); + await waitForPosition(); + // eslint-disable-next-line testing-library/prefer-screen-queries const labelNode = getByText(container, label); await userEvent.click(labelNode); @@ -285,6 +306,8 @@ describe('MultiSelect', () => { const { container } = render( ); + await waitForPosition(); + // eslint-disable-next-line testing-library/prefer-screen-queries const labelNode = getByText(container, label); await userEvent.click(labelNode); @@ -307,6 +330,7 @@ describe('MultiSelect', () => { initialSelectedItems={[items[0], items[1]]} /> ); + await waitForPosition(); expect( // eslint-disable-next-line testing-library/no-node-access @@ -324,7 +348,36 @@ describe('MultiSelect', () => { ).toBeInstanceOf(HTMLElement); }); - it('should place the given id on the ___ node when passed in as a prop', () => { + it('should trigger onChange with selected items', async () => { + let selectedItems = []; + const testFunction = jest.fn((e) => (selectedItems = e?.selectedItems)); + const items = generateItems(4, generateGenericItem); + const label = 'test-label'; + const { container } = render( + + ); + await waitForPosition(); + + // eslint-disable-next-line testing-library/prefer-screen-queries + const labelNode = getByText(container, label); + await userEvent.click(labelNode); + + const [item] = items; + // eslint-disable-next-line testing-library/prefer-screen-queries + const itemNode = getByText(container, item.label); + + await userEvent.click(itemNode); + // Assert that the onChange callback returned the selected items and assigned it to selectedItems + expect(testFunction.mock.results[0].value).toEqual(selectedItems); + }); + + it('should place the given id on the ___ node when passed in as a prop', async () => { const items = generateItems(4, generateGenericItem); const label = 'test-label'; @@ -336,6 +389,7 @@ describe('MultiSelect', () => { initialSelectedItems={[items[0], items[1]]} /> ); + await waitForPosition(); // eslint-disable-next-line testing-library/no-node-access expect(document.getElementById('custom-id')).toBeTruthy(); @@ -358,6 +412,8 @@ describe('MultiSelect', () => { itemToString={(item) => (item ? item.text : '')} /> ); + await waitForPosition(); + // eslint-disable-next-line testing-library/prefer-screen-queries const labelNode = getByText(container, label); @@ -399,6 +455,7 @@ describe('MultiSelect', () => { } /> ); + await waitForPosition(); // eslint-disable-next-line testing-library/prefer-screen-queries const labelNode = getByText(container, label); @@ -410,7 +467,7 @@ describe('MultiSelect', () => { expect(document.querySelector('span[role="img"]')).toBeTruthy(); }); - it('should support custom translation with translateWithId', () => { + it('should support custom translation with translateWithId', async () => { const items = generateItems(4, generateGenericItem); const label = 'test-label'; const translateWithId = jest.fn(() => 'message'); @@ -423,6 +480,7 @@ describe('MultiSelect', () => { items={items} /> ); + await waitForPosition(); expect(translateWithId).toHaveBeenCalled(); }); @@ -440,6 +498,7 @@ describe('MultiSelect', () => { items={items} /> ); + await waitForPosition(); // eslint-disable-next-line testing-library/prefer-screen-queries const labelNode = getByText(container, label); @@ -453,7 +512,8 @@ describe('MultiSelect', () => { expect(testFunction).toHaveBeenCalledTimes(1); }); - it('should support an invalid state with invalidText that describes the field', () => { + + it('should support an invalid state with invalidText that describes the field', async () => { const items = generateItems(4, generateGenericItem); const label = 'test-label'; @@ -466,6 +526,7 @@ describe('MultiSelect', () => { items={items} /> ); + await waitForPosition(); // eslint-disable-next-line testing-library/prefer-screen-queries expect(getByText(container, 'Fool of a Took!')).toBeInTheDocument(); @@ -488,6 +549,7 @@ describe('MultiSelect', () => { items={items} /> ); + await waitForPosition(); // click the label to open the multiselect options menu // eslint-disable-next-line testing-library/prefer-screen-queries @@ -509,20 +571,24 @@ describe('MultiSelect', () => { expect(optionsArray[0]).toHaveAttribute('aria-label', 'Item 2'); }); - 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(); const items = generateItems(4, generateGenericItem); const label = 'test-label'; render(); + await waitForPosition(); + expect(ref.current).toHaveAttribute('aria-haspopup', 'listbox'); }); - it('should respect slug prop', () => { + it('should respect slug prop', async () => { const items = generateItems(4, generateGenericItem); const label = 'test-label'; const { container } = render( } /> ); + await waitForPosition(); + expect(container.firstChild).toHaveClass( `${prefix}--list-box__wrapper--slug` );