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

feat: Added floating-ui hook usage for ComboBox #16585

Merged
merged 11 commits into from
Jun 11, 2024
Original file line number Diff line number Diff line change
Expand Up @@ -1112,6 +1112,9 @@ Map {
"type": "string",
},
"ariaLabel": [Function],
"autoAlign": Object {
"type": "bool",
},
"className": Object {
"type": "string",
},
Expand Down
33 changes: 20 additions & 13 deletions packages/react/src/components/ComboBox/ComboBox-test.js
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ import {
assertMenuClosed,
generateItems,
generateGenericItem,
waitForPosition,
} from '../ListBox/test-helpers';
import ComboBox from '../ComboBox';
import { act } from 'react';
Expand Down Expand Up @@ -144,23 +145,24 @@ describe('ComboBox', () => {
expect(findInputNode()).toHaveDisplayValue('Apple');
});

it('should respect slug prop', () => {
it('should respect slug prop', async () => {
const { container } = render(<ComboBox {...mockProps} slug={<Slug />} />);

await waitForPosition();
expect(container.firstChild).toHaveClass(
`${prefix}--list-box__wrapper--slug`
);
});

describe('should display initially selected item found in `initialSelectedItem`', () => {
it('using an object type for the `initialSelectedItem` prop', () => {
it('using an object type for the `initialSelectedItem` prop', async () => {
render(
<ComboBox {...mockProps} initialSelectedItem={mockProps.items[0]} />
);
await waitForPosition();
expect(findInputNode()).toHaveDisplayValue(mockProps.items[0].label);
});

it('using a string type for the `initialSelectedItem` prop', () => {
it('using a string type for the `initialSelectedItem` prop', async () => {
// Replace the 'items' property in mockProps with a list of strings
mockProps = {
...mockProps,
Expand All @@ -170,35 +172,35 @@ describe('ComboBox', () => {
render(
<ComboBox {...mockProps} initialSelectedItem={mockProps.items[1]} />
);

await waitForPosition();
expect(findInputNode()).toHaveDisplayValue(mockProps.items[1]);
});
});

describe('should display selected item found in `selectedItem`', () => {
it('using an object type for the `selectedItem` prop', () => {
it('using an object type for the `selectedItem` prop', async () => {
render(<ComboBox {...mockProps} selectedItem={mockProps.items[0]} />);

await waitForPosition();
expect(findInputNode()).toHaveDisplayValue(mockProps.items[0].label);
});

it('using a string type for the `selectedItem` prop', () => {
it('using a string type for the `selectedItem` prop', async () => {
// Replace the 'items' property in mockProps with a list of strings
mockProps = {
...mockProps,
items: ['1', '2', '3'],
};

render(<ComboBox {...mockProps} selectedItem={mockProps.items[1]} />);

await waitForPosition();
expect(findInputNode()).toHaveDisplayValue(mockProps.items[1]);
});
});

describe('when disabled', () => {
it('should not let the user edit the input node', async () => {
render(<ComboBox {...mockProps} disabled={true} />);

await waitForPosition();
expect(findInputNode()).toHaveAttribute('disabled');

expect(findInputNode()).toHaveDisplayValue('');
Expand All @@ -210,6 +212,7 @@ describe('ComboBox', () => {

it('should not let the user expand the menu', async () => {
render(<ComboBox {...mockProps} disabled={true} />);
await waitForPosition();
await openMenu();
expect(findListBoxNode()).not.toHaveClass(
`${prefix}--list-box--expanded`
Expand All @@ -220,7 +223,7 @@ describe('ComboBox', () => {
describe('when readonly', () => {
it('should not let the user edit the input node', async () => {
render(<ComboBox {...mockProps} readOnly={true} />);

await waitForPosition();
expect(findInputNode()).toHaveAttribute('readonly');

expect(findInputNode()).toHaveDisplayValue('');
Expand All @@ -232,6 +235,7 @@ describe('ComboBox', () => {

it('should not let the user expand the menu', async () => {
render(<ComboBox {...mockProps} disabled={true} />);
await waitForPosition();
await openMenu();
expect(findListBoxNode()).not.toHaveClass(
`${prefix}--list-box--expanded`
Expand All @@ -240,9 +244,9 @@ describe('ComboBox', () => {
});

describe('downshift quirks', () => {
it('should set `inputValue` to an empty string if a false-y value is given', () => {
it('should set `inputValue` to an empty string if a false-y value is given', async () => {
render(<ComboBox {...mockProps} />);

await waitForPosition();
expect(findInputNode()).toHaveDisplayValue('');
});

Expand All @@ -257,6 +261,7 @@ describe('ComboBox', () => {
</div>
</>
);
await waitForPosition();
const firstCombobox = screen.getByTestId('combobox-1');
const secondCombobox = screen.getByTestId('combobox-2');

Expand Down Expand Up @@ -291,6 +296,7 @@ describe('ComboBox', () => {
});
it('should open menu without moving focus on pressing Alt+ DownArrow', async () => {
render(<ComboBox {...mockProps} />);
await waitForPosition();
act(() => {
screen.getByRole('combobox').focus();
});
Expand All @@ -300,6 +306,7 @@ describe('ComboBox', () => {

it('should close menu and return focus to combobox on pressing Alt+ UpArrow', async () => {
render(<ComboBox {...mockProps} />);
await waitForPosition();
await openMenu();
await userEvent.keyboard('{Alt>}{ArrowUp}');
assertMenuClosed(mockProps);
Expand Down
15 changes: 15 additions & 0 deletions packages/react/src/components/ComboBox/ComboBox.stories.js
Original file line number Diff line number Diff line change
Expand Up @@ -92,6 +92,21 @@ export const AllowCustomValue = () => {
</div>
);
};
export const ExperimentalAutoAlign = () => (
<div style={{ width: 400 }}>
<div style={{ height: 300 }}></div>
<ComboBox
onChange={() => {}}
id="carbon-combobox"
items={items}
itemToString={(item) => (item ? item.text : '')}
titleText="ComboBox title"
helperText="Combobox helper text"
autoAlign={true}
/>
<div style={{ height: 800 }}></div>
</div>
);

export const _WithLayer = () => (
<WithLayer>
Expand Down
43 changes: 42 additions & 1 deletion packages/react/src/components/ComboBox/ComboBox.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,7 @@ import mergeRefs from '../../tools/mergeRefs';
import deprecate from '../../prop-types/deprecate';
import { usePrefix } from '../../internal/usePrefix';
import { FormContext } from '../FluidForm';
import { useFloating, flip, autoUpdate } from '@floating-ui/react';

const {
InputBlur,
Expand Down Expand Up @@ -150,6 +151,13 @@ export interface ComboBoxProps<ItemType>
*/
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;

/**
* An optional className to add to the container node
*/
Expand Down Expand Up @@ -313,6 +321,7 @@ const ComboBox = forwardRef(
const {
['aria-label']: ariaLabel = 'Choose an item',
ariaLabel: deprecatedAriaLabel,
autoAlign = false,
className: containerClassName,
direction = 'bottom',
disabled = false,
Expand Down Expand Up @@ -342,6 +351,30 @@ const ComboBox = forwardRef(
slug,
...rest
} = props;
const { refs, floatingStyles } = useFloating(
autoAlign
? {
placement: direction,
strategy: 'fixed',
Gururajj77 marked this conversation as resolved.
Show resolved Hide resolved
middleware: [flip()],
whileElementsMounted: autoUpdate,
}
: {}
);
const parentWidth = (refs?.reference?.current as HTMLElement)?.clientWidth;

useEffect(() => {
if (autoAlign) {
Object.keys(floatingStyles).forEach((style) => {
if (refs.floating.current) {
refs.floating.current.style[style] = floatingStyles[style];
}
});
if (parentWidth && refs.floating.current) {
refs.floating.current.style.width = parentWidth + 'px';
}
}
}, [autoAlign, floatingStyles, refs.floating, parentWidth]);
const prefix = usePrefix();
const { isFluid } = useContext(FormContext);
const textInput = useRef<HTMLInputElement>(null);
Expand Down Expand Up @@ -630,6 +663,7 @@ const ComboBox = forwardRef(
light={light}
size={size}
warn={warn}
ref={refs.setReference}
warnText={warnText}
warnTextId={warnTextId}>
<div className={`${prefix}--list-box__field`}>
Expand Down Expand Up @@ -739,7 +773,8 @@ const ComboBox = forwardRef(
<ListBox.Menu
{...getMenuProps({
'aria-label': deprecatedAriaLabel || ariaLabel,
})}>
})}
ref={mergeRefs(getMenuProps().ref, refs.setFloating)}>
{isOpen
? filterItems(items, itemToString, inputValue).map(
(item, index) => {
Expand Down Expand Up @@ -821,6 +856,12 @@ ComboBox.propTypes = {
PropTypes.string,
'This prop syntax has been deprecated. Please use the new `aria-label`.'
),
/**
* **Experimental**: Will attempt to automatically align the floating
* element to avoid collisions with the viewport and being clipped by
* ancestor elements.
*/
autoAlign: PropTypes.bool,

/**
* An optional className to add to the container node
Expand Down
Loading
Loading