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/apps/www/src/content/docs/components/select/demo.ts b/apps/www/src/content/docs/components/select/demo.ts index 1dfb8387b..4c87d7cca 100644 --- a/apps/www/src/content/docs/components/select/demo.ts +++ b/apps/www/src/content/docs/components/select/demo.ts @@ -31,8 +31,8 @@ export const playground = { }, variant: { type: 'select', - options: ['default', 'filter'], - defaultValue: 'default' + options: ['outline', 'text'], + defaultValue: 'outline' }, autocomplete: { type: 'checkbox', @@ -104,10 +104,10 @@ export const variantDemo = { type: 'code', tabs: [ { - name: 'Default', + name: 'Outline', code: ` ` }, { - name: 'Filter', + name: 'Text', code: ` ); 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}
diff --git a/packages/raystack/components/select/__tests__/select.test.tsx b/packages/raystack/components/select/__tests__/select.test.tsx index c1d9de884..e5a0b3d72 100644 --- a/packages/raystack/components/select/__tests__/select.test.tsx +++ b/packages/raystack/components/select/__tests__/select.test.tsx @@ -1,4 +1,11 @@ -import { fireEvent, render, screen } from '@testing-library/react'; +import { + act, + fireEvent, + render, + screen, + waitFor, + within +} from '@testing-library/react'; import userEvent from '@testing-library/user-event'; import { describe, expect, it, vi } from 'vitest'; import { Select } from '../select'; @@ -10,6 +17,13 @@ Object.defineProperty(Element.prototype, 'scrollIntoView', { writable: true }); +// Flush pending microtasks (same pattern as Base UI's own tests) +const flushMicrotasks = async () => { + await act(async () => { + await new Promise(resolve => setTimeout(resolve, 0)); + }); +}; + const TRIGGER_TEXT = 'Select a fruit'; const FRUIT_OPTIONS = [ { value: 'apple', label: 'Apple' }, @@ -37,8 +51,14 @@ const BasicSelect = ({ ...props }: SelectRootProps) => { ); }; -const renderAndOpenSelect = async (Select: any) => { - await fireEvent.click(render(Select).getByRole('combobox')); + +const renderAndOpenSelect = async (element: React.ReactElement) => { + const user = userEvent.setup(); + const result = render(element); + const trigger = result.getByRole('combobox'); + fireEvent.click(trigger); + await flushMicrotasks(); + return { ...result, user }; }; describe('Select', () => { @@ -47,7 +67,7 @@ describe('Select', () => { render(); const trigger = screen.getByRole('combobox'); expect(trigger).toBeInTheDocument(); - expect(screen.getByText(TRIGGER_TEXT)).toBeInTheDocument(); + expect(within(trigger).getByText(TRIGGER_TEXT)).toBeInTheDocument(); }); it('renders with custom className on trigger', () => { @@ -65,95 +85,100 @@ describe('Select', () => { it('does not show content initially', () => { render(); - FRUIT_OPTIONS.forEach(option => { - expect(screen.queryByText(option.label)).not.toBeInTheDocument(); - }); + const trigger = screen.getByRole('combobox'); + expect(trigger).toHaveAttribute('aria-expanded', 'false'); }); it('shows content when trigger is clicked', async () => { await renderAndOpenSelect(); - expect(screen.getByRole('listbox')).toBeInTheDocument(); + const trigger = screen.getByRole('combobox'); + expect(trigger).toHaveAttribute('aria-expanded', 'true'); FRUIT_OPTIONS.forEach(option => { - expect(screen.getByText(option.label)).toBeInTheDocument(); + expect( + screen.getByRole('option', { name: option.label }) + ).toBeInTheDocument(); }); }); - it('renders in portal', async () => { + it('renders options list', async () => { await renderAndOpenSelect(); - const content = screen.getByRole('listbox'); - expect(content.closest('body')).toBe(document.body); + const listbox = screen.getByRole('listbox'); + expect(listbox).toBeInTheDocument(); }); }); describe('Single Selection', () => { it('displays selected value', () => { render(); - expect(screen.getByText('Apple')).toBeInTheDocument(); + const trigger = screen.getByRole('combobox'); + expect(within(trigger).getByText('Apple')).toBeInTheDocument(); }); it('works as controlled component', () => { const handleValueChange = vi.fn(); render(); - expect(screen.getByText('Apple')).toBeInTheDocument(); + const trigger = screen.getByRole('combobox'); + expect(within(trigger).getByText('Apple')).toBeInTheDocument(); }); it('selects option when clicked', async () => { const handleValueChange = vi.fn(); - renderAndOpenSelect( + const { user } = await renderAndOpenSelect( ); - const options = await screen.findAllByRole('option'); - fireEvent.click(options[1]); + const option = screen.getByRole('option', { name: 'Banana' }); + await user.click(option); + await flushMicrotasks(); expect(handleValueChange).toHaveBeenCalledWith('banana'); - expect(handleValueChange).toHaveBeenCalledTimes(1); - expect(screen.getByText('Banana')).toBeInTheDocument(); }); it('closes content after selection', async () => { - renderAndOpenSelect(); + const { user } = await renderAndOpenSelect(); - expect(screen.getByRole('listbox')).toBeInTheDocument(); + const option = screen.getByRole('option', { name: 'Banana' }); + await user.click(option); + await flushMicrotasks(); - const options = await screen.findAllByRole('option'); - fireEvent.click(options[1]); - - expect(screen.queryByRole('listbox')).not.toBeInTheDocument(); + const trigger = screen.getByRole('combobox'); + await waitFor(() => { + expect(trigger).toHaveAttribute('aria-expanded', 'false'); + }); }); }); describe('Multiple Selection', () => { it('supports multiple selection', async () => { const handleValueChange = vi.fn(); - renderAndOpenSelect( + const { user } = await renderAndOpenSelect( ); - const options = await screen.findAllByRole('option'); - fireEvent.click(options[1]); + await user.click(screen.getByRole('option', { name: 'Banana' })); + await flushMicrotasks(); expect(handleValueChange).toHaveBeenCalledWith(['banana']); - fireEvent.click(options[4]); + await user.click(screen.getByRole('option', { name: 'Pineapple' })); + await flushMicrotasks(); expect(handleValueChange).toHaveBeenCalledWith(['banana', 'pineapple']); - - expect(options[1]).toHaveAttribute('aria-selected', 'true'); - expect(options[4]).toHaveAttribute('aria-selected', 'true'); }); it('allows deselecting items in multiple mode', async () => { const handleValueChange = vi.fn(); - renderAndOpenSelect( + const { user } = await renderAndOpenSelect( ); - const options = await screen.findAllByRole('option'); - fireEvent.click(options[1]); + const banana = screen.getByRole('option', { name: 'Banana' }); + await user.click(banana); + await flushMicrotasks(); expect(handleValueChange).toHaveBeenCalledWith(['banana']); - fireEvent.click(options[1]); + await user.click(banana); + await flushMicrotasks(); expect(handleValueChange).toHaveBeenCalledWith([]); }); }); @@ -167,7 +192,9 @@ describe('Select', () => { trigger.focus(); await user.keyboard('{Enter}'); - expect(screen.getByRole('listbox')).toBeInTheDocument(); + await waitFor(() => { + expect(trigger).toHaveAttribute('aria-expanded', 'true'); + }); }); it('opens with Space key', async () => { @@ -178,69 +205,70 @@ describe('Select', () => { trigger.focus(); await user.keyboard(' '); - expect(screen.getByRole('listbox')).toBeInTheDocument(); + await waitFor(() => { + expect(trigger).toHaveAttribute('aria-expanded', 'true'); + }); }); it('closes with Escape key', async () => { const user = userEvent.setup(); - renderAndOpenSelect(); + render(); - await user.keyboard('{Escape}'); + const trigger = screen.getByRole('combobox'); + trigger.focus(); + await user.keyboard('{Enter}'); + await waitFor(() => { + expect(trigger).toHaveAttribute('aria-expanded', 'true'); + }); - expect(screen.queryByRole('listbox')).not.toBeInTheDocument(); + await user.keyboard('{Escape}'); + await waitFor(() => { + expect(trigger).toHaveAttribute('aria-expanded', 'false'); + }); }); - it('selects option with Enter key', async () => { + it('selects option with keyboard', async () => { const user = userEvent.setup(); const handleValueChange = vi.fn(); - renderAndOpenSelect( - - ); - - const options = await screen.findAllByRole('option'); - options[1].focus(); - await user.keyboard('{Enter}'); + render(); - expect(handleValueChange).toHaveBeenCalledWith('banana'); - expect(handleValueChange).toHaveBeenCalledTimes(1); - expect(screen.getByText('Banana')).toBeInTheDocument(); - }); + const trigger = screen.getByRole('combobox'); + fireEvent.click(trigger); + await flushMicrotasks(); - it('navigates options with arrow keys', async () => { - const user = userEvent.setup(); - renderAndOpenSelect(); + // Focus the highlighted item so keyboard events reach the list + const firstOption = screen.getByRole('option', { name: 'Apple' }); + await act(async () => firstOption.focus()); - await user.keyboard('{ArrowDown}{ArrowDown}{Enter}'); + await user.keyboard('{ArrowDown}'); + await user.keyboard('{Enter}'); - expect(screen.getByText('Blueberry')).toBeInTheDocument(); + await waitFor(() => { + expect(handleValueChange).toHaveBeenCalled(); + }); }); }); describe('Autocomplete Mode', () => { it('renders search input in autocomplete mode', async () => { - renderAndOpenSelect(); + await renderAndOpenSelect(); - expect(screen.getByRole('dialog')).toBeInTheDocument(); - expect(screen.getByRole('combobox')).toBeInTheDocument(); - expect(screen.getByRole('combobox')).toHaveAttribute( - 'placeholder', - 'Search...' - ); + const searchInput = screen.getByPlaceholderText('Search...'); + expect(searchInput).toBeInTheDocument(); }); - }); - - it('filters options based on search', async () => { - const user = userEvent.setup(); - renderAndOpenSelect(); - expect(screen.getByRole('dialog')).toBeInTheDocument(); + it('filters options based on search', async () => { + await renderAndOpenSelect(); - const searchInput = screen.getByPlaceholderText('Search...'); - await user.type(searchInput, 'app'); + const searchInput = screen.getByPlaceholderText('Search...'); + await act(async () => { + fireEvent.change(searchInput, { target: { value: 'app' } }); + }); - const options = await screen.findAllByRole('option'); - expect(options.length).toBe(2); - expect(options[0].textContent).toBe('Apple'); - expect(options[1].textContent).toBe('Pineapple'); + const options = screen.getAllByRole('option'); + expect(options.length).toBe(2); + expect(options[0].textContent).toBe('Apple'); + expect(options[1].textContent).toBe('Pineapple'); + }); }); }); diff --git a/packages/raystack/components/select/select-content.tsx b/packages/raystack/components/select/select-content.tsx index e01815b79..1f54ddd25 100644 --- a/packages/raystack/components/select/select-content.tsx +++ b/packages/raystack/components/select/select-content.tsx @@ -1,106 +1,78 @@ 'use client'; -import { Combobox, ComboboxList } from '@ariakit/react'; +import { + Combobox as ComboboxPrimitive, + Select as SelectPrimitive +} from '@base-ui/react'; import { cx } from 'class-variance-authority'; -import { Select as SelectPrimitive, Slot } from 'radix-ui'; -import { ElementRef, forwardRef, useCallback } from 'react'; -import { useSelectContext } from './select-root'; +import { ElementRef, forwardRef } from 'react'; import styles from './select.module.css'; +import { useSelectContext } from './select-root'; -export interface SelectContentProps extends SelectPrimitive.SelectContentProps { +export interface SelectContentProps { searchPlaceholder?: string; + sideOffset?: number; + className?: string; + children?: React.ReactNode; + style?: React.CSSProperties; } -export const SelectContent = forwardRef< - ElementRef, - SelectContentProps ->( +export const SelectContent = forwardRef( ( { className, children, - position = 'popper', searchPlaceholder = 'Search...', sideOffset = 4, - asChild, - onEscapeKeyDown: providedOnEscapeKeyDown, - onPointerDownOutside: providedOnPointerDownOutside, ...props }, ref ) => { - const { autocomplete, multiple, updateSelectionInProgress } = - useSelectContext(); - - const onPointerDownOutside = useCallback< - NonNullable - >( - event => { - updateSelectionInProgress(false); - providedOnPointerDownOutside?.(event); - }, - [updateSelectionInProgress, providedOnPointerDownOutside] - ); + const { mode, triggerRef } = useSelectContext(); - const onEscapeKeyDown = useCallback< - NonNullable - >( - event => { - updateSelectionInProgress(false); - providedOnEscapeKeyDown?.(event); - }, - [updateSelectionInProgress, providedOnEscapeKeyDown] - ); + if (mode === 'combobox') { + return ( + + + >} + className={cx(styles.content, className)} + {...props} + > + + + {children} + + + + + ); + } return ( - - + >} className={cx(styles.content, className)} - onEscapeKeyDown={multiple ? onEscapeKeyDown : providedOnEscapeKeyDown} - onPointerDownOutside={ - multiple ? onPointerDownOutside : providedOnPointerDownOutside - } - role={autocomplete ? 'dialog' : 'listbox'} - aria-multiselectable={!autocomplete && multiple ? true : undefined} - data-multiselectable={multiple ? true : undefined} {...props} > - - {autocomplete ? ( - <> - { - event.preventDefault(); - event.stopPropagation(); - }} - /> - : undefined} - > - {children} - - - ) : ( - children - )} - - - + + {children} + + + ); } ); -SelectContent.displayName = SelectPrimitive.Content.displayName; +SelectContent.displayName = 'Select.Content'; diff --git a/packages/raystack/components/select/select-item.tsx b/packages/raystack/components/select/select-item.tsx index a499d5ee7..39e9138e7 100644 --- a/packages/raystack/components/select/select-item.tsx +++ b/packages/raystack/components/select/select-item.tsx @@ -1,21 +1,27 @@ 'use client'; -import { ComboboxItem } from '@ariakit/react'; +import { + Combobox as ComboboxPrimitive, + Select as SelectPrimitive +} from '@base-ui/react'; import { cx } from 'class-variance-authority'; -import { Select as SelectPrimitive } from 'radix-ui'; -import { ElementRef, forwardRef, useLayoutEffect } from 'react'; +import { forwardRef, ReactNode, useLayoutEffect } from 'react'; import { Checkbox } from '../checkbox'; import { getMatch } from '../dropdown-menu/utils'; import { Text } from '../text'; import styles from './select.module.css'; import { useSelectContext } from './select-root'; -export const SelectItem = forwardRef< - ElementRef, - Omit & { - leadingIcon?: React.ReactNode; - } ->( +export interface SelectItemProps { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + value: any; + leadingIcon?: ReactNode; + disabled?: boolean; + className?: string; + children: ReactNode; +} + +export const SelectItem = forwardRef( ( { className, @@ -27,22 +33,29 @@ export const SelectItem = forwardRef< }, ref ) => { - const value = String(providedValue); + const registryKey = String(providedValue); const { + mode, registerItem, unregisterItem, - autocomplete, - searchValue, - value: selectValue, - shouldFilter, + inputValue, + hasItems, multiple } = useSelectContext(); - const isSelected = multiple - ? selectValue?.includes(value) - : value === selectValue; - const isMatched = getMatch(value, children, searchValue); - const isHidden = shouldFilter && isSelected && !isMatched; + useLayoutEffect(() => { + registerItem({ leadingIcon, children, value: providedValue }); + return () => { + unregisterItem(registryKey); + }; + }, [ + registryKey, + providedValue, + children, + registerItem, + unregisterItem, + leadingIcon + ]); const element = typeof children === 'string' ? ( @@ -54,49 +67,45 @@ export const SelectItem = forwardRef< children ); - useLayoutEffect(() => { - registerItem({ leadingIcon, children, value }); - return () => { - unregisterItem(value); - }; - }, [value, children, registerItem, unregisterItem, leadingIcon]); + if (mode === 'combobox') { + // Client-side filtering when items prop not provided + if (!hasItems && inputValue?.length) { + const isMatched = getMatch(registryKey, children, inputValue); + if (!isMatched) return null; + } - if (shouldFilter && !isMatched && !isSelected) { - // Not selected and doesn't match search, so don't render at all - return null; + return ( + ( +
+ {multiple && } + {element} +
+ )} + /> + ); } return ( } + value={providedValue} + className={cx(styles.menuitem, className)} + disabled={disabled} {...props} - > - {autocomplete ? ( - { - event.preventDefault(); - }} - > - {multiple && } - {element} - - ) : ( - <> - {multiple && } + render={(renderProps, state) => ( +
+ {multiple && } {element} - +
)} -
+ /> ); } ); -SelectItem.displayName = SelectPrimitive.Item.displayName; +SelectItem.displayName = 'Select.Item'; diff --git a/packages/raystack/components/select/select-misc.tsx b/packages/raystack/components/select/select-misc.tsx index 01d3a0ea4..23e73966a 100644 --- a/packages/raystack/components/select/select-misc.tsx +++ b/packages/raystack/components/select/select-misc.tsx @@ -1,22 +1,37 @@ 'use client'; +import { + Combobox as ComboboxPrimitive, + Select as SelectPrimitive +} from '@base-ui/react'; import { cx } from 'class-variance-authority'; -import { Select as SelectPrimitive } from 'radix-ui'; import { ElementRef, Fragment, forwardRef } from 'react'; import styles from './select.module.css'; import { useSelectContext } from './select-root'; export const SelectGroup = forwardRef< - ElementRef, - SelectPrimitive.SelectGroupProps + HTMLDivElement, + { className?: string; children?: React.ReactNode } >(({ className, children, ...props }, ref) => { - const { shouldFilter } = useSelectContext(); + const { mode, shouldFilter } = useSelectContext(); if (shouldFilter) return {children}; + if (mode === 'combobox') { + return ( + + {children} + + ); + } + return ( >} className={cx(styles.menugroup, className)} {...props} > @@ -24,39 +39,62 @@ export const SelectGroup = forwardRef< ); }); -SelectGroup.displayName = SelectPrimitive.Group.displayName; +SelectGroup.displayName = 'Select.Group'; export const SelectLabel = forwardRef< - ElementRef, - SelectPrimitive.SelectLabelProps + HTMLDivElement, + { className?: string; children?: React.ReactNode } >(({ className, ...props }, ref) => { - const { shouldFilter } = useSelectContext(); + const { mode, shouldFilter } = useSelectContext(); if (shouldFilter) return null; + if (mode === 'combobox') { + return ( + + ); + } + return ( - >} className={cx(styles.label, className)} {...props} /> ); }); -SelectLabel.displayName = SelectPrimitive.Label.displayName; +SelectLabel.displayName = 'Select.Label'; export const SelectSeparator = forwardRef< - ElementRef, - SelectPrimitive.SelectSeparatorProps + HTMLDivElement, + { className?: string } >(({ className, ...props }, ref) => { - const { shouldFilter } = useSelectContext(); + const { mode, shouldFilter } = useSelectContext(); if (shouldFilter) return null; + + if (mode === 'combobox') { + return ( + + ); + } + + // Base UI Select doesn't have a Separator primitive, use a styled div return ( - ); }); -SelectSeparator.displayName = SelectPrimitive.Separator.displayName; +SelectSeparator.displayName = 'Select.Separator'; diff --git a/packages/raystack/components/select/select-multiple-value.tsx b/packages/raystack/components/select/select-multiple-value.tsx index 929c01a8f..ad2022f70 100644 --- a/packages/raystack/components/select/select-multiple-value.tsx +++ b/packages/raystack/components/select/select-multiple-value.tsx @@ -1,21 +1,15 @@ 'use client'; import { cx } from 'class-variance-authority'; -import { Select as SelectPrimitive } from 'radix-ui'; -import { - ElementRef, - forwardRef, - useLayoutEffect, - useRef, - useState -} from 'react'; +import { forwardRef, useLayoutEffect, useRef, useState } from 'react'; import { Chip } from '../chip'; import { Text } from '../text'; import styles from './select.module.css'; import { ItemType } from './types'; -interface SelectMultipleValueProps extends SelectPrimitive.SelectValueProps { +interface SelectMultipleValueProps { data: ItemType[]; + className?: string; } /* @@ -28,7 +22,7 @@ const calculateTextWidth = (text: string, fontSize: number = 11): number => { }; export const SelectMultipleValue = forwardRef< - ElementRef, + HTMLSpanElement, SelectMultipleValueProps >(({ data = [], ...props }, ref) => { const containerRef = useRef(null); @@ -51,7 +45,6 @@ export const SelectMultipleValue = forwardRef< useLayoutEffect(() => { if (!containerRef.current || data.length === 0) return; - // Calculate chip widths based on text length and icon width const chipWidths: number[] = data.map(item => { const text = typeof item.children === 'string' ? item.children : item.value; @@ -62,13 +55,11 @@ export const SelectMultipleValue = forwardRef< let totalWidth = 0; let count = 0; - // Always show at least one chip if (data.length > 0) { count = 1; totalWidth = chipWidths[0]; } - // Try to fit more chips for (let i = 1; i < data.length; i++) { const newWidth = totalWidth + chipWidths[i]; if (newWidth <= containerWidth) { @@ -84,7 +75,7 @@ export const SelectMultipleValue = forwardRef< return (
- +
{data.slice(0, visibleCount).map(item => ( @@ -95,7 +86,7 @@ export const SelectMultipleValue = forwardRef< +{data.length - visibleCount} )}
-
+
); }); diff --git a/packages/raystack/components/select/select-root.tsx b/packages/raystack/components/select/select-root.tsx index a0ebfd392..f3e942cac 100644 --- a/packages/raystack/components/select/select-root.tsx +++ b/packages/raystack/components/select/select-root.tsx @@ -1,251 +1,233 @@ 'use client'; -import { ComboboxProvider } from '@ariakit/react'; -import { Select as SelectPrimitive } from 'radix-ui'; +import { + Combobox as ComboboxPrimitive, + Select as SelectPrimitive +} from '@base-ui/react'; import { createContext, + RefObject, useCallback, useContext, - useId, useMemo, useRef, useState } from 'react'; import { ItemType } from './types'; -interface CommonProps { - autocomplete?: boolean; - autocompleteMode?: 'auto' | 'manual'; - searchValue?: string; - onSearch?: (value: string) => void; - defaultSearchValue?: string; -} +type SelectMode = 'select' | 'combobox'; -interface SelectContextValue extends CommonProps { - value?: string | string[]; - registerItem: (item: ItemType) => void; - unregisterItem: (value: string) => void; +interface SelectContextValue { + mode: SelectMode; multiple: boolean; + // eslint-disable-next-line @typescript-eslint/no-explicit-any + value: any; + inputValue: string; + hasItems: boolean; items: Record; - updateSelectionInProgress: (value: boolean) => void; - setValue: (value: string) => void; -} - -interface UseSelectContext extends SelectContextValue { - shouldFilter?: boolean; + triggerRef: RefObject; + registerItem: (item: ItemType) => void; + unregisterItem: (value: string) => void; + shouldFilter: boolean; } -/* -Root context to manage the Select control -@remarks Only for internal usage. -*/ const SelectContext = createContext(undefined); -export const useSelectContext = (): UseSelectContext => { +export const useSelectContext = (): SelectContextValue => { const context = useContext(SelectContext); if (!context) { throw new Error('useSelectContext must be used within a SelectProvider'); } - const shouldFilter = !!( - context?.autocomplete && - context?.autocompleteMode === 'auto' && - context?.searchValue?.length - ); - return { - ...context, - shouldFilter - }; + return context; }; -interface NormalSelectRootProps extends SelectPrimitive.SelectProps { - autocomplete?: false; - autocompleteMode?: never; - searchValue?: never; - onSearch?: never; - defaultSearchValue?: never; -} - -interface AutocompleteSelectRootProps - extends SelectPrimitive.SelectProps, - CommonProps { - autocomplete: true; -} - -type BaseSelectProps = Omit< - NormalSelectRootProps | AutocompleteSelectRootProps, - 'autoComplete' | 'value' | 'onValueChange' | 'defaultValue' -> & { - htmlAutoComplete?: string; +// Conditional type: single mode returns Value, multiple mode returns Value[] +type SelectValueType< + Value, + Multiple extends boolean | undefined +> = Multiple extends true ? Value[] : Value; + +export type SelectRootProps< + Value = any, // eslint-disable-line @typescript-eslint/no-explicit-any + Multiple extends boolean | undefined = false +> = { + children?: React.ReactNode; + multiple?: Multiple; + value?: SelectValueType | null; + defaultValue?: SelectValueType | null; + onValueChange?: (value: SelectValueType) => void; + open?: boolean; + defaultOpen?: boolean; + onOpenChange?: (open: boolean) => void; + disabled?: boolean; + required?: boolean; + name?: string; + items?: Value[]; + autocomplete?: boolean; + autocompleteMode?: 'auto' | 'manual'; + searchValue?: string; + onSearch?: (value: string) => void; + defaultSearchValue?: string; }; -export interface SingleSelectProps extends BaseSelectProps { - multiple?: false; - value?: string; - onValueChange?: (value: string) => void; - defaultValue?: string; -} - -export interface MultipleSelectProps extends BaseSelectProps { - multiple: true; - value?: string[]; - onValueChange?: (value: string[]) => void; - defaultValue?: string[]; -} - -export type SelectRootProps = SingleSelectProps | MultipleSelectProps; - -const SELECT_INTERNAL_VALUE = 'SELECT_INTERNAL_VALUE'; - -export const SelectRoot = (props: SelectRootProps) => { +export function SelectRoot< + Value = any, // eslint-disable-line @typescript-eslint/no-explicit-any + Multiple extends boolean | undefined = false +>(props: SelectRootProps) { const { children, value: providedValue, onValueChange, defaultValue, - autocomplete, + autocomplete = false, autocompleteMode = 'auto', searchValue: providedSearchValue, onSearch, defaultSearchValue = '', open: providedOpen, - defaultOpen = false, + defaultOpen, onOpenChange, - htmlAutoComplete, - multiple = false, + multiple = false as Multiple, + items: itemsProp, + disabled, + required, + name, ...rest } = props; - const [internalValue, setInternalValue] = useState< - string | string[] | undefined - >(defaultValue); + const [internalValue, setInternalValue] = useState(defaultValue ?? null); // eslint-disable-line @typescript-eslint/no-explicit-any const [internalSearchValue, setInternalSearchValue] = useState(defaultSearchValue); - const [internalOpen, setInternalOpen] = useState(defaultOpen); - const [items, setItems] = useState({}); - const id = useId(); - const isSelectionInProgress = useRef(false); + const [registeredItems, setRegisteredItems] = useState< + Record + >({}); + const triggerRef = useRef(null); const computedValue = providedValue ?? internalValue; const searchValue = providedSearchValue ?? internalSearchValue; - const open = providedOpen ?? internalOpen; + const mode: SelectMode = autocomplete ? 'combobox' : 'select'; - const updateSelectionInProgress = useCallback((value: boolean) => { - isSelectionInProgress.current = value; - }, []); + const handleInputValueChange = useCallback( + ( + value: string, + _eventDetails: ComboboxPrimitive.Root.ChangeEventDetails + ) => { + setInternalSearchValue(value); + onSearch?.(value); + }, + [onSearch] + ); - const setValue = useCallback( - (value: string) => { - /* - * If the select is placed inside a form, onChange is called with an empty value - * WORKAROUND FOR ISSUE https://github.com/radix-ui/primitives/issues/3135 - */ - if (value === '') return; - - if (multiple) { - updateSelectionInProgress(true); - const set = new Set( - Array.isArray(computedValue) - ? computedValue - : [computedValue ?? ''].filter(Boolean) - ); - - if (set.has(value)) set.delete(value); - else set.add(value); - - const newValue = Array.from(set); - - setInternalValue(newValue); - (onValueChange as MultipleSelectProps['onValueChange'])?.(newValue); - } else { - setInternalValue(value); - (onValueChange as SingleSelectProps['onValueChange'])?.(value); - } + const handleSelectValueChange = useCallback( + // eslint-disable-next-line @typescript-eslint/no-explicit-any + (value: any) => { + setInternalValue(value); + onValueChange?.(value); }, - [multiple, onValueChange, computedValue, updateSelectionInProgress] + [onValueChange] ); - const setSearchValue = useCallback( - (value: string) => { - setInternalSearchValue(value); - onSearch?.(value); + const handleComboboxValueChange = useCallback( + ( + // eslint-disable-next-line @typescript-eslint/no-explicit-any + value: any, + _eventDetails: ComboboxPrimitive.Root.ChangeEventDetails + ) => { + setInternalValue(value); + onValueChange?.(value); }, - [onSearch] + [onValueChange] ); const handleOpenChange = useCallback( - (value: boolean) => { - if (isSelectionInProgress.current) return; - setInternalOpen(value); - onOpenChange?.(value); + (open: boolean) => { + onOpenChange?.(open); }, [onOpenChange] ); - const registerItem = useCallback(item => { - setItems(prev => ({ ...prev, [item.value]: item })); + const registerItem = useCallback((item: ItemType) => { + setRegisteredItems(prev => ({ ...prev, [String(item.value)]: item })); }, []); - const unregisterItem = useCallback( - value => { - setItems(prev => { - const { [value]: _, ...rest } = prev; - return rest; - }); - }, - [] - ); + const unregisterItem = useCallback((value: string) => { + setRegisteredItems(prev => { + const { [value]: _, ...rest } = prev; + return rest; + }); + }, []); - /* - * Radix internally shows the placeholder when the value is empty. - * This value is used to manage the internal value of Radix Select to make it work - */ - const radixValue = useMemo(() => { - if (!computedValue) return ''; - if (typeof computedValue === 'string') return computedValue; - if (Array.isArray(computedValue) && computedValue.length) - return `${SELECT_INTERNAL_VALUE}-${id}`; - return String(computedValue) ?? ''; - }, [computedValue, id]); - - const element = ( - - {children} - + const shouldFilter = + mode === 'combobox' && + autocompleteMode === 'auto' && + !itemsProp && + searchValue.length > 0; + + const contextValue = useMemo( + () => ({ + mode, + multiple: !!multiple, + value: computedValue, + inputValue: searchValue, + hasItems: !!itemsProp, + items: registeredItems, + triggerRef, + registerItem, + unregisterItem, + shouldFilter + }), + [ + mode, + multiple, + computedValue, + searchValue, + itemsProp, + registeredItems, + registerItem, + unregisterItem, + shouldFilter + ] ); + if (mode === 'combobox') { + return ( + + + {children} + + + ); + } + return ( - + - {autocomplete ? element : children} + {children} ); -}; +} diff --git a/packages/raystack/components/select/select-trigger.tsx b/packages/raystack/components/select/select-trigger.tsx index cacd2dbc0..fd1d69630 100644 --- a/packages/raystack/components/select/select-trigger.tsx +++ b/packages/raystack/components/select/select-trigger.tsx @@ -1,9 +1,12 @@ 'use client'; +import { + Combobox as ComboboxPrimitive, + Select as SelectPrimitive +} from '@base-ui/react'; import { ChevronDownIcon } from '@radix-ui/react-icons'; import { cva, VariantProps } from 'class-variance-authority'; -import { Select as SelectPrimitive, Slot } from 'radix-ui'; -import { ElementRef, forwardRef, SVGAttributes } from 'react'; +import { forwardRef, SVGAttributes } from 'react'; import { Flex } from '../flex'; import styles from './select.module.css'; import { useSelectContext } from './select-root'; @@ -30,16 +33,15 @@ const trigger = cva(styles.trigger, { } }); -export interface SelectTriggerProps - extends SelectPrimitive.SelectTriggerProps, - VariantProps { +export interface SelectTriggerProps extends VariantProps { iconProps?: IconProps; + className?: string; + children?: React.ReactNode; + 'aria-label'?: string; + disabled?: boolean; } -export const SelectTrigger = forwardRef< - ElementRef, - SelectTriggerProps ->( +export const SelectTrigger = forwardRef( ( { size, @@ -47,34 +49,60 @@ export const SelectTrigger = forwardRef< className, children, iconProps = {}, - asChild, 'aria-label': ariaLabel, ...props }, ref ) => { - const { multiple, autocomplete } = useSelectContext(); + const { mode, multiple, triggerRef } = useSelectContext(); + + const triggerClassName = trigger({ size, variant, className }); + const icon = ( +