Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Added Floating UI to MultiSelect and FilterableMultiSelect #16689

Merged
merged 9 commits into from
Jun 21, 2024
Merged
3 changes: 3 additions & 0 deletions packages/react/src/components/ListBox/test-helpers.js
Original file line number Diff line number Diff line change
Expand Up @@ -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 = () => {
Expand Down Expand Up @@ -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.
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,7 @@ import React, {
type FocusEvent,
type KeyboardEvent,
ReactElement,
useLayoutEffect,
} from 'react';
import { defaultFilterItems } from '../ComboBox/tools/filter';
import {
Expand All @@ -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,
Expand Down Expand Up @@ -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<HTMLInputElement>(null);
const filterableMultiSelectInstanceId = useId();

Expand Down Expand Up @@ -713,7 +751,7 @@ const FilterableMultiSelect = React.forwardRef(function FilterableMultiSelect<
warnText={warnText}
isOpen={isOpen}
size={size}>
<div className={`${prefix}--list-box__field`}>
<div className={`${prefix}--list-box__field`} ref={refs.setReference}>
{controlledSelectedItems.length > 0 && (
// @ts-expect-error: It is expecting a non-required prop called: "onClearSelection"
<ListBoxSelection
Expand Down Expand Up @@ -766,7 +804,7 @@ const FilterableMultiSelect = React.forwardRef(function FilterableMultiSelect<
</div>
{normalizedSlug}

<ListBox.Menu {...menuProps}>
<ListBox.Menu {...menuProps} ref={refs.setFloating}>
{isOpen
? sortedItems.map((item, index) => {
const isChecked =
Expand Down
56 changes: 36 additions & 20 deletions packages/react/src/components/MultiSelect/MultiSelect.stories.js
Original file line number Diff line number Diff line change
Expand Up @@ -226,16 +226,24 @@ Playground.argTypes = {

export const Default = () => {
return (
<div style={{ width: 300 }}>
<MultiSelect
label="Multiselect Label"
id="carbon-multiselect-example"
titleText="Multiselect title"
helperText="This is helper text"
items={items}
itemToString={(item) => (item ? item.text : '')}
selectionFeedback="top-after-reopen"
/>
<div
style={{
width: 2000,
height: 2000,
display: 'flex',
placeItems: 'center',
}}>
<div style={{ width: 300 }}>
<MultiSelect
label="Multiselect Label"
id="carbon-multiselect-example"
titleText="Multiselect title"
helperText="This is helper text"
items={items}
itemToString={(item) => (item ? item.text : '')}
selectionFeedback="top-after-reopen"
/>
</div>
guidari marked this conversation as resolved.
Show resolved Hide resolved
</div>
);
};
Expand All @@ -259,16 +267,24 @@ export const WithInitialSelectedItems = () => {

export const Filterable = (args) => {
return (
<div style={{ width: 300 }}>
<FilterableMultiSelect
id="carbon-multiselect-example-3"
titleText="Multiselect title"
helperText="This is helper text"
items={items}
itemToString={(item) => (item ? item.text : '')}
selectionFeedback="top-after-reopen"
{...args}
/>
<div
style={{
width: 2000,
height: 2000,
display: 'flex',
placeItems: 'center',
}}>
<div style={{ width: 300 }}>
<FilterableMultiSelect
id="carbon-multiselect-example-3"
titleText="Multiselect title"
helperText="This is helper text"
items={items}
itemToString={(item) => (item ? item.text : '')}
selectionFeedback="top-after-reopen"
{...args}
/>
</div>
</div>
);
};
Expand Down
47 changes: 45 additions & 2 deletions packages/react/src/components/MultiSelect/MultiSelect.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@ import React, {
useState,
useMemo,
ReactNode,
useLayoutEffect,
} from 'react';
import ListBox, {
ListBoxSize,
Expand All @@ -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 {
Expand Down Expand Up @@ -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) => {
Expand Down Expand Up @@ -686,7 +724,9 @@ const MultiSelect = React.forwardRef(
className={`${prefix}--list-box__invalid-icon ${prefix}--list-box__invalid-icon--warning`}
/>
)}
<div className={multiSelectFieldWrapperClasses}>
<div
className={multiSelectFieldWrapperClasses}
ref={refs.setReference}>
{selectedItems.length > 0 && (
<ListBox.Selection
readOnly={readOnly}
Expand Down Expand Up @@ -722,7 +762,10 @@ const MultiSelect = React.forwardRef(
</button>
{normalizedSlug}
</div>
<ListBox.Menu {...getMenuProps()}>
<ListBox.Menu
{...getMenuProps({
ref: refs.setFloating,
})}>
{isOpen &&
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
sortItems!(
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ import {
findMenuIconNode,
generateItems,
generateGenericItem,
waitForPosition,
} from '../../ListBox/test-helpers';
import { Slug } from '../../Slug';

Expand All @@ -41,17 +42,23 @@ describe('FilterableMultiSelect', () => {

it('should display all items when the menu is open', async () => {
render(<FilterableMultiSelect {...mockProps} />);
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(<FilterableMultiSelect {...mockProps} open />);
await waitForPosition();

assertMenuOpen(mockProps);
});

it('should open the menu with a down arrow', async () => {
render(<FilterableMultiSelect {...mockProps} />);
await waitForPosition();

const menuIconNode = findMenuIconNode();

await userEvent.type(menuIconNode, '{arrowdown}');
Expand All @@ -60,6 +67,8 @@ describe('FilterableMultiSelect', () => {

it('should let the user toggle the menu by the menu icon', async () => {
render(<FilterableMultiSelect {...mockProps} />);
await waitForPosition();

await userEvent.click(findMenuIconNode());

assertMenuOpen(mockProps);
Expand All @@ -70,6 +79,8 @@ describe('FilterableMultiSelect', () => {

it('should not close the menu after a user makes a selection', async () => {
render(<FilterableMultiSelect {...mockProps} />);
await waitForPosition();

await openMenu();

await userEvent.click(screen.getAllByRole('option')[0]);
Expand All @@ -79,6 +90,8 @@ describe('FilterableMultiSelect', () => {

it('should filter a list of items by the input value', async () => {
render(<FilterableMultiSelect {...mockProps} placeholder="test" />);
await waitForPosition();

await openMenu();
expect(screen.getAllByRole('option').length).toBe(mockProps.items.length);

Expand All @@ -89,6 +102,8 @@ describe('FilterableMultiSelect', () => {

it('should call `onChange` with each update to selected items', async () => {
render(<FilterableMultiSelect {...mockProps} selectionFeedback="top" />);
await waitForPosition();

await openMenu();

// Select the first two items
Expand Down Expand Up @@ -122,6 +137,8 @@ describe('FilterableMultiSelect', () => {

it('should let items stay at their position after selecting', async () => {
render(<FilterableMultiSelect {...mockProps} selectionFeedback="fixed" />);
await waitForPosition();

await openMenu();

// Select the first two items
Expand All @@ -142,6 +159,8 @@ describe('FilterableMultiSelect', () => {

it('should not clear input value after a user makes a selection', async () => {
render(<FilterableMultiSelect {...mockProps} placeholder="test" />);
await waitForPosition();

await openMenu();

await userEvent.type(screen.getByPlaceholderText('test'), '3');
Expand All @@ -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(
<FilterableMultiSelect {...mockProps} slug={<Slug />} />
);
await waitForPosition();

expect(container.firstChild).toHaveClass(
`${prefix}--list-box__wrapper--slug`
);
Expand Down
Loading
Loading