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
2 changes: 1 addition & 1 deletion packages/react/src/components/ListBox/test-helpers.js
Original file line number Diff line number Diff line change
Expand Up @@ -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 = () => {
Expand Down
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 @@ -84,6 +91,13 @@ export interface FilterableMultiSelectProps<Item extends ItemBase>
/** @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;

/**
Expand Down Expand Up @@ -275,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',
Expand Down Expand Up @@ -332,6 +347,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<HTMLInputElement>(null);
const filterableMultiSelectInstanceId = useId();

Expand Down Expand Up @@ -710,7 +762,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 @@ -763,7 +815,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 Expand Up @@ -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
*/
Expand Down
83 changes: 68 additions & 15 deletions packages/react/src/components/MultiSelect/MultiSelect.stories.js
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -115,24 +115,38 @@ const items = [
];

export const Playground = (args) => {
const ref = useRef();
useEffect(() => {
ref?.current?.scrollIntoView({ block: 'center', inline: 'center' });
});
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"
{...args}
/>
<div style={{ width: '5000px', height: '5000px' }}>
<div
style={{
position: 'absolute',
top: '2500px',
left: '2500px',
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"
ref={ref}
{...args}
/>
</div>
</div>
);
};

Playground.args = {
size: 'md',
autoAlign: false,
type: 'default',
titleText: 'This is a MultiSelect Title',
disabled: false,
Expand Down Expand Up @@ -227,7 +241,10 @@ Playground.argTypes = {

export const Default = () => {
return (
<div style={{ width: 300 }}>
<div
style={{
width: 300,
}}>
<MultiSelect
label="Multiselect Label"
id="carbon-multiselect-example"
Expand All @@ -243,7 +260,10 @@ export const Default = () => {

export const WithInitialSelectedItems = () => {
return (
<div style={{ width: 300 }}>
<div
style={{
width: 300,
}}>
<MultiSelect
label="Multiselect Label"
id="carbon-multiselect-example-2"
Expand All @@ -260,7 +280,10 @@ export const WithInitialSelectedItems = () => {

export const Filterable = (args) => {
return (
<div style={{ width: 300 }}>
<div
style={{
width: 300,
}}>
<FilterableMultiSelect
id="carbon-multiselect-example-3"
titleText="Multiselect title"
Expand Down Expand Up @@ -356,3 +379,33 @@ export const _Controlled = () => {
</div>
);
};

export const ExperimentalAutoAlign = () => {
const ref = useRef();
useEffect(() => {
ref?.current?.scrollIntoView({ block: 'center', inline: 'center' });
});
return (
<div style={{ width: '5000px', height: '5000px' }}>
<div
style={{
position: 'absolute',
top: '2500px',
left: '2500px',
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"
ref={ref}
autoAlign
/>
</div>
</div>
);
};
68 changes: 66 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 @@ -147,6 +154,13 @@ export interface MultiSelectProps<ItemType>
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;

/**
Expand Down Expand Up @@ -325,6 +339,7 @@ export interface MultiSelectProps<ItemType>
const MultiSelect = React.forwardRef(
<ItemType,>(
{
autoAlign = false,
className: containerClassName,
id,
items,
Expand Down Expand Up @@ -383,6 +398,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) => {
Expand Down Expand Up @@ -686,7 +738,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 +776,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 Expand Up @@ -796,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
Expand Down
Loading
Loading