From f54712b365284d6743889d3e63380473ab27b6ba Mon Sep 17 00:00:00 2001 From: guidari Date: Mon, 27 May 2024 14:56:38 -0300 Subject: [PATCH 1/6] feat: added floating ui to multiselect and filterablemultiselect --- .../MultiSelect/FilterableMultiSelect.tsx | 48 +++++++++++++++- .../MultiSelect/MultiSelect.stories.js | 56 ++++++++++++------- .../components/MultiSelect/MultiSelect.tsx | 47 +++++++++++++++- 3 files changed, 126 insertions(+), 25 deletions(-) diff --git a/packages/react/src/components/MultiSelect/FilterableMultiSelect.tsx b/packages/react/src/components/MultiSelect/FilterableMultiSelect.tsx index 785471470d62..828b8e5efd8a 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 { @@ -47,6 +48,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, @@ -332,6 +339,37 @@ const FilterableMultiSelect = React.forwardRef(function FilterableMultiSelect< selectedItems: selected, }); + const { refs, floatingStyles, middlewareData } = useFloating({ + 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(() => { + Object.keys(floatingStyles).forEach((style) => { + if (refs.floating.current) { + refs.floating.current.style[style] = floatingStyles[style]; + } + }); + }, [floatingStyles, refs.floating, middlewareData, open]); + const textInput = useRef(null); const filterableMultiSelectInstanceId = useId(); @@ -665,7 +703,11 @@ const FilterableMultiSelect = React.forwardRef(function FilterableMultiSelect< }, }) ); - const menuProps = getMenuProps({}, { suppressRefError: true }); + const menuProps = getMenuProps({ + ...getMenuProps({ + ref: refs.setFloating, + }), + }); const handleFocus = (evt: FocusEvent | undefined) => { if ( @@ -713,7 +755,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 = diff --git a/packages/react/src/components/MultiSelect/MultiSelect.stories.js b/packages/react/src/components/MultiSelect/MultiSelect.stories.js index 8381bdd3a1cc..8d68b88dded2 100644 --- a/packages/react/src/components/MultiSelect/MultiSelect.stories.js +++ b/packages/react/src/components/MultiSelect/MultiSelect.stories.js @@ -226,16 +226,24 @@ Playground.argTypes = { export const Default = () => { return ( -
- (item ? item.text : '')} - selectionFeedback="top-after-reopen" - /> +
+
+ (item ? item.text : '')} + selectionFeedback="top-after-reopen" + /> +
); }; @@ -259,16 +267,24 @@ export const WithInitialSelectedItems = () => { export const Filterable = (args) => { return ( -
- (item ? item.text : '')} - selectionFeedback="top-after-reopen" - {...args} - /> +
+
+ (item ? item.text : '')} + selectionFeedback="top-after-reopen" + {...args} + /> +
); }; diff --git a/packages/react/src/components/MultiSelect/MultiSelect.tsx b/packages/react/src/components/MultiSelect/MultiSelect.tsx index c8d711cf0371..da75bb74e082 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, @@ -40,6 +41,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 { @@ -383,6 +390,37 @@ const MultiSelect = React.forwardRef( selectedItems: selected, }); + const { refs, floatingStyles, middlewareData } = useFloating({ + 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(() => { + Object.keys(floatingStyles).forEach((style) => { + if (refs.floating.current) { + refs.floating.current.style[style] = floatingStyles[style]; + } + }); + }, [floatingStyles, refs.floating, middlewareData, open]); + // Filter out items with an object having undefined values const filteredItems = useMemo(() => { return items.filter((item) => { @@ -686,7 +724,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!( From 3c49e44e7a9403818154c9c1e4de87d51646cb6e Mon Sep 17 00:00:00 2001 From: guidari Date: Wed, 5 Jun 2024 11:11:41 -0300 Subject: [PATCH 2/6] fix: fixed act error --- .../src/components/ListBox/test-helpers.js | 3 ++ .../MultiSelect/FilterableMultiSelect.tsx | 6 +-- .../__tests__/FilterableMultiSelect-test.js | 25 ++++++++- .../MultiSelect/__tests__/MultiSelect-test.js | 54 ++++++++++++++++--- 4 files changed, 73 insertions(+), 15 deletions(-) diff --git a/packages/react/src/components/ListBox/test-helpers.js b/packages/react/src/components/ListBox/test-helpers.js index 5d2a72ef6b12..b6e32691b551 100644 --- a/packages/react/src/components/ListBox/test-helpers.js +++ b/packages/react/src/components/ListBox/test-helpers.js @@ -7,6 +7,7 @@ const prefix = 'cds'; import userEvent from '@testing-library/user-event'; +import { act } from 'react-dom/test-utils'; // Finding nodes in a ListBox export const findListBoxNode = () => { @@ -105,3 +106,5 @@ export const generateItems = (amount, generator) => .map((_, i) => generator(i)); export const customItemToString = ({ field }) => field; + +export const waitForPosition = () => act(async () => {}); // Flush microtasks. Position state is ready by this line. diff --git a/packages/react/src/components/MultiSelect/FilterableMultiSelect.tsx b/packages/react/src/components/MultiSelect/FilterableMultiSelect.tsx index 828b8e5efd8a..0b7c19d85c79 100644 --- a/packages/react/src/components/MultiSelect/FilterableMultiSelect.tsx +++ b/packages/react/src/components/MultiSelect/FilterableMultiSelect.tsx @@ -703,11 +703,7 @@ const FilterableMultiSelect = React.forwardRef(function FilterableMultiSelect< }, }) ); - const menuProps = getMenuProps({ - ...getMenuProps({ - ref: refs.setFloating, - }), - }); + const menuProps = getMenuProps({}, { suppressRefError: true }); const handleFocus = (evt: FocusEvent | undefined) => { if ( 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 819d65bacac4..130be4bca738 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 @@ -338,6 +362,7 @@ describe('MultiSelect', () => { items={items} /> ); + await waitForPosition(); // eslint-disable-next-line testing-library/prefer-screen-queries const labelNode = getByText(container, label); @@ -352,7 +377,7 @@ describe('MultiSelect', () => { expect(testFunction.mock.results[0].value).toEqual(selectedItems); }); - it('should place the given id on the ___ node when passed in as a prop', () => { + 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'; @@ -364,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(); @@ -386,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); @@ -427,6 +455,7 @@ describe('MultiSelect', () => { } /> ); + await waitForPosition(); // eslint-disable-next-line testing-library/prefer-screen-queries const labelNode = getByText(container, label); @@ -438,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'); @@ -451,6 +480,7 @@ describe('MultiSelect', () => { items={items} /> ); + await waitForPosition(); expect(translateWithId).toHaveBeenCalled(); }); @@ -468,6 +498,7 @@ describe('MultiSelect', () => { items={items} /> ); + await waitForPosition(); // eslint-disable-next-line testing-library/prefer-screen-queries const labelNode = getByText(container, label); @@ -482,7 +513,7 @@ describe('MultiSelect', () => { expect(testFunction).toHaveBeenCalledTimes(1); }); - it('should call onChange when the selection changes outside of the component', () => { + it('should call onChange when the selection changes outside of the component', async () => { const handleChange = jest.fn(); const items = generateItems(4, generateGenericItem); const props = { @@ -493,6 +524,7 @@ describe('MultiSelect', () => { items, }; const { rerender } = render(); + await waitForPosition(); expect(handleChange).not.toHaveBeenCalled(); @@ -506,7 +538,7 @@ describe('MultiSelect', () => { }); }); - 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'; @@ -519,6 +551,7 @@ describe('MultiSelect', () => { items={items} /> ); + await waitForPosition(); // eslint-disable-next-line testing-library/prefer-screen-queries expect(getByText(container, 'Fool of a Took!')).toBeInTheDocument(); @@ -541,6 +574,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 @@ -562,20 +596,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` ); From 8f43336a9d65ac63b8600d15c5ac7e7c05348163 Mon Sep 17 00:00:00 2001 From: guidari Date: Fri, 14 Jun 2024 10:01:55 -0300 Subject: [PATCH 3/6] fix: added autoAlign --- .../MultiSelect/FilterableMultiSelect.tsx | 77 +++++++---- .../MultiSelect/MultiSelect.stories.js | 123 ++++++++++++------ .../components/MultiSelect/MultiSelect.tsx | 77 +++++++---- 3 files changed, 178 insertions(+), 99 deletions(-) diff --git a/packages/react/src/components/MultiSelect/FilterableMultiSelect.tsx b/packages/react/src/components/MultiSelect/FilterableMultiSelect.tsx index 0b7c19d85c79..8a527a633417 100644 --- a/packages/react/src/components/MultiSelect/FilterableMultiSelect.tsx +++ b/packages/react/src/components/MultiSelect/FilterableMultiSelect.tsx @@ -91,6 +91,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; /** @@ -282,6 +289,7 @@ const FilterableMultiSelect = React.forwardRef(function FilterableMultiSelect< Item extends ItemBase >( { + autoAlign = false, className: containerClassName, clearSelectionDescription = 'Total items selected: ', clearSelectionText = 'To clear selection, press Delete or Backspace', @@ -339,36 +347,42 @@ const FilterableMultiSelect = React.forwardRef(function FilterableMultiSelect< selectedItems: selected, }); - const { refs, floatingStyles, middlewareData } = useFloating({ - 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, - }); + 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(() => { - Object.keys(floatingStyles).forEach((style) => { - if (refs.floating.current) { - refs.floating.current.style[style] = floatingStyles[style]; - } - }); - }, [floatingStyles, refs.floating, middlewareData, open]); + 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(); @@ -887,6 +901,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 8d68b88dded2..92abd2a68703 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'; @@ -114,24 +114,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, @@ -228,29 +242,27 @@ export const Default = () => { return (
-
- (item ? item.text : '')} - selectionFeedback="top-after-reopen" - /> -
+ (item ? item.text : '')} + selectionFeedback="top-after-reopen" + />
); }; export const WithInitialSelectedItems = () => { return ( -
+
{ return (
-
- (item ? item.text : '')} - selectionFeedback="top-after-reopen" - {...args} - /> -
+ (item ? item.text : '')} + selectionFeedback="top-after-reopen" + {...args} + />
); }; @@ -371,3 +378,33 @@ export const _Controlled = () => {
); }; + +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 da75bb74e082..ad5b8c0201e1 100644 --- a/packages/react/src/components/MultiSelect/MultiSelect.tsx +++ b/packages/react/src/components/MultiSelect/MultiSelect.tsx @@ -154,6 +154,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; /** @@ -332,6 +339,7 @@ export interface MultiSelectProps const MultiSelect = React.forwardRef( ( { + autoAlign = false, className: containerClassName, id, items, @@ -390,36 +398,42 @@ const MultiSelect = React.forwardRef( selectedItems: selected, }); - const { refs, floatingStyles, middlewareData } = useFloating({ - 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, - }); + 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(() => { - Object.keys(floatingStyles).forEach((style) => { - if (refs.floating.current) { - refs.floating.current.style[style] = floatingStyles[style]; - } - }); - }, [floatingStyles, refs.floating, middlewareData, open]); + 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(() => { @@ -839,6 +853,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 From b82efde9f1c7d3c6d986c3f55c080556f3797ec8 Mon Sep 17 00:00:00 2001 From: guidari Date: Mon, 17 Jun 2024 14:23:53 -0300 Subject: [PATCH 4/6] fix: fixed import --- packages/react/src/components/ListBox/test-helpers.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/react/src/components/ListBox/test-helpers.js b/packages/react/src/components/ListBox/test-helpers.js index b6e32691b551..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 'react-dom/test-utils'; +import { act } from 'react'; // Finding nodes in a ListBox export const findListBoxNode = () => { From 4dd139e23ebdd42c3db63bfbd5d86c7e3c8c1eed Mon Sep 17 00:00:00 2001 From: guidari Date: Fri, 21 Jun 2024 09:21:54 -0300 Subject: [PATCH 5/6] fix: updated snapshot --- .../react/__tests__/__snapshots__/PublicAPI-test.js.snap | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/packages/react/__tests__/__snapshots__/PublicAPI-test.js.snap b/packages/react/__tests__/__snapshots__/PublicAPI-test.js.snap index 57ad9ac6f903..0af3845d7a53 100644 --- a/packages/react/__tests__/__snapshots__/PublicAPI-test.js.snap +++ b/packages/react/__tests__/__snapshots__/PublicAPI-test.js.snap @@ -3415,6 +3415,9 @@ Map { "propTypes": Object { "aria-label": [Function], "ariaLabel": [Function], + "autoAlign": Object { + "type": "bool", + }, "clearSelectionDescription": Object { "type": "string", }, @@ -5088,6 +5091,9 @@ Map { "propTypes": Object { "aria-label": [Function], "ariaLabel": [Function], + "autoAlign": Object { + "type": "bool", + }, "clearSelectionDescription": Object { "type": "string", }, @@ -5336,6 +5342,9 @@ Map { "render": [Function], }, "propTypes": Object { + "autoAlign": Object { + "type": "bool", + }, "className": Object { "type": "string", }, From 1bb3f2ad9b3fc9fcbb0c3f4135fc78dd05e681fb Mon Sep 17 00:00:00 2001 From: guidari Date: Fri, 21 Jun 2024 11:23:59 -0300 Subject: [PATCH 6/6] test: removed wrong test --- .../MultiSelect/__tests__/MultiSelect-test.js | 25 ------------------- 1 file changed, 25 deletions(-) diff --git a/packages/react/src/components/MultiSelect/__tests__/MultiSelect-test.js b/packages/react/src/components/MultiSelect/__tests__/MultiSelect-test.js index 130be4bca738..62d20b703a21 100644 --- a/packages/react/src/components/MultiSelect/__tests__/MultiSelect-test.js +++ b/packages/react/src/components/MultiSelect/__tests__/MultiSelect-test.js @@ -513,31 +513,6 @@ describe('MultiSelect', () => { expect(testFunction).toHaveBeenCalledTimes(1); }); - it('should call onChange when the selection changes outside of the component', async () => { - const handleChange = jest.fn(); - const items = generateItems(4, generateGenericItem); - const props = { - id: 'custom-id', - onChange: handleChange, - selectedItems: [], - label: 'test-label', - items, - }; - const { rerender } = render(); - await waitForPosition(); - - expect(handleChange).not.toHaveBeenCalled(); - - act(() => { - rerender(); - }); - - expect(handleChange).toHaveBeenCalledTimes(1); - expect(handleChange.mock.lastCall[0]).toMatchObject({ - selectedItems: [items[0]], - }); - }); - it('should support an invalid state with invalidText that describes the field', async () => { const items = generateItems(4, generateGenericItem); const label = 'test-label';