Skip to content

Commit 0c029ef

Browse files
Merge pull request #785 from glints-dev/feature/combo-box
feat: combo box multi select
2 parents f425b45 + ed68102 commit 0c029ef

24 files changed

Lines changed: 656 additions & 0 deletions
Lines changed: 239 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,239 @@
1+
import { Meta, Story } from '@storybook/react';
2+
import React, { useCallback, useEffect, useState } from 'react';
3+
import { withGlintsPortalContainer } from '../../helpers/storybook/Decorators';
4+
5+
import { Blue } from '../utilities/colors';
6+
import { space8 } from '../utilities/spacing';
7+
8+
import { Combobox, ComboboxProps } from './Combobox';
9+
import { StyledTag } from './comboboxStoryHelper/TagStyle';
10+
11+
(Combobox as React.FunctionComponent<ComboboxProps>).displayName = 'Combobox';
12+
13+
export default {
14+
title: '@next/Combobox',
15+
component: Combobox,
16+
decorators: [withGlintsPortalContainer],
17+
} as Meta;
18+
19+
const countries = [
20+
{ label: 'Indonesia', value: 'Indonesia' },
21+
{ label: 'Malaysia', value: 'Malaysia' },
22+
{ label: 'Singapore', value: 'Singapore' },
23+
{ label: 'Taiwan', value: 'Taiwan' },
24+
{ label: 'Vietnam', value: 'Vietnam' },
25+
];
26+
27+
const MultiSelectTemplate: Story<ComboboxProps> = args => {
28+
const [inputValue, setInputValue] = useState('');
29+
const [selectedOptions, setSelectedOptions] = useState([]);
30+
const [isSearchEmpty, setIsSearchEmpty] = useState(false);
31+
32+
const [options, setOptions] = useState(countries);
33+
34+
const handleInputChange = (value: string) => {
35+
setInputValue(value);
36+
37+
if (value === '') {
38+
setOptions(countries);
39+
return;
40+
}
41+
42+
const filterRegex = new RegExp(value, 'i');
43+
const filterOptions = options.filter(country =>
44+
country.label.match(filterRegex)
45+
);
46+
setOptions(filterOptions);
47+
};
48+
49+
const handleSelect = (selected: string) => {
50+
if (selectedOptions.includes(selected)) {
51+
setSelectedOptions(selectedOptions.filter(option => option !== selected));
52+
} else {
53+
setSelectedOptions([...selectedOptions, selected]);
54+
}
55+
};
56+
57+
const optionsMarkup =
58+
options?.length > 0
59+
? options.map(option => {
60+
const { label, value } = option;
61+
62+
return (
63+
<Combobox.Option
64+
key={value}
65+
label={label}
66+
value={value}
67+
selected={selectedOptions.includes(value)}
68+
/>
69+
);
70+
})
71+
: null;
72+
73+
const removeTag = useCallback(
74+
tag => () => {
75+
const options = [...selectedOptions];
76+
options.splice(options.indexOf(tag), 1);
77+
setSelectedOptions(options);
78+
},
79+
[selectedOptions]
80+
);
81+
82+
const tagsMarkup = selectedOptions.map(option => (
83+
<StyledTag
84+
key={`option-${option}`}
85+
onRemove={removeTag(option)}
86+
textColor={Blue.S99}
87+
>
88+
{option}
89+
</StyledTag>
90+
));
91+
92+
useEffect(() => {
93+
if (options.length === 0) {
94+
setIsSearchEmpty(true);
95+
}
96+
97+
if (options.length > 0 && isSearchEmpty === true) {
98+
setIsSearchEmpty(false);
99+
}
100+
}, [isSearchEmpty, options]);
101+
102+
return (
103+
<div style={{ maxWidth: '500px' }}>
104+
<Combobox.Label>Label</Combobox.Label>
105+
<Combobox
106+
{...args}
107+
activator={
108+
<Combobox.TextInput
109+
value={inputValue}
110+
onChange={(value: string) => handleInputChange(value)}
111+
placeholder="Search"
112+
/>
113+
}
114+
>
115+
<Combobox.OptionList onSelect={handleSelect} isEmpty={isSearchEmpty}>
116+
{optionsMarkup}
117+
</Combobox.OptionList>
118+
</Combobox>
119+
<div style={{ paddingTop: space8 }}>{tagsMarkup}</div>
120+
</div>
121+
);
122+
};
123+
124+
export const MultiSelect = MultiSelectTemplate.bind({});
125+
126+
MultiSelect.args = {
127+
allowMultiple: true,
128+
};
129+
130+
MultiSelect.parameters = {
131+
docs: {
132+
source: {
133+
code: `
134+
const countries = [
135+
{ label: 'Indonesia', value: 'Indonesia' },
136+
{ label: 'Malaysia', value: 'Malaysia' },
137+
{ label: 'Singapore', value: 'Singapore' },
138+
{ label: 'Taiwan', value: 'Taiwan' },
139+
{ label: 'Vietnam', value: 'Vietnam' },
140+
];
141+
142+
const [inputValue, setInputValue] = useState('');
143+
const [selectedOptions, setSelectedOptions] = useState([]);
144+
const [isSearchEmpty, setIsSearchEmpty] = useState(false);
145+
146+
const [options, setOptions] = useState(countries);
147+
148+
const handleInputChange = (value: string) => {
149+
setInputValue(value);
150+
151+
if (value === '') {
152+
setOptions(countries);
153+
return;
154+
}
155+
156+
const filterRegex = new RegExp(value, 'i');
157+
const filterOptions = options.filter(country =>
158+
country.label.match(filterRegex)
159+
);
160+
setOptions(filterOptions);
161+
};
162+
163+
const handleSelect = (selected: string) => {
164+
if (selectedOptions.includes(selected)) {
165+
setSelectedOptions(selectedOptions.filter(option => option !== selected));
166+
} else {
167+
setSelectedOptions([...selectedOptions, selected]);
168+
}
169+
};
170+
171+
const optionsMarkup =
172+
options.length > 0
173+
? options.map(option => {
174+
const { label, value } = option;
175+
176+
return (
177+
<Combobox.Option
178+
key={value}
179+
label={label}
180+
value={value}
181+
selected={selectedOptions.includes(value)}
182+
/>
183+
);
184+
})
185+
: null;
186+
187+
const removeTag = useCallback(
188+
tag => () => {
189+
const options = [...selectedOptions];
190+
options.splice(options.indexOf(tag), 1);
191+
setSelectedOptions(options);
192+
},
193+
[selectedOptions]
194+
);
195+
196+
const tagsMarkup = selectedOptions.map(option => (
197+
<StyledTag
198+
key={\`option-\${option}\`}
199+
onRemove={removeTag(option)}
200+
textColor={Blue.S99}
201+
>
202+
{option}
203+
</StyledTag>
204+
));
205+
206+
useEffect(() => {
207+
if (options.length === 0) {
208+
setIsSearchEmpty(true);
209+
}
210+
211+
if (options.length > 0 && isSearchEmpty === true) {
212+
setIsSearchEmpty(false);
213+
}
214+
}, [isSearchEmpty, options]);
215+
216+
return (
217+
<div style={{ width: '500px' }}>
218+
<Combobox.Label>Label</Combobox.Label>
219+
<Combobox
220+
{...args}
221+
activator={
222+
<Combobox.TextInput
223+
value={inputValue}
224+
onChange={(value: string) => handleInputChange(value)}
225+
placeholder="Search"
226+
/>
227+
}
228+
>
229+
<Combobox.OptionList onSelect={handleSelect} isEmpty={isSearchEmpty}>
230+
{optionsMarkup}
231+
</Combobox.OptionList>
232+
</Combobox>
233+
<div style={{ paddingTop: space8 }}>{tagsMarkup}</div>
234+
</div>
235+
);
236+
`,
237+
},
238+
},
239+
};

