Skip to content

Commit a414ef9

Browse files
author
Austin Green
authored
feat(dropdowns): introduce Multiselect component (#439)
1 parent c32503f commit a414ef9

File tree

16 files changed

+1121
-58
lines changed

16 files changed

+1121
-58
lines changed

.lintstagedrc

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,18 @@
1+
{
2+
"*.{js,ts,tsx}": [
3+
"stylelint",
4+
"eslint",
5+
"jest --config=utils/test/jest.config.js --findRelatedTests",
6+
"prettier --write",
7+
"git add"
8+
],
9+
"!(*CHANGELOG).md": [
10+
"markdownlint",
11+
"prettier --write",
12+
"git add"
13+
],
14+
"**/package.json": [
15+
"prettier-package-json --write",
16+
"git add"
17+
]
18+
}

lint-staged.config.js

Lines changed: 0 additions & 23 deletions
This file was deleted.
File renamed without changes.
File renamed without changes.
File renamed without changes.
Lines changed: 138 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,138 @@
1+
The `Multiselect` component renders a customizable "tag" for each selected item.
2+
3+
This can be any element, but the surrounding field is designed to work with
4+
our standard `Tag` component.
5+
6+
```jsx static
7+
<Dropdown>
8+
<Field>
9+
<Label>Example Multiselect</Label>
10+
<Multiselect
11+
renderItem={({ value, removeValue }) => (
12+
<Tag size="large">
13+
{value} <Close onClick={() => removeValue()} />
14+
</Tag>
15+
)}
16+
/>
17+
</Field>
18+
<Menu>{/** customizable items **/}</Menu>
19+
</Dropdown>
20+
```
21+
22+
```js
23+
const debounce = require('lodash.debounce');
24+
const { Tag, Close } = require('@zendeskgarden/react-tags/src');
25+
26+
const options = [
27+
'Aster',
28+
"Bachelor's button",
29+
'Celosia',
30+
'Dusty miller',
31+
'Everlasting winged',
32+
"Four o'clock",
33+
'Geranium',
34+
'Honesty',
35+
'Impatiens',
36+
'Johnny jump-up',
37+
'Kale',
38+
'Lobelia',
39+
'Marigold',
40+
'Nasturtium',
41+
'Ocimum (basil)',
42+
'Petunia',
43+
'Quaking grass',
44+
'Rose moss',
45+
'Salvia',
46+
'Torenia',
47+
'Ursinia',
48+
'Verbena',
49+
'Wax begonia',
50+
'Xeranthemum',
51+
'Yellow cosmos',
52+
'Zinnia'
53+
];
54+
55+
function ExampleAutocomplete() {
56+
const [selectedItems, setSelectedItems] = React.useState([
57+
options[0],
58+
options[1],
59+
options[2],
60+
options[3],
61+
options[4],
62+
options[5]
63+
]);
64+
const [inputValue, setInputValue] = React.useState('');
65+
const [isLoading, setIsLoading] = React.useState(false);
66+
const [matchingOptions, setMatchingOptions] = React.useState(options);
67+
68+
/**
69+
* Debounce filtering
70+
*/
71+
const filterMatchingOptionsRef = React.useRef(
72+
debounce(value => {
73+
const matchingOptions = options.filter(option => {
74+
return (
75+
option
76+
.trim()
77+
.toLowerCase()
78+
.indexOf(value.trim().toLowerCase()) !== -1
79+
);
80+
});
81+
82+
setMatchingOptions(matchingOptions);
83+
setIsLoading(false);
84+
}, 300)
85+
);
86+
87+
React.useEffect(() => {
88+
setIsLoading(true);
89+
filterMatchingOptionsRef.current(inputValue);
90+
}, [inputValue]);
91+
92+
const renderOptions = () => {
93+
if (isLoading) {
94+
return <Item disabled>Loading items...</Item>;
95+
}
96+
97+
if (matchingOptions.length === 0) {
98+
return <Item disabled>No matches found</Item>;
99+
}
100+
101+
return matchingOptions.map(option => (
102+
<Item key={option} value={option}>
103+
<span>{option}</span>
104+
</Item>
105+
));
106+
};
107+
108+
return (
109+
<Dropdown
110+
inputValue={inputValue}
111+
selectedItems={selectedItems}
112+
onSelect={items => setSelectedItems(items)}
113+
downshiftProps={{ defaultHighlightedIndex: 0 }}
114+
onStateChange={changes => {
115+
if (Object.prototype.hasOwnProperty.call(changes, 'inputValue')) {
116+
setInputValue(changes.inputValue);
117+
}
118+
}}
119+
>
120+
<Field>
121+
<Label>Multiselect with debounce</Label>
122+
<Hint>This example includes basic debounce logic</Hint>
123+
<Multiselect
124+
small
125+
renderItem={({ value, removeValue }) => (
126+
<Tag>
127+
{value} <Close onClick={() => removeValue()} />
128+
</Tag>
129+
)}
130+
/>
131+
</Field>
132+
<Menu small>{renderOptions()}</Menu>
133+
</Dropdown>
134+
);
135+
}
136+
137+
<ExampleAutocomplete />;
138+
```
File renamed without changes.

packages/dropdowns/package.json

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,7 @@
2020
},
2121
"types": "./dist/typings/index.d.ts",
2222
"dependencies": {
23+
"@zendeskgarden/container-selection": "^1.1.5",
2324
"@zendeskgarden/container-utilities": "^0.1.2",
2425
"classnames": "^2.2.5",
2526
"downshift": "^3.2.7",
@@ -51,6 +52,6 @@
5152
"access": "public"
5253
},
5354
"zendeskgarden:library": "GardenDropdowns",
54-
"zendeskgarden:max_size": "31 kB",
55+
"zendeskgarden:max_size": "35 kB",
5556
"zendeskgarden:src": "src/index.ts"
5657
}

