Skip to content

Commit

Permalink
feat(multiselect combobox): expose state hook / inversion of control (#…
Browse files Browse the repository at this point in the history
…3470)

* feat(multiselect-combobox): add state hook functionality

* chore: minor improvement to the dropdown open orientation

* docs(multiselect-combobox): fix link to hook

* fix: changesets message
  • Loading branch information
TheSisb committed Sep 6, 2023
1 parent 6feadaf commit 4d5dc68
Show file tree
Hide file tree
Showing 12 changed files with 342 additions and 32 deletions.
6 changes: 5 additions & 1 deletion .changeset/late-forks-vanish.md
Expand Up @@ -3,4 +3,8 @@
'@twilio-paste/core': patch
---

[Codemods] Include new ProgressBar Exports
[Codemods] Include new mappings:

- `ProgressBar`
- `useMultiselectCombobox`
- `useMultiSelectPrimitive`
6 changes: 6 additions & 0 deletions .changeset/odd-weeks-pull.md
@@ -0,0 +1,6 @@
---
'@twilio-paste/combobox': minor
'@twilio-paste/core': minor
---

[MultiSelect Combobox] allow inversion of control with the internal state so that consumers can manage the selectedItems array themselves. This enables functionality like clearing the selectedItems through an external Button.
1 change: 1 addition & 0 deletions packages/paste-codemods/tools/.cache/mappings.json
Expand Up @@ -77,6 +77,7 @@
"ComboboxListboxOption": "@twilio-paste/core/combobox",
"MultiselectCombobox": "@twilio-paste/core/combobox",
"useCombobox": "@twilio-paste/core/combobox",
"useMultiselectCombobox": "@twilio-paste/core/combobox",
"DataGrid": "@twilio-paste/core/data-grid",
"DataGridBody": "@twilio-paste/core/data-grid",
"DataGridCell": "@twilio-paste/core/data-grid",
Expand Down
@@ -1,12 +1,13 @@
import * as React from 'react';
import {render, screen, fireEvent} from '@testing-library/react';
import {render, act, screen, fireEvent} from '@testing-library/react';
import type {RenderOptions} from '@testing-library/react';
import {Theme} from '@twilio-paste/theme';
import {Form} from '@twilio-paste/form';
import {Button} from '@twilio-paste/button';
import filter from 'lodash/filter';
import uniq from 'lodash/uniq';

import {MultiselectCombobox} from '../src';
import {MultiselectCombobox, useMultiselectCombobox} from '../src';
import type {MultiselectComboboxProps} from '../src';

