diff --git a/packages/ui/src/components/ComboBox/ComboBox.stories.tsx b/packages/ui/src/components/ComboBox/ComboBox.stories.tsx new file mode 100644 index 000000000..f5b6c66f9 --- /dev/null +++ b/packages/ui/src/components/ComboBox/ComboBox.stories.tsx @@ -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 = { + title: 'Form/ComboBox', + component: ComboBox, + argTypes: { + debounce: { + control: 'number', + defaultValue: 300, + }, + }, + parameters: { + layout: 'centered', + }, +}; + +export default meta; +type Story = StoryObj; + +function ComboBoxStory(args: Pick) { + const [_value, setValue] = useState(''); + const inputRef = useRef(null); + + return ( + setValue(value || '')} + > + + + + + + ); +} + +export const Usage: Story = { + render: ComboBoxStory, +}; diff --git a/packages/ui/src/components/ComboBox/ComboBox.tsx b/packages/ui/src/components/ComboBox/ComboBox.tsx new file mode 100644 index 000000000..b02dd0b66 --- /dev/null +++ b/packages/ui/src/components/ComboBox/ComboBox.tsx @@ -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 = Pick< + InputFieldProps, + 'onFocus' | 'onClick' | 'onBlur' | 'onKeyDown' | 'children' +> & { + suggestions: Array; + 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; + /** + * @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; +}; +export type ComboBoxContentProps = FlexProps; +export type ComboBoxItemProps = BoxProps & + Pick, 'itemNameSelector'> & { + suggestion: T | null; + onItemSelected: (suggestion: T) => void; + }; + +export type Context = Pick< + ComboBoxProps, + 'itemNameSelector' | 'onFocus' | 'onClick' | 'onBlur' | 'onKeyDown' +> & { + filteredSuggestions: Array; + handleSuggestionClick: (suggestion: T) => void; + onChange: (event: React.ChangeEvent) => void; +}; + +const context = createContext(undefined); + +function useComboBoxContext() { + const data = useContext(context); + if (!data) { + throw new Error('ComboBox context is required'); + } + return data; +} + +const ComboBoxRoot = createComponent({ + 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; + + const [isPopoverOpen, setIsPopoverOpen] = useState(false); + const [itemSelected, setItemSelected] = useState(false); + const [filteredSuggestions, setFilteredSuggestions] = + useState>(suggestions); + const debounceTimeout = useRef(); + + const onChange = (event: React.ChangeEvent) => { + 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) => { + _onFocus?.(e); + setIsPopoverOpen(true); + }; + + const onClick = ( + e: MouseEvent, + ) => { + _onClick?.(e); + setIsPopoverOpen(true); + }; + + const onBlur = (e: FocusEvent) => { + _onBlur?.(e); + if (strict && !itemSelected) { + _onChange?.(undefined); + } + }; + + const onKeyDown = (event: KeyboardEvent) => { + _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( + () => ({ + itemNameSelector, + handleSuggestionClick, + filteredSuggestions, + onChange, + onFocus, + onClick, + onBlur, + onKeyDown, + }), + [filteredSuggestions, itemNameSelector, onItemSelected], + ); + + return ( + + {children} + + ); + }, +}); + +export const ComboBoxInput = createComponent({ + 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 ( + + ); + }, +}); + +function ComboBoxItemBase({ + onItemSelected, + suggestion, + itemNameSelector, + className, +}: ComboBoxItemProps) { + if (!suggestion) return null; + + const onClick = () => { + onItemSelected(suggestion); + }; + + const onKeyDown = (event: React.KeyboardEvent) => { + if (event.key === 'Enter') { + onItemSelected(suggestion); + } + }; + + return ( +
+ {itemNameSelector?.(suggestion) ?? (suggestion as string)} +
+ ); +} + +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. */} + +
+ + e.preventDefault()} + > + + {filteredSuggestions.map((suggestion: string) => ( + + ))} + + + + ); + }, +}); + +export const ComboBox = withNamespace( + ComboBoxRoot as (props: ComboBoxProps) => JSX.Element, + { + Item: ComboBoxItem, + Content: ComboBoxContent, + Input: ComboBoxInput, + InputField: ComboBoxInputField, + }, +); diff --git a/packages/ui/src/components/ComboBox/index.tsx b/packages/ui/src/components/ComboBox/index.tsx new file mode 100644 index 000000000..a86918145 --- /dev/null +++ b/packages/ui/src/components/ComboBox/index.tsx @@ -0,0 +1,11 @@ +export { + ComboBox, + ComboBoxItem, + ComboBoxContent, +} from './ComboBox'; + +export type { + ComboBoxProps, + ComboBoxItemProps, + ComboBoxContentProps, +} from './ComboBox'; diff --git a/packages/ui/src/index.tsx b/packages/ui/src/index.tsx index 7e76c80b4..7cd51d25e 100644 --- a/packages/ui/src/index.tsx +++ b/packages/ui/src/index.tsx @@ -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'; diff --git a/packages/ui/src/utils/component.tsx b/packages/ui/src/utils/component.tsx index f6b492067..f21d86e22 100644 --- a/packages/ui/src/utils/component.tsx +++ b/packages/ui/src/utils/component.tsx @@ -10,6 +10,17 @@ import type { WithAsProps, } from './types'; +export type CreatedForwardedComponent< + P extends PropsOf, + C extends ElementType, +> = React.ForwardRefExoticComponent< + React.PropsWithoutRef

& React.RefAttributes> +>; +export type PolymorphicComponentProps = { + id: string; + polymorphic?: boolean; +}; + type CreateOpts

, C extends ElementType> = { id: string; baseElement?: C; @@ -21,7 +32,7 @@ type CreateOpts

, C extends ElementType> = { export function createComponent< P extends PropsOf, C extends ElementType, ->(opts: CreateOpts) { +>(opts: CreateOpts): CreatedForwardedComponent { const { id, baseElement: El = 'div' as C, @@ -34,22 +45,22 @@ export function createComponent< } type T = ElementRef; - const Comp = forwardRef((props, ref) => { - const baseClass = getClass?.(props) ?? props.className; - const className = cx(baseClass, fClass(id)); - const itemProps = { ref, ...props, className } as any; - return render ? render(El, itemProps) : ; - }); + const Comp: CreatedForwardedComponent = forwardRef( + (props, ref) => { + const baseClass = getClass?.(props) ?? props.className; + const className = cx(baseClass, fClass(id)); + const itemProps = { ref, ...props, className } as any; + + return render ? render(El, itemProps) : ; + }, + ); if (opts.defaultProps) { Comp.defaultProps = opts.defaultProps; } - const ReturnComp = Comp as typeof Comp & { - id: string; - polymorphic?: boolean; - }; + const ReturnComp = Comp as typeof Comp & PolymorphicComponentProps; ReturnComp.id = id; ReturnComp.displayName = id;