Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
57 changes: 57 additions & 0 deletions src/components/experimental/ComboBox/ComboBox.spec.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,57 @@
import React from 'react';
import { render, screen, fireEvent, waitFor } from '@testing-library/react';
import { ComboBox } from './ComboBox';
import { ListBoxItem } from '../ListBox/ListBox';

const mockItems = [
{ id: '1', name: 'Luke Skywalker' },
{ id: '2', name: 'Darth Vader' },
{ id: '3', name: 'Leia Organa' }
];

describe('ComboBox', () => {
it('renders with label and placeholder', () => {
render(
<ComboBox label="Star Wars Character" placeholder="Select a character" items={mockItems}>
{item => <ListBoxItem>{item.name}</ListBoxItem>}
</ComboBox>
);

expect(screen.getByText('Star Wars Character')).toBeInTheDocument();
expect(screen.getByPlaceholderText('Select a character')).toBeInTheDocument();
});

it('opens dropdown on type', async () => {
render(
<ComboBox label="Star Wars Character" items={mockItems}>
{item => <ListBoxItem>{item.name}</ListBoxItem>}
</ComboBox>
);

const input = screen.getByRole('combobox');
fireEvent.click(input);
fireEvent.change(input, { target: { value: 'Luke' } });

await waitFor(() => {
expect(screen.getByText('Luke Skywalker')).toBeInTheDocument();
expect(screen.getByText('Darth Vader')).toBeInTheDocument();
expect(screen.getByText('Leia Organa')).toBeInTheDocument();
});
});

it('calls onSelectionChange when an item is selected', async () => {
const onSelectionChange = jest.fn();
render(
<ComboBox label="Star Wars Character" items={mockItems} onSelectionChange={onSelectionChange}>
{item => <ListBoxItem>{item.name}</ListBoxItem>}
</ComboBox>
);

const input = screen.getByRole('combobox');
fireEvent.click(input);
fireEvent.change(input, { target: { value: 'Luke' } });
fireEvent.click(screen.getByText('Luke Skywalker'));

await waitFor(() => expect(onSelectionChange).toHaveBeenCalledWith('1'));
});
});
53 changes: 36 additions & 17 deletions src/components/experimental/ComboBox/ComboBox.tsx
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import React, { ReactElement, useState } from 'react';
import React, { forwardRef, ReactElement, Ref, useState } from 'react';
import {
ComboBox as BaseComboBox,
ComboBoxProps as BaseComboBoxProps,
Expand All @@ -19,6 +19,7 @@ import { Button } from '../Field/Button';
import { Footer } from '../Field/Footer';
import { Wrapper } from '../Field/Wrapper';
import XCrossCircleIcon from '../../../icons/actions/XCrossCircleIcon';
import useMergeRefs from '../../../utils/hooks/useMergeRefs';
import { VisuallyHidden } from '../../VisuallyHidden/VisuallyHidden';

const defaultAriaStrings = {
Expand All @@ -40,25 +41,33 @@ interface ComboBoxFieldProps extends Pick<FieldProps, 'label' | 'description' |
clearFieldButton: string;
messageFieldIsCleared: string;
};
inputRef?: Ref<HTMLInputElement>;
}

