diff --git a/apps/www/src/components/datatable-demo.tsx b/apps/www/src/components/datatable-demo.tsx index 39cbd0a80..d23cdf8f6 100644 --- a/apps/www/src/components/datatable-demo.tsx +++ b/apps/www/src/components/datatable-demo.tsx @@ -74,13 +74,13 @@ export const columns: DataTableColumnDef[] = [ ], filterType: 'multiselect', enableColumnFilter: true, - enableHiding: true, - + enableHiding: true }, { accessorKey: 'email', header: 'Email', - cell: ({ row }) =>
{row.getValue('email')}
+ cell: ({ row }) =>
{row.getValue('email')}
, + enableColumnFilter: true }, { accessorKey: 'amount', diff --git a/apps/www/src/components/playground/combobox-examples.tsx b/apps/www/src/components/playground/combobox-examples.tsx new file mode 100644 index 000000000..57efb9e0b --- /dev/null +++ b/apps/www/src/components/playground/combobox-examples.tsx @@ -0,0 +1,49 @@ +'use client'; + +import { Combobox, Flex } from '@raystack/apsara'; +import PlaygroundLayout from './playground-layout'; + +export function ComboboxExamples() { + return ( + + + + + + Apple + Banana + Blueberry + Grapes + Pineapple + + + + + + Apple + Banana + Blueberry + Grapes + Pineapple + + + + + + + Fruits + Apple + Banana + + + + Vegetables + Carrot + Broccoli + + + + + + ); +} diff --git a/apps/www/src/components/playground/index.ts b/apps/www/src/components/playground/index.ts index f853044ee..3f657dd3d 100644 --- a/apps/www/src/components/playground/index.ts +++ b/apps/www/src/components/playground/index.ts @@ -1,3 +1,4 @@ +export * from './amount-examples'; export * from './announcement-bar-examples'; export * from './avatar-examples'; export * from './badge-examples'; @@ -8,14 +9,15 @@ export * from './callout-examples'; export * from './checkbox-examples'; export * from './chip-examples'; export * from './code-block-examples'; +export * from './combobox-examples'; export * from './command-examples'; export * from './container-examples'; export * from './data-table-examples'; export * from './dialog-examples'; export * from './dropdown-menu-examples'; export * from './empty-state-examples'; -export * from './flex-examples'; export * from './filter-chip-examples'; +export * from './flex-examples'; export * from './headline-examples'; export * from './icon-button-examples'; export * from './image-examples'; @@ -31,14 +33,13 @@ export * from './select-examples'; export * from './separator-examples'; export * from './sheet-examples'; export * from './sidebar-examples'; +export * from './skeleton-examples'; export * from './slider-examples'; export * from './spinner-examples'; export * from './switch-examples'; export * from './table-examples'; export * from './tabs-examples'; -export * from './text-examples'; export * from './text-area-examples'; +export * from './text-examples'; export * from './toast-examples'; export * from './tooltip-examples'; -export * from './skeleton-examples'; -export * from './amount-examples'; diff --git a/apps/www/src/content/docs/components/combobox/demo.ts b/apps/www/src/content/docs/components/combobox/demo.ts index 163c04e91..3e92bd429 100644 --- a/apps/www/src/content/docs/components/combobox/demo.ts +++ b/apps/www/src/content/docs/components/combobox/demo.ts @@ -6,7 +6,7 @@ export const getCode = (props: Record) => { const { multiple, ...rest } = props; return ` - + Apple Banana @@ -48,53 +48,24 @@ export const basicDemo = { ` }; -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 + Pineapple + Strawberry + Watermelon + Kiwi + Lemon + Lime + Lemon ` }; @@ -103,23 +74,56 @@ export const groupDemo = { type: 'code', code: ` - + Fruits - Apple - Banana + Apple + Banana Vegetables - Carrot - Broccoli + Carrot + Broccoli ` }; +export const iconDemo = { + type: 'code', + code: ` + + + + }>Apple + }>Banana + }>Grape + }>Orange + + ` +}; + +export const withLabelDemo = { + type: 'code', + code: ` + + + + Apple + Banana + Blueberry + Grapes + + ` +}; + export const controlledDemo = { type: 'code', code: ` diff --git a/apps/www/src/content/docs/components/combobox/index.mdx b/apps/www/src/content/docs/components/combobox/index.mdx index 09613d306..f5fe4735f 100644 --- a/apps/www/src/content/docs/components/combobox/index.mdx +++ b/apps/www/src/content/docs/components/combobox/index.mdx @@ -7,11 +7,11 @@ tag: new import { playground, basicDemo, - sizeDemo, - iconDemo, multipleDemo, groupDemo, - controlledDemo + iconDemo, + withLabelDemo, + controlledDemo, } from "./demo.ts"; @@ -26,7 +26,7 @@ import { Combobox } from "@raystack/apsara"; 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). +The root element is the parent component that manages the combobox state including open/close, input value, and selection. @@ -44,7 +44,9 @@ The dropdown container that holds the combobox items. ### Combobox.Item Props -Individual selectable options within the combobox. +Individual selectable options within the combobox. In single mode, selecting an item closes the dropdown. In multiple mode, items show checkboxes and the dropdown remains open. + +When no `value` prop is provided, the text content of `children` is used as the value. @@ -56,7 +58,7 @@ 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. +Renders a label in a combobox group. This component should be wrapped with `Combobox.Group`. @@ -68,46 +70,38 @@ Visual divider between combobox items or groups. ## Examples -### Basic Combobox +### Basic -A simple combobox with search functionality. +A simple combobox with search filtering built in. Type to filter options. -### Size +### With Icons -The combobox input supports different sizes. +You can pass the `leadingIcon` prop to `Combobox.Item` to display icons before item text. - + -### Multiple Selection +### With Label and Helper Text -To enable multiple selection, pass the `multiple` prop to the Combobox root element. +The input supports `label`, `helperText`, and other `InputField` props. -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. +Organize items into groups with labels and visual separators. Groups and labels are automatically hidden when the user is searching. -### Controlled - -You can control the combobox value and input value using the `value`, `onValueChange`, `inputValue`, and `onInputValueChange` props. +### Multiple Selection - +Pass the `multiple` prop to enable multi-select. Selected values appear as chips in the input. Items display checkboxes in multiple mode. -## Accessibility + -The Combobox component follows WAI-ARIA guidelines: +### Controlled -- 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 +Use `value` and `onValueChange` for controlled behavior. + diff --git a/packages/raystack/components/combobox/__tests__/combobox.test.tsx b/packages/raystack/components/combobox/__tests__/combobox.test.tsx index b76a60615..cc7079663 100644 --- a/packages/raystack/components/combobox/__tests__/combobox.test.tsx +++ b/packages/raystack/components/combobox/__tests__/combobox.test.tsx @@ -33,8 +33,11 @@ const BasicCombobox = (props: ComboboxRootProps) => { ); }; -const renderAndOpenCombobox = async (Combobox: React.ReactElement) => { - await fireEvent.click(render(Combobox).getByPlaceholderText('Enter a fruit')); + +const clickOption = async (element: HTMLElement) => { + const option = element.closest('[role="option"]') ?? element; + fireEvent.pointerDown(option); + fireEvent.click(option); }; describe('Combobox', () => { @@ -93,7 +96,7 @@ describe('Combobox', () => { await user.click(input); const bananaOption = await screen.findByText('Banana'); - await user.click(bananaOption); + await clickOption(bananaOption); expect(handleValueChange).toHaveBeenCalledWith('banana'); }); @@ -110,7 +113,7 @@ describe('Combobox', () => { }); const bananaOption = await screen.findByText('Banana'); - await user.click(bananaOption); + await clickOption(bananaOption); await waitFor(() => { expect(screen.queryByRole('listbox')).not.toBeInTheDocument(); @@ -125,7 +128,7 @@ describe('Combobox', () => { await user.click(input); const appleOption = await screen.findByText('Apple'); - await user.click(appleOption); + await clickOption(appleOption); expect(input).toHaveValue('apple'); }); @@ -141,11 +144,11 @@ describe('Combobox', () => { await user.click(input); const bananaOption = await screen.findByText('Banana'); - await user.click(bananaOption); + await clickOption(bananaOption); expect(handleValueChange).toHaveBeenCalledWith(['banana']); const pineappleOption = await screen.findByText('Pineapple'); - await user.click(pineappleOption); + await clickOption(pineappleOption); expect(handleValueChange).toHaveBeenCalledWith(['banana', 'pineapple']); }); @@ -158,10 +161,10 @@ describe('Combobox', () => { await user.click(input); const bananaOption = await screen.findByText('Banana'); - await user.click(bananaOption); + await clickOption(bananaOption); expect(handleValueChange).toHaveBeenCalledWith(['banana']); - await user.click(bananaOption); + await clickOption(bananaOption); expect(handleValueChange).toHaveBeenCalledWith([]); }); @@ -177,17 +180,10 @@ describe('Combobox', () => { }); const bananaOption = await screen.findByText('Banana'); - await user.click(bananaOption); + await clickOption(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', () => { @@ -359,7 +355,7 @@ describe('Combobox', () => { await user.click(input); await waitFor(() => { - expect(onOpenChange).toHaveBeenCalledWith(true); + expect(onOpenChange).toHaveBeenCalledWith(true, expect.anything()); }); }); @@ -394,18 +390,13 @@ describe('Combobox', () => { }); it('marks selected items correctly', async () => { - const user = userEvent.setup(); - render(); - - const input = screen.getByRole('combobox'); - await user.click(input); + render(); await waitFor(() => { const appleOption = screen .getByText('Apple') .closest('[role="option"]'); expect(appleOption).toHaveAttribute('aria-selected', 'true'); - expect(appleOption).toHaveAttribute('data-selected', 'true'); }); }); @@ -441,29 +432,9 @@ describe('Combobox', () => { await user.click(input); const appleOption = await screen.findByText('Apple'); - await user.click(appleOption); + await clickOption(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 004e9bcc5..02d632c7f 100644 --- a/packages/raystack/components/combobox/combobox-content.tsx +++ b/packages/raystack/components/combobox/combobox-content.tsx @@ -1,97 +1,59 @@ 'use client'; -import { ComboboxList } from '@ariakit/react'; +import { Combobox as ComboboxPrimitive } from '@base-ui/react'; import { cx } from 'class-variance-authority'; -import { Popover as PopoverPrimitive } from 'radix-ui'; -import { - ComponentPropsWithoutRef, - ElementRef, - forwardRef, - useCallback -} from 'react'; +import { ElementRef, forwardRef } from 'react'; import styles from './combobox.module.css'; import { useComboboxContext } from './combobox-root'; export interface ComboboxContentProps extends Omit< - ComponentPropsWithoutRef, - 'asChild' - > {} + ComboboxPrimitive.Positioner.Props, + 'render' | 'className' | 'style' + >, + ComboboxPrimitive.Popup.Props {} export const ComboboxContent = forwardRef< - ElementRef, + ElementRef, ComboboxContentProps >( ( { className, children, + style, + render, + initialFocus, + finalFocus, sideOffset = 4, - align = 'start', - onOpenAutoFocus, - onInteractOutside, - onFocusOutside, - ...props + ...positionerProps }, ref ) => { - const { inputRef, listRef, value, setInputValue, multiple } = - useComboboxContext(); - - 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] - ); + const { inputContainerRef } = useComboboxContext(); return ( - - + - - {children} - - - + + + {children} + + + + ); } ); -ComboboxContent.displayName = 'ComboboxContent'; +ComboboxContent.displayName = 'Combobox.Content'; diff --git a/packages/raystack/components/combobox/combobox-input.tsx b/packages/raystack/components/combobox/combobox-input.tsx index cb751d8d1..eb396c058 100644 --- a/packages/raystack/components/combobox/combobox-input.tsx +++ b/packages/raystack/components/combobox/combobox-input.tsx @@ -1,15 +1,8 @@ 'use client'; -import { Combobox } from '@ariakit/react'; +import { Combobox as ComboboxPrimitive } from '@base-ui/react'; import { ChevronDownIcon } from '@radix-ui/react-icons'; -import { Popover as PopoverPrimitive } from 'radix-ui'; -import { - ElementRef, - FocusEvent, - forwardRef, - KeyboardEvent, - useCallback -} from 'react'; +import { ElementRef, forwardRef } from 'react'; import { InputField } from '../input-field'; import { InputFieldProps } from '../input-field/input-field'; import styles from './combobox.module.css'; @@ -22,71 +15,31 @@ export interface ComboboxInputProps > {} export const ComboboxInput = forwardRef< - ElementRef, + ElementRef, ComboboxInputProps ->(({ 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)); - } - } - }, - [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] - ); - +>(({ ...props }, ref) => { + const { multiple, inputContainerRef, value, onValueChange } = + useComboboxContext(); return ( - -
- ({ - label: val, - onRemove: () => - setValue((value as string[])?.filter(v => v !== val)) - })) - : undefined - } - trailingIcon={} - {...props} - /> + ({ + label: val, + onRemove: () => + onValueChange?.((value as string[])?.filter(v => v !== val)) + })) + : undefined } - onBlur={handleOnBlur} - onKeyDown={handleOnKeyDown} + trailingIcon={} + {...props} /> -
-
+ } + /> ); }); -ComboboxInput.displayName = 'ComboboxInput'; +ComboboxInput.displayName = 'Combobox.Input'; diff --git a/packages/raystack/components/combobox/combobox-item.tsx b/packages/raystack/components/combobox/combobox-item.tsx index 162c283fb..8b8531786 100644 --- a/packages/raystack/components/combobox/combobox-item.tsx +++ b/packages/raystack/components/combobox/combobox-item.tsx @@ -1,23 +1,19 @@ 'use client'; -import { ComboboxItem as AriakitComboboxItem } from '@ariakit/react'; +import { Combobox as ComboboxPrimitive } from '@base-ui/react'; import { cx } from 'class-variance-authority'; -import { ComponentPropsWithoutRef, ElementRef, forwardRef } from 'react'; +import { forwardRef, ReactNode } 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'; -export interface ComboboxItemProps - extends ComponentPropsWithoutRef { - leadingIcon?: React.ReactNode; +export interface ComboboxItemProps extends ComboboxPrimitive.Item.Props { + leadingIcon?: ReactNode; } -export const ComboboxItem = forwardRef< - ElementRef, - ComboboxItemProps ->( +export const ComboboxItem = forwardRef( ( { className, @@ -25,21 +21,24 @@ export const ComboboxItem = forwardRef< value: providedValue, leadingIcon, disabled, + render, ...props }, ref ) => { const value = providedValue - ? String(providedValue) + ? providedValue : typeof children === 'string' ? children : undefined; - const { multiple, value: comboboxValue, inputValue } = useComboboxContext(); - const isSelected = multiple - ? comboboxValue?.includes(value ?? '') - : value === comboboxValue; - const isMatched = getMatch(value, children, inputValue); + const { multiple, inputValue, hasItems } = useComboboxContext(); + + // When items prop is not provided on Root, use custom filtering + if (!hasItems && inputValue?.length) { + const isMatched = getMatch(value, children, inputValue); + if (!isMatched) return null; + } const element = typeof children === 'string' ? ( @@ -51,26 +50,25 @@ export const ComboboxItem = forwardRef< children ); - if (inputValue?.length && !isMatched) { - // Doesn't match search, so don't render at all - return null; - } - return ( - - {multiple && } - {element} - + render={ + render + ? render + : (renderProps, state) => ( +
+ {multiple && } + {element} +
+ ) + } + /> ); } ); -ComboboxItem.displayName = 'ComboboxItem'; +ComboboxItem.displayName = 'Combobox.Item'; diff --git a/packages/raystack/components/combobox/combobox-misc.tsx b/packages/raystack/components/combobox/combobox-misc.tsx index dbe2c91b1..8fa7b8716 100644 --- a/packages/raystack/components/combobox/combobox-misc.tsx +++ b/packages/raystack/components/combobox/combobox-misc.tsx @@ -1,64 +1,60 @@ 'use client'; -import { - ComboboxGroup as AriakitComboboxGroup, - ComboboxGroupLabel as AriakitComboboxGroupLabel, - ComboboxSeparator as AriakitComboboxSeparator -} from '@ariakit/react'; +import { Combobox as ComboboxPrimitive } from '@base-ui/react'; import { cx } from 'class-variance-authority'; -import { ComponentPropsWithoutRef, ElementRef, forwardRef } from 'react'; +import { ElementRef, forwardRef } from 'react'; import styles from './combobox.module.css'; import { useComboboxContext } from './combobox-root'; export const ComboboxLabel = forwardRef< - ElementRef, - ComponentPropsWithoutRef + ElementRef, + ComboboxPrimitive.GroupLabel.Props >(({ className, ...props }, ref) => { - const { inputValue } = useComboboxContext(); - if (inputValue?.length) return null; + const { inputValue, hasItems } = useComboboxContext(); + if (!hasItems && inputValue?.length) return null; return ( - ); }); -ComboboxLabel.displayName = 'ComboboxLabel'; +ComboboxLabel.displayName = 'Combobox.Label'; export const ComboboxGroup = forwardRef< - ElementRef, - ComponentPropsWithoutRef + ElementRef, + ComboboxPrimitive.Group.Props >(({ className, children, ...props }, ref) => { - const { inputValue } = useComboboxContext(); - if (inputValue?.length) return children; + const { inputValue, hasItems } = useComboboxContext(); + if (!hasItems && inputValue?.length) return children; return ( - {children} - + ); }); -ComboboxGroup.displayName = 'ComboboxGroup'; +ComboboxGroup.displayName = 'Combobox.Group'; export const ComboboxSeparator = forwardRef< - ElementRef, - ComponentPropsWithoutRef + ElementRef, + ComboboxPrimitive.Separator.Props >(({ className, ...props }, ref) => { - const { inputValue } = useComboboxContext(); - if (inputValue?.length) return null; + const { inputValue, hasItems } = useComboboxContext(); + if (!hasItems && inputValue?.length) return null; return ( - ); }); -ComboboxSeparator.displayName = 'ComboboxSeparator'; +ComboboxSeparator.displayName = 'Combobox.Separator'; diff --git a/packages/raystack/components/combobox/combobox-root.tsx b/packages/raystack/components/combobox/combobox-root.tsx index 795d061e8..a5ec15506 100644 --- a/packages/raystack/components/combobox/combobox-root.tsx +++ b/packages/raystack/components/combobox/combobox-root.tsx @@ -1,172 +1,145 @@ 'use client'; -import { ComboboxProvider, ComboboxProviderProps } from '@ariakit/react'; -import { Popover as PopoverPrimitive } from 'radix-ui'; +import { Combobox as ComboboxPrimitive } from '@base-ui/react'; import { createContext, RefObject, useCallback, useContext, + useMemo, useRef, useState } from 'react'; -interface ComboboxContextValue { - setValue: (value: string | string[]) => void; - value?: string | string[]; - inputValue?: string; - setInputValue: (inputValue: string) => void; - open: boolean; - setOpen: (open: boolean) => void; - inputRef: RefObject; +interface ComboboxContextValue { multiple: boolean; - listRef: RefObject; + inputValue: string; + hasItems: boolean; + inputContainerRef: RefObject; + value: Value | Value[] | null | undefined; + onValueChange?: (value: Value | Value[] | null) => void; } -const ComboboxContext = createContext( - undefined -); +const ComboboxContext = createContext< + ComboboxContextValue | undefined +>(undefined); -export const useComboboxContext = (): ComboboxContextValue => { +export const useComboboxContext = < + Value = string +>(): ComboboxContextValue => { const context = useContext(ComboboxContext); if (!context) { throw new Error( 'useComboboxContext must be used within a ComboboxProvider' ); } - return context; + return context as ComboboxContextValue; }; -export interface BaseComboboxRootProps + +export interface BaseComboboxRootProps extends Omit< - ComboboxProviderProps, - | 'value' - | 'setValue' - | 'selectedValue' - | 'setSelectedValue' - | 'defaultSelectedValue' - | 'defaultValue' - | 'resetValueOnHide' - | 'resetValueOnSelect' + ComboboxPrimitive.Root.Props, + 'onValueChange' | 'onInputValueChange' | 'multiple' > { - onOpenChange?: (open: boolean) => void; - modal?: boolean; - inputValue?: string; onInputValueChange?: (inputValue: string) => void; - defaultInputValue?: string; } -export interface SingleComboboxProps extends BaseComboboxRootProps { + +export interface SingleComboboxProps + extends BaseComboboxRootProps { multiple?: false; - value?: string; - onValueChange?: (value: string) => void; - defaultValue?: string; + value?: Value | null; + defaultValue?: Value | null; + onValueChange?: (value: Value | null) => void; } -export interface MultipleComboboxProps extends BaseComboboxRootProps { + +export interface MultipleComboboxProps + extends BaseComboboxRootProps { multiple: true; - value?: string[]; - onValueChange?: (value: string[]) => void; - defaultValue?: string[]; + value?: Value[]; + defaultValue?: Value[]; + onValueChange?: (value: Value[]) => void; } -export type ComboboxRootProps = SingleComboboxProps | MultipleComboboxProps; -export const ComboboxRoot = ({ - modal = false, +export type ComboboxRootProps = + | SingleComboboxProps + | MultipleComboboxProps; + +export const ComboboxRoot = ({ multiple = false, children, - value: providedValue, - defaultValue = multiple ? [] : undefined, onValueChange, - inputValue: providedInputValue, onInputValueChange, - defaultInputValue, - open: providedOpen, - defaultOpen = false, - onOpenChange, + value: providedValue, + defaultValue, + items, ...props -}: ComboboxRootProps) => { +}: ComboboxRootProps) => { + const [inputValue, setInputValue] = useState(''); const [internalValue, setInternalValue] = useState< - string | string[] | undefined - >(defaultValue); - const [internalInputValue, setInternalInputValue] = - useState(defaultInputValue); - const [internalOpen, setInternalOpen] = useState(defaultOpen); + Value | Value[] | null | undefined + >(defaultValue ?? null); + const inputContainerRef = useRef(null); - const inputRef = useRef(null); - const listRef = useRef(null); + const computedValue = providedValue ?? internalValue; - const value = providedValue ?? internalValue; - const inputValue = providedInputValue ?? internalInputValue; - const open = providedOpen ?? internalOpen; + const handleInputValueChange = useCallback( + ( + value: string, + eventDetails: ComboboxPrimitive.Root.ChangeEventDetails + ) => { + setInputValue(value); + onInputValueChange?.(value); + }, + [onInputValueChange] + ); - const setValue = useCallback( - (newValue: string | string[] | undefined) => { + const handleValueChange = useCallback( + ( + value: Value | Value[] | null, + eventDetails: ComboboxPrimitive.Root.ChangeEventDetails + ) => { + setInternalValue(value); if (multiple) { - const formattedValue = newValue - ? Array.isArray(newValue) - ? newValue - : [newValue] - : []; - setInternalValue(formattedValue); - (onValueChange as MultipleComboboxProps['onValueChange'])?.( - formattedValue + (onValueChange as MultipleComboboxProps['onValueChange'])?.( + value as Value[] ); } else { - setInternalValue(String(newValue)); - (onValueChange as SingleComboboxProps['onValueChange'])?.( - String(newValue) + (onValueChange as SingleComboboxProps['onValueChange'])?.( + value as Value | null ); } }, [onValueChange, multiple] ); - const setInputValue = useCallback( - (newValue: string) => { - if (!multiple && newValue.length === 0) setValue(''); - setInternalInputValue(newValue); - onInputValueChange?.(newValue); - }, - [onInputValueChange, setValue, multiple] - ); - - const setOpen = useCallback( - (newOpen: boolean) => { - setInternalOpen(newOpen); - onOpenChange?.(newOpen); - }, - [onOpenChange] + const contextValue = useMemo( + () => ({ + multiple, + inputValue, + hasItems: !!items, + inputContainerRef, + value: computedValue, + onValueChange: handleValueChange + }), + [multiple, inputValue, items, computedValue, handleValueChange] ); return ( } > - - - {children} - - + + {children} + ); }; diff --git a/packages/raystack/components/combobox/combobox.module.css b/packages/raystack/components/combobox/combobox.module.css index 7a1a67533..9d9dfaaa7 100644 --- a/packages/raystack/components/combobox/combobox.module.css +++ b/packages/raystack/components/combobox/combobox.module.css @@ -1,5 +1,8 @@ -.content { +.positioner { z-index: var(--rs-z-index-portal); +} + +.content { font-size: var(--rs-font-size-small); line-height: var(--rs-line-height-small); letter-spacing: var(--rs-letter-spacing-small); @@ -9,7 +12,7 @@ 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); + min-width: var(--anchor-width); overflow: auto; } @@ -41,8 +44,7 @@ border-radius: var(--rs-radius-2); } -.menuitem[data-highlighted], -.menuitem[data-active-item="true"] { +.menuitem[data-highlighted] { outline: none; cursor: pointer; background: var(--rs-color-background-base-primary-hover); diff --git a/packages/raystack/components/dropdown-menu/utils.ts b/packages/raystack/components/dropdown-menu/utils.ts index 9cdddf6bc..8d8a54053 100644 --- a/packages/raystack/components/dropdown-menu/utils.ts +++ b/packages/raystack/components/dropdown-menu/utils.ts @@ -1,9 +1,9 @@ -import { ReactNode } from "react"; +import { ReactNode } from 'react'; export const getMatch = ( - value?: string, + value?: any, children?: ReactNode, - search?: string, + search?: string ) => { if (!search?.length) return true; const childrenValue = getChildrenValue(children)?.toLowerCase(); @@ -15,8 +15,8 @@ export const getMatch = ( }; export const getChildrenValue = (children?: ReactNode) => { - if (typeof children === "string") return children; - if (typeof children === "object" && children !== null) { + if (typeof children === 'string') return children; + if (typeof children === 'object' && children !== null) { return children.toString(); } return null; diff --git a/packages/raystack/components/input-field/input-field.tsx b/packages/raystack/components/input-field/input-field.tsx index b63c4609e..826030fa5 100644 --- a/packages/raystack/components/input-field/input-field.tsx +++ b/packages/raystack/components/input-field/input-field.tsx @@ -2,7 +2,12 @@ import { InfoCircledIcon } from '@radix-ui/react-icons'; import { cva, cx, type VariantProps } from 'class-variance-authority'; -import { ComponentPropsWithoutRef, forwardRef, ReactNode } from 'react'; +import { + ComponentPropsWithoutRef, + forwardRef, + ReactNode, + RefObject +} from 'react'; import { Chip } from '../chip'; import { Tooltip } from '../tooltip'; import styles from './input-field.module.css'; @@ -43,6 +48,7 @@ export interface InputFieldProps maxChipsVisible?: number; infoTooltip?: string; variant?: 'default' | 'borderless'; + containerRef?: RefObject; } export const InputField = forwardRef( @@ -65,6 +71,7 @@ export const InputField = forwardRef( size, infoTooltip, variant = 'default', + containerRef, ...props }, ref @@ -95,6 +102,7 @@ export const InputField = forwardRef( disabled && styles['input-disabled-wrapper'], chips?.length && styles['has-chips'] )} + ref={containerRef} > {leadingIcon && (
{leadingIcon}