src/@next/Combobox/Combobox.tsx

Lines changed: 85 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,85 @@
1+
import React, { useCallback, useState } from 'react';
2+
import { Popover } from '../Popover';
3+
import { Typography } from '../Typography';
4+
import { Neutral } from '../utilities/colors';
5+
import { TextInput, OptionList, Option } from './components';
6+
import { ComboboxOptionContext } from './components/OptionList/OptionListContext';
7+
import { ComboboxTextInputContext } from './components/TextInput/TextInputContext';
8+
9+
export interface ComboboxProps {
10+
activator: React.ReactElement;
11+
allowMultiple?: boolean;
12+
children?: React.ReactNode;
13+
label?: React.ReactNode;
14+
onClose?: () => void;
15+
}
16+
17+
export const Combobox = ({
18+
activator,
19+
allowMultiple = false,
20+
children,
21+
onClose,
22+
}: ComboboxProps) => {
23+
const [popoverActive, setPopoverActive] = useState(false);
24+
const [textInputWidth, setTextInputWidth] = useState();
25+
26+
const handleClose = useCallback(() => {
27+
setPopoverActive(false);
28+
onClose?.();
29+
}, [onClose]);
30+
31+
const handleFocus = () => {
32+
setPopoverActive(true);
33+
};
34+
35+
const handleBlur = () => {
36+
if (popoverActive) {
37+
handleClose();
38+
}
39+
};
40+
41+
const textInputContextValue = {
42+
onFocus: handleFocus,
43+
onBlur: handleBlur,
44+
textInputWidth,
45+
setTextInputWidth,
46+
};
47+
48+
const optionContextValue = {
49+
allowMultiple,
50+
};
51+
52+
return (
53+
<Popover
54+
active={popoverActive}
55+
activator={
56+
<ComboboxTextInputContext.Provider value={textInputContextValue}>
57+
{activator}
58+
</ComboboxTextInputContext.Provider>
59+
}
60+
onClose={handleClose}
61+
autofocusTarget="none"
62+
preventFocusOnClose
63+
fullWidth
64+
>
65+
<Popover.Pane>
66+
<ComboboxOptionContext.Provider value={optionContextValue}>
67+
<ComboboxTextInputContext.Provider value={textInputContextValue}>
68+
{children}
69+
</ComboboxTextInputContext.Provider>
70+
</ComboboxOptionContext.Provider>
71+
</Popover.Pane>
72+
</Popover>
73+
);
74+
};
75+
76+
const Label = ({ children }: { children: React.ReactNode }) => (
77+
<Typography as="span" variant="subtitle2" color={Neutral.B18}>
78+
{children}
79+
</Typography>
80+
);
81+
82+
Combobox.Label = Label;
83+
Combobox.TextInput = TextInput;
84+
Combobox.OptionList = OptionList;
85+
Combobox.Option = Option;
Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
1+
import styled from 'styled-components';
2+
import { Tag } from '../../Tag';
3+
import { space8 } from '../../utilities/spacing';
4+
5+
export const StyledTag = styled(Tag)`
6+
margin-right: ${space8};
7+
`;
Lines changed: 51 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,51 @@
1+
import React from 'react';
2+
import { Checkbox } from '../../../Checkbox';
3+
import { Typography } from '../../../Typography';
4+
import { Neutral } from '../../../utilities/colors';
5+
import { useOption, useOptionList } from './OptionListContext';
6+
import { EmptyOptionContainer } from './OptionListStyle';
7+
8+
export type OptionType = {
9+
label: string;
10+
value: string;
11+
};
12+
13+
export interface OptionProps extends OptionType {
14+
selected?: boolean;
15+
}
16+
17+
export interface NoOptionListProps {
18+
noOptionsMessage?: React.ReactNode;
19+
}
20+
21+
export const Option = ({ label, value, selected }: OptionProps) => {
22+
const optionContext = useOption();
23+
const { allowMultiple } = optionContext;
24+
const optionListContext = useOptionList();
25+
const { onOptionSelect } = optionListContext;
26+
27+
const handleOptionSelect = (event: React.MouseEvent) => {
28+
event.preventDefault();
29+
event.stopPropagation();
30+
31+
onOptionSelect({ value });
32+
};
33+
34+
return (
35+
<li value={value} onClick={handleOptionSelect} data-active={selected}>
36+
{allowMultiple ? <Checkbox label={label} checked={selected} /> : label}
37+
</li>
38+
);
39+
};
40+
41+
export const NoOptionList = ({
42+
noOptionsMessage = 'No matching results',
43+
}: NoOptionListProps) => {
44+
return (
45+
<EmptyOptionContainer>
46+
<Typography as="span" variant="body2" color={Neutral.B40}>
47+
{noOptionsMessage}
48+
</Typography>
49+
</EmptyOptionContainer>
50+
);
51+
};

0 commit comments

Comments
 (0)