interface ComboBoxProps<T extends Record<string, unknown>>
extends ComboBoxFieldProps,
Omit<BaseComboBoxProps<T>, 'children'> {
Omit<BaseComboBoxProps<T>, 'children'>,
React.RefAttributes<HTMLDivElement> {
children: React.ReactNode | ((item: T) => React.ReactNode);
}

const ComboBoxInput = React.forwardRef<HTMLDivElement, ComboBoxFieldProps>(
({ label, placeholder, leadingIcon, ariaStrings }, forwardedRef) => {
({ label, placeholder, leadingIcon, ariaStrings, inputRef: externalInputRef }, forwardedRef) => {
const state = React.useContext(ComboBoxStateContext);
const inputRef = React.useRef<HTMLInputElement>(null);
const internalInputRef = React.useRef<HTMLInputElement>(null);

const combinedInputRef = useMergeRefs(internalInputRef, externalInputRef);

return (
<FakeInput $isVisuallyFocused={state?.isOpen} ref={forwardedRef} onClick={() => inputRef.current?.focus()}>
<FakeInput
$isVisuallyFocused={state?.isOpen}
ref={forwardedRef}
onClick={() => internalInputRef.current?.focus()}
>
{leadingIcon}
<InnerWrapper>
<Label $flying={Boolean(placeholder || state?.inputValue?.length > 0)}>{label}</Label>
<Input placeholder={placeholder} ref={inputRef} />
<Input placeholder={placeholder} ref={combinedInputRef} />
</InnerWrapper>
{state?.inputValue?.length > 0 ? (
<Button
Expand All @@ -80,16 +89,21 @@ const ComboBoxInput = React.forwardRef<HTMLDivElement, ComboBoxFieldProps>(
}
);

function ComboBox<T extends Record<string, unknown>>({
label,
children,
placeholder,
leadingIcon,
ariaStrings = defaultAriaStrings,
errorMessage,
description,
...props
}: ComboBoxProps<T>): ReactElement {
function ComboBoxComponent<T extends Record<string, unknown>>(
props: ComboBoxProps<T>,
inputRef: React.Ref<HTMLInputElement>
): ReactElement {
const {
label,
children,
placeholder,
leadingIcon,
ariaStrings = defaultAriaStrings,
errorMessage,
description,
...restProps
} = props;

const [menuWidth, setMenuWidth] = useState<string | null>(null);
const triggerRef = React.useRef<HTMLDivElement>(null);
const isSSR = useIsSSR();
Expand All @@ -107,12 +121,13 @@ function ComboBox<T extends Record<string, unknown>>({
});

return (
<BaseComboBox<T> aria-label={label} shouldFocusWrap {...props}>
<BaseComboBox<T> aria-label={label} shouldFocusWrap {...restProps}>
{({ isInvalid }) => (
<>
<Wrapper>
<ComboBoxInput
ref={isSSR ? null : triggerRef}
inputRef={inputRef}
label={label}
placeholder={placeholder}
leadingIcon={leadingIcon}
Expand All @@ -132,4 +147,8 @@ function ComboBox<T extends Record<string, unknown>>({
);
}

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

export { ComboBox };
81 changes: 80 additions & 1 deletion src/components/experimental/ComboBox/docs/ComboBox.stories.tsx
Original file line number Diff line number Diff line change
@@ -1,7 +1,8 @@
import React from 'react';
import React, { useRef } from 'react';
import { StoryObj, Meta } from '@storybook/react';
import { ComboBox } from '../ComboBox';
import { ListBoxItem } from '../../ListBox/ListBox';
import { Button } from '../../../Button/Button';
import DogIcon from '../../../../icons/basic/DogIcon';

const meta: Meta = {
Expand Down Expand Up @@ -153,3 +154,81 @@ export const FullyControlled: Story = {
);
}
};

export const WithRef: StoryObj<typeof ComboBox> = {
render: () => {
const [items, setItems] = React.useState<Character[]>([]);
const [filterText, setFilterText] = React.useState('');
const comboBoxRef = useRef<HTMLInputElement>(null);

React.useEffect(() => {
let ignore = false;

async function startFetching() {
const res = await fetch(`https://swapi.py4e.com/api/people/?search=${filterText}`);
const json = await res.json();

if (!ignore) {
setItems(json.results);
}
}

// eslint-disable-next-line no-void
void startFetching();

return () => {
ignore = true;
};
}, [filterText]);

const handleFocus = () => {
if (comboBoxRef.current) {
comboBoxRef.current.focus();
// eslint-disable-next-line no-console
console.log('input focused');
}
};

const handleBlur = () => {
if (comboBoxRef.current) {
comboBoxRef.current.blur();
// eslint-disable-next-line no-console
console.log('input blurred');
}
};

const handleGetValue = () => {
if (comboBoxRef.current) {
// eslint-disable-next-line no-console
console.log('current value:', comboBoxRef.current.value);
}
};

return (
<div>
<div style={{ marginTop: '10px', zIndex: -1 }}>
<Button onClick={handleFocus} size="small" style={{ marginRight: '5px' }}>
Focus
</Button>
<Button onClick={handleBlur} size="small" style={{ marginRight: '5px' }}>
Blur
</Button>
<Button onClick={handleGetValue} size="small">
Get value
</Button>
</div>
<ComboBox
label="Star Wars Character"
ref={comboBoxRef}
items={items}
inputValue={filterText}
onInputChange={setFilterText}
>
{item => <ListBoxItem id={item.name}>{item.name}</ListBoxItem>}
</ComboBox>
{/* Adding this div so the dropdown appears below and doesn't overlap with the buttons */}
<div style={{ height: '45px' }} />
</div>
);
}
};
21 changes: 21 additions & 0 deletions src/utils/hooks/useMergeRefs.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
export default function useMergeRefs<T>(
...inputRefs: (React.Ref<T> | undefined)[]
): React.Ref<T> | React.RefCallback<T> {
const filteredInputRefs = inputRefs.filter(Boolean);

if (filteredInputRefs.length <= 1) {
const firstRef = filteredInputRefs[0];
return firstRef || null;
}

return function mergedRefs(ref) {
filteredInputRefs.forEach(inputRef => {
if (typeof inputRef === 'function') {
inputRef(ref);
} else if (inputRef) {
// eslint-disable-next-line no-param-reassign
(inputRef as React.MutableRefObject<T | null>).current = ref;
}
});
};
}
Loading