Skip to content

Commit 74c0089

Browse files
feat(fuselage): Clear MultiSelect filter after selection (#641)
* Clear filter after selection * Detach `MultiSelect` anchors Co-authored-by: Tasso Evangelista <tasso.evangelista@rocket.chat>
1 parent 9710be0 commit 74c0089

12 files changed

+347
-199
lines changed

packages/fuselage/.lintstagedrc.json

+5
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
{
2+
"*.{js,mjs,ts,tsx,mdx}": "eslint --fix",
3+
"*.{css,scss}": "stylelint --fix",
4+
"*.{json,jsonc,md,yml,xml,svg}": "prettier --write"
5+
}

packages/fuselage/package.json

+2
Original file line numberDiff line numberDiff line change
@@ -56,6 +56,7 @@
5656
"@rocket.chat/fuselage-tokens": "workspace:~",
5757
"@rocket.chat/memo": "workspace:~",
5858
"invariant": "^2.2.4",
59+
"react-is": "~17.0.2",
5960
"react-keyed-flatten-children": "^1.3.0"
6061
},
6162
"devDependencies": {
@@ -81,6 +82,7 @@
8182
"@testing-library/react": "^12.1.2",
8283
"@types/invariant": "^2.2.35",
8384
"@types/jest": "~27.4.0",
85+
"@types/react-is": "^17",
8486
"autoprefixer": "~10.4.2",
8587
"babel-loader": "~8.2.3",
8688
"caniuse-lite": "~1.0.30001311",
Original file line numberDiff line numberDiff line change
@@ -1,14 +1,31 @@
1-
import { ComponentProps, ForwardRefExoticComponent } from 'react';
1+
import {
2+
ComponentProps,
3+
ElementType,
4+
ForwardRefExoticComponent,
5+
ReactNode,
6+
} from 'react';
27

38
import { Box } from '../Box';
49

510
type MultiSelectOptions = readonly (readonly [string, string, boolean?])[];
611

12+
type MultiSelectAnchorParams = {
13+
children: ReactNode;
14+
disabled: boolean;
15+
onClick: MouseEventHandler;
16+
onBlur: FocusEventHandler;
17+
onKeyUp: KeyboardEventHandler;
18+
onKeyDown: KeyboardEventHandler;
19+
};
20+
721
type MultiSelectProps = Omit<ComponentProps<typeof Box>, 'onChange'> & {
822
error?: string;
923
options: MultiSelectOptions;
1024
onChange: (value: MultiSelectOptions[number][0]) => void;
1125
customEmpty?: string;
26+
anchor?:
27+
| ElementType<MultiSelectAnchorParams>
28+
| ((params: MultiSelectAnchorParams) => ReactNode);
1229
};
1330

1431
export const MultiSelect: ForwardRefExoticComponent<MultiSelectProps>;

packages/fuselage/src/components/MultiSelect/MultiSelect.js

+140-137
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ import {
44
useResizeObserver,
55
} from '@rocket.chat/fuselage-hooks';
66
import React, { useState, useRef, useEffect, memo, forwardRef } from 'react';
7+
import { isForwardRef } from 'react-is';
78

89
import AnimatedVisibility from '../AnimatedVisibility';
910
import { Box } from '../Box';
@@ -13,7 +14,8 @@ import { Icon } from '../Icon';
1314
import Margins from '../Margins';
1415
import { Options, CheckOption, useCursor } from '../Options';
1516
import Position from '../Position';
16-
import { Focus, Addon } from '../Select/Select';
17+
import SelectAddon from '../Select/SelectAddon';
18+
import MultiSelectAnchor from './MultiSelectAnchor';
1719

1820
const SelectedOptions = memo((props) => <Chip {...props} />);
1921

@@ -23,152 +25,153 @@ const prevent = (e) => {
2325
e.nativeEvent.stopImmediatePropagation();
2426
};
2527

26-
export const MultiSelect = forwardRef(
27-
(
28-
{
29-
value,
30-
filter,
31-
options = [],
32-
error,
33-
disabled,
34-
anchor: Anchor = Focus,
35-
onChange = () => {},
36-
getLabel = ([, label] = []) => label,
37-
getValue = ([value]) => value,
38-
placeholder,
39-
renderOptions: _Options = Options,
40-
customEmpty,
41-
...props
42-
},
43-
ref
44-
) => {
45-
const [internalValue, setInternalValue] = useState(value || []);
28+
export const MultiSelect = forwardRef(function MultiSelect(
29+
{
30+
value,
31+
filter,
32+
options = [],
33+
error,
34+
disabled,
35+
anchor: Anchor = MultiSelectAnchor,
36+
onChange = () => {},
37+
getLabel = ([, label] = []) => label,
38+
getValue = ([value]) => value,
39+
placeholder,
40+
renderOptions: _Options = Options,
41+
customEmpty,
42+
onSelect,
43+
...props
44+
},
45+
ref
46+
) {
47+
const [internalValue, setInternalValue] = useState(value || []);
4648

47-
const currentValue = value !== undefined ? value : internalValue;
48-
const option = options.find((option) => getValue(option) === currentValue);
49-
const index = options.indexOf(option);
49+
const option = options.find((option) => getValue(option) === internalValue);
50+
const index = options.indexOf(option);
5051

51-
const internalChanged = ([value]) => {
52-
if (currentValue.includes(value)) {
53-
const newValue = currentValue.filter((item) => item !== value);
54-
setInternalValue(newValue);
55-
return onChange(newValue);
56-
}
57-
const newValue = [...currentValue, value];
52+
const internalChanged = ([value]) => {
53+
if (internalValue.includes(value)) {
54+
const newValue = internalValue.filter((item) => item !== value);
5855
setInternalValue(newValue);
5956
return onChange(newValue);
60-
};
57+
}
58+
const newValue = [...internalValue, value];
59+
setInternalValue(newValue);
60+
onSelect();
61+
return onChange(newValue);
62+
};
6163

62-
const mapOptions = ([value, label]) => {
63-
if (currentValue.includes(value)) {
64-
return [value, label, true];
65-
}
66-
return [value, label];
67-
};
68-
const applyFilter = ([, option]) =>
69-
!filter || ~option.toLowerCase().indexOf(filter.toLowerCase());
70-
const filteredOptions = options.filter(applyFilter).map(mapOptions);
71-
const [cursor, handleKeyDown, handleKeyUp, reset, [visible, hide, show]] =
72-
useCursor(index, filteredOptions, internalChanged);
64+
const mapOptions = ([value, label]) => {
65+
if (internalValue.includes(value)) {
66+
return [value, label, true];
67+
}
68+
return [value, label];
69+
};
70+
const applyFilter = ([, option]) =>
71+
!filter || ~option.toLowerCase().indexOf(filter.toLowerCase());
72+
const filteredOptions = options.filter(applyFilter).map(mapOptions);
73+
const [cursor, handleKeyDown, handleKeyUp, reset, [visible, hide, show]] =
74+
useCursor(index, filteredOptions, internalChanged);
7375

74-
useEffect(reset, [filter]);
76+
useEffect(reset, [filter]);
7577

76-
const innerRef = useRef();
77-
const anchorRef = useMergedRefs(ref, innerRef);
78+
const innerRef = useRef();
79+
const anchorRef = useMergedRefs(ref, innerRef);
7880

79-
const { ref: containerRef, borderBoxSize } = useResizeObserver();
81+
const { ref: containerRef, borderBoxSize } = useResizeObserver();
8082

81-
return (
82-
<Box
83-
is='div'
84-
rcx-select
85-
className={[error && 'invalid', disabled && 'disabled']}
86-
ref={containerRef}
87-
onClick={useMutableCallback(() =>
88-
visible === AnimatedVisibility.VISIBLE
89-
? hide()
90-
: innerRef.current.focus() & show()
91-
)}
92-
disabled={disabled}
93-
{...props}
94-
>
95-
<Flex.Item grow={1}>
96-
<Margins inline='x4'>
97-
<Flex.Container>
98-
<Box is='div'>
99-
<Box
100-
is='div'
101-
display='flex'
102-
alignItems='center'
103-
flexWrap='wrap'
104-
margin='-x8'
105-
role='listbox'
106-
>
107-
<Margins all='x4'>
108-
<Anchor
109-
disabled={disabled}
110-
ref={anchorRef}
111-
aria-haspopup='listbox'
112-
onClick={show}
113-
onBlur={hide}
114-
onKeyUp={handleKeyUp}
115-
onKeyDown={handleKeyDown}
116-
order={1}
117-
rcx-input-box--undecorated
118-
children={!value ? option || placeholder : null}
83+
const handleClick = useMutableCallback((e) => {
84+
if (e.target.tagName !== 'I') {
85+
innerRef.current?.focus();
86+
}
87+
return visible === AnimatedVisibility.VISIBLE ? hide() : show();
88+
});
89+
90+
const renderAnchor = isForwardRef(Anchor)
91+
? (params) => <Anchor {...params} />
92+
: Anchor;
93+
94+
return (
95+
<Box
96+
is='div'
97+
rcx-select
98+
className={[error && 'invalid', disabled && 'disabled']}
99+
ref={containerRef}
100+
onClick={handleClick}
101+
disabled={disabled}
102+
{...props}
103+
>
104+
<Flex.Item grow={1}>
105+
<Margins inline='x4'>
106+
<Flex.Container>
107+
<Box is='div'>
108+
<Box
109+
is='div'
110+
display='flex'
111+
alignItems='center'
112+
flexWrap='wrap'
113+
margin='-x8'
114+
role='listbox'
115+
>
116+
<Margins all='x4'>
117+
{renderAnchor({
118+
ref: anchorRef,
119+
children: !value ? option || placeholder : null,
120+
disabled,
121+
onClick: show,
122+
onBlur: hide,
123+
onKeyDown: handleKeyDown,
124+
onKeyUp: handleKeyUp,
125+
})}
126+
{internalValue.map((value) => (
127+
<SelectedOptions
128+
tabIndex={-1}
129+
role='option'
130+
key={value}
131+
onMouseDown={(e) =>
132+
prevent(e) & internalChanged([value]) && false
133+
}
134+
children={getLabel(
135+
options.find(([val]) => val === value)
136+
)}
119137
/>
120-
{currentValue.map((value) => (
121-
<SelectedOptions
122-
tabIndex={-1}
123-
role='option'
124-
key={value}
125-
onMouseDown={(e) =>
126-
prevent(e) & internalChanged([value]) && false
127-
}
128-
children={getLabel(
129-
options.find(([val]) => val === value)
130-
)}
131-
/>
132-
))}
133-
</Margins>
134-
</Box>
138+
))}
139+
</Margins>
135140
</Box>
136-
</Flex.Container>
137-
</Margins>
138-
</Flex.Item>
139-
<Flex.Item grow={0} shrink={0}>
140-
<Margins inline='x4'>
141-
<Addon
142-
children={
143-
<Icon
144-
name={
145-
visible === AnimatedVisibility.VISIBLE
146-
? 'cross'
147-
: 'chevron-down'
148-
}
149-
size='x20'
150-
/>
141+
</Box>
142+
</Flex.Container>
143+
</Margins>
144+
</Flex.Item>
145+
<Flex.Item grow={0} shrink={0}>
146+
<Margins inline='x4'>
147+
<SelectAddon>
148+
<Icon
149+
name={
150+
visible === AnimatedVisibility.VISIBLE
151+
? 'cross'
152+
: 'chevron-down'
151153
}
154+
size='x20'
152155
/>
153-
</Margins>
154-
</Flex.Item>
155-
<AnimatedVisibility visibility={visible}>
156-
<Position anchor={containerRef}>
157-
<_Options
158-
width={borderBoxSize.inlineSize}
159-
onMouseDown={prevent}
160-
multiple
161-
filter={filter}
162-
renderItem={CheckOption}
163-
role='listbox'
164-
options={filteredOptions}
165-
onSelect={internalChanged}
166-
cursor={cursor}
167-
customEmpty={customEmpty}
168-
/>
169-
</Position>
170-
</AnimatedVisibility>
171-
</Box>
172-
);
173-
}
174-
);
156+
</SelectAddon>
157+
</Margins>
158+
</Flex.Item>
159+
<AnimatedVisibility visibility={visible}>
160+
<Position anchor={containerRef}>
161+
<_Options
162+
width={borderBoxSize.inlineSize}
163+
onMouseDown={prevent}
164+
multiple
165+
filter={filter}
166+
renderItem={CheckOption}
167+
role='listbox'
168+
options={filteredOptions}
169+
onSelect={internalChanged}
170+
cursor={cursor}
171+
customEmpty={customEmpty}
172+
/>
173+
</Position>
174+
</AnimatedVisibility>
175+
</Box>
176+
);
177+
});
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,36 @@
1+
import React, {
2+
FocusEventHandler,
3+
forwardRef,
4+
KeyboardEventHandler,
5+
MouseEventHandler,
6+
ReactNode,
7+
Ref,
8+
} from 'react';
9+
10+
import SelectFocus from '../Select/SelectFocus';
11+
12+
type MultiSelectAnchorProps = {
13+
children: ReactNode;
14+
disabled: boolean;
15+
onClick: MouseEventHandler;
16+
onBlur: FocusEventHandler;
17+
onKeyUp: KeyboardEventHandler;
18+
onKeyDown: KeyboardEventHandler;
19+
};
20+
21+
const MultiSelectAnchor = forwardRef(function MultiSelectAnchor(
22+
props: MultiSelectAnchorProps,
23+
ref: Ref<Element>
24+
) {
25+
return (
26+
<SelectFocus
27+
rcx-input-box--undecorated
28+
ref={ref}
29+
aria-haspopup='listbox'
30+
order={1}
31+
{...props}
32+
/>
33+
);
34+
});
35+
36+
export default MultiSelectAnchor;

0 commit comments

Comments
 (0)