const items = [
Expand Down Expand Up @@ -38,6 +39,7 @@ const MultiselectComboboxMock: React.FC<Partial<MultiselectComboboxProps>> = (pr
const filteredItems = React.useMemo(() => getFilteredItems(inputValue), [inputValue]);
return (
<MultiselectCombobox
selectedItemsLabelText="Selected books:"
labelText="Choose a book:"
helpText="Reading books can be good for your mental health."
items={filteredItems}
Expand All @@ -46,7 +48,7 @@ const MultiselectComboboxMock: React.FC<Partial<MultiselectComboboxProps>> = (pr
onInputValueChange={({inputValue: newInputValue = ''}) => {
setInputValue(newInputValue);
}}
onSelectedItemsChange={(selectedItems: string[]) => {
onSelectedItemsChange={() => {
// eslint-disable-next-line no-console
// console.log(selectedItems);
}}
Expand Down Expand Up @@ -108,6 +110,40 @@ const GroupedMultiselectComboboxMock: React.FC<Partial<MultiselectComboboxProps>
);
};

const StateHookMock: React.FC<Partial<MultiselectComboboxProps>> = (props) => {
const [inputValue, setInputValue] = React.useState('');
const filteredItems = React.useMemo(() => getFilteredGroupedItems(inputValue), [inputValue]);

const state = useMultiselectCombobox<any>({
initialSelectedItems: filteredItems.slice(0, 2),
onSelectedItemsChange: props.onSelectedItemsChange,
});

return (
<>
<Button variant="primary" onClick={() => state.setSelectedItems([])}>
Clear
</Button>
<MultiselectCombobox
state={state}
groupItemsBy="group"
items={filteredItems}
inputValue={inputValue}
itemToString={(item: GroupedItem) => (item ? item.label : '')}
onInputValueChange={({inputValue: newInputValue = ''}) => {
setInputValue(newInputValue);
}}
onSelectedItemsChange={props.onSelectedItemsChange}
labelText="Choose a Paste Component"
selectedItemsLabelText="Selected Paste components"
helpText="Paste components are the building blocks of your product UI."
initialIsOpen
optionTemplate={(item: GroupedItem) => <div>{item.label}</div>}
/>
</>
);
};

describe('MultiselectCombobox', () => {
beforeEach(() => {
jest.clearAllMocks();
Expand Down Expand Up @@ -280,4 +316,24 @@ describe('MultiselectCombobox', () => {
expect(mockSelectedItemsChangeFn.mock.results[2].value).toEqual([{group: 'Components', label: 'Button'}]);
});
});

describe('Inversion of control', () => {
it('allows clearing selected items from an external button click', () => {
const mockSelectedItemsChangeFn = jest.fn((selectedItems) => selectedItems);
render(<StateHookMock onSelectedItemsChange={mockSelectedItemsChangeFn} />, {
wrapper: ThemeWrapper,
});

const pillGroup = screen.getAllByRole('listbox')[0];
expect(pillGroup?.childNodes.length).toBe(2);

act(() => {
screen.getByRole('button', {name: 'Clear'}).click();
});

expect(pillGroup?.childNodes.length).toBe(0);
expect(mockSelectedItemsChangeFn).toHaveBeenCalledTimes(1);
expect(mockSelectedItemsChangeFn.mock.results[0].value).toEqual({activeIndex: -1, selectedItems: [], type: 10});
});
});
});
Expand Up @@ -16,6 +16,7 @@ export const ListBoxPositioner: React.FC<ListBoxPositionerProps> = ({inputBoxRef
const dropdownBoxHeight = dropdownBoxDimensions?.height;

const styles = React.useMemo((): BoxStyleProps => {
// If it's closed, return an empty object
if (dropdownBoxHeight == null || inputBoxDimensions == null || dropdownBoxHeight === 0) {
return {};
}
Expand All @@ -25,7 +26,10 @@ export const ListBoxPositioner: React.FC<ListBoxPositionerProps> = ({inputBoxRef
* 1- Dropdown height is bigger than window height
* - Then show at the top of the viewport
* 2- Dropdown height + inputbox bottom is bigger than viewport height
* - Show upwards
* 2.1- inputbox top - Dropdown height is < 0 (offscreen topwise)
* - Show downwards
* 2.2- else
* - Show upwards
* 3- Dropdown height + inputbox bottom is smaller than viewport height
* - Show downwards
*/
Expand All @@ -39,7 +43,10 @@ export const ListBoxPositioner: React.FC<ListBoxPositionerProps> = ({inputBoxRef
width: inputBoxDimensions?.width,
};
}
if (dropdownBoxHeight + inputBoxDimensions?.bottom >= windowHeight) {
if (
dropdownBoxHeight + inputBoxDimensions?.bottom >= windowHeight &&
inputBoxDimensions?.top - dropdownBoxHeight > 0
) {
return {
position: 'fixed',
// 6px to account for border things, should be fine on all themes
Expand Down
5 changes: 4 additions & 1 deletion packages/paste-core/components/combobox/src/index.tsx
@@ -1,4 +1,7 @@
export {useComboboxPrimitive as useCombobox} from '@twilio-paste/combobox-primitive';
export {
useComboboxPrimitive as useCombobox,
useMultiSelectPrimitive as useMultiselectCombobox,
} from '@twilio-paste/combobox-primitive';
export type {
UseComboboxPrimitiveState as UseComboboxState,
UseComboboxPrimitiveStateChange as UseComboboxStateChange,
Expand Down
Expand Up @@ -9,15 +9,16 @@ import {Label} from '@twilio-paste/label';
import {HelpText} from '@twilio-paste/help-text';
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 {useComboboxPrimitive} from '@twilio-paste/combobox-primitive';
import {InputBox, InputChevronWrapper, getInputChevronIconColor} from '@twilio-paste/input-box';
import {Portal} from '@twilio-paste/reakit-library';

import {ListBoxPositioner} from '../ListboxPositioner';
import {GrowingInput} from './GrowingInput';
import {ComboboxListbox} from '../styles/ComboboxListbox';
import {extractPropsFromState} from './extractPropsFromState';
import {ListBoxPositioner} from '../ListboxPositioner';
import {ComboboxItems} from '../ComboboxItems';
import type {Item, MultiselectComboboxProps} from '../types';
import {ComboboxListbox} from '../styles/ComboboxListbox';
import type {MultiselectComboboxProps} from '../types';
import {getHelpTextVariant} from '../helpers';

export const MultiselectCombobox = React.forwardRef<HTMLInputElement, MultiselectComboboxProps>(
Expand All @@ -27,6 +28,7 @@ export const MultiselectCombobox = React.forwardRef<HTMLInputElement, Multiselec
disabled,
hasError,
helpText,
state,
initialSelectedItems = [],
disabledItems,
inputValue,
Expand Down Expand Up @@ -80,7 +82,8 @@ export const MultiselectCombobox = React.forwardRef<HTMLInputElement, Multiselec
// Action prop that adds the item to the selection. Best used in useSelect and useCombobox prop onStateChange or onSelectedItemChange
addSelectedItem,
selectedItems,
} = useMultiSelectPrimitive<Item>({
} = extractPropsFromState({
state,
initialSelectedItems,
onSelectedItemsChange,
});
Expand Down Expand Up @@ -252,7 +255,6 @@ export const MultiselectCombobox = React.forwardRef<HTMLInputElement, Multiselec
* but if this is a required field and there are no selected items, we don't want
* to submit the form
*/

const {onKeyDown} = props;
const handleKeyDown = React.useCallback(
(event: React.KeyboardEvent<HTMLInputElement>) => {
Expand All @@ -265,7 +267,7 @@ export const MultiselectCombobox = React.forwardRef<HTMLInputElement, Multiselec
[required, selectedItems, onKeyDown]
);

// FIX: a11y issue where `aria-expanded` isn't being set until the dropdown opens the very first time
// Fix the a11y issue where `aria-expanded` isn't being set until the dropdown opens the very first time
const comboboxProps = getComboboxProps({
disabled,
role: 'combobox',
Expand Down
@@ -0,0 +1,28 @@
import {useMultiSelectPrimitive} from '@twilio-paste/combobox-primitive';
import type {UseMultiSelectPrimitiveReturnValue} from '@twilio-paste/combobox-primitive';
import isEmpty from 'lodash/isEmpty';

import type {Item, MultiselectComboboxProps} from '../types';

interface DefaultStateProps {
initialSelectedItems: MultiselectComboboxProps['initialSelectedItems'];
onSelectedItemsChange: MultiselectComboboxProps['onSelectedItemsChange'];
state: MultiselectComboboxProps['state'];
}

export const extractPropsFromState = ({
state,
initialSelectedItems,
onSelectedItemsChange,
}: DefaultStateProps): UseMultiSelectPrimitiveReturnValue<Item> => {
// If they're providing their own state management, we don't need to do anything
if (state != null && !isEmpty(state)) {
return state;
}

// Otherwise, we'll use our own state management
return useMultiSelectPrimitive<Item>({
initialSelectedItems,
onSelectedItemsChange,
});
};
5 changes: 4 additions & 1 deletion packages/paste-core/components/combobox/src/types.ts
Expand Up @@ -4,6 +4,8 @@ import type {
UseComboboxPrimitiveProps,
UseComboboxPrimitiveState,
UseComboboxPrimitiveReturnValue,
UseMultiSelectPrimitiveReturnValue,
UseMultiSelectPrimitiveStateChange,
} from '@twilio-paste/combobox-primitive';
import type {InputVariants, InputProps} from '@twilio-paste/input';
import type {VirtualItem} from 'react-virtual';
Expand Down Expand Up @@ -97,10 +99,11 @@ export interface MultiselectComboboxProps
| 'hideVisibleLabel'
> {
initialSelectedItems?: any[];
onSelectedItemsChange?: (newSelectedItems: any[]) => void;
onSelectedItemsChange?: (newSelectedItems: UseMultiSelectPrimitiveStateChange<Item>) => void;
selectedItemsLabelText: string;
i18nKeyboardControls?: string;
maxHeight?: BoxStyleProps['maxHeight'];
state?: UseMultiSelectPrimitiveReturnValue<Item>;
}

export interface ComboboxItemsProps
Expand Down

0 comments on commit 4d5dc68

Please sign in to comment.