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
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -3418,6 +3418,9 @@ Map {
"propTypes": Object {
"aria-label": [Function],
"ariaLabel": [Function],
"autoAlign": Object {
"type": "bool",
},
"clearSelectionDescription": Object {
"type": "string",
},
Expand Down Expand Up @@ -5091,6 +5094,9 @@ Map {
"propTypes": Object {
"aria-label": [Function],
"ariaLabel": [Function],
"autoAlign": Object {
"type": "bool",
},
"clearSelectionDescription": Object {
"type": "string",
},
Expand Down Expand Up @@ -5339,6 +5345,9 @@ Map {
"render": [Function],
},
"propTypes": Object {
"autoAlign": Object {
"type": "bool",
},
"className": Object {
"type": "string",
},
Expand Down
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 @@ -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,
Expand Down Expand Up @@ -83,6 +90,13 @@ export interface FilterableMultiSelectProps<ItemType>
/** @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 @@ -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',
Expand Down Expand Up @@ -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<HTMLInputElement>(null);
const filterableMultiSelectInstanceId = useId();

Expand Down Expand Up @@ -712,7 +764,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 @@ -765,7 +817,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>
);
};
Loading
Loading