Skip to content

Commit ef9dedd

Browse files
feat(fuselage-forms): Add MultiSelectFiltered component (#1942)
1 parent 9574ce2 commit ef9dedd

6 files changed

Lines changed: 100 additions & 28 deletions

File tree

.changeset/moody-baths-wear.md

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
---
2+
'@rocket.chat/fuselage-forms': minor
3+
'@rocket.chat/fuselage': minor
4+
---
5+
6+
feat(fuselage-forms): Add `MultiSelectFiltered` component

packages/fuselage-forms/src/Field/FieldContext.ts

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -156,6 +156,25 @@ export const useFieldReferencedByLabel = () => {
156156
);
157157
};
158158

159+
// label has id and input is aria-labelledby + has id for aria-controls
160+
export const useFieldReferencedByLabelWithId = () => {
161+
const { id, descriptors, setFieldType } = useContext(FieldContext);
162+
163+
useEffect(() => {
164+
setFieldType('referencedByLabel');
165+
}, [setFieldType]);
166+
167+
return useMemo(
168+
() => ({
169+
id,
170+
'aria-labelledby': getInputId(id, descriptors),
171+
'aria-describedby': getDescribedBy(descriptors, id),
172+
...getAriaInvalid(descriptors),
173+
}),
174+
[descriptors, id],
175+
);
176+
};
177+
159178
// label is rendered visually hidden inside the inputs wrapper label
160179
export const useFieldWrappedByInputLabel = (): [
161180
ReactNode,

packages/fuselage-forms/src/Inputs/WrappedInputComponents.ts

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -12,12 +12,14 @@ import {
1212
TelephoneInput as TelephoneInputComponent,
1313
UrlInput as UrlInputComponent,
1414
MultiSelect as MultiSelectComponent,
15+
MultiSelectFiltered as MultiSelectFilteredComponent,
1516
Slider as SliderComponent,
1617
} from '@rocket.chat/fuselage';
1718

1819
import {
1920
withLabelId,
2021
withAriaLabelledBy,
22+
withAriaLabelledByAndId,
2123
withVisuallyHiddenLabel,
2224
} from './withLabelHelpers';
2325

@@ -35,7 +37,12 @@ export const UrlInput = withLabelId(UrlInputComponent);
3537

3638
// with aria-labelledby
3739
export const Select = withAriaLabelledBy(SelectComponent);
38-
export const MultiSelect = withAriaLabelledBy(MultiSelectComponent);
40+
41+
// with aria-labelledby + id for aria-controls
42+
export const MultiSelect = withAriaLabelledByAndId(MultiSelectComponent);
43+
export const MultiSelectFiltered = withAriaLabelledByAndId(
44+
MultiSelectFilteredComponent,
45+
);
3946
export const Slider = withAriaLabelledBy(
4047
SliderComponent,
4148
) as typeof SliderComponent;

packages/fuselage-forms/src/Inputs/withLabelHelpers.tsx

Lines changed: 30 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@ import { VisuallyHidden } from 'react-aria';
1313
import {
1414
useFieldReferencedByInput,
1515
useFieldReferencedByLabel,
16+
useFieldReferencedByLabelWithId,
1617
useFieldWrappedByInputLabel,
1718
} from '../Field/FieldContext';
1819

@@ -62,6 +63,29 @@ function withAriaLabelledBy<TProps, TRef>(
6263
return WrappedComponent;
6364
}
6465

66+
type WithLabelledByAndId = { 'aria-labelledby'?: string; 'id'?: string };
67+
68+
function withAriaLabelledByAndId<TProps, TRef>(
69+
Component: ForwardRefExoticComponent<
70+
TProps & WithLabelledByAndId & RefAttributes<TRef>
71+
>,
72+
) {
73+
const WrappedComponent = forwardRef<TRef, TProps>(function (props, ref) {
74+
const labelProps = useFieldReferencedByLabelWithId();
75+
return (
76+
<Component
77+
{...(props as TProps & WithLabelledByAndId)}
78+
{...labelProps}
79+
ref={ref}
80+
/>
81+
);
82+
});
83+
84+
WrappedComponent.displayName = `withAriaLabelledByAndId(${Component.displayName ?? Component.name ?? 'InputComponent'})`;
85+
86+
return WrappedComponent;
87+
}
88+
6589
type WithChildrenLabel = { labelChildren: ReactNode };
6690

6791
function withVisuallyHiddenLabel<TProps, TRef>(
@@ -87,4 +111,9 @@ function withVisuallyHiddenLabel<TProps, TRef>(
87111
return WrappedComponent;
88112
}
89113

90-
export { withLabelId, withAriaLabelledBy, withVisuallyHiddenLabel };
114+
export {
115+
withLabelId,
116+
withAriaLabelledBy,
117+
withAriaLabelledByAndId,
118+
withVisuallyHiddenLabel,
119+
};

packages/fuselage-forms/src/__snapshots__/test.spec.tsx.snap

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -224,12 +224,14 @@ exports[`renders WithMultiSelect without crashing 1`] = `
224224
class="rcx-box rcx-box--full rcx-css-w398ts"
225225
>
226226
<button
227+
aria-controls="react-aria-:rb:-listbox"
227228
aria-describedby="react-aria-:rb:-description react-aria-:rb:-error react-aria-:rb:-hint"
228229
aria-expanded="false"
229230
aria-haspopup="listbox"
230231
aria-invalid="true"
231232
aria-labelledby="react-aria-:rb:-label"
232233
class="rcx-box rcx-box--full rcx-input-box--undecorated rcx-select__focus rcx-css-trljwa rcx-css-3fq6z9"
234+
id="react-aria-:rb:"
233235
role="combobox"
234236
type="button"
235237
/>
Lines changed: 35 additions & 26 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
import type { Dispatch, SetStateAction } from 'react';
2-
import { useState } from 'react';
2+
import { useState, forwardRef } from 'react';
33

44
import type { IconProps } from '../Icon';
55

@@ -14,31 +14,40 @@ export type MultiSelectFilteredProps = MultiSelectProps & {
1414
addonIcon?: IconProps['name'];
1515
};
1616

17-
const MultiSelectFiltered = ({
18-
options,
19-
placeholder,
20-
filter: propFilter,
21-
setFilter: propSetFilter,
22-
...props
23-
}: MultiSelectFilteredProps) => {
24-
const [filter, setFilter] = useState('');
17+
const MultiSelectFiltered = forwardRef<
18+
HTMLInputElement,
19+
MultiSelectFilteredProps
20+
>(
21+
(
22+
{
23+
options,
24+
placeholder,
25+
filter: propFilter,
26+
setFilter: propSetFilter,
27+
...props
28+
},
29+
ref,
30+
) => {
31+
const [filter, setFilter] = useState('');
2532

26-
return (
27-
<MultiSelect
28-
{...props}
29-
filter={propFilter || filter}
30-
setFilter={propSetFilter || setFilter}
31-
options={options}
32-
anchor={(params: MultiSelectAnchorParams) => (
33-
<MultiSelectFilteredAnchor
34-
placeholder={placeholder}
35-
filter={propFilter || filter}
36-
onChangeFilter={propSetFilter || setFilter}
37-
{...params}
38-
/>
39-
)}
40-
/>
41-
);
42-
};
33+
return (
34+
<MultiSelect
35+
{...props}
36+
ref={ref}
37+
filter={propFilter || filter}
38+
setFilter={propSetFilter || setFilter}
39+
options={options}
40+
anchor={(params: MultiSelectAnchorParams) => (
41+
<MultiSelectFilteredAnchor
42+
placeholder={placeholder}
43+
filter={propFilter || filter}
44+
onChangeFilter={propSetFilter || setFilter}
45+
{...params}
46+
/>
47+
)}
48+
/>
49+
);
50+
},
51+
);
4352

4453
export default MultiSelectFiltered;

0 commit comments

Comments
 (0)