From 363c7d621b1faeb7067cd1ead4039821f0e1c888 Mon Sep 17 00:00:00 2001 From: Peter Sieg Date: Mon, 22 Apr 2024 18:20:27 -0400 Subject: [PATCH] feat(APT-1610): add portalEl prop to Combobox 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. --- src/components/Combobox/Combobox.spec.js | 17 ++++++++- src/components/Combobox/Combobox.stories.js | 20 ++++++++++ src/components/Combobox/Combobox.tsx | 42 ++++++++++++--------- 3 files changed, 60 insertions(+), 19 deletions(-) diff --git a/src/components/Combobox/Combobox.spec.js b/src/components/Combobox/Combobox.spec.js index 7e6ce4c53..9741ce7a7 100644 --- a/src/components/Combobox/Combobox.spec.js +++ b/src/components/Combobox/Combobox.spec.js @@ -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'; @@ -361,6 +361,21 @@ describe('', () => { ); }); + it('can render the dropdown menu inside a portal', async () => { + render(); + 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(); diff --git a/src/components/Combobox/Combobox.stories.js b/src/components/Combobox/Combobox.stories.js index 7a3d17c60..c51e96b5d 100644 --- a/src/components/Combobox/Combobox.stories.js +++ b/src/components/Combobox/Combobox.stories.js @@ -203,3 +203,23 @@ export const CustomOptions = () => { /> ); }; + +export const PortalElement = () => { + const [value, setValue] = useState(); + return ( + <> +
value: {value}
+ + + ); +}; diff --git a/src/components/Combobox/Combobox.tsx b/src/components/Combobox/Combobox.tsx index eceb05466..4a88e5c31 100644 --- a/src/components/Combobox/Combobox.tsx +++ b/src/components/Combobox/Combobox.tsx @@ -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'; @@ -37,6 +37,7 @@ interface ComboboxProps extends Omit { renderOption?: (option: Option) => React.ReactNode; menuMaxHeight?: string; multi?: boolean; + portalEl?: HTMLElement; } const defaultProps = { @@ -60,6 +61,7 @@ function Combobox({ menuMaxHeight, multi, noResultsLabel = defaultProps.noResultsLabel, + portalEl, onChange = defaultProps.onChange, onCreate, isValidNewOption = defaultProps.isValidNewOption, @@ -118,7 +120,7 @@ function Combobox({ if (open && !multi && selected && inputElement?.current) { window.setTimeout(() => { - inputElement!.current!.setSelectionRange(0, 0); + inputElement!.current?.setSelectionRange(0, 0); }, 1); } }, [open, multi, selected]); @@ -332,6 +334,25 @@ function Combobox({ return {noResultsLabel}; }; + const menu = ( + + {grouped ? renderGroupedOptions(optionsProp as OptionGroup[]) : renderOptions(options)} + {noMatches && renderNoOptions()} + + ); + return ( <> {multi && (selected as Option[]).length > 0 && ( @@ -442,22 +463,7 @@ function Combobox({ - - {grouped ? renderGroupedOptions(optionsProp as OptionGroup[]) : renderOptions(options)} - {noMatches && renderNoOptions()} - + {portalEl ?
{createPortal(menu, portalEl)}
: menu} );