Skip to content

Commit 5a71da0

Browse files
authored
feat(combo-box): external input ref support (#506)
* feat: useMergeRefs hook * feat: add support for external input ref * feat: add withRef story * test: add unit tests for ComboBox component
1 parent b25eefa commit 5a71da0

File tree

4 files changed

+194
-18
lines changed

4 files changed

+194
-18
lines changed
Lines changed: 57 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,57 @@
1+
import React from 'react';
2+
import { render, screen, fireEvent, waitFor } from '@testing-library/react';
3+
import { ComboBox } from './ComboBox';
4+
import { ListBoxItem } from '../ListBox/ListBox';
5+
6+
const mockItems = [
7+
{ id: '1', name: 'Luke Skywalker' },
8+
{ id: '2', name: 'Darth Vader' },
9+
{ id: '3', name: 'Leia Organa' }
10+
];
11+
12+
describe('ComboBox', () => {
13+
it('renders with label and placeholder', () => {
14+
render(
15+
<ComboBox label="Star Wars Character" placeholder="Select a character" items={mockItems}>
16+
{item => <ListBoxItem>{item.name}</ListBoxItem>}
17+
</ComboBox>
18+
);
19+
20+
expect(screen.getByText('Star Wars Character')).toBeInTheDocument();
21+
expect(screen.getByPlaceholderText('Select a character')).toBeInTheDocument();
22+
});
23+
24+
it('opens dropdown on type', async () => {
25+
render(
26+
<ComboBox label="Star Wars Character" items={mockItems}>
27+
{item => <ListBoxItem>{item.name}</ListBoxItem>}
28+
</ComboBox>
29+
);
30+
31+
const input = screen.getByRole('combobox');
32+
fireEvent.click(input);
33+
fireEvent.change(input, { target: { value: 'Luke' } });
34+
35+
await waitFor(() => {
36+
expect(screen.getByText('Luke Skywalker')).toBeInTheDocument();
37+
expect(screen.getByText('Darth Vader')).toBeInTheDocument();
38+
expect(screen.getByText('Leia Organa')).toBeInTheDocument();
39+
});
40+
});
41+
42+
it('calls onSelectionChange when an item is selected', async () => {
43+
const onSelectionChange = jest.fn();
44+
render(
45+
<ComboBox label="Star Wars Character" items={mockItems} onSelectionChange={onSelectionChange}>
46+
{item => <ListBoxItem>{item.name}</ListBoxItem>}
47+
</ComboBox>
48+
);
49+
50+
const input = screen.getByRole('combobox');
51+
fireEvent.click(input);
52+
fireEvent.change(input, { target: { value: 'Luke' } });
53+
fireEvent.click(screen.getByText('Luke Skywalker'));
54+
55+
await waitFor(() => expect(onSelectionChange).toHaveBeenCalledWith('1'));
56+
});
57+
});

src/components/experimental/ComboBox/ComboBox.tsx

Lines changed: 36 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import React, { ReactElement, useState } from 'react';
1+
import React, { forwardRef, ReactElement, Ref, useState } from 'react';
22
import {
33
ComboBox as BaseComboBox,
44
ComboBoxProps as BaseComboBoxProps,
@@ -19,6 +19,7 @@ import { Button } from '../Field/Button';
1919
import { Footer } from '../Field/Footer';
2020
import { Wrapper } from '../Field/Wrapper';
2121
import XCrossCircleIcon from '../../../icons/actions/XCrossCircleIcon';
22+
import useMergeRefs from '../../../utils/hooks/useMergeRefs';
2223
import { VisuallyHidden } from '../../VisuallyHidden/VisuallyHidden';
2324

2425
const defaultAriaStrings = {
@@ -40,25 +41,33 @@ interface ComboBoxFieldProps extends Pick<FieldProps, 'label' | 'description' |
4041
clearFieldButton: string;
4142
messageFieldIsCleared: string;
4243
};
44+
inputRef?: Ref<HTMLInputElement>;
4345
}
4446

4547
interface ComboBoxProps<T extends Record<string, unknown>>
4648
extends ComboBoxFieldProps,
47-
Omit<BaseComboBoxProps<T>, 'children'> {
49+
Omit<BaseComboBoxProps<T>, 'children'>,
50+
React.RefAttributes<HTMLDivElement> {
4851
children: React.ReactNode | ((item: T) => React.ReactNode);
4952
}
5053

5154
const ComboBoxInput = React.forwardRef<HTMLDivElement, ComboBoxFieldProps>(
52-
({ label, placeholder, leadingIcon, ariaStrings }, forwardedRef) => {
55+
({ label, placeholder, leadingIcon, ariaStrings, inputRef: externalInputRef }, forwardedRef) => {
5356
const state = React.useContext(ComboBoxStateContext);
54-
const inputRef = React.useRef<HTMLInputElement>(null);
57+
const internalInputRef = React.useRef<HTMLInputElement>(null);
58+
59+
const combinedInputRef = useMergeRefs(internalInputRef, externalInputRef);
5560

5661
return (
57-
<FakeInput $isVisuallyFocused={state?.isOpen} ref={forwardedRef} onClick={() => inputRef.current?.focus()}>
62+
<FakeInput
63+
$isVisuallyFocused={state?.isOpen}
64+
ref={forwardedRef}
65+
onClick={() => internalInputRef.current?.focus()}
66+
>
5867
{leadingIcon}
5968
<InnerWrapper>
6069
<Label $flying={Boolean(placeholder || state?.inputValue?.length > 0)}>{label}</Label>
61-
<Input placeholder={placeholder} ref={inputRef} />
70+
<Input placeholder={placeholder} ref={combinedInputRef} />
6271
</InnerWrapper>
6372
{state?.inputValue?.length > 0 ? (
6473
<Button
@@ -80,16 +89,21 @@ const ComboBoxInput = React.forwardRef<HTMLDivElement, ComboBoxFieldProps>(
8089
}
8190
);
8291

83-
function ComboBox<T extends Record<string, unknown>>({
84-
label,
85-
children,
86-
placeholder,
87-
leadingIcon,
88-
ariaStrings = defaultAriaStrings,
89-
errorMessage,
90-
description,
91-
...props
92-
}: ComboBoxProps<T>): ReactElement {
92+
function ComboBoxComponent<T extends Record<string, unknown>>(
93+
props: ComboBoxProps<T>,
94+
inputRef: React.Ref<HTMLInputElement>
95+
): ReactElement {
96+
const {
97+
label,
98+
children,
99+
placeholder,
100+
leadingIcon,
101+
ariaStrings = defaultAriaStrings,
102+
errorMessage,
103+
description,
104+
...restProps
105+
} = props;
106+
93107
const [menuWidth, setMenuWidth] = useState<string | null>(null);
94108
const triggerRef = React.useRef<HTMLDivElement>(null);
95109
const isSSR = useIsSSR();
@@ -107,12 +121,13 @@ function ComboBox<T extends Record<string, unknown>>({
107121
});
108122

109123
return (
110-
<BaseComboBox<T> aria-label={label} shouldFocusWrap {...props}>
124+
<BaseComboBox<T> aria-label={label} shouldFocusWrap {...restProps}>
111125
{({ isInvalid }) => (
112126
<>
113127
<Wrapper>
114128
<ComboBoxInput
115129
ref={isSSR ? null : triggerRef}
130+
inputRef={inputRef}
116131
label={label}
117132
placeholder={placeholder}
118133
leadingIcon={leadingIcon}
@@ -132,4 +147,8 @@ function ComboBox<T extends Record<string, unknown>>({
132147
);
133148
}
134149

150+
const ComboBox = forwardRef(ComboBoxComponent) as <T extends Record<string, unknown>>(
151+
props: ComboBoxProps<T> & { ref?: React.Ref<HTMLInputElement> }
152+
) => ReactElement;
153+
135154
export { ComboBox };

src/components/experimental/ComboBox/docs/ComboBox.stories.tsx

Lines changed: 80 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,8 @@
1-
import React from 'react';
1+
import React, { useRef } from 'react';
22
import { StoryObj, Meta } from '@storybook/react';
33
import { ComboBox } from '../ComboBox';
44
import { ListBoxItem } from '../../ListBox/ListBox';
5+
import { Button } from '../../../Button/Button';
56
import DogIcon from '../../../../icons/basic/DogIcon';
67

78
const meta: Meta = {
@@ -153,3 +154,81 @@ export const FullyControlled: Story = {
153154
);
154155
}
155156
};
157+
158+
export const WithRef: StoryObj<typeof ComboBox> = {
159+
render: () => {
160+
const [items, setItems] = React.useState<Character[]>([]);
161+
const [filterText, setFilterText] = React.useState('');
162+
const comboBoxRef = useRef<HTMLInputElement>(null);
163+
164+
React.useEffect(() => {
165+
let ignore = false;
166+
167+
async function startFetching() {
168+
const res = await fetch(`https://swapi.py4e.com/api/people/?search=${filterText}`);
169+
const json = await res.json();
170+
171+
if (!ignore) {
172+
setItems(json.results);
173+
}
174+
}
175+
176+
// eslint-disable-next-line no-void
177+
void startFetching();
178+
179+
return () => {
180+
ignore = true;
181+
};
182+
}, [filterText]);
183+
184+
const handleFocus = () => {
185+
if (comboBoxRef.current) {
186+
comboBoxRef.current.focus();
187+
// eslint-disable-next-line no-console
188+
console.log('input focused');
189+
}
190+
};
191+
192+
const handleBlur = () => {
193+
if (comboBoxRef.current) {
194+
comboBoxRef.current.blur();
195+
// eslint-disable-next-line no-console
196+
console.log('input blurred');
197+
}
198+
};
199+
200+
const handleGetValue = () => {
201+
if (comboBoxRef.current) {
202+
// eslint-disable-next-line no-console
203+
console.log('current value:', comboBoxRef.current.value);
204+
}
205+
};
206+
207+
return (
208+
<div>
209+
<div style={{ marginTop: '10px', zIndex: -1 }}>
210+
<Button onClick={handleFocus} size="small" style={{ marginRight: '5px' }}>
211+
Focus
212+
</Button>
213+
<Button onClick={handleBlur} size="small" style={{ marginRight: '5px' }}>
214+
Blur
215+
</Button>
216+
<Button onClick={handleGetValue} size="small">
217+
Get value
218+
</Button>
219+
</div>
220+
<ComboBox
221+
label="Star Wars Character"
222+
ref={comboBoxRef}
223+
items={items}
224+
inputValue={filterText}
225+
onInputChange={setFilterText}
226+
>
227+
{item => <ListBoxItem id={item.name}>{item.name}</ListBoxItem>}
228+
</ComboBox>
229+
{/* Adding this div so the dropdown appears below and doesn't overlap with the buttons */}
230+
<div style={{ height: '45px' }} />
231+
</div>
232+
);
233+
}
234+
};

src/utils/hooks/useMergeRefs.ts

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,21 @@
1+
export default function useMergeRefs<T>(
2+
...inputRefs: (React.Ref<T> | undefined)[]
3+
): React.Ref<T> | React.RefCallback<T> {
4+
const filteredInputRefs = inputRefs.filter(Boolean);
5+
6+
if (filteredInputRefs.length <= 1) {
7+
const firstRef = filteredInputRefs[0];
8+
return firstRef || null;
9+
}
10+
11+
return function mergedRefs(ref) {
12+
filteredInputRefs.forEach(inputRef => {
13+
if (typeof inputRef === 'function') {
14+
inputRef(ref);
15+
} else if (inputRef) {
16+
// eslint-disable-next-line no-param-reassign
17+
(inputRef as React.MutableRefObject<T | null>).current = ref;
18+
}
19+
});
20+
};
21+
}

0 commit comments

Comments
 (0)