Skip to content

Commit

Permalink
fix(combobox): listbox overflow scrolling containers (#2949)
Browse files Browse the repository at this point in the history
  • Loading branch information
shleewhite authored Jan 18, 2023
1 parent 85ddd61 commit 2fe476a
Show file tree
Hide file tree
Showing 9 changed files with 294 additions and 109 deletions.
6 changes: 6 additions & 0 deletions .changeset/breezy-bottles-listen.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
---
'@twilio-paste/combobox': patch
'@twilio-paste/core': patch
---

[Combobox] Render the listbox in a portal to fix a bug where the contents of the listbox are cut off when it placed inside a scrolling container. Adds a new dependency on @radix-ui/react-use-rect.
Original file line number Diff line number Diff line change
Expand Up @@ -64,7 +64,9 @@ describe('Combobox data-paste-element attributes', () => {
expect(screen.getAllByRole('presentation')[0].getAttribute('data-paste-element')).toEqual('COMBOBOX_GROUPNAME');
expect(screen.getAllByRole('option')).toHaveLength(3);
expect(screen.getAllByRole('option')[0].getAttribute('data-paste-element')).toEqual('COMBOBOX_LIST_ITEM');
expect(container.querySelectorAll('[data-paste-element="COMBOBOX_LIST_ITEM_TEXT"]')).toHaveLength(3);
expect(screen.getByRole('listbox').querySelectorAll('[data-paste-element="COMBOBOX_LIST_ITEM_TEXT"]')).toHaveLength(
3
);
expect(container.querySelector('[data-paste-element="COMBOBOX_PREFIX"]')).toBeInTheDocument();
expect(container.querySelector('[data-paste-element="COMBOBOX_SUFFIX"]')).toBeInTheDocument();
});
Expand All @@ -80,7 +82,7 @@ describe('Combobox data-paste-element attributes', () => {
expect(screen.getAllByRole('presentation')[0].getAttribute('data-paste-element')).toEqual('FOO_GROUPNAME');
expect(screen.getAllByRole('option')).toHaveLength(3);
expect(screen.getAllByRole('option')[0].getAttribute('data-paste-element')).toEqual('FOO_LIST_ITEM');
expect(container.querySelectorAll('[data-paste-element="FOO_LIST_ITEM_TEXT"]')).toHaveLength(3);
expect(screen.getByRole('listbox').querySelectorAll('[data-paste-element="FOO_LIST_ITEM_TEXT"]')).toHaveLength(3);
expect(container.querySelector('[data-paste-element="FOO_PREFIX"]')).toBeInTheDocument();
expect(container.querySelector('[data-paste-element="FOO_SUFFIX"]')).toBeInTheDocument();
});
Expand All @@ -102,20 +104,20 @@ describe('Combobox customization', () => {
'background-color',
'rgba(242, 47, 70, 0.1)'
);
expect(container.querySelector('[data-paste-element="COMBOBOX_LISTBOX"]')).toHaveStyleRule(
'background-color',
'rgb(204, 228, 255)'
);
expect(container.querySelector('[data-paste-element="COMBOBOX_LIST"]')).toHaveStyleRule(

const listbox = screen.getByRole('listbox');

expect(listbox).toHaveStyleRule('background-color', 'rgb(204, 228, 255)');
expect(listbox.querySelector('[data-paste-element="COMBOBOX_LIST"]')).toHaveStyleRule(
'background-color',
'rgb(153, 205, 255)'
);
expect(container.querySelector('[data-paste-element="COMBOBOX_GROUPNAME"]')).toHaveStyleRule('cursor', 'help');
expect(container.querySelector('[data-paste-element="COMBOBOX_LIST_ITEM"]')).toHaveStyleRule(
expect(listbox.querySelector('[data-paste-element="COMBOBOX_GROUPNAME"]')).toHaveStyleRule('cursor', 'help');
expect(listbox.querySelector('[data-paste-element="COMBOBOX_LIST_ITEM"]')).toHaveStyleRule(
'background-color',
'rgb(0, 20, 137)'
);
expect(container.querySelector('[data-paste-element="COMBOBOX_LIST_ITEM_TEXT"]')).toHaveStyleRule(
expect(listbox.querySelector('[data-paste-element="COMBOBOX_LIST_ITEM_TEXT"]')).toHaveStyleRule(
'font-weight',
'700'
);
Expand Down Expand Up @@ -144,20 +146,23 @@ describe('Combobox customization', () => {
'background-color',
'rgba(242, 47, 70, 0.1)'
);
expect(container.querySelector('[data-paste-element="FOO_LISTBOX"]')).toHaveStyleRule(
'background-color',
'rgb(204, 228, 255)'
);
expect(container.querySelector('[data-paste-element="FOO_LIST"]')).toHaveStyleRule(
expect(screen.getByRole('listbox')).toHaveStyleRule('background-color', 'rgb(204, 228, 255)');
expect(screen.getByRole('listbox').querySelector('[data-paste-element="FOO_LIST"]')).toHaveStyleRule(
'background-color',
'rgb(153, 205, 255)'
);
expect(container.querySelector('[data-paste-element="FOO_GROUPNAME"]')).toHaveStyleRule('cursor', 'help');
expect(container.querySelector('[data-paste-element="FOO_LIST_ITEM"]')).toHaveStyleRule(
expect(screen.getByRole('listbox').querySelector('[data-paste-element="FOO_GROUPNAME"]')).toHaveStyleRule(
'cursor',
'help'
);
expect(screen.getByRole('listbox').querySelector('[data-paste-element="FOO_LIST_ITEM"]')).toHaveStyleRule(
'background-color',
'rgb(0, 20, 137)'
);
expect(container.querySelector('[data-paste-element="FOO_LIST_ITEM_TEXT"]')).toHaveStyleRule('font-weight', '700');
expect(screen.getByRole('listbox').querySelector('[data-paste-element="FOO_LIST_ITEM_TEXT"]')).toHaveStyleRule(
'font-weight',
'700'
);
expect(container.querySelector('[data-paste-element="FOO_PREFIX"]')).toHaveStyleRule(
'background-color',
'rgb(235, 86, 86)'
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -70,9 +70,11 @@ describe('MultiselectCombobox data-paste-element attributes', () => {
container.querySelectorAll(`[data-paste-element="MULTISELECT_COMBOBOX_PILL"]`)[0].getAttribute('role')
).toEqual('option');

expect(container.querySelectorAll(`[data-paste-element="MULTISELECT_COMBOBOX_LIST_ITEM"]`).length).toEqual(3);
const dropdownlistbox = screen.getByRole('listbox', {name: 'Choose a letter:'});

expect(dropdownlistbox.querySelectorAll(`[data-paste-element="MULTISELECT_COMBOBOX_LIST_ITEM"]`).length).toEqual(3);
expect(
container.querySelectorAll(`[data-paste-element="MULTISELECT_COMBOBOX_LIST_ITEM"]`)[0].getAttribute('role')
dropdownlistbox.querySelectorAll(`[data-paste-element="MULTISELECT_COMBOBOX_LIST_ITEM"]`)[0].getAttribute('role')
).toEqual('option');

const [pillgroupListbox, comboboxListbox] = screen.getAllByRole('listbox');
Expand All @@ -84,7 +86,9 @@ describe('MultiselectCombobox data-paste-element attributes', () => {
'MULTISELECT_COMBOBOX_GROUPNAME'
);

expect(container.querySelectorAll('[data-paste-element="MULTISELECT_COMBOBOX_LIST_ITEM_TEXT"]')).toHaveLength(3);
expect(dropdownlistbox.querySelectorAll('[data-paste-element="MULTISELECT_COMBOBOX_LIST_ITEM_TEXT"]')).toHaveLength(
3
);
expect(container.querySelector('[data-paste-element="MULTISELECT_COMBOBOX_PREFIX"]')).toBeInTheDocument();
expect(container.querySelector('[data-paste-element="MULTISELECT_COMBOBOX_SUFFIX"]')).toBeInTheDocument();
});
Expand All @@ -98,19 +102,20 @@ describe('MultiselectCombobox data-paste-element attributes', () => {
expect(container.querySelectorAll(`[data-paste-element="FOO_PILL"]`).length).toEqual(2);
expect(container.querySelectorAll(`[data-paste-element="FOO_PILL"]`)[0].getAttribute('role')).toEqual('option');

expect(container.querySelectorAll(`[data-paste-element="FOO_LIST_ITEM"]`).length).toEqual(3);
expect(container.querySelectorAll(`[data-paste-element="FOO_LIST_ITEM"]`)[0].getAttribute('role')).toEqual(
const [pillgroupListbox, comboboxListbox] = screen.getAllByRole('listbox');

expect(comboboxListbox.querySelectorAll(`[data-paste-element="FOO_LIST_ITEM"]`).length).toEqual(3);
expect(comboboxListbox.querySelectorAll(`[data-paste-element="FOO_LIST_ITEM"]`)[0].getAttribute('role')).toEqual(
'option'
);

const [pillgroupListbox, comboboxListbox] = screen.getAllByRole('listbox');
expect(pillgroupListbox.getAttribute('data-paste-element')).toEqual('FOO_PILL_GROUP');
expect(comboboxListbox.getAttribute('data-paste-element')).toEqual('FOO_LISTBOX');
expect(screen.getAllByRole('group')[0].getAttribute('data-paste-element')).toEqual('FOO_LIST');
expect(screen.getAllByRole('presentation')).toHaveLength(2);
expect(screen.getAllByRole('presentation')[0].getAttribute('data-paste-element')).toEqual('FOO_GROUPNAME');

expect(container.querySelectorAll('[data-paste-element="FOO_LIST_ITEM_TEXT"]')).toHaveLength(3);
expect(comboboxListbox.querySelectorAll('[data-paste-element="FOO_LIST_ITEM_TEXT"]')).toHaveLength(3);
expect(container.querySelector('[data-paste-element="FOO_PREFIX"]')).toBeInTheDocument();
expect(container.querySelector('[data-paste-element="FOO_SUFFIX"]')).toBeInTheDocument();
});
Expand All @@ -137,23 +142,23 @@ describe('MultiselectCombobox customization', () => {
'background-color',
'rgba(242, 47, 70, 0.1)'
);
expect(container.querySelector('[data-paste-element="MULTISELECT_COMBOBOX_LISTBOX"]')).toHaveStyleRule(
'background-color',
'rgb(204, 228, 255)'
);
expect(container.querySelector('[data-paste-element="MULTISELECT_COMBOBOX_LIST"]')).toHaveStyleRule(

const dropdownlistbox = screen.getByRole('listbox', {name: 'Choose a letter:'});

expect(dropdownlistbox).toHaveStyleRule('background-color', 'rgb(204, 228, 255)');
expect(dropdownlistbox.querySelector('[data-paste-element="MULTISELECT_COMBOBOX_LIST"]')).toHaveStyleRule(
'background-color',
'rgb(153, 205, 255)'
);
expect(container.querySelector('[data-paste-element="MULTISELECT_COMBOBOX_GROUPNAME"]')).toHaveStyleRule(
expect(dropdownlistbox.querySelector('[data-paste-element="MULTISELECT_COMBOBOX_GROUPNAME"]')).toHaveStyleRule(
'cursor',
'help'
);
expect(container.querySelector('[data-paste-element="MULTISELECT_COMBOBOX_LIST_ITEM"]')).toHaveStyleRule(
expect(dropdownlistbox.querySelector('[data-paste-element="MULTISELECT_COMBOBOX_LIST_ITEM"]')).toHaveStyleRule(
'background-color',
'rgb(0, 20, 137)'
);
expect(container.querySelector('[data-paste-element="MULTISELECT_COMBOBOX_LIST_ITEM_TEXT"]')).toHaveStyleRule(
expect(dropdownlistbox.querySelector('[data-paste-element="MULTISELECT_COMBOBOX_LIST_ITEM_TEXT"]')).toHaveStyleRule(
'font-weight',
'700'
);
Expand Down Expand Up @@ -182,20 +187,23 @@ describe('MultiselectCombobox customization', () => {
'background-color',
'rgba(242, 47, 70, 0.1)'
);
expect(container.querySelector('[data-paste-element="FOO_LISTBOX"]')).toHaveStyleRule(
'background-color',
'rgb(204, 228, 255)'
);
expect(container.querySelector('[data-paste-element="FOO_LIST"]')).toHaveStyleRule(

const dropdownlistbox = screen.getByRole('listbox', {name: 'Choose a letter:'});

expect(dropdownlistbox).toHaveStyleRule('background-color', 'rgb(204, 228, 255)');
expect(dropdownlistbox.querySelector('[data-paste-element="FOO_LIST"]')).toHaveStyleRule(
'background-color',
'rgb(153, 205, 255)'
);
expect(container.querySelector('[data-paste-element="FOO_GROUPNAME"]')).toHaveStyleRule('cursor', 'help');
expect(container.querySelector('[data-paste-element="FOO_LIST_ITEM"]')).toHaveStyleRule(
expect(dropdownlistbox.querySelector('[data-paste-element="FOO_GROUPNAME"]')).toHaveStyleRule('cursor', 'help');
expect(dropdownlistbox.querySelector('[data-paste-element="FOO_LIST_ITEM"]')).toHaveStyleRule(
'background-color',
'rgb(0, 20, 137)'
);
expect(container.querySelector('[data-paste-element="FOO_LIST_ITEM_TEXT"]')).toHaveStyleRule('font-weight', '700');
expect(dropdownlistbox.querySelector('[data-paste-element="FOO_LIST_ITEM_TEXT"]')).toHaveStyleRule(
'font-weight',
'700'
);
expect(container.querySelector('[data-paste-element="FOO_PREFIX"]')).toHaveStyleRule(
'background-color',
'rgb(235, 86, 86)'
Expand Down
1 change: 1 addition & 0 deletions packages/paste-core/components/combobox/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,7 @@
"tsc": "tsc"
},
"dependencies": {
"@radix-ui/react-use-rect": "1.0.0",
"lodash": "4.17.21",
"react-virtual": "2.10.4"
},
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,8 @@ import {ScreenReaderOnly} from '@twilio-paste/screen-reader-only';
import {FormPillGroup, FormPill, useFormPillState} from '@twilio-paste/form-pill-group';
import {useComboboxPrimitive, useMultiSelectPrimitive} from '@twilio-paste/combobox-primitive';
import {InputBox, InputChevronWrapper, getInputChevronIconColor} from '@twilio-paste/input-box';
import {Portal} from '@twilio-paste/reakit-library';
import {useRect} from '@radix-ui/react-use-rect';

import {GrowingInput} from './GrowingInput';
import {ComboboxListbox} from '../styles/ComboboxListbox';
Expand Down Expand Up @@ -57,6 +59,10 @@ export const MultiselectCombobox = React.forwardRef<HTMLInputElement, Multiselec
const parentRef = React.useRef(null);
const {width} = useWindowSize();

// gets the dimensions of the inputBox to position the listbox
const inputBoxRef = React.useRef<HTMLDivElement>(null);
const inputBoxDimensions = useRect(inputBoxRef.current);

const onSelectedItemsChange = React.useCallback(
(changes) => {
if (onSelectedItemsChangeProp) {
Expand Down Expand Up @@ -258,6 +264,7 @@ export const MultiselectCombobox = React.forwardRef<HTMLInputElement, Multiselec
insertBefore={insertBefore}
insertAfter={insertAfter}
variant={variant}
ref={inputBoxRef}
>
<Box
{...getToggleButtonProps({disabled})}
Expand Down Expand Up @@ -331,29 +338,39 @@ export const MultiselectCombobox = React.forwardRef<HTMLInputElement, Multiselec
</InputChevronWrapper>
</Box>
</InputBox>

<ComboboxListbox
{...getMenuProps({ref: parentRef})}
element={`${element}_LISTBOX`}
hidden={!isOpen}
aria-multiselectable="true"
>
<ComboboxItems
ref={scrollToIndexRef}
items={items}
element={element}
getItemProps={getItemProps}
highlightedIndex={highlightedIndex}
selectedItems={selectedItems}
disabledItems={disabledItems}
totalSize={rowVirtualizer.totalSize}
virtualItems={rowVirtualizer.virtualItems}
optionTemplate={optionTemplate}
groupItemsBy={groupItemsBy}
groupLabelTemplate={groupLabelTemplate}
emptyState={emptyState}
/>
</ComboboxListbox>
<Portal>
<Box
position="absolute"
top={inputBoxDimensions?.bottom}
left={inputBoxDimensions?.left}
right={inputBoxDimensions?.right}
width={inputBoxDimensions?.width}
zIndex="zIndex90"
>
<ComboboxListbox
{...getMenuProps({ref: parentRef})}
element={`${element}_LISTBOX`}
hidden={!isOpen}
aria-multiselectable="true"
>
<ComboboxItems
ref={scrollToIndexRef}
items={items}
element={element}
getItemProps={getItemProps}
highlightedIndex={highlightedIndex}
selectedItems={selectedItems}
disabledItems={disabledItems}
totalSize={rowVirtualizer.totalSize}
virtualItems={rowVirtualizer.virtualItems}
optionTemplate={optionTemplate}
groupItemsBy={groupItemsBy}
groupLabelTemplate={groupLabelTemplate}
emptyState={emptyState}
/>
</ComboboxListbox>
</Box>
</Portal>
{helpText && (
<HelpText id={helpTextId} variant={getHelpTextVariant(variant, hasError)} element={`${element}_HELP_TEXT`}>
{helpText}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,8 @@ import {Label} from '@twilio-paste/label';
import {HelpText} from '@twilio-paste/help-text';
import type {HelpTextVariants} from '@twilio-paste/help-text';
import type {InputVariants} from '@twilio-paste/input';
import {Portal} from '@twilio-paste/reakit-library';
import {useRect} from '@radix-ui/react-use-rect';

import {ComboboxInputSelect} from '../styles/ComboboxInputSelect';
import {ComboboxInputWrapper} from '../styles/ComboboxInputWrapper';
Expand Down Expand Up @@ -70,6 +72,10 @@ const Combobox = React.forwardRef<HTMLInputElement, ComboboxProps>(
const helpTextId = useUID();
const {width} = useWindowSize();

// gets the dimensions of the inputBox to position the listbox
const inputBoxRef = React.useRef<HTMLDivElement>(null);
const inputBoxDimensions = useRect(inputBoxRef.current);

const {
getComboboxProps,
getInputProps,
Expand Down Expand Up @@ -148,6 +154,7 @@ const Combobox = React.forwardRef<HTMLInputElement, ComboboxProps>(
insertBefore={insertBefore}
insertAfter={insertAfter}
variant={variant}
ref={inputBoxRef}
>
<ComboboxInputWrapper {...getComboboxProps({role: 'combobox'})}>
<ComboboxInputSelect
Expand All @@ -170,22 +177,34 @@ const Combobox = React.forwardRef<HTMLInputElement, ComboboxProps>(
)}
</ComboboxInputWrapper>
</InputBox>
<ComboboxListbox hidden={!isOpen} element={`${element}_LISTBOX`} {...getMenuProps({ref: parentRef})}>
<ComboboxItems
ref={scrollToIndexRef}
items={items}
element={element}
getItemProps={getItemProps}
highlightedIndex={highlightedIndex}
disabledItems={disabledItems}
optionTemplate={optionTemplate}
groupItemsBy={groupItemsBy}
groupLabelTemplate={groupLabelTemplate}
totalSize={rowVirtualizer.totalSize}
virtualItems={rowVirtualizer.virtualItems}
emptyState={emptyState}
/>
</ComboboxListbox>
<Portal>
<Box
position="absolute"
top={inputBoxDimensions?.bottom}
left={inputBoxDimensions?.left}
right={inputBoxDimensions?.right}
width={inputBoxDimensions?.width}
zIndex="zIndex90"
display={isOpen ? 'block' : 'none'}
>
<ComboboxListbox hidden={!isOpen} element={`${element}_LISTBOX`} {...getMenuProps({ref: parentRef})}>
<ComboboxItems
ref={scrollToIndexRef}
items={items}
element={element}
getItemProps={getItemProps}
highlightedIndex={highlightedIndex}
disabledItems={disabledItems}
optionTemplate={optionTemplate}
groupItemsBy={groupItemsBy}
groupLabelTemplate={groupLabelTemplate}
totalSize={rowVirtualizer.totalSize}
virtualItems={rowVirtualizer.virtualItems}
emptyState={emptyState}
/>
</ComboboxListbox>
</Box>
</Portal>
{helpText && (
<HelpText id={helpTextId} variant={getHelpTextVariant(variant, hasError)}>
{helpText}
Expand Down
Loading

0 comments on commit 2fe476a

Please sign in to comment.