Skip to content

Commit

Permalink
feat: ComboBox component (#315)
Browse files Browse the repository at this point in the history
  • Loading branch information
arthurgeron authored May 22, 2024
1 parent 6e7596e commit e398259
Show file tree
Hide file tree
Showing 5 changed files with 375 additions and 11 deletions.
43 changes: 43 additions & 0 deletions packages/ui/src/components/ComboBox/ComboBox.stories.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,43 @@
import type { Meta, StoryObj } from '@storybook/react';
import { useRef, useState } from 'react';
import { ComboBox, type ComboBoxProps } from './ComboBox';

const meta: Meta<typeof ComboBox> = {
title: 'Form/ComboBox',
component: ComboBox,
argTypes: {
debounce: {
control: 'number',
defaultValue: 300,
},
},
parameters: {
layout: 'centered',
},
};

export default meta;
type Story = StoryObj<typeof ComboBox>;

function ComboBoxStory(args: Pick<ComboBoxProps, 'debounce'>) {
const [_value, setValue] = useState<string>('');
const inputRef = useRef<HTMLInputElement>(null);

return (
<ComboBox
{...args}
inputRef={inputRef}
suggestions={['Apple', 'Banana', 'Cherry', 'Date', 'Elderberry']}
onItemSelected={(value) => setValue(value || '')}
>
<ComboBox.Input>
<ComboBox.InputField ref={inputRef} placeholder="Type a fruit name" />
</ComboBox.Input>
<ComboBox.Content />
</ComboBox>
);
}

export const Usage: Story = {
render: ComboBoxStory,
};
298 changes: 298 additions & 0 deletions packages/ui/src/components/ComboBox/ComboBox.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,298 @@
import React, {
useMemo,
useState,
useRef,
memo,
type FocusEvent,
type KeyboardEvent,
type MouseEvent,
useContext,
createContext,
} from 'react';
import { createComponent, withNamespace } from '../../utils/component';
import { type BoxProps, Flex, type FlexProps } from '../Box';
import { Input, type InputFieldProps, type InputProps } from '../Input';
import { Popover } from '../Popover';
import { Text } from '../Text';

export type ComboBoxProps<T = string> = Pick<
InputFieldProps,
'onFocus' | 'onClick' | 'onBlur' | 'onKeyDown' | 'children'
> & {
suggestions: Array<T>;
debounce?: number;
value?: string | undefined;
onChange?: (value: string | undefined) => void;
suggestionFilter?: (suggestion: T) => boolean;
itemNameSelector?: (suggestion: T) => string;
onItemSelected: (suggestion: T) => void;
inputRef: React.RefObject<HTMLInputElement>;
/**
* @description If true, input can only be a value from the ComboBox list
*/
strict?: boolean;
};

export type ComboBoxInputProps = InputProps;
export type ComboBoxInputFieldProps = InputFieldProps & {
ref: React.Ref<HTMLInputElement>;
};
export type ComboBoxContentProps = FlexProps;
export type ComboBoxItemProps<T = string> = BoxProps &
Pick<ComboBoxProps<T>, 'itemNameSelector'> & {
suggestion: T | null;
onItemSelected: (suggestion: T) => void;
};

export type Context<T = string> = Pick<
ComboBoxProps<T>,
'itemNameSelector' | 'onFocus' | 'onClick' | 'onBlur' | 'onKeyDown'
> & {
filteredSuggestions: Array<T>;
handleSuggestionClick: (suggestion: T) => void;
onChange: (event: React.ChangeEvent<HTMLInputElement>) => void;
};

const context = createContext<Context | undefined>(undefined);

function useComboBoxContext() {
const data = useContext<Context | undefined>(context);
if (!data) {
throw new Error('ComboBox context is required');
}
return data;
}

const ComboBoxRoot = createComponent<ComboBoxProps, typeof Input>({
id: 'ComboBox',
render: (_, { children, ...props }) => {
const {
suggestions,
strict,
debounce = 300,
suggestionFilter,
itemNameSelector,
onChange: _onChange,
onBlur: _onBlur,
onClick: _onClick,
onFocus: _onFocus,
onKeyDown: _onKeyDown,
value,
onItemSelected,
inputRef,
} = props as ComboBoxProps<string>;

const [isPopoverOpen, setIsPopoverOpen] = useState(false);
const [itemSelected, setItemSelected] = useState(false);
const [filteredSuggestions, setFilteredSuggestions] =
useState<Array<string>>(suggestions);
const debounceTimeout = useRef<NodeJS.Timeout>();

const onChange = (event: React.ChangeEvent<HTMLInputElement>) => {
setItemSelected(false);
const input = event.target.value;
clearTimeout(debounceTimeout.current);
debounceTimeout.current = setTimeout(() => {
clearTimeout(debounceTimeout.current);
debounceTimeout.current = undefined;
const filter =
suggestionFilter ??
((_suggestion) =>
typeof _suggestion === 'string'
? _suggestion.toLowerCase().includes(input.toLowerCase())
: false);
const newFilteredSuggestions = suggestions.filter(filter);
_onChange?.(input);
setFilteredSuggestions(
!newFilteredSuggestions?.length && !input
? suggestions
: newFilteredSuggestions,
);
}, debounce);
};

const handleSuggestionClick = (suggestion: string) => {
inputRef.current?.focus();
const value = itemNameSelector?.(suggestion) ?? (suggestion as string);
if (inputRef.current) {
inputRef.current.value = value;
}
setIsPopoverOpen(false);
if (strict) {
setItemSelected(true);
}
if (onItemSelected) {
onItemSelected(suggestion);
return;
}
_onChange?.(value);
};

const onFocus = (e: FocusEvent<HTMLInputElement, Element>) => {
_onFocus?.(e);
setIsPopoverOpen(true);
};

const onClick = (
e: MouseEvent<HTMLInputElement, globalThis.MouseEvent>,
) => {
_onClick?.(e);
setIsPopoverOpen(true);
};

const onBlur = (e: FocusEvent<HTMLInputElement, Element>) => {
_onBlur?.(e);
if (strict && !itemSelected) {
_onChange?.(undefined);
}
};

const onKeyDown = (event: KeyboardEvent<HTMLInputElement>) => {
_onKeyDown?.(event);
switch (event.key) {
case 'Enter': {
const selectedValue = strict ? suggestionFilter?.[0] : value;
selectedValue && handleSuggestionClick(selectedValue);
setIsPopoverOpen(false);
break;
}
case 'Escape':
setIsPopoverOpen(false);
break;
default:
break;
}
};

const providerData = useMemo<Context>(
() => ({
itemNameSelector,
handleSuggestionClick,
filteredSuggestions,
onChange,
onFocus,
onClick,
onBlur,
onKeyDown,
}),
[filteredSuggestions, itemNameSelector, onItemSelected],
);

return (
<context.Provider value={providerData}>
<Popover open={isPopoverOpen}>{children}</Popover>
</context.Provider>
);
},
});

export const ComboBoxInput = createComponent<ComboBoxInputProps, typeof Input>({
id: 'ComboBoxInput',
baseElement: Input,
});

export const ComboBoxInputField = createComponent<
ComboBoxInputFieldProps,
typeof Input.Field
>({
id: 'ComboBoxInputField',
render: (_, { ref, ...props }) => {
const { onChange, onFocus, onClick, onBlur, onKeyDown } =
useComboBoxContext();

return (
<Input.Field
ref={ref}
onChange={onChange}
onFocus={onFocus}
onClick={onClick}
onBlur={onBlur}
onKeyDown={onKeyDown}
{...props}
/>
);
},
});

function ComboBoxItemBase<T = string>({
onItemSelected,
suggestion,
itemNameSelector,
className,
}: ComboBoxItemProps<T>) {
if (!suggestion) return null;

const onClick = () => {
onItemSelected(suggestion);
};

const onKeyDown = (event: React.KeyboardEvent<HTMLDivElement>) => {
if (event.key === 'Enter') {
onItemSelected(suggestion);
}
};

return (
<div className={className} onClick={onClick} onKeyDown={onKeyDown}>
<Text>{itemNameSelector?.(suggestion) ?? (suggestion as string)}</Text>
</div>
);
}

export const ComboBoxItem = memo(
createComponent({
id: 'ComboBoxItem',
baseElement: ComboBoxItemBase,
}),
) as typeof ComboBoxItemBase;

export const ComboBoxContent = createComponent<
ComboBoxContentProps,
typeof Flex
>({
id: 'ComboBoxContent',
render: (_, { className, style, ...props }) => {
const { filteredSuggestions, itemNameSelector, handleSuggestionClick } =
useComboBoxContext();

return (
<>
{/* Popover uses the trigger's position and container to determine the position and size of the content window. */}
<Popover.Trigger>
<div />
</Popover.Trigger>
<Popover.Content
// Goes against accessibility rules but otherwise we'd be constantly losing focus on the input
onOpenAutoFocus={(e) => e.preventDefault()}
>
<Flex
className={className}
direction="column"
gap="2"
style={style}
{...props}
>
{filteredSuggestions.map((suggestion: string) => (
<ComboBoxItem
key={itemNameSelector?.(suggestion) ?? (suggestion as string)}
suggestion={suggestion}
itemNameSelector={itemNameSelector}
onItemSelected={handleSuggestionClick}
/>
))}
</Flex>
</Popover.Content>
</>
);
},
});

export const ComboBox = withNamespace(
ComboBoxRoot as <T = string>(props: ComboBoxProps<T>) => JSX.Element,
{
Item: ComboBoxItem,
Content: ComboBoxContent,
Input: ComboBoxInput,
InputField: ComboBoxInputField,
},
);
11 changes: 11 additions & 0 deletions packages/ui/src/components/ComboBox/index.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
export {
ComboBox,
ComboBoxItem,
ComboBoxContent,
} from './ComboBox';

export type {
ComboBoxProps,
ComboBoxItemProps,
ComboBoxContentProps,
} from './ComboBox';
1 change: 1 addition & 0 deletions packages/ui/src/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ export * from './components/Alert';
export * from './components/AlertDialog';
export * from './components/AspectRatio';
export * from './components/Asset';
export * from './components/ComboBox';
export * from './components/Avatar';
export * from './components/Badge';
export * from './components/Box';
Expand Down
Loading

0 comments on commit e398259

Please sign in to comment.