packages/dropdowns/src/Dropdown/Dropdown.spec.tsx

Lines changed: 0 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -37,20 +37,6 @@ const ExampleDropdown = (props: IDropdownProps) => (
3737

3838
describe('Dropdown', () => {
3939
describe('Custom keyboard nav', () => {
40-
it('selects item on TAB key', () => {
41-
const onSelectSpy = jest.fn();
42-
const { container, getByTestId } = render(<ExampleDropdown onSelect={onSelectSpy} />);
43-
44-
const trigger = getByTestId('trigger');
45-
const input = container.querySelector('input');
46-
47-
fireEvent.click(trigger);
48-
fireEvent.keyDown(input!, { key: 'ArrowDown', keyCode: 40 });
49-
fireEvent.keyDown(input!, { key: 'Tab', keyCode: 9 });
50-
51-
expect(onSelectSpy.mock.calls[0][0]).toBe('previous-item');
52-
});
53-
5440
it('selects previous item on left arrow key in LTR mode', () => {
5541
const onSelectSpy = jest.fn();
5642
const { container, getByTestId } = render(<ExampleDropdown onSelect={onSelectSpy} />);

packages/dropdowns/src/Dropdown/Dropdown.tsx

Lines changed: 29 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,9 @@ import { Manager } from 'react-popper';
1212
import { withTheme, isRtl } from '@zendeskgarden/react-theming';
1313
import { KEY_CODES, composeEventHandlers } from '@zendeskgarden/container-utilities';
1414

15+
export const REMOVE_ITEM_STATE_TYPE = 'REMOVE_ITEM';
16+
export const TAB_SELECT_ITEM_STATE_TYPE = 'TAB_ITEM';
17+
1518
export interface IDropdownContext {
1619
itemIndexRef: React.MutableRefObject<number>;
1720
previousItemRef: React.MutableRefObject<any>;
@@ -20,6 +23,7 @@ export interface IDropdownContext {
2023
popperReferenceElementRef: React.MutableRefObject<any>;
2124
selectedItems?: any[];
2225
downshift: ControllerStateAndHelpers<any>;
26+
containsMultiselectRef: React.MutableRefObject<boolean>;
2327
}
2428

2529
export const DropdownContext = React.createContext<IDropdownContext | undefined>(undefined);
@@ -64,32 +68,30 @@ const Dropdown: React.FunctionComponent<IDropdownProps> = props => {
6468
const previousItemRef = useRef<number | undefined>(undefined);
6569
const previousIndexRef = useRef<number | undefined>(undefined);
6670
const nextItemsHashRef = useRef<object>({});
71+
const containsMultiselectRef = useRef(false);
6772

6873
// Used to inform Menu (Popper) that a full-width menu is needed
6974
const popperReferenceElementRef = useRef<any>(null);
7075

7176
/**
7277
* Add additional keyboard nav to the basics provided by Downshift
7378
**/
74-
const customGetInputProps = ({ onKeyDown, ...other }: any, downshift: any, rtl: any) => {
79+
const customGetInputProps = (
80+
{ onKeyDown, ...other }: any,
81+
downshift: ControllerStateAndHelpers<any>,
82+
rtl: any
83+
) => {
7584
return {
76-
onKeyDown: composeEventHandlers(onKeyDown, (e: any) => {
85+
onKeyDown: composeEventHandlers(onKeyDown, (e: KeyboardEvent) => {
7786
const PREVIOUS_KEY = rtl ? KEY_CODES.RIGHT : KEY_CODES.LEFT;
7887
const NEXT_KEY = rtl ? KEY_CODES.LEFT : KEY_CODES.RIGHT;
7988

8089
if (downshift.isOpen) {
81-
// Select highlighted item on TAB
82-
if (e.keyCode === KEY_CODES.TAB) {
83-
e.preventDefault();
84-
e.stopPropagation();
85-
86-
downshift.selectHighlightedItem();
87-
}
88-
8990
// Select previous item if available
9091
if (
9192
e.keyCode === PREVIOUS_KEY &&
9293
previousIndexRef.current !== null &&
94+
previousIndexRef.current !== undefined &&
9395
!downshift.inputValue
9496
) {
9597
e.preventDefault();
@@ -106,7 +108,7 @@ const Dropdown: React.FunctionComponent<IDropdownProps> = props => {
106108
e.preventDefault();
107109
e.stopPropagation();
108110

109-
downshift.selectItemAtIndex(downshift.highlightedIndex);
111+
downshift.selectItemAtIndex(downshift.highlightedIndex!);
110112
}
111113
}
112114
} else if (
@@ -184,15 +186,26 @@ const Dropdown: React.FunctionComponent<IDropdownProps> = props => {
184186
switch (changes.type) {
185187
case Downshift.stateChangeTypes.controlledPropUpdatedSelectedItem:
186188
case Downshift.stateChangeTypes.mouseUp:
187-
case Downshift.stateChangeTypes.keyDownEnter:
188-
case Downshift.stateChangeTypes.clickItem:
189-
case Downshift.stateChangeTypes.clickButton:
190189
case Downshift.stateChangeTypes.keyDownSpaceButton:
191190
case Downshift.stateChangeTypes.blurButton:
192191
return {
193192
...changes,
194193
inputValue: ''
195194
};
195+
case Downshift.stateChangeTypes.keyDownEnter:
196+
case Downshift.stateChangeTypes.clickItem:
197+
case TAB_SELECT_ITEM_STATE_TYPE as any: {
198+
const updatedState = { ...changes, inputValue: '' };
199+
200+
if (containsMultiselectRef.current) {
201+
updatedState.isOpen = true;
202+
updatedState.highlightedIndex = _state.highlightedIndex;
203+
}
204+
205+
return updatedState;
206+
}
207+
case REMOVE_ITEM_STATE_TYPE as any:
208+
return { ...changes, isOpen: false };
196209
default:
197210
return changes;
198211
}
@@ -208,7 +221,8 @@ const Dropdown: React.FunctionComponent<IDropdownProps> = props => {
208221
nextItemsHashRef,
209222
popperReferenceElementRef,
210223
selectedItems,
211-
downshift: transformDownshift(downshift)
224+
downshift: transformDownshift(downshift),
225+
containsMultiselectRef
212226
}}
213227
>
214228
{children}

0 commit comments

Comments
 (0)