Skip to content

Commit

Permalink
feat(APT-1610): add portalEl prop to Combobox
Browse files Browse the repository at this point in the history
This allows space/position constrained implementations of combobox to render the dropdown menu in another component so the positioning of the menu is not dependent on the parent element.
  • Loading branch information
chasingmaxwell committed Apr 23, 2024
1 parent d2cb166 commit ebdacbc
Show file tree
Hide file tree
Showing 3 changed files with 60 additions and 19 deletions.
17 changes: 16 additions & 1 deletion src/components/Combobox/Combobox.spec.js
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import assert from 'assert';
import { render, fireEvent, cleanup } from '@testing-library/react';
import { render, fireEvent, cleanup, screen, within } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import React from 'react';
import sinon from 'sinon';
Expand Down Expand Up @@ -361,6 +361,21 @@ describe('<Combobox />', () => {
);
});

it('can render the dropdown menu inside a portal', async () => {
render(<Combobox options={OPTIONS} portalEl={document.body} />);
const toggle = await screen.findByTestId('react-gears-combobox-button');
await userEvent.click(toggle);
const dropdown = await screen.findByTestId('react-gears-combobox-dropdownmenu');

expect(dropdown.parentElement).toEqual(document.body);

expect(
within(await screen.findByTestId('react-gears-combobox-dropdown')).queryByTestId(
'react-gears-combobox-dropdownmenu'
)
).toBeNull();
});

describe('default filterOptions ', () => {
it('should filter by input (case insensitive)', () => {
const combobox = render(<Combobox options={OPTIONS} />);
Expand Down
20 changes: 20 additions & 0 deletions src/components/Combobox/Combobox.stories.js
Original file line number Diff line number Diff line change
Expand Up @@ -203,3 +203,23 @@ export const CustomOptions = () => {
/>
);
};

export const PortalElement = () => {
const [value, setValue] = useState();
return (
<>
<div>value: {value}</div>
<Combobox
direction={select('direction', ['', 'down', 'up'], '')}
onChange={setValue}
options={options}
value={value}
disabled={boolean('disabled', Combobox.defaultProps.disabled)}
noResultsLabel={text('noResultsLabel', Combobox.defaultProps.noResultsLabel)}
placeholder={text('placeholder', Combobox.defaultProps.placeholder)}
inputClassName={text('inputClassName', '')}
portalEl={document.body}
/>
</>
);
};
42 changes: 24 additions & 18 deletions src/components/Combobox/Combobox.tsx
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import equal from 'fast-deep-equal';
import React, { useEffect, useState, useRef, useMemo } from 'react';
import { findDOMNode } from 'react-dom';
import { createPortal, findDOMNode } from 'react-dom';
import { DropdownProps, InputProps } from 'reactstrap';
import Badge from '../Badge/Badge';
import Button from '../Button/Button';
Expand Down Expand Up @@ -37,6 +37,7 @@ interface ComboboxProps<T> extends Omit<InputProps, 'onChange'> {
renderOption?: (option: Option<T>) => React.ReactNode;
menuMaxHeight?: string;
multi?: boolean;
portalEl?: HTMLElement;
}

const defaultProps = {
Expand All @@ -60,6 +61,7 @@ function Combobox<T>({
menuMaxHeight,
multi,
noResultsLabel = defaultProps.noResultsLabel,
portalEl,
onChange = defaultProps.onChange,
onCreate,
isValidNewOption = defaultProps.isValidNewOption,
Expand Down Expand Up @@ -118,7 +120,7 @@ function Combobox<T>({

if (open && !multi && selected && inputElement?.current) {
window.setTimeout(() => {
inputElement!.current!.setSelectionRange(0, 0);
inputElement!.current?.setSelectionRange(0, 0);
}, 1);
}
}, [open, multi, selected]);
Expand Down Expand Up @@ -332,6 +334,25 @@ function Combobox<T>({
return <DropdownItem disabled>{noResultsLabel}</DropdownItem>;
};

const menu = (
<DropdownMenu
data-testid="react-gears-combobox-dropdownmenu"
className="p-0 w-100"
style={{ maxHeight: menuMaxHeight || '12rem', overflowY: 'auto' }}
{...dropdownProps}
ref={dropdownMenu}
role="listbox"
aria-activedescendant={
visibleOptions[focusedOptionIndex] &&
`option-${JSON.stringify(visibleOptions[focusedOptionIndex].value)}`
}
aria-multiselectable={multi}
>
{grouped ? renderGroupedOptions(optionsProp as OptionGroup<T>[]) : renderOptions(options)}
{noMatches && renderNoOptions()}
</DropdownMenu>
);

return (
<>
{multi && (selected as Option<T>[]).length > 0 && (
Expand Down Expand Up @@ -442,22 +463,7 @@ function Combobox<T>({
</Button>
</InputGroup>
</DropdownToggle>
<DropdownMenu
data-testid="react-gears-combobox-dropdownmenu"
className="p-0 w-100"
style={{ maxHeight: menuMaxHeight || '12rem', overflowY: 'auto' }}
{...dropdownProps}
ref={dropdownMenu}
role="listbox"
aria-activedescendant={
visibleOptions[focusedOptionIndex] &&
`option-${JSON.stringify(visibleOptions[focusedOptionIndex].value)}`
}
aria-multiselectable={multi}
>
{grouped ? renderGroupedOptions(optionsProp as OptionGroup<T>[]) : renderOptions(options)}
{noMatches && renderNoOptions()}
</DropdownMenu>
{portalEl ? <div>{createPortal(menu, portalEl)}</div> : menu}
</Dropdown>
</>
);
Expand Down

0 comments on commit ebdacbc

Please sign in to comment.