From ca7c56e49566fd490f81b5ca4c2673258e1fe7b4 Mon Sep 17 00:00:00 2001 From: Rohan Chakraborty Date: Tue, 6 Jan 2026 12:14:25 +0530 Subject: [PATCH 1/3] wip: combobox --- apps/www/src/app/examples/combobox/page.tsx | 28 +++ .../components/combobox/combobox-content.tsx | 75 ++++++++ .../components/combobox/combobox-input.tsx | 88 +++++++++ .../components/combobox/combobox-item.tsx | 86 +++++++++ .../components/combobox/combobox-misc.tsx | 54 ++++++ .../components/combobox/combobox-root.tsx | 144 ++++++++++++++ .../components/combobox/combobox.module.css | 181 ++++++++++++++++++ .../raystack/components/combobox/combobox.tsx | 18 ++ .../raystack/components/combobox/index.ts | 1 + .../raystack/components/combobox/types.ts | 7 + packages/raystack/index.tsx | 13 +- 11 files changed, 689 insertions(+), 6 deletions(-) create mode 100644 apps/www/src/app/examples/combobox/page.tsx create mode 100644 packages/raystack/components/combobox/combobox-content.tsx create mode 100644 packages/raystack/components/combobox/combobox-input.tsx create mode 100644 packages/raystack/components/combobox/combobox-item.tsx create mode 100644 packages/raystack/components/combobox/combobox-misc.tsx create mode 100644 packages/raystack/components/combobox/combobox-root.tsx create mode 100644 packages/raystack/components/combobox/combobox.module.css create mode 100644 packages/raystack/components/combobox/combobox.tsx create mode 100644 packages/raystack/components/combobox/index.ts create mode 100644 packages/raystack/components/combobox/types.ts diff --git a/apps/www/src/app/examples/combobox/page.tsx b/apps/www/src/app/examples/combobox/page.tsx new file mode 100644 index 00000000..d1d0ee09 --- /dev/null +++ b/apps/www/src/app/examples/combobox/page.tsx @@ -0,0 +1,28 @@ +'use client'; +import { Combobox, Flex } from '@raystack/apsara'; + +const Page = () => { + return ( + + + + + Item 1 + Item 2 + Item 3 + + + + ); +}; + +export default Page; diff --git a/packages/raystack/components/combobox/combobox-content.tsx b/packages/raystack/components/combobox/combobox-content.tsx new file mode 100644 index 00000000..602435a9 --- /dev/null +++ b/packages/raystack/components/combobox/combobox-content.tsx @@ -0,0 +1,75 @@ +'use client'; + +import { ComboboxList } from '@ariakit/react'; +import { cx } from 'class-variance-authority'; +import { Popover as PopoverPrimitive } from 'radix-ui'; +import { ComponentPropsWithoutRef, ElementRef, forwardRef } from 'react'; +import styles from './combobox.module.css'; +import { useComboboxContext } from './combobox-root'; + +export interface ComboboxContentProps + extends Omit< + ComponentPropsWithoutRef, + 'asChild' + > { + width?: 'trigger' | 'auto' | number; +} + +export const ComboboxContent = forwardRef< + ElementRef, + ComboboxContentProps +>( + ( + { + className, + children, + sideOffset = 4, + width = 'trigger', + align = 'start', + onOpenAutoFocus, + onInteractOutside, + ...props + }, + ref + ) => { + const { inputRef, listRef } = useComboboxContext(); + + const widthStyle = + width === 'trigger' + ? { minWidth: 'var(--radix-popover-trigger-width)' } + : width === 'auto' + ? {} + : { width }; + + return ( + + { + event.preventDefault(); + onOpenAutoFocus?.(event); + }} + onInteractOutside={event => { + const target = event.target as Element | null; + const isInput = target === inputRef.current; + const inListbox = target && listRef.current?.contains(target); + if (isInput || inListbox) { + event.preventDefault(); + } + onInteractOutside?.(event); + }} + {...props} + > + + {children} + + + + ); + } +); +ComboboxContent.displayName = 'ComboboxContent'; diff --git a/packages/raystack/components/combobox/combobox-input.tsx b/packages/raystack/components/combobox/combobox-input.tsx new file mode 100644 index 00000000..2094372b --- /dev/null +++ b/packages/raystack/components/combobox/combobox-input.tsx @@ -0,0 +1,88 @@ +'use client'; + +import { Combobox } from '@ariakit/react'; +import { ChevronDownIcon } from '@radix-ui/react-icons'; +import { cva, VariantProps } from 'class-variance-authority'; +import { Popover as PopoverPrimitive } from 'radix-ui'; +import { ComponentPropsWithoutRef, ElementRef, forwardRef } from 'react'; +import { Flex } from '../flex'; +import styles from './combobox.module.css'; +import { useComboboxContext } from './combobox-root'; + +const input = cva(styles.input, { + variants: { + size: { + small: styles['input-small'], + medium: styles['input-medium'] + }, + variant: { + outline: styles['input-outline'], + text: styles['input-text'] + } + }, + defaultVariants: { + size: 'medium', + variant: 'outline' + } +}); + +export interface ComboboxInputProps + extends Omit, 'size'>, + VariantProps { + showIcon?: boolean; +} + +export const ComboboxInput = forwardRef< + ElementRef, + ComboboxInputProps +>( + ( + { + size, + variant, + className, + placeholder = 'Search...', + showIcon = true, + ...props + }, + ref + ) => { + const { inputRef, listRef, setOpen } = useComboboxContext(); + + return ( + + + { + // Handle both refs + if (typeof ref === 'function') { + ref(node); + } else if (ref) { + ref.current = node; + } + ( + inputRef as React.MutableRefObject + ).current = node; + }} + placeholder={placeholder} + className={styles.inputField} + onFocus={() => setOpen(true)} + onBlurCapture={event => { + const target = event.relatedTarget as Element | null; + const isInput = target === inputRef.current; + const inListbox = target && listRef.current?.contains(target); + if (isInput || inListbox) { + event.preventDefault(); + } + }} + {...props} + /> + {showIcon && ( + + + ); + } +); +ComboboxInput.displayName = 'ComboboxInput'; diff --git a/packages/raystack/components/combobox/combobox-item.tsx b/packages/raystack/components/combobox/combobox-item.tsx new file mode 100644 index 00000000..ba1a5ea6 --- /dev/null +++ b/packages/raystack/components/combobox/combobox-item.tsx @@ -0,0 +1,86 @@ +'use client'; + +import { ComboboxItem as AriakitComboboxItem } from '@ariakit/react'; +import { cx } from 'class-variance-authority'; +import { + ComponentPropsWithoutRef, + ElementRef, + forwardRef, + useLayoutEffect +} from 'react'; +import { Text } from '../text'; +import styles from './combobox.module.css'; +import { useComboboxContext } from './combobox-root'; + +export interface ComboboxItemProps + extends ComponentPropsWithoutRef { + leadingIcon?: React.ReactNode; +} + +export const ComboboxItem = forwardRef< + ElementRef, + ComboboxItemProps +>( + ( + { + className, + children, + value: providedValue, + leadingIcon, + disabled, + ...props + }, + ref + ) => { + const value = String(providedValue); + const { + registerItem, + unregisterItem, + value: selectedValue, + onValueChange, + setOpen + } = useComboboxContext(); + + const isSelected = value === selectedValue; + + const handleClick = () => { + if (disabled) return; + onValueChange?.(value); + setOpen(false); + }; + + const element = + typeof children === 'string' ? ( + <> + {leadingIcon &&
{leadingIcon}
} + {children} + + ) : ( + children + ); + + useLayoutEffect(() => { + registerItem({ leadingIcon, children, value }); + return () => { + unregisterItem(value); + }; + }, [value, children, registerItem, unregisterItem, leadingIcon]); + + return ( + + {element} + + ); + } +); +ComboboxItem.displayName = 'ComboboxItem'; diff --git a/packages/raystack/components/combobox/combobox-misc.tsx b/packages/raystack/components/combobox/combobox-misc.tsx new file mode 100644 index 00000000..88c4ace3 --- /dev/null +++ b/packages/raystack/components/combobox/combobox-misc.tsx @@ -0,0 +1,54 @@ +'use client'; + +import { + ComboboxGroup as AriakitComboboxGroup, + ComboboxGroupLabel as AriakitComboboxGroupLabel, + ComboboxSeparator as AriakitComboboxSeparator +} from '@ariakit/react'; +import { cx } from 'class-variance-authority'; +import { ComponentPropsWithoutRef, ElementRef, forwardRef } from 'react'; +import styles from './combobox.module.css'; + +export const ComboboxLabel = forwardRef< + ElementRef, + ComponentPropsWithoutRef +>(({ className, ...props }, ref) => { + return ( + + ); +}); +ComboboxLabel.displayName = 'ComboboxLabel'; + +export const ComboboxGroup = forwardRef< + ElementRef, + ComponentPropsWithoutRef +>(({ className, children, ...props }, ref) => { + return ( + + {children} + + ); +}); +ComboboxGroup.displayName = 'ComboboxGroup'; + +export const ComboboxSeparator = forwardRef< + ElementRef, + ComponentPropsWithoutRef +>(({ className, ...props }, ref) => { + return ( + + ); +}); +ComboboxSeparator.displayName = 'ComboboxSeparator'; diff --git a/packages/raystack/components/combobox/combobox-root.tsx b/packages/raystack/components/combobox/combobox-root.tsx new file mode 100644 index 00000000..2a657f09 --- /dev/null +++ b/packages/raystack/components/combobox/combobox-root.tsx @@ -0,0 +1,144 @@ +'use client'; + +import { ComboboxProvider } from '@ariakit/react'; +import { Popover as PopoverPrimitive } from 'radix-ui'; +import { + createContext, + ReactNode, + RefObject, + useCallback, + useContext, + useRef, + useState +} from 'react'; +import { ComboboxItemType } from './types'; + +interface ComboboxContextValue { + value?: string; + onValueChange?: (value: string) => void; + searchValue: string; + onSearch?: (value: string) => void; + open: boolean; + setOpen: (open: boolean) => void; + inputRef: RefObject; + listRef: RefObject; + registerItem: (item: ComboboxItemType) => void; + unregisterItem: (value: string) => void; + items: Record; +} + +const ComboboxContext = createContext( + undefined +); + +export const useComboboxContext = (): ComboboxContextValue => { + const context = useContext(ComboboxContext); + if (!context) { + throw new Error( + 'useComboboxContext must be used within a ComboboxProvider' + ); + } + return context; +}; + +export interface ComboboxRootProps { + children: ReactNode; + value?: string; + onValueChange?: (value: string) => void; + searchValue?: string; + onSearch?: (value: string) => void; + defaultSearchValue?: string; + open?: boolean; + defaultOpen?: boolean; + onOpenChange?: (open: boolean) => void; + modal?: boolean; +} + +export const ComboboxRoot = (props: ComboboxRootProps) => { + const { + children, + value, + onValueChange, + searchValue: providedSearchValue, + onSearch, + defaultSearchValue = '', + open: providedOpen, + defaultOpen = false, + onOpenChange, + modal = false + } = props; + + const [internalSearchValue, setInternalSearchValue] = + useState(defaultSearchValue); + const [internalOpen, setInternalOpen] = useState(defaultOpen); + const [items, setItems] = useState({}); + + const inputRef = useRef(null); + const listRef = useRef(null); + + const searchValue = providedSearchValue ?? internalSearchValue; + const open = providedOpen ?? internalOpen; + + const setSearchValue = useCallback( + (newValue: string) => { + setInternalSearchValue(newValue); + onSearch?.(newValue); + }, + [onSearch] + ); + + const setOpen = useCallback( + (newOpen: boolean) => { + setInternalOpen(newOpen); + onOpenChange?.(newOpen); + }, + [onOpenChange] + ); + + const registerItem = useCallback( + item => { + setItems(prev => ({ ...prev, [item.value]: item })); + }, + [] + ); + + const unregisterItem = useCallback( + itemValue => { + setItems(prev => { + const { [itemValue]: _, ...rest } = prev; + return rest; + }); + }, + [] + ); + + return ( + + + + {children} + + + + ); +}; diff --git a/packages/raystack/components/combobox/combobox.module.css b/packages/raystack/components/combobox/combobox.module.css new file mode 100644 index 00000000..681c43e8 --- /dev/null +++ b/packages/raystack/components/combobox/combobox.module.css @@ -0,0 +1,181 @@ +.input { + display: flex; + justify-content: space-between; + align-items: center; + color: var(--rs-color-foreground-base-primary); + background-color: var(--rs-color-background-base-primary); + font-size: var(--rs-font-size-small); + line-height: var(--rs-line-height-small); + border-radius: var(--rs-radius-2); + letter-spacing: var(--rs-letter-spacing-small); + user-select: none; + -webkit-user-select: none; + transition: all 0.2s ease; +} + +.input-outline { + border: 0.5px solid var(--rs-color-border-base-secondary); + box-shadow: var(--rs-shadow-feather); +} + +.input-text { + border: none; + box-shadow: none; +} + +.input-small { + padding: var(--rs-space-2); + min-height: 24px; +} + +.input-medium { + padding: var(--rs-space-3); + min-height: 32px; +} + +.inputField { + flex: 1; + background: transparent; + border: none; + outline: none; + color: var(--rs-color-foreground-base-primary); + font-size: inherit; + line-height: inherit; + letter-spacing: inherit; + min-width: 0; +} + +.inputField::placeholder { + color: var(--rs-color-foreground-base-tertiary); +} + +.inputField:focus { + outline: none; +} + +.inputIcon { + width: 16px; + height: 16px; + margin-left: var(--rs-space-3); + flex-shrink: 0; + color: var(--rs-color-foreground-base-secondary); + transition: transform 0.2s ease; +} + +.input:hover { + cursor: text; + background-color: var(--rs-color-background-base-primary-hover); +} + +.input:focus-within { + outline: 1px solid var(--rs-color-border-accent-emphasis); +} + +.input[data-disabled] { + background: var(--rs-color-background-base-primary); + border-color: var(--rs-color-border-base-tertiary); + opacity: 0.4; + cursor: not-allowed !important; + pointer-events: none; +} + +.content { + z-index: var(--rs-z-index-portal); + font-size: var(--rs-font-size-small); + line-height: var(--rs-line-height-small); + letter-spacing: var(--rs-letter-spacing-small); + box-sizing: border-box; + background-color: var(--rs-color-background-base-primary); + border-radius: var(--rs-radius-2); + box-shadow: var(--rs-shadow-soft); + border: 1px solid var(--rs-color-border-base-primary); + max-height: 320px; + overflow: auto; + animation: fadeIn 0.15s ease; +} + +@keyframes fadeIn { + from { + opacity: 0; + transform: translateY(-4px); + } + to { + opacity: 1; + transform: translateY(0); + } +} + +.list { + padding: var(--rs-space-2); +} + +.item { + display: flex; + align-items: center; + position: relative; + gap: var(--rs-space-3); + padding: var(--rs-space-3); + color: var(--rs-color-foreground-base-primary); + white-space: normal; + word-break: break-word; + border-radius: var(--rs-radius-2); + cursor: pointer; +} + +.item[data-active-item="true"], +.item:hover { + outline: none; + background: var(--rs-color-background-base-primary-hover); +} + +.item[data-selected="true"] { + background: var(--rs-color-background-neutral-secondary); +} + +.item[aria-disabled="true"] { + opacity: 0.6; + pointer-events: none; + cursor: not-allowed; +} + +.label { + padding: var(--rs-space-2) var(--rs-space-3); + font-weight: var(--rs-font-weight-medium); + font-size: var(--rs-font-size-mini); + color: var(--rs-color-foreground-base-secondary); +} + +.group { + display: flex; + flex-direction: column; +} + +.separator { + height: 1px; + margin: var(--rs-space-2) calc(var(--rs-space-3) * -1); + background: var(--rs-color-border-base-primary); +} + +.itemIcon { + display: flex; + align-items: center; + justify-content: center; + flex-shrink: 0; +} + +.itemIcon svg { + width: 16px; + height: 16px; +} + +.input-small .inputIcon { + width: 12px; + height: 12px; + margin-left: var(--rs-space-2); +} + +.input-small .inputField { + font-size: var(--rs-font-size-mini); + line-height: var(--rs-line-height-mini); + letter-spacing: var(--rs-letter-spacing-mini); +} diff --git a/packages/raystack/components/combobox/combobox.tsx b/packages/raystack/components/combobox/combobox.tsx new file mode 100644 index 00000000..3ae662e2 --- /dev/null +++ b/packages/raystack/components/combobox/combobox.tsx @@ -0,0 +1,18 @@ +import { ComboboxContent } from './combobox-content'; +import { ComboboxInput } from './combobox-input'; +import { ComboboxItem } from './combobox-item'; +import { + ComboboxGroup, + ComboboxLabel, + ComboboxSeparator +} from './combobox-misc'; +import { ComboboxRoot } from './combobox-root'; + +export const Combobox = Object.assign(ComboboxRoot, { + Input: ComboboxInput, + Content: ComboboxContent, + Item: ComboboxItem, + Group: ComboboxGroup, + Label: ComboboxLabel, + Separator: ComboboxSeparator +}); diff --git a/packages/raystack/components/combobox/index.ts b/packages/raystack/components/combobox/index.ts new file mode 100644 index 00000000..a9e4295d --- /dev/null +++ b/packages/raystack/components/combobox/index.ts @@ -0,0 +1 @@ +export { Combobox } from './combobox'; diff --git a/packages/raystack/components/combobox/types.ts b/packages/raystack/components/combobox/types.ts new file mode 100644 index 00000000..04773d4f --- /dev/null +++ b/packages/raystack/components/combobox/types.ts @@ -0,0 +1,7 @@ +import { ReactNode } from 'react'; + +export type ComboboxItemType = { + leadingIcon?: ReactNode; + children: ReactNode; + value: string; +}; diff --git a/packages/raystack/index.tsx b/packages/raystack/index.tsx index 2c48fec6..3e38a0df 100644 --- a/packages/raystack/index.tsx +++ b/packages/raystack/index.tsx @@ -14,6 +14,8 @@ export { Callout } from './components/callout'; export { Checkbox } from './components/checkbox'; export { Chip } from './components/chip'; export { CodeBlock } from './components/code-block'; +export * from './components/color-picker'; +export { Combobox } from './components/combobox'; export { Command } from './components/command'; export { Container } from './components/container'; export { CopyButton } from './components/copy-button'; @@ -22,14 +24,15 @@ export { DataTableColumnDef, DataTableQuery, DataTableSort, - useDataTable, - EmptyFilterValue + EmptyFilterValue, + useDataTable } from './components/data-table'; export { Dialog } from './components/dialog'; export { DropdownMenu } from './components/dropdown-menu'; export { EmptyState } from './components/empty-state'; export { FilterChip } from './components/filter-chip'; export { Flex } from './components/flex'; +export { Grid } from './components/grid'; export { Headline } from './components/headline'; export { IconButton } from './components/icon-button'; export { Image } from './components/image'; @@ -45,12 +48,12 @@ export { Search } from './components/search'; export { Select } from './components/select'; export { Separator } from './components/separator'; export { Sheet } from './components/sheet'; +export { SidePanel } from './components/side-panel'; export { Sidebar } from './components/sidebar'; export { Skeleton } from './components/skeleton'; export { Slider } from './components/slider'; export { Spinner } from './components/spinner'; export { Switch } from './components/switch'; -export { SidePanel } from './components/side-panel'; export { Table } from './components/table'; export { Tabs } from './components/tabs'; export { Text } from './components/text'; @@ -61,7 +64,5 @@ export { ThemeSwitcher, useTheme } from './components/theme-provider'; -export { toast, ToastContainer } from './components/toast'; +export { ToastContainer, toast } from './components/toast'; export { Tooltip } from './components/tooltip'; -export { Grid } from './components/grid'; -export * from './components/color-picker'; From a65b1cb7908b4e00fda4d3ff55e73cfc5ac7457d Mon Sep 17 00:00:00 2001 From: Rohan Chakraborty Date: Mon, 12 Jan 2026 03:09:19 +0530 Subject: [PATCH 2/3] feat: combobox --- .../components/combobox/combobox-content.tsx | 69 +++++--- .../components/combobox/combobox-input.tsx | 146 +++++++-------- .../components/combobox/combobox-item.tsx | 52 +++--- .../components/combobox/combobox-misc.tsx | 10 ++ .../components/combobox/combobox-root.tsx | 166 ++++++++++-------- .../components/combobox/combobox.module.css | 159 +++-------------- .../raystack/components/combobox/types.ts | 7 - .../components/input-field/input-field.tsx | 7 +- .../components/select/select-item.tsx | 2 +- .../components/select/select-misc.tsx | 2 +- .../components/select/select-trigger.tsx | 6 +- 11 files changed, 283 insertions(+), 343 deletions(-) delete mode 100644 packages/raystack/components/combobox/types.ts diff --git a/packages/raystack/components/combobox/combobox-content.tsx b/packages/raystack/components/combobox/combobox-content.tsx index 602435a9..66972c24 100644 --- a/packages/raystack/components/combobox/combobox-content.tsx +++ b/packages/raystack/components/combobox/combobox-content.tsx @@ -3,7 +3,12 @@ import { ComboboxList } from '@ariakit/react'; import { cx } from 'class-variance-authority'; import { Popover as PopoverPrimitive } from 'radix-ui'; -import { ComponentPropsWithoutRef, ElementRef, forwardRef } from 'react'; +import { + ComponentPropsWithoutRef, + ElementRef, + forwardRef, + useCallback +} from 'react'; import styles from './combobox.module.css'; import { useComboboxContext } from './combobox-root'; @@ -28,19 +33,51 @@ export const ComboboxContent = forwardRef< align = 'start', onOpenAutoFocus, onInteractOutside, + onFocusOutside, ...props }, ref ) => { - const { inputRef, listRef } = useComboboxContext(); + const { inputRef, listRef, value, setInputValue, multiple } = + useComboboxContext(); - const widthStyle = - width === 'trigger' - ? { minWidth: 'var(--radix-popover-trigger-width)' } - : width === 'auto' - ? {} - : { width }; + const handleOnInteractOutside = useCallback< + NonNullable< + ComponentPropsWithoutRef< + typeof PopoverPrimitive.Content + >['onInteractOutside'] + > + >( + event => { + const target = event.target as Element | null; + const isInput = target === inputRef.current; + const inListbox = target && listRef.current?.contains(target); + if (isInput || inListbox) { + event.preventDefault(); + return; + } + if (!multiple) { + if (typeof value === 'string' && value.length) setInputValue(value); + else setInputValue(''); + } + onInteractOutside?.(event); + }, + [onInteractOutside, inputRef, listRef, multiple, value, setInputValue] + ); + const handleOnOpenAutoFocus = useCallback< + NonNullable< + ComponentPropsWithoutRef< + typeof PopoverPrimitive.Content + >['onOpenAutoFocus'] + > + >( + event => { + event.preventDefault(); + onOpenAutoFocus?.(event); + }, + [onOpenAutoFocus] + ); return ( { - event.preventDefault(); - onOpenAutoFocus?.(event); - }} - onInteractOutside={event => { - const target = event.target as Element | null; - const isInput = target === inputRef.current; - const inListbox = target && listRef.current?.contains(target); - if (isInput || inListbox) { - event.preventDefault(); - } - onInteractOutside?.(event); - }} + onOpenAutoFocus={handleOnOpenAutoFocus} + onInteractOutside={handleOnInteractOutside} {...props} > diff --git a/packages/raystack/components/combobox/combobox-input.tsx b/packages/raystack/components/combobox/combobox-input.tsx index 2094372b..cb751d8d 100644 --- a/packages/raystack/components/combobox/combobox-input.tsx +++ b/packages/raystack/components/combobox/combobox-input.tsx @@ -2,87 +2,91 @@ import { Combobox } from '@ariakit/react'; import { ChevronDownIcon } from '@radix-ui/react-icons'; -import { cva, VariantProps } from 'class-variance-authority'; import { Popover as PopoverPrimitive } from 'radix-ui'; -import { ComponentPropsWithoutRef, ElementRef, forwardRef } from 'react'; -import { Flex } from '../flex'; +import { + ElementRef, + FocusEvent, + forwardRef, + KeyboardEvent, + useCallback +} from 'react'; +import { InputField } from '../input-field'; +import { InputFieldProps } from '../input-field/input-field'; import styles from './combobox.module.css'; import { useComboboxContext } from './combobox-root'; -const input = cva(styles.input, { - variants: { - size: { - small: styles['input-small'], - medium: styles['input-medium'] - }, - variant: { - outline: styles['input-outline'], - text: styles['input-text'] - } - }, - defaultVariants: { - size: 'medium', - variant: 'outline' - } -}); - export interface ComboboxInputProps - extends Omit, 'size'>, - VariantProps { - showIcon?: boolean; -} + extends Omit< + InputFieldProps, + 'trailingIcon' | 'suffix' | 'chips' | 'maxChipsVisible' + > {} export const ComboboxInput = forwardRef< ElementRef, ComboboxInputProps ->( - ( - { - size, - variant, - className, - placeholder = 'Search...', - showIcon = true, - ...props +>(({ onBlur, ...props }, ref) => { + const { + inputRef, + listRef, + value, + multiple, + inputValue, + setInputValue, + setValue + } = useComboboxContext(); + + const handleOnKeyDown = useCallback( + (event: KeyboardEvent) => { + if (event.key === 'Backspace') { + if (multiple && !inputValue?.length) { + event.preventDefault(); + setValue((value as string[])?.slice(0, -1)); + } + } }, - ref - ) => { - const { inputRef, listRef, setOpen } = useComboboxContext(); + [multiple, inputValue, value, setValue] + ); + const handleOnBlur = useCallback( + (event: FocusEvent) => { + const target = event.relatedTarget as Element | null; + const isInput = target === inputRef.current; + const inListbox = target && listRef.current?.contains(target); + if (isInput || inListbox) return; + if (!multiple) { + if (typeof value === 'string' && value.length) setInputValue(value); + else setInputValue(''); + } + onBlur?.(event); + }, + [onBlur, multiple, value, inputRef, listRef, setInputValue] + ); - return ( - - - { - // Handle both refs - if (typeof ref === 'function') { - ref(node); - } else if (ref) { - ref.current = node; - } - ( - inputRef as React.MutableRefObject - ).current = node; - }} - placeholder={placeholder} - className={styles.inputField} - onFocus={() => setOpen(true)} - onBlurCapture={event => { - const target = event.relatedTarget as Element | null; - const isInput = target === inputRef.current; - const inListbox = target && listRef.current?.contains(target); - if (isInput || inListbox) { - event.preventDefault(); + return ( + +
+ ({ + label: val, + onRemove: () => + setValue((value as string[])?.filter(v => v !== val)) + })) + : undefined } - }} - {...props} - /> - {showIcon && ( -
+
+ ); +}); ComboboxInput.displayName = 'ComboboxInput'; diff --git a/packages/raystack/components/combobox/combobox-item.tsx b/packages/raystack/components/combobox/combobox-item.tsx index ba1a5ea6..162c283f 100644 --- a/packages/raystack/components/combobox/combobox-item.tsx +++ b/packages/raystack/components/combobox/combobox-item.tsx @@ -2,12 +2,9 @@ import { ComboboxItem as AriakitComboboxItem } from '@ariakit/react'; import { cx } from 'class-variance-authority'; -import { - ComponentPropsWithoutRef, - ElementRef, - forwardRef, - useLayoutEffect -} from 'react'; +import { ComponentPropsWithoutRef, ElementRef, forwardRef } from 'react'; +import { Checkbox } from '../checkbox'; +import { getMatch } from '../dropdown-menu/utils'; import { Text } from '../text'; import styles from './combobox.module.css'; import { useComboboxContext } from './combobox-root'; @@ -32,22 +29,17 @@ export const ComboboxItem = forwardRef< }, ref ) => { - const value = String(providedValue); - const { - registerItem, - unregisterItem, - value: selectedValue, - onValueChange, - setOpen - } = useComboboxContext(); + const value = providedValue + ? String(providedValue) + : typeof children === 'string' + ? children + : undefined; + const { multiple, value: comboboxValue, inputValue } = useComboboxContext(); - const isSelected = value === selectedValue; - - const handleClick = () => { - if (disabled) return; - onValueChange?.(value); - setOpen(false); - }; + const isSelected = multiple + ? comboboxValue?.includes(value ?? '') + : value === comboboxValue; + const isMatched = getMatch(value, children, inputValue); const element = typeof children === 'string' ? ( @@ -59,25 +51,23 @@ export const ComboboxItem = forwardRef< children ); - useLayoutEffect(() => { - registerItem({ leadingIcon, children, value }); - return () => { - unregisterItem(value); - }; - }, [value, children, registerItem, unregisterItem, leadingIcon]); + if (inputValue?.length && !isMatched) { + // Doesn't match search, so don't render at all + return null; + } return ( + {multiple && } {element} ); diff --git a/packages/raystack/components/combobox/combobox-misc.tsx b/packages/raystack/components/combobox/combobox-misc.tsx index 88c4ace3..4ea7b27e 100644 --- a/packages/raystack/components/combobox/combobox-misc.tsx +++ b/packages/raystack/components/combobox/combobox-misc.tsx @@ -8,11 +8,15 @@ import { import { cx } from 'class-variance-authority'; import { ComponentPropsWithoutRef, ElementRef, forwardRef } from 'react'; import styles from './combobox.module.css'; +import { useComboboxContext } from './combobox-root'; export const ComboboxLabel = forwardRef< ElementRef, ComponentPropsWithoutRef >(({ className, ...props }, ref) => { + const { inputValue } = useComboboxContext(); + if (inputValue?.length) return null; + return ( , ComponentPropsWithoutRef >(({ className, children, ...props }, ref) => { + const { inputValue } = useComboboxContext(); + if (inputValue?.length) return null; + return ( , ComponentPropsWithoutRef >(({ className, ...props }, ref) => { + const { inputValue } = useComboboxContext(); + if (inputValue?.length) return null; + return ( void; - searchValue: string; - onSearch?: (value: string) => void; + setValue: (value: string | string[]) => void; + value?: string | string[]; + inputValue?: string; + setInputValue: (inputValue: string) => void; open: boolean; setOpen: (open: boolean) => void; inputRef: RefObject; + multiple: boolean; listRef: RefObject; - registerItem: (item: ComboboxItemType) => void; - unregisterItem: (value: string) => void; - items: Record; } const ComboboxContext = createContext( @@ -40,51 +36,96 @@ export const useComboboxContext = (): ComboboxContextValue => { } return context; }; - -export interface ComboboxRootProps { - children: ReactNode; - value?: string; - onValueChange?: (value: string) => void; - searchValue?: string; - onSearch?: (value: string) => void; - defaultSearchValue?: string; - open?: boolean; - defaultOpen?: boolean; +export interface BaseComboboxRootProps + extends Omit< + ComboboxProviderProps, + | 'value' + | 'setValue' + | 'selectedValue' + | 'setSelectedValue' + | 'defaultSelectedValue' + | 'defaultValue' + | 'resetValueOnHide' + | 'resetValueOnSelect' + > { onOpenChange?: (open: boolean) => void; modal?: boolean; + inputValue?: string; + onInputValueChange?: (inputValue: string) => void; + defaultInputValue?: string; } +export interface SingleComboboxProps extends BaseComboboxRootProps { + multiple?: false; + value?: string; + onValueChange?: (value: string) => void; + defaultValue?: string; +} +export interface MultipleComboboxProps extends BaseComboboxRootProps { + multiple: true; + value?: string[]; + onValueChange?: (value: string[]) => void; + defaultValue?: string[]; +} +export type ComboboxRootProps = SingleComboboxProps | MultipleComboboxProps; -export const ComboboxRoot = (props: ComboboxRootProps) => { - const { - children, - value, - onValueChange, - searchValue: providedSearchValue, - onSearch, - defaultSearchValue = '', - open: providedOpen, - defaultOpen = false, - onOpenChange, - modal = false - } = props; - - const [internalSearchValue, setInternalSearchValue] = - useState(defaultSearchValue); +export const ComboboxRoot = ({ + modal = false, + multiple = false, + children, + value: providedValue, + defaultValue = multiple ? [] : undefined, + onValueChange, + inputValue: providedInputValue, + onInputValueChange, + defaultInputValue, + open: providedOpen, + defaultOpen = false, + onOpenChange, + ...props +}: ComboboxRootProps) => { + const [internalValue, setInternalValue] = useState< + string | string[] | undefined + >(defaultValue); + const [internalInputValue, setInternalInputValue] = + useState(defaultInputValue); const [internalOpen, setInternalOpen] = useState(defaultOpen); - const [items, setItems] = useState({}); const inputRef = useRef(null); const listRef = useRef(null); - const searchValue = providedSearchValue ?? internalSearchValue; + const value = providedValue ?? internalValue; + const inputValue = providedInputValue ?? internalInputValue; const open = providedOpen ?? internalOpen; - const setSearchValue = useCallback( + const setValue = useCallback( + (newValue: string | string[] | undefined) => { + if (multiple) { + const formattedValue = newValue + ? Array.isArray(newValue) + ? newValue + : [newValue] + : []; + setInternalValue(formattedValue); + (onValueChange as MultipleComboboxProps['onValueChange'])?.( + formattedValue + ); + } else { + setInternalValue(String(newValue)); + (onValueChange as SingleComboboxProps['onValueChange'])?.( + String(newValue) + ); + } + }, + [onValueChange, multiple] + ); + + const setInputValue = useCallback( (newValue: string) => { - setInternalSearchValue(newValue); - onSearch?.(newValue); + if (!multiple && newValue.length === 0) setValue(''); + setInternalInputValue(newValue); + onInputValueChange?.(newValue); }, - [onSearch] + [onInputValueChange, setValue, multiple] ); const setOpen = useCallback( @@ -95,46 +136,33 @@ export const ComboboxRoot = (props: ComboboxRootProps) => { [onOpenChange] ); - const registerItem = useCallback( - item => { - setItems(prev => ({ ...prev, [item.value]: item })); - }, - [] - ); - - const unregisterItem = useCallback( - itemValue => { - setItems(prev => { - const { [itemValue]: _, ...rest } = prev; - return rest; - }); - }, - [] - ); - return ( {children} diff --git a/packages/raystack/components/combobox/combobox.module.css b/packages/raystack/components/combobox/combobox.module.css index 681c43e8..7a1a6753 100644 --- a/packages/raystack/components/combobox/combobox.module.css +++ b/packages/raystack/components/combobox/combobox.module.css @@ -1,84 +1,3 @@ -.input { - display: flex; - justify-content: space-between; - align-items: center; - color: var(--rs-color-foreground-base-primary); - background-color: var(--rs-color-background-base-primary); - font-size: var(--rs-font-size-small); - line-height: var(--rs-line-height-small); - border-radius: var(--rs-radius-2); - letter-spacing: var(--rs-letter-spacing-small); - user-select: none; - -webkit-user-select: none; - transition: all 0.2s ease; -} - -.input-outline { - border: 0.5px solid var(--rs-color-border-base-secondary); - box-shadow: var(--rs-shadow-feather); -} - -.input-text { - border: none; - box-shadow: none; -} - -.input-small { - padding: var(--rs-space-2); - min-height: 24px; -} - -.input-medium { - padding: var(--rs-space-3); - min-height: 32px; -} - -.inputField { - flex: 1; - background: transparent; - border: none; - outline: none; - color: var(--rs-color-foreground-base-primary); - font-size: inherit; - line-height: inherit; - letter-spacing: inherit; - min-width: 0; -} - -.inputField::placeholder { - color: var(--rs-color-foreground-base-tertiary); -} - -.inputField:focus { - outline: none; -} - -.inputIcon { - width: 16px; - height: 16px; - margin-left: var(--rs-space-3); - flex-shrink: 0; - color: var(--rs-color-foreground-base-secondary); - transition: transform 0.2s ease; -} - -.input:hover { - cursor: text; - background-color: var(--rs-color-background-base-primary-hover); -} - -.input:focus-within { - outline: 1px solid var(--rs-color-border-accent-emphasis); -} - -.input[data-disabled] { - background: var(--rs-color-background-base-primary); - border-color: var(--rs-color-border-base-tertiary); - opacity: 0.4; - cursor: not-allowed !important; - pointer-events: none; -} - .content { z-index: var(--rs-z-index-portal); font-size: var(--rs-font-size-small); @@ -90,26 +9,27 @@ box-shadow: var(--rs-shadow-soft); border: 1px solid var(--rs-color-border-base-primary); max-height: 320px; + min-width: var(--radix-popover-trigger-width); overflow: auto; - animation: fadeIn 0.15s ease; -} - -@keyframes fadeIn { - from { - opacity: 0; - transform: translateY(-4px); - } - to { - opacity: 1; - transform: translateY(0); - } } .list { padding: var(--rs-space-2); } -.item { +.itemIcon { + display: flex; + align-items: center; + justify-content: center; + flex-shrink: 0; +} + +.itemIcon svg { + width: 16px; + height: 16px; +} + +.menuitem { display: flex; align-items: center; position: relative; @@ -119,35 +39,30 @@ white-space: normal; word-break: break-word; border-radius: var(--rs-radius-2); - cursor: pointer; } -.item[data-active-item="true"], -.item:hover { +.menuitem[data-highlighted], +.menuitem[data-active-item="true"] { outline: none; + cursor: pointer; background: var(--rs-color-background-base-primary-hover); } - -.item[data-selected="true"] { - background: var(--rs-color-background-neutral-secondary); -} - -.item[aria-disabled="true"] { +.menuitem[data-disabled] { opacity: 0.6; pointer-events: none; - cursor: not-allowed; +} + +.list:empty { + padding: 0; +} +.content:has(.list:empty) { + border: none; } .label { padding: var(--rs-space-2) var(--rs-space-3); font-weight: var(--rs-font-weight-medium); font-size: var(--rs-font-size-mini); - color: var(--rs-color-foreground-base-secondary); -} - -.group { - display: flex; - flex-direction: column; } .separator { @@ -155,27 +70,3 @@ margin: var(--rs-space-2) calc(var(--rs-space-3) * -1); background: var(--rs-color-border-base-primary); } - -.itemIcon { - display: flex; - align-items: center; - justify-content: center; - flex-shrink: 0; -} - -.itemIcon svg { - width: 16px; - height: 16px; -} - -.input-small .inputIcon { - width: 12px; - height: 12px; - margin-left: var(--rs-space-2); -} - -.input-small .inputField { - font-size: var(--rs-font-size-mini); - line-height: var(--rs-line-height-mini); - letter-spacing: var(--rs-letter-spacing-mini); -} diff --git a/packages/raystack/components/combobox/types.ts b/packages/raystack/components/combobox/types.ts deleted file mode 100644 index 04773d4f..00000000 --- a/packages/raystack/components/combobox/types.ts +++ /dev/null @@ -1,7 +0,0 @@ -import { ReactNode } from 'react'; - -export type ComboboxItemType = { - leadingIcon?: ReactNode; - children: ReactNode; - value: string; -}; diff --git a/packages/raystack/components/input-field/input-field.tsx b/packages/raystack/components/input-field/input-field.tsx index a58ec262..b63c4609 100644 --- a/packages/raystack/components/input-field/input-field.tsx +++ b/packages/raystack/components/input-field/input-field.tsx @@ -1,11 +1,10 @@ 'use client'; import { InfoCircledIcon } from '@radix-ui/react-icons'; -import { type VariantProps, cva, cx } from 'class-variance-authority'; -import { ComponentPropsWithoutRef, ReactNode, forwardRef } from 'react'; -import { Tooltip } from '../tooltip'; - +import { cva, cx, type VariantProps } from 'class-variance-authority'; +import { ComponentPropsWithoutRef, forwardRef, ReactNode } from 'react'; import { Chip } from '../chip'; +import { Tooltip } from '../tooltip'; import styles from './input-field.module.css'; // Todo: Add a dropdown support diff --git a/packages/raystack/components/select/select-item.tsx b/packages/raystack/components/select/select-item.tsx index f9ed7f47..a499d5ee 100644 --- a/packages/raystack/components/select/select-item.tsx +++ b/packages/raystack/components/select/select-item.tsx @@ -7,8 +7,8 @@ import { ElementRef, forwardRef, useLayoutEffect } from 'react'; import { Checkbox } from '../checkbox'; import { getMatch } from '../dropdown-menu/utils'; import { Text } from '../text'; -import { useSelectContext } from './select-root'; import styles from './select.module.css'; +import { useSelectContext } from './select-root'; export const SelectItem = forwardRef< ElementRef, diff --git a/packages/raystack/components/select/select-misc.tsx b/packages/raystack/components/select/select-misc.tsx index e8b5bfed..01d3a0ea 100644 --- a/packages/raystack/components/select/select-misc.tsx +++ b/packages/raystack/components/select/select-misc.tsx @@ -3,8 +3,8 @@ import { cx } from 'class-variance-authority'; import { Select as SelectPrimitive } from 'radix-ui'; import { ElementRef, Fragment, forwardRef } from 'react'; -import { useSelectContext } from './select-root'; import styles from './select.module.css'; +import { useSelectContext } from './select-root'; export const SelectGroup = forwardRef< ElementRef, diff --git a/packages/raystack/components/select/select-trigger.tsx b/packages/raystack/components/select/select-trigger.tsx index 7592f85d..cacd2dbc 100644 --- a/packages/raystack/components/select/select-trigger.tsx +++ b/packages/raystack/components/select/select-trigger.tsx @@ -1,12 +1,12 @@ 'use client'; import { ChevronDownIcon } from '@radix-ui/react-icons'; -import { VariantProps, cva } from 'class-variance-authority'; +import { cva, VariantProps } from 'class-variance-authority'; import { Select as SelectPrimitive, Slot } from 'radix-ui'; -import { ElementRef, SVGAttributes, forwardRef } from 'react'; +import { ElementRef, forwardRef, SVGAttributes } from 'react'; import { Flex } from '../flex'; -import { useSelectContext } from './select-root'; import styles from './select.module.css'; +import { useSelectContext } from './select-root'; export interface IconProps extends SVGAttributes { children?: never; From 1d7ec5ad7c4fb06dd0f2a1c4f1e1d168bf01277a Mon Sep 17 00:00:00 2001 From: Rohan Chakraborty Date: Mon, 12 Jan 2026 04:12:28 +0530 Subject: [PATCH 3/3] feat: combobox docs and tests --- .../content/docs/components/combobox/demo.ts | 149 ++++++ .../docs/components/combobox/index.mdx | 113 +++++ .../content/docs/components/combobox/props.ts | 133 +++++ .../combobox/__tests__/combobox.test.tsx | 469 ++++++++++++++++++ .../components/combobox/combobox-content.tsx | 5 +- .../components/combobox/combobox-misc.tsx | 2 +- .../select/__tests__/select.test.tsx | 228 --------- 7 files changed, 866 insertions(+), 233 deletions(-) create mode 100644 apps/www/src/content/docs/components/combobox/demo.ts create mode 100644 apps/www/src/content/docs/components/combobox/index.mdx create mode 100644 apps/www/src/content/docs/components/combobox/props.ts create mode 100644 packages/raystack/components/combobox/__tests__/combobox.test.tsx diff --git a/apps/www/src/content/docs/components/combobox/demo.ts b/apps/www/src/content/docs/components/combobox/demo.ts new file mode 100644 index 00000000..163c04e9 --- /dev/null +++ b/apps/www/src/content/docs/components/combobox/demo.ts @@ -0,0 +1,149 @@ +'use client'; + +import { getPropsString } from '@/lib/utils'; + +export const getCode = (props: Record) => { + const { multiple, ...rest } = props; + return ` + + + + Apple + Banana + Blueberry + Grapes + Pineapple + +
`; +}; + +export const playground = { + type: 'playground', + controls: { + label: { type: 'text', initialValue: 'Fruits' }, + size: { + type: 'select', + options: ['small', 'large'], + defaultValue: 'large' + }, + multiple: { + type: 'checkbox', + defaultValue: false + } + }, + getCode +}; + +export const basicDemo = { + type: 'code', + code: ` + + + + Apple + Banana + Grape + Orange + + ` +}; + +export const iconDemo = { + type: 'code', + code: ` + + + + }>Apple + }>Banana + }>Grape + }>Orange + + ` +}; + +export const sizeDemo = { + type: 'code', + code: ` + + + + + Option 1 + Option 2 + + + + + + Option 1 + Option 2 + + + ` +}; + +export const multipleDemo = { + type: 'code', + code: ` + + + + Apple + Banana + Grape + Orange + Pineapple + Mango + + ` +}; + +export const groupDemo = { + type: 'code', + code: ` + + + + + Fruits + Apple + Banana + + + + Vegetables + Carrot + Broccoli + + + ` +}; + +export const controlledDemo = { + type: 'code', + code: ` + function ControlledDemo() { + const [value, setValue] = React.useState(""); + const [inputValue, setInputValue] = React.useState(""); + + return ( + + Selected: {value || "None"} + + + + Apple + Banana + Grape + + + + ); + }` +}; diff --git a/apps/www/src/content/docs/components/combobox/index.mdx b/apps/www/src/content/docs/components/combobox/index.mdx new file mode 100644 index 00000000..09613d30 --- /dev/null +++ b/apps/www/src/content/docs/components/combobox/index.mdx @@ -0,0 +1,113 @@ +--- +title: Combobox +description: An input field with an attached dropdown that allows users to search and select from a list of options. +tag: new +--- + +import { + playground, + basicDemo, + sizeDemo, + iconDemo, + multipleDemo, + groupDemo, + controlledDemo +} from "./demo.ts"; + + + +## Usage + +```tsx +import { Combobox } from "@raystack/apsara"; +``` + +## Combobox Props + +The Combobox component is composed of several parts, each with their own props. + +The root element is the parent component that manages the combobox state including open/close, input value, and selection. It is built using [Ariakit ComboboxProvider](https://ariakit.org/reference/combobox-provider) and [Radix Popover](https://www.radix-ui.com/primitives/docs/components/popover). + + + +### Combobox.Input Props + +The input field that triggers the combobox dropdown and allows users to type and filter options. + + + +### Combobox.Content Props + +The dropdown container that holds the combobox items. + + + +### Combobox.Item Props + +Individual selectable options within the combobox. + + + +### Combobox.Group Props + +A way to group related combobox items together. + + + +### Combobox.Label Props + +Renders a label in a combobox group. This component should be used inside Combobox.Group. + + + +### Combobox.Separator Props + +Visual divider between combobox items or groups. + + + +## Examples + +### Basic Combobox + +A simple combobox with search functionality. + + + +### Size + +The combobox input supports different sizes. + + + +### Multiple Selection + +To enable multiple selection, pass the `multiple` prop to the Combobox root element. + +When multiple selection is enabled, the value, onValueChange, and defaultValue will be an array of strings. Selected items are displayed as chips in the input field. + + + +### Groups and Separators + +Use Combobox.Group, Combobox.Label, and Combobox.Separator to organize items into logical groups. + + + +### Controlled + +You can control the combobox value and input value using the `value`, `onValueChange`, `inputValue`, and `onInputValueChange` props. + + + +## Accessibility + +The Combobox component follows WAI-ARIA guidelines: + +- Input has role `combobox` +- Content has role `listbox` +- Items have role `option` +- Supports keyboard navigation (Arrow keys, Enter, Escape) +- ARIA labels and descriptions for screen readers +- Focus management between input and listbox + diff --git a/apps/www/src/content/docs/components/combobox/props.ts b/apps/www/src/content/docs/components/combobox/props.ts new file mode 100644 index 00000000..9c7db505 --- /dev/null +++ b/apps/www/src/content/docs/components/combobox/props.ts @@ -0,0 +1,133 @@ +export interface ComboboxRootProps { + /** Enables multiple selection. + * When enabled, value, onValueChange, and defaultValue will be an array of strings. + * @default false + */ + multiple?: boolean; + + /** The controlled value of the combobox. + * For single selection: string + * For multiple selection: string[] + */ + value?: string | string[]; + + /** The default value of the combobox (uncontrolled). + * For single selection: string + * For multiple selection: string[] + */ + defaultValue?: string | string[]; + + /** Callback fired when the value changes. + * For single selection: (value: string) => void + * For multiple selection: (value: string[]) => void + */ + onValueChange?: (value: string | string[]) => void; + + /** The controlled input value of the combobox. */ + inputValue?: string; + + /** The default input value (uncontrolled). */ + defaultInputValue?: string; + + /** Callback fired when the input value changes. */ + onInputValueChange?: (inputValue: string) => void; + + /** Whether the combobox is open. + * @default false + */ + open?: boolean; + + /** The default open state (uncontrolled). + * @default false + */ + defaultOpen?: boolean; + + /** Callback fired when the open state changes. */ + onOpenChange?: (open: boolean) => void; + + /** Whether the popover should be modal. + * @default false + */ + modal?: boolean; +} + +export interface ComboboxInputProps { + /** + * Size variant of the input field + * @default "large" + */ + size?: 'small' | 'large'; + + /** Text label above the input */ + label?: string; + + /** Helper text below the input */ + helperText?: string; + + /** Error message to display below the input */ + error?: string; + + /** Whether the input is disabled */ + disabled?: boolean; + + /** Icon element to display at the start of input */ + leadingIcon?: React.ReactNode; + + /** Shows "(Optional)" text next to label */ + optional?: boolean; + + /** Text or symbol to show before input value */ + prefix?: string; + + /** Placeholder text for the input field. */ + placeholder?: string; + + /** Additional CSS class names. */ + className?: string; +} + +export interface ComboboxContentProps { + /** Alignment of the content relative to the trigger. + * @default "start" + */ + align?: 'start' | 'center' | 'end'; + + /** Distance from the trigger in pixels. + * @default 4 + */ + sideOffset?: number; + + /** Additional CSS class names. */ + className?: string; +} + +export interface ComboboxItemProps { + /** The value of the item. If not provided, the item content will be used as the value. */ + value?: string; + + /** Whether the item is disabled. + * @default false + */ + disabled?: boolean; + + /** Leading icon to display before the item text. */ + leadingIcon?: React.ReactNode; + + /** Additional CSS class names. */ + className?: string; +} + +export interface ComboboxGroupProps { + /** Additional CSS class names. */ + className?: string; +} + +export interface ComboboxLabelProps { + /** Additional CSS class names. */ + className?: string; +} + +export interface ComboboxSeparatorProps { + /** Additional CSS class names. */ + className?: string; +} diff --git a/packages/raystack/components/combobox/__tests__/combobox.test.tsx b/packages/raystack/components/combobox/__tests__/combobox.test.tsx new file mode 100644 index 00000000..b76a6061 --- /dev/null +++ b/packages/raystack/components/combobox/__tests__/combobox.test.tsx @@ -0,0 +1,469 @@ +import { fireEvent, render, screen, waitFor } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import { describe, expect, it, vi } from 'vitest'; +import { Combobox } from '../combobox'; +import { ComboboxRootProps } from '../combobox-root'; + +// Mock scrollIntoView for test environment +Object.defineProperty(Element.prototype, 'scrollIntoView', { + value: vi.fn(), + writable: true +}); + +const PLACEHOLDER_TEXT = 'Enter a fruit'; +const FRUIT_OPTIONS = [ + { value: 'apple', label: 'Apple' }, + { value: 'banana', label: 'Banana' }, + { value: 'blueberry', label: 'Blueberry' }, + { value: 'grapes', label: 'Grapes' }, + { value: 'pineapple', label: 'Pineapple' } +]; + +const BasicCombobox = (props: ComboboxRootProps) => { + return ( + + + + {FRUIT_OPTIONS.map(option => ( + + {option.label} + + ))} + + + ); +}; +const renderAndOpenCombobox = async (Combobox: React.ReactElement) => { + await fireEvent.click(render(Combobox).getByPlaceholderText('Enter a fruit')); +}; + +describe('Combobox', () => { + describe('Basic Rendering', () => { + it('renders combobox input', () => { + render(); + const input = screen.getByRole('combobox'); + expect(input).toBeInTheDocument(); + expect(input).toHaveAttribute('placeholder', PLACEHOLDER_TEXT); + }); + + it('does not show content initially', () => { + render(); + FRUIT_OPTIONS.forEach(option => { + expect(screen.queryByText(option.label)).not.toBeInTheDocument(); + }); + }); + + it('shows content when input is clicked', async () => { + const user = userEvent.setup(); + render(); + + const input = screen.getByRole('combobox'); + await user.click(input); + + await waitFor(() => { + expect(screen.getByRole('listbox')).toBeInTheDocument(); + }); + + FRUIT_OPTIONS.forEach(option => { + expect(screen.getByText(option.label)).toBeInTheDocument(); + }); + }); + + it('renders in portal', async () => { + const user = userEvent.setup(); + render(); + + const input = screen.getByRole('combobox'); + await user.click(input); + + await waitFor(() => { + const content = screen.getByRole('listbox'); + expect(content.closest('body')).toBe(document.body); + }); + }); + }); + + describe('Single Selection', () => { + it('selects option when clicked', async () => { + const user = userEvent.setup(); + const handleValueChange = vi.fn(); + render(); + + const input = screen.getByRole('combobox'); + await user.click(input); + + const bananaOption = await screen.findByText('Banana'); + await user.click(bananaOption); + + expect(handleValueChange).toHaveBeenCalledWith('banana'); + }); + + it('closes content after selection in single mode', async () => { + const user = userEvent.setup(); + render(); + + const input = screen.getByRole('combobox'); + await user.click(input); + + await waitFor(() => { + expect(screen.getByRole('listbox')).toBeInTheDocument(); + }); + + const bananaOption = await screen.findByText('Banana'); + await user.click(bananaOption); + + await waitFor(() => { + expect(screen.queryByRole('listbox')).not.toBeInTheDocument(); + }); + }); + + it('updates input value with selected item', async () => { + const user = userEvent.setup(); + render(); + + const input = screen.getByRole('combobox'); + await user.click(input); + + const appleOption = await screen.findByText('Apple'); + await user.click(appleOption); + + expect(input).toHaveValue('apple'); + }); + }); + + describe('Multiple Selection', () => { + it('supports multiple selection', async () => { + const user = userEvent.setup(); + const handleValueChange = vi.fn(); + render(); + + const input = screen.getByRole('combobox'); + await user.click(input); + + const bananaOption = await screen.findByText('Banana'); + await user.click(bananaOption); + expect(handleValueChange).toHaveBeenCalledWith(['banana']); + + const pineappleOption = await screen.findByText('Pineapple'); + await user.click(pineappleOption); + expect(handleValueChange).toHaveBeenCalledWith(['banana', 'pineapple']); + }); + + it('allows deselecting items in multiple mode', async () => { + const user = userEvent.setup(); + const handleValueChange = vi.fn(); + render(); + + const input = screen.getByRole('combobox'); + await user.click(input); + + const bananaOption = await screen.findByText('Banana'); + await user.click(bananaOption); + expect(handleValueChange).toHaveBeenCalledWith(['banana']); + + await user.click(bananaOption); + expect(handleValueChange).toHaveBeenCalledWith([]); + }); + + it('keeps dropdown open after selection in multiple mode', async () => { + const user = userEvent.setup(); + render(); + + const input = screen.getByRole('combobox'); + await user.click(input); + + await waitFor(() => { + expect(screen.getByRole('listbox')).toBeInTheDocument(); + }); + + const bananaOption = await screen.findByText('Banana'); + await user.click(bananaOption); + + expect(screen.getByRole('listbox')).toBeInTheDocument(); + }); + + it('displays selected values as chips', () => { + render(); + + expect(screen.getByText('apple')).toBeInTheDocument(); + expect(screen.getByText('banana')).toBeInTheDocument(); + }); + }); + + describe('Keyboard Navigation', () => { + it('opens with typing', async () => { + const user = userEvent.setup(); + render(); + + const input = screen.getByRole('combobox'); + await user.type(input, 'a'); + + await waitFor(() => { + expect(screen.getByRole('listbox')).toBeInTheDocument(); + }); + }); + + it('closes with Escape key', async () => { + const user = userEvent.setup(); + render(); + + const input = screen.getByRole('combobox'); + await user.click(input); + + await waitFor(() => { + expect(screen.getByRole('listbox')).toBeInTheDocument(); + }); + + await user.keyboard('{Escape}'); + + await waitFor(() => { + expect(screen.queryByRole('listbox')).not.toBeInTheDocument(); + }); + }); + }); + + describe('Search/Filter', () => { + it('filters options based on input', async () => { + const user = userEvent.setup(); + render(); + + const input = screen.getByRole('combobox'); + await user.type(input, 'app'); + + await waitFor(() => { + const options = screen.getAllByRole('option'); + expect(options.length).toBe(2); + expect(options[0].textContent).toBe('Apple'); + expect(options[1].textContent).toBe('Pineapple'); + }); + }); + + it('shows all options when search is cleared', async () => { + const user = userEvent.setup(); + render(); + + const input = screen.getByRole('combobox'); + await user.type(input, 'app'); + + await waitFor(() => { + expect(screen.getAllByRole('option').length).toBe(2); + }); + + await user.clear(input); + + await waitFor(() => { + expect(screen.getAllByRole('option').length).toBe(FRUIT_OPTIONS.length); + }); + }); + + it('calls onInputValueChange when typing', async () => { + const user = userEvent.setup(); + const handleInputChange = vi.fn(); + render(); + + const input = screen.getByRole('combobox'); + await user.type(input, 'a'); + + expect(handleInputChange).toHaveBeenCalledWith('a'); + }); + }); + + describe('Grouping and Labels', () => { + it('renders grouped options', async () => { + const user = userEvent.setup(); + render( + + + + + Fruits + Apple + Banana + + + + Vegetables + Carrot + Broccoli + + + + ); + + const input = screen.getByRole('combobox'); + await user.click(input); + + await waitFor(() => { + expect(screen.getByText('Fruits')).toBeInTheDocument(); + expect(screen.getByText('Vegetables')).toBeInTheDocument(); + expect(screen.getByText('Apple')).toBeInTheDocument(); + expect(screen.getByText('Carrot')).toBeInTheDocument(); + }); + }); + + it('hides labels when searching', async () => { + const user = userEvent.setup(); + render( + + + + + Fruits + Apple + + + + ); + + const input = screen.getByRole('combobox'); + await user.type(input, 'app'); + + await waitFor(() => { + expect(screen.queryByText('Fruits')).not.toBeInTheDocument(); + expect(screen.getByText('Apple')).toBeInTheDocument(); + }); + }); + }); + + describe('Controlled Mode', () => { + it('works as controlled open state', () => { + const { rerender } = render( + + + + Option 1 + + + ); + + expect(screen.queryByRole('listbox')).not.toBeInTheDocument(); + + rerender( + + + + Option 1 + + + ); + + expect(screen.getByRole('listbox')).toBeInTheDocument(); + }); + + it('calls onOpenChange when state changes', async () => { + const user = userEvent.setup(); + const onOpenChange = vi.fn(); + render(); + + const input = screen.getByRole('combobox'); + await user.click(input); + + await waitFor(() => { + expect(onOpenChange).toHaveBeenCalledWith(true); + }); + }); + + it('works with controlled input value', () => { + const handleInputChange = vi.fn(); + render( + + ); + + const input = screen.getByRole('combobox'); + expect(input).toHaveValue('test'); + }); + }); + + describe('Accessibility', () => { + it('has correct ARIA roles', async () => { + const user = userEvent.setup(); + render(); + + const input = screen.getByRole('combobox'); + expect(input).toBeInTheDocument(); + + await user.click(input); + + await waitFor(() => { + const listbox = screen.getByRole('listbox'); + expect(listbox).toBeInTheDocument(); + }); + }); + + it('marks selected items correctly', async () => { + const user = userEvent.setup(); + render(); + + const input = screen.getByRole('combobox'); + await user.click(input); + + await waitFor(() => { + const appleOption = screen + .getByText('Apple') + .closest('[role="option"]'); + expect(appleOption).toHaveAttribute('aria-selected', 'true'); + expect(appleOption).toHaveAttribute('data-selected', 'true'); + }); + }); + + it('options have correct role', async () => { + const user = userEvent.setup(); + render(); + + const input = screen.getByRole('combobox'); + await user.click(input); + + await waitFor(() => { + const options = screen.getAllByRole('option'); + expect(options.length).toBe(FRUIT_OPTIONS.length); + }); + }); + }); + + describe('Item without explicit value', () => { + it('uses children text as value when value prop is not provided', async () => { + const user = userEvent.setup(); + const handleValueChange = vi.fn(); + render( + + + + Apple + Banana + + + ); + + const input = screen.getByRole('combobox'); + await user.click(input); + + const appleOption = await screen.findByText('Apple'); + await user.click(appleOption); + + expect(handleValueChange).toHaveBeenCalledWith('Apple'); + }); + }); + + describe('Backspace behavior in multiple mode', () => { + it('removes last selected item on backspace when input is empty', async () => { + const user = userEvent.setup(); + const handleValueChange = vi.fn(); + render( + + ); + + const input = screen.getByRole('combobox'); + await user.click(input); + await user.keyboard('{Backspace}'); + + expect(handleValueChange).toHaveBeenCalledWith(['apple']); + }); + }); +}); diff --git a/packages/raystack/components/combobox/combobox-content.tsx b/packages/raystack/components/combobox/combobox-content.tsx index 66972c24..004e9bcc 100644 --- a/packages/raystack/components/combobox/combobox-content.tsx +++ b/packages/raystack/components/combobox/combobox-content.tsx @@ -16,9 +16,7 @@ export interface ComboboxContentProps extends Omit< ComponentPropsWithoutRef, 'asChild' - > { - width?: 'trigger' | 'auto' | number; -} + > {} export const ComboboxContent = forwardRef< ElementRef, @@ -29,7 +27,6 @@ export const ComboboxContent = forwardRef< className, children, sideOffset = 4, - width = 'trigger', align = 'start', onOpenAutoFocus, onInteractOutside, diff --git a/packages/raystack/components/combobox/combobox-misc.tsx b/packages/raystack/components/combobox/combobox-misc.tsx index 4ea7b27e..dbe2c91b 100644 --- a/packages/raystack/components/combobox/combobox-misc.tsx +++ b/packages/raystack/components/combobox/combobox-misc.tsx @@ -32,7 +32,7 @@ export const ComboboxGroup = forwardRef< ComponentPropsWithoutRef >(({ className, children, ...props }, ref) => { const { inputValue } = useComboboxContext(); - if (inputValue?.length) return null; + if (inputValue?.length) return children; return ( { expect(options[0].textContent).toBe('Apple'); expect(options[1].textContent).toBe('Pineapple'); }); - // const user = userEvent.setup(); - // render(); - - // const trigger = screen.getByRole('combobox'); - // await user.click(trigger); - - // await waitFor(() => { - // expect(screen.getByRole('dialog')).toBeInTheDocument(); - // }); - - // const searchInput = screen.getByPlaceholderText('Search...'); - // await user.type(searchInput, 'app'); - // await user.clear(searchInput); - - // await waitFor(() => { - // FRUIT_OPTIONS.forEach(option => { - // expect(screen.getByText(option.label)).toBeInTheDocument(); - // }); - // }); - // }); - // }); - - // describe('Grouping and Labels', () => { - // it('renders grouped options', async () => { - // const user = userEvent.setup(); - // render( - // - // ); - - // const trigger = screen.getByRole('combobox'); - // await user.click(trigger); - - // await waitFor(() => { - // expect(screen.getByText('Fruits')).toBeInTheDocument(); - // expect(screen.getByText('Vegetables')).toBeInTheDocument(); - // expect(screen.getByText('Apple')).toBeInTheDocument(); - // expect(screen.getByText('Carrot')).toBeInTheDocument(); - // }); - // }); - // }); - - // describe('Controlled Mode', () => { - // it('works as controlled component', () => { - // const { rerender } = render( - // - // ); - - // expect(screen.queryByRole('listbox')).not.toBeInTheDocument(); - - // rerender( - // - // ); - - // expect(screen.getByRole('listbox')).toBeInTheDocument(); - // }); - - // it('calls onOpenChange when state changes', async () => { - // const user = userEvent.setup(); - // const onOpenChange = vi.fn(); - // render(); - - // const trigger = screen.getByRole('combobox'); - // await user.click(trigger); - - // expect(onOpenChange).toHaveBeenCalledWith(true); - // }); - // }); - - // describe('Accessibility', () => { - // it('has correct ARIA roles', async () => { - // const user = userEvent.setup(); - // render(); - - // const trigger = screen.getByRole('combobox'); - // await user.click(trigger); - - // await waitFor(() => { - // const listbox = screen.getByRole('listbox'); - // expect(listbox).toBeInTheDocument(); - // expect(listbox).toHaveAttribute('aria-multiselectable', 'false'); - // }); - // }); - - // it('has correct ARIA roles for multiple selection', async () => { - // const user = userEvent.setup(); - // render(); - - // const trigger = screen.getByRole('combobox'); - // await user.click(trigger); - - // await waitFor(() => { - // const listbox = screen.getByRole('listbox'); - // expect(listbox).toBeInTheDocument(); - // expect(listbox).toHaveAttribute('aria-multiselectable', 'true'); - // }); - // }); - - // it('has correct ARIA roles for autocomplete', async () => { - // const user = userEvent.setup(); - // render(); - - // const trigger = screen.getByRole('combobox'); - // await user.click(trigger); - - // await waitFor(() => { - // const dialog = screen.getByRole('dialog'); - // expect(dialog).toBeInTheDocument(); - // }); - // }); - - // it('uses custom ARIA label when provided', () => { - // render( - // - // ); - - // const trigger = screen.getByRole('combobox'); - // expect(trigger).toHaveAttribute('aria-label', 'Custom select label'); - // }); - - // it('marks selected items correctly', async () => { - // const user = userEvent.setup(); - // render(); - - // const trigger = screen.getByRole('combobox'); - // await user.click(trigger); - - // await waitFor(() => { - // const appleOption = screen.getByText('Apple'); - // expect(appleOption).toHaveAttribute('aria-selected', 'true'); - // expect(appleOption).toHaveAttribute('data-checked', 'true'); - // }); - // }); - // }); - - // describe('Disabled State', () => { - // it('disables individual items', async () => { - // const user = userEvent.setup(); - // render( - // - // ); - - // const trigger = screen.getByRole('combobox'); - // await user.click(trigger); - - // await waitFor(() => { - // const bananaOption = screen.getByText('Banana'); - // expect(bananaOption).toHaveAttribute('disabled'); - // }); - // }); - - // it('prevents selection of disabled items', async () => { - // const user = userEvent.setup(); - // const handleValueChange = vi.fn(); - // render( - // - // ); - - // const trigger = screen.getByRole('combobox'); - // await user.click(trigger); - - // await waitFor(() => { - // expect(screen.getByRole('listbox')).toBeInTheDocument(); - // }); - - // const bananaOption = screen.getByText('Banana'); - // await user.click(bananaOption); - - // expect(handleValueChange).not.toHaveBeenCalled(); - // }); - // }); });