diff --git a/apps/www/src/app/examples/combobox/page.tsx b/apps/www/src/app/examples/combobox/page.tsx new file mode 100644 index 000000000..d1d0ee09b --- /dev/null +++ b/apps/www/src/app/examples/combobox/page.tsx @@ -0,0 +1,28 @@ +'use client'; +import { Combobox, Flex } from '@raystack/apsara'; + +const Page = () => { + return ( + + + + + Item 1 + Item 2 + Item 3 + + + + ); +}; + +export default Page; diff --git a/apps/www/src/content/docs/components/combobox/demo.ts b/apps/www/src/content/docs/components/combobox/demo.ts new file mode 100644 index 000000000..163c04e91 --- /dev/null +++ b/apps/www/src/content/docs/components/combobox/demo.ts @@ -0,0 +1,149 @@ +'use client'; + +import { getPropsString } from '@/lib/utils'; + +export const getCode = (props: Record) => { + const { multiple, ...rest } = props; + return ` + + + + Apple + Banana + Blueberry + Grapes + Pineapple + + `; +}; + +export const playground = { + type: 'playground', + controls: { + label: { type: 'text', initialValue: 'Fruits' }, + size: { + type: 'select', + options: ['small', 'large'], + defaultValue: 'large' + }, + multiple: { + type: 'checkbox', + defaultValue: false + } + }, + getCode +}; + +export const basicDemo = { + type: 'code', + code: ` + + + + Apple + Banana + Grape + Orange + + ` +}; + +export const iconDemo = { + type: 'code', + code: ` + + + + }>Apple + }>Banana + }>Grape + }>Orange + + ` +}; + +export const sizeDemo = { + type: 'code', + code: ` + + + + + Option 1 + Option 2 + + + + + + Option 1 + Option 2 + + + ` +}; + +export const multipleDemo = { + type: 'code', + code: ` + + + + Apple + Banana + Grape + Orange + Pineapple + Mango + + ` +}; + +export const groupDemo = { + type: 'code', + code: ` + + + + + Fruits + Apple + Banana + + + + Vegetables + Carrot + Broccoli + + + ` +}; + +export const controlledDemo = { + type: 'code', + code: ` + function ControlledDemo() { + const [value, setValue] = React.useState(""); + const [inputValue, setInputValue] = React.useState(""); + + return ( + + Selected: {value || "None"} + + + + Apple + Banana + Grape + + + + ); + }` +}; diff --git a/apps/www/src/content/docs/components/combobox/index.mdx b/apps/www/src/content/docs/components/combobox/index.mdx new file mode 100644 index 000000000..09613d306 --- /dev/null +++ b/apps/www/src/content/docs/components/combobox/index.mdx @@ -0,0 +1,113 @@ +--- +title: Combobox +description: An input field with an attached dropdown that allows users to search and select from a list of options. +tag: new +--- + +import { + playground, + basicDemo, + sizeDemo, + iconDemo, + multipleDemo, + groupDemo, + controlledDemo +} from "./demo.ts"; + + + +## Usage + +```tsx +import { Combobox } from "@raystack/apsara"; +``` + +## Combobox Props + +The Combobox component is composed of several parts, each with their own props. + +The root element is the parent component that manages the combobox state including open/close, input value, and selection. It is built using [Ariakit ComboboxProvider](https://ariakit.org/reference/combobox-provider) and [Radix Popover](https://www.radix-ui.com/primitives/docs/components/popover). + + + +### Combobox.Input Props + +The input field that triggers the combobox dropdown and allows users to type and filter options. + + + +### Combobox.Content Props + +The dropdown container that holds the combobox items. + + + +### Combobox.Item Props + +Individual selectable options within the combobox. + + + +### Combobox.Group Props + +A way to group related combobox items together. + + + +### Combobox.Label Props + +Renders a label in a combobox group. This component should be used inside Combobox.Group. + + + +### Combobox.Separator Props + +Visual divider between combobox items or groups. + + + +## Examples + +### Basic Combobox + +A simple combobox with search functionality. + + + +### Size + +The combobox input supports different sizes. + + + +### Multiple Selection + +To enable multiple selection, pass the `multiple` prop to the Combobox root element. + +When multiple selection is enabled, the value, onValueChange, and defaultValue will be an array of strings. Selected items are displayed as chips in the input field. + + + +### Groups and Separators + +Use Combobox.Group, Combobox.Label, and Combobox.Separator to organize items into logical groups. + + + +### Controlled + +You can control the combobox value and input value using the `value`, `onValueChange`, `inputValue`, and `onInputValueChange` props. + + + +## Accessibility + +The Combobox component follows WAI-ARIA guidelines: + +- Input has role `combobox` +- Content has role `listbox` +- Items have role `option` +- Supports keyboard navigation (Arrow keys, Enter, Escape) +- ARIA labels and descriptions for screen readers +- Focus management between input and listbox + diff --git a/apps/www/src/content/docs/components/combobox/props.ts b/apps/www/src/content/docs/components/combobox/props.ts new file mode 100644 index 000000000..9c7db505c --- /dev/null +++ b/apps/www/src/content/docs/components/combobox/props.ts @@ -0,0 +1,133 @@ +export interface ComboboxRootProps { + /** Enables multiple selection. + * When enabled, value, onValueChange, and defaultValue will be an array of strings. + * @default false + */ + multiple?: boolean; + + /** The controlled value of the combobox. + * For single selection: string + * For multiple selection: string[] + */ + value?: string | string[]; + + /** The default value of the combobox (uncontrolled). + * For single selection: string + * For multiple selection: string[] + */ + defaultValue?: string | string[]; + + /** Callback fired when the value changes. + * For single selection: (value: string) => void + * For multiple selection: (value: string[]) => void + */ + onValueChange?: (value: string | string[]) => void; + + /** The controlled input value of the combobox. */ + inputValue?: string; + + /** The default input value (uncontrolled). */ + defaultInputValue?: string; + + /** Callback fired when the input value changes. */ + onInputValueChange?: (inputValue: string) => void; + + /** Whether the combobox is open. + * @default false + */ + open?: boolean; + + /** The default open state (uncontrolled). + * @default false + */ + defaultOpen?: boolean; + + /** Callback fired when the open state changes. */ + onOpenChange?: (open: boolean) => void; + + /** Whether the popover should be modal. + * @default false + */ + modal?: boolean; +} + +export interface ComboboxInputProps { + /** + * Size variant of the input field + * @default "large" + */ + size?: 'small' | 'large'; + + /** Text label above the input */ + label?: string; + + /** Helper text below the input */ + helperText?: string; + + /** Error message to display below the input */ + error?: string; + + /** Whether the input is disabled */ + disabled?: boolean; + + /** Icon element to display at the start of input */ + leadingIcon?: React.ReactNode; + + /** Shows "(Optional)" text next to label */ + optional?: boolean; + + /** Text or symbol to show before input value */ + prefix?: string; + + /** Placeholder text for the input field. */ + placeholder?: string; + + /** Additional CSS class names. */ + className?: string; +} + +export interface ComboboxContentProps { + /** Alignment of the content relative to the trigger. + * @default "start" + */ + align?: 'start' | 'center' | 'end'; + + /** Distance from the trigger in pixels. + * @default 4 + */ + sideOffset?: number; + + /** Additional CSS class names. */ + className?: string; +} + +export interface ComboboxItemProps { + /** The value of the item. If not provided, the item content will be used as the value. */ + value?: string; + + /** Whether the item is disabled. + * @default false + */ + disabled?: boolean; + + /** Leading icon to display before the item text. */ + leadingIcon?: React.ReactNode; + + /** Additional CSS class names. */ + className?: string; +} + +export interface ComboboxGroupProps { + /** Additional CSS class names. */ + className?: string; +} + +export interface ComboboxLabelProps { + /** Additional CSS class names. */ + className?: string; +} + +export interface ComboboxSeparatorProps { + /** Additional CSS class names. */ + className?: string; +} diff --git a/packages/raystack/components/combobox/__tests__/combobox.test.tsx b/packages/raystack/components/combobox/__tests__/combobox.test.tsx new file mode 100644 index 000000000..b76a60615 --- /dev/null +++ b/packages/raystack/components/combobox/__tests__/combobox.test.tsx @@ -0,0 +1,469 @@ +import { fireEvent, render, screen, waitFor } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import { describe, expect, it, vi } from 'vitest'; +import { Combobox } from '../combobox'; +import { ComboboxRootProps } from '../combobox-root'; + +// Mock scrollIntoView for test environment +Object.defineProperty(Element.prototype, 'scrollIntoView', { + value: vi.fn(), + writable: true +}); + +const PLACEHOLDER_TEXT = 'Enter a fruit'; +const FRUIT_OPTIONS = [ + { value: 'apple', label: 'Apple' }, + { value: 'banana', label: 'Banana' }, + { value: 'blueberry', label: 'Blueberry' }, + { value: 'grapes', label: 'Grapes' }, + { value: 'pineapple', label: 'Pineapple' } +]; + +const BasicCombobox = (props: ComboboxRootProps) => { + return ( + + + + {FRUIT_OPTIONS.map(option => ( + + {option.label} + + ))} + + + ); +}; +const renderAndOpenCombobox = async (Combobox: React.ReactElement) => { + await fireEvent.click(render(Combobox).getByPlaceholderText('Enter a fruit')); +}; + +describe('Combobox', () => { + describe('Basic Rendering', () => { + it('renders combobox input', () => { + render(); + const input = screen.getByRole('combobox'); + expect(input).toBeInTheDocument(); + expect(input).toHaveAttribute('placeholder', PLACEHOLDER_TEXT); + }); + + it('does not show content initially', () => { + render(); + FRUIT_OPTIONS.forEach(option => { + expect(screen.queryByText(option.label)).not.toBeInTheDocument(); + }); + }); + + it('shows content when input is clicked', async () => { + const user = userEvent.setup(); + render(); + + const input = screen.getByRole('combobox'); + await user.click(input); + + await waitFor(() => { + expect(screen.getByRole('listbox')).toBeInTheDocument(); + }); + + FRUIT_OPTIONS.forEach(option => { + expect(screen.getByText(option.label)).toBeInTheDocument(); + }); + }); + + it('renders in portal', async () => { + const user = userEvent.setup(); + render(); + + const input = screen.getByRole('combobox'); + await user.click(input); + + await waitFor(() => { + const content = screen.getByRole('listbox'); + expect(content.closest('body')).toBe(document.body); + }); + }); + }); + + describe('Single Selection', () => { + it('selects option when clicked', async () => { + const user = userEvent.setup(); + const handleValueChange = vi.fn(); + render(); + + const input = screen.getByRole('combobox'); + await user.click(input); + + const bananaOption = await screen.findByText('Banana'); + await user.click(bananaOption); + + expect(handleValueChange).toHaveBeenCalledWith('banana'); + }); + + it('closes content after selection in single mode', async () => { + const user = userEvent.setup(); + render(); + + const input = screen.getByRole('combobox'); + await user.click(input); + + await waitFor(() => { + expect(screen.getByRole('listbox')).toBeInTheDocument(); + }); + + const bananaOption = await screen.findByText('Banana'); + await user.click(bananaOption); + + await waitFor(() => { + expect(screen.queryByRole('listbox')).not.toBeInTheDocument(); + }); + }); + + it('updates input value with selected item', async () => { + const user = userEvent.setup(); + render(); + + const input = screen.getByRole('combobox'); + await user.click(input); + + const appleOption = await screen.findByText('Apple'); + await user.click(appleOption); + + expect(input).toHaveValue('apple'); + }); + }); + + describe('Multiple Selection', () => { + it('supports multiple selection', async () => { + const user = userEvent.setup(); + const handleValueChange = vi.fn(); + render(); + + const input = screen.getByRole('combobox'); + await user.click(input); + + const bananaOption = await screen.findByText('Banana'); + await user.click(bananaOption); + expect(handleValueChange).toHaveBeenCalledWith(['banana']); + + const pineappleOption = await screen.findByText('Pineapple'); + await user.click(pineappleOption); + expect(handleValueChange).toHaveBeenCalledWith(['banana', 'pineapple']); + }); + + it('allows deselecting items in multiple mode', async () => { + const user = userEvent.setup(); + const handleValueChange = vi.fn(); + render(); + + const input = screen.getByRole('combobox'); + await user.click(input); + + const bananaOption = await screen.findByText('Banana'); + await user.click(bananaOption); + expect(handleValueChange).toHaveBeenCalledWith(['banana']); + + await user.click(bananaOption); + expect(handleValueChange).toHaveBeenCalledWith([]); + }); + + it('keeps dropdown open after selection in multiple mode', async () => { + const user = userEvent.setup(); + render(); + + const input = screen.getByRole('combobox'); + await user.click(input); + + await waitFor(() => { + expect(screen.getByRole('listbox')).toBeInTheDocument(); + }); + + const bananaOption = await screen.findByText('Banana'); + await user.click(bananaOption); + + expect(screen.getByRole('listbox')).toBeInTheDocument(); + }); + + it('displays selected values as chips', () => { + render(); + + expect(screen.getByText('apple')).toBeInTheDocument(); + expect(screen.getByText('banana')).toBeInTheDocument(); + }); + }); + + describe('Keyboard Navigation', () => { + it('opens with typing', async () => { + const user = userEvent.setup(); + render(); + + const input = screen.getByRole('combobox'); + await user.type(input, 'a'); + + await waitFor(() => { + expect(screen.getByRole('listbox')).toBeInTheDocument(); + }); + }); + + it('closes with Escape key', async () => { + const user = userEvent.setup(); + render(); + + const input = screen.getByRole('combobox'); + await user.click(input); + + await waitFor(() => { + expect(screen.getByRole('listbox')).toBeInTheDocument(); + }); + + await user.keyboard('{Escape}'); + + await waitFor(() => { + expect(screen.queryByRole('listbox')).not.toBeInTheDocument(); + }); + }); + }); + + describe('Search/Filter', () => { + it('filters options based on input', async () => { + const user = userEvent.setup(); + render(); + + const input = screen.getByRole('combobox'); + await user.type(input, 'app'); + + await waitFor(() => { + const options = screen.getAllByRole('option'); + expect(options.length).toBe(2); + expect(options[0].textContent).toBe('Apple'); + expect(options[1].textContent).toBe('Pineapple'); + }); + }); + + it('shows all options when search is cleared', async () => { + const user = userEvent.setup(); + render(); + + const input = screen.getByRole('combobox'); + await user.type(input, 'app'); + + await waitFor(() => { + expect(screen.getAllByRole('option').length).toBe(2); + }); + + await user.clear(input); + + await waitFor(() => { + expect(screen.getAllByRole('option').length).toBe(FRUIT_OPTIONS.length); + }); + }); + + it('calls onInputValueChange when typing', async () => { + const user = userEvent.setup(); + const handleInputChange = vi.fn(); + render(); + + const input = screen.getByRole('combobox'); + await user.type(input, 'a'); + + expect(handleInputChange).toHaveBeenCalledWith('a'); + }); + }); + + describe('Grouping and Labels', () => { + it('renders grouped options', async () => { + const user = userEvent.setup(); + render( + + + + + Fruits + Apple + Banana + + + + Vegetables + Carrot + Broccoli + + + + ); + + const input = screen.getByRole('combobox'); + await user.click(input); + + await waitFor(() => { + expect(screen.getByText('Fruits')).toBeInTheDocument(); + expect(screen.getByText('Vegetables')).toBeInTheDocument(); + expect(screen.getByText('Apple')).toBeInTheDocument(); + expect(screen.getByText('Carrot')).toBeInTheDocument(); + }); + }); + + it('hides labels when searching', async () => { + const user = userEvent.setup(); + render( + + + + + Fruits + Apple + + + + ); + + const input = screen.getByRole('combobox'); + await user.type(input, 'app'); + + await waitFor(() => { + expect(screen.queryByText('Fruits')).not.toBeInTheDocument(); + expect(screen.getByText('Apple')).toBeInTheDocument(); + }); + }); + }); + + describe('Controlled Mode', () => { + it('works as controlled open state', () => { + const { rerender } = render( + + + + Option 1 + + + ); + + expect(screen.queryByRole('listbox')).not.toBeInTheDocument(); + + rerender( + + + + Option 1 + + + ); + + expect(screen.getByRole('listbox')).toBeInTheDocument(); + }); + + it('calls onOpenChange when state changes', async () => { + const user = userEvent.setup(); + const onOpenChange = vi.fn(); + render(); + + const input = screen.getByRole('combobox'); + await user.click(input); + + await waitFor(() => { + expect(onOpenChange).toHaveBeenCalledWith(true); + }); + }); + + it('works with controlled input value', () => { + const handleInputChange = vi.fn(); + render( + + ); + + const input = screen.getByRole('combobox'); + expect(input).toHaveValue('test'); + }); + }); + + describe('Accessibility', () => { + it('has correct ARIA roles', async () => { + const user = userEvent.setup(); + render(); + + const input = screen.getByRole('combobox'); + expect(input).toBeInTheDocument(); + + await user.click(input); + + await waitFor(() => { + const listbox = screen.getByRole('listbox'); + expect(listbox).toBeInTheDocument(); + }); + }); + + it('marks selected items correctly', async () => { + const user = userEvent.setup(); + render(); + + const input = screen.getByRole('combobox'); + await user.click(input); + + await waitFor(() => { + const appleOption = screen + .getByText('Apple') + .closest('[role="option"]'); + expect(appleOption).toHaveAttribute('aria-selected', 'true'); + expect(appleOption).toHaveAttribute('data-selected', 'true'); + }); + }); + + it('options have correct role', async () => { + const user = userEvent.setup(); + render(); + + const input = screen.getByRole('combobox'); + await user.click(input); + + await waitFor(() => { + const options = screen.getAllByRole('option'); + expect(options.length).toBe(FRUIT_OPTIONS.length); + }); + }); + }); + + describe('Item without explicit value', () => { + it('uses children text as value when value prop is not provided', async () => { + const user = userEvent.setup(); + const handleValueChange = vi.fn(); + render( + + + + Apple + Banana + + + ); + + const input = screen.getByRole('combobox'); + await user.click(input); + + const appleOption = await screen.findByText('Apple'); + await user.click(appleOption); + + expect(handleValueChange).toHaveBeenCalledWith('Apple'); + }); + }); + + describe('Backspace behavior in multiple mode', () => { + it('removes last selected item on backspace when input is empty', async () => { + const user = userEvent.setup(); + const handleValueChange = vi.fn(); + render( + + ); + + const input = screen.getByRole('combobox'); + await user.click(input); + await user.keyboard('{Backspace}'); + + expect(handleValueChange).toHaveBeenCalledWith(['apple']); + }); + }); +}); diff --git a/packages/raystack/components/combobox/combobox-content.tsx b/packages/raystack/components/combobox/combobox-content.tsx new file mode 100644 index 000000000..004e9bcc5 --- /dev/null +++ b/packages/raystack/components/combobox/combobox-content.tsx @@ -0,0 +1,97 @@ +'use client'; + +import { ComboboxList } from '@ariakit/react'; +import { cx } from 'class-variance-authority'; +import { Popover as PopoverPrimitive } from 'radix-ui'; +import { + ComponentPropsWithoutRef, + ElementRef, + forwardRef, + useCallback +} from 'react'; +import styles from './combobox.module.css'; +import { useComboboxContext } from './combobox-root'; + +export interface ComboboxContentProps + extends Omit< + ComponentPropsWithoutRef, + 'asChild' + > {} + +export const ComboboxContent = forwardRef< + ElementRef, + ComboboxContentProps +>( + ( + { + className, + children, + sideOffset = 4, + align = 'start', + onOpenAutoFocus, + onInteractOutside, + onFocusOutside, + ...props + }, + 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] + ); + return ( + + + + {children} + + + + ); + } +); +ComboboxContent.displayName = 'ComboboxContent'; diff --git a/packages/raystack/components/combobox/combobox-input.tsx b/packages/raystack/components/combobox/combobox-input.tsx new file mode 100644 index 000000000..cb751d8d1 --- /dev/null +++ b/packages/raystack/components/combobox/combobox-input.tsx @@ -0,0 +1,92 @@ +'use client'; + +import { Combobox } from '@ariakit/react'; +import { ChevronDownIcon } from '@radix-ui/react-icons'; +import { Popover as PopoverPrimitive } from 'radix-ui'; +import { + ElementRef, + FocusEvent, + forwardRef, + KeyboardEvent, + useCallback +} from 'react'; +import { InputField } from '../input-field'; +import { InputFieldProps } from '../input-field/input-field'; +import styles from './combobox.module.css'; +import { useComboboxContext } from './combobox-root'; + +export interface ComboboxInputProps + extends Omit< + InputFieldProps, + 'trailingIcon' | 'suffix' | 'chips' | 'maxChipsVisible' + > {} + +export const ComboboxInput = forwardRef< + 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] + ); + + return ( + +
+ ({ + label: val, + onRemove: () => + setValue((value as string[])?.filter(v => v !== val)) + })) + : undefined + } + trailingIcon={} + {...props} + /> + } + onBlur={handleOnBlur} + onKeyDown={handleOnKeyDown} + /> +
+
+ ); +}); +ComboboxInput.displayName = 'ComboboxInput'; diff --git a/packages/raystack/components/combobox/combobox-item.tsx b/packages/raystack/components/combobox/combobox-item.tsx new file mode 100644 index 000000000..162c283fb --- /dev/null +++ b/packages/raystack/components/combobox/combobox-item.tsx @@ -0,0 +1,76 @@ +'use client'; + +import { ComboboxItem as AriakitComboboxItem } from '@ariakit/react'; +import { cx } from 'class-variance-authority'; +import { ComponentPropsWithoutRef, ElementRef, forwardRef } from 'react'; +import { Checkbox } from '../checkbox'; +import { getMatch } from '../dropdown-menu/utils'; +import { Text } from '../text'; +import styles from './combobox.module.css'; +import { useComboboxContext } from './combobox-root'; + +export interface ComboboxItemProps + extends ComponentPropsWithoutRef { + leadingIcon?: React.ReactNode; +} + +export const ComboboxItem = forwardRef< + ElementRef, + ComboboxItemProps +>( + ( + { + className, + children, + value: providedValue, + leadingIcon, + disabled, + ...props + }, + ref + ) => { + const value = providedValue + ? String(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 element = + typeof children === 'string' ? ( + <> + {leadingIcon &&
{leadingIcon}
} + {children} + + ) : ( + children + ); + + if (inputValue?.length && !isMatched) { + // Doesn't match search, so don't render at all + return null; + } + + return ( + + {multiple && } + {element} + + ); + } +); +ComboboxItem.displayName = 'ComboboxItem'; diff --git a/packages/raystack/components/combobox/combobox-misc.tsx b/packages/raystack/components/combobox/combobox-misc.tsx new file mode 100644 index 000000000..dbe2c91b1 --- /dev/null +++ b/packages/raystack/components/combobox/combobox-misc.tsx @@ -0,0 +1,64 @@ +'use client'; + +import { + ComboboxGroup as AriakitComboboxGroup, + ComboboxGroupLabel as AriakitComboboxGroupLabel, + ComboboxSeparator as AriakitComboboxSeparator +} from '@ariakit/react'; +import { cx } from 'class-variance-authority'; +import { ComponentPropsWithoutRef, ElementRef, forwardRef } from 'react'; +import styles from './combobox.module.css'; +import { useComboboxContext } from './combobox-root'; + +export const ComboboxLabel = forwardRef< + ElementRef, + ComponentPropsWithoutRef +>(({ className, ...props }, ref) => { + const { inputValue } = useComboboxContext(); + if (inputValue?.length) return null; + + return ( + + ); +}); +ComboboxLabel.displayName = 'ComboboxLabel'; + +export const ComboboxGroup = forwardRef< + ElementRef, + ComponentPropsWithoutRef +>(({ className, children, ...props }, ref) => { + const { inputValue } = useComboboxContext(); + if (inputValue?.length) return children; + + return ( + + {children} + + ); +}); +ComboboxGroup.displayName = 'ComboboxGroup'; + +export const ComboboxSeparator = forwardRef< + ElementRef, + ComponentPropsWithoutRef +>(({ className, ...props }, ref) => { + const { inputValue } = useComboboxContext(); + if (inputValue?.length) return null; + + return ( + + ); +}); +ComboboxSeparator.displayName = 'ComboboxSeparator'; diff --git a/packages/raystack/components/combobox/combobox-root.tsx b/packages/raystack/components/combobox/combobox-root.tsx new file mode 100644 index 000000000..795d061e8 --- /dev/null +++ b/packages/raystack/components/combobox/combobox-root.tsx @@ -0,0 +1,172 @@ +'use client'; + +import { ComboboxProvider, ComboboxProviderProps } from '@ariakit/react'; +import { Popover as PopoverPrimitive } from 'radix-ui'; +import { + createContext, + RefObject, + useCallback, + useContext, + 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; + multiple: boolean; + listRef: RefObject; +} + +const ComboboxContext = createContext( + undefined +); + +export const useComboboxContext = (): ComboboxContextValue => { + const context = useContext(ComboboxContext); + if (!context) { + throw new Error( + 'useComboboxContext must be used within a ComboboxProvider' + ); + } + return context; +}; +export interface BaseComboboxRootProps + extends Omit< + ComboboxProviderProps, + | 'value' + | 'setValue' + | 'selectedValue' + | 'setSelectedValue' + | 'defaultSelectedValue' + | 'defaultValue' + | 'resetValueOnHide' + | 'resetValueOnSelect' + > { + onOpenChange?: (open: boolean) => void; + modal?: boolean; + inputValue?: string; + onInputValueChange?: (inputValue: string) => void; + defaultInputValue?: string; +} +export interface SingleComboboxProps extends BaseComboboxRootProps { + multiple?: false; + value?: string; + onValueChange?: (value: string) => void; + defaultValue?: string; +} +export interface MultipleComboboxProps extends BaseComboboxRootProps { + multiple: true; + value?: string[]; + onValueChange?: (value: string[]) => void; + defaultValue?: string[]; +} +export type ComboboxRootProps = SingleComboboxProps | MultipleComboboxProps; + +export const ComboboxRoot = ({ + modal = false, + multiple = false, + children, + value: providedValue, + defaultValue = multiple ? [] : undefined, + onValueChange, + inputValue: providedInputValue, + onInputValueChange, + defaultInputValue, + open: providedOpen, + defaultOpen = false, + onOpenChange, + ...props +}: ComboboxRootProps) => { + const [internalValue, setInternalValue] = useState< + string | string[] | undefined + >(defaultValue); + const [internalInputValue, setInternalInputValue] = + useState(defaultInputValue); + const [internalOpen, setInternalOpen] = useState(defaultOpen); + + const inputRef = useRef(null); + const listRef = useRef(null); + + const value = providedValue ?? internalValue; + const inputValue = providedInputValue ?? internalInputValue; + const open = providedOpen ?? internalOpen; + + const setValue = useCallback( + (newValue: string | string[] | undefined) => { + if (multiple) { + const formattedValue = newValue + ? Array.isArray(newValue) + ? newValue + : [newValue] + : []; + setInternalValue(formattedValue); + (onValueChange as MultipleComboboxProps['onValueChange'])?.( + formattedValue + ); + } else { + setInternalValue(String(newValue)); + (onValueChange as SingleComboboxProps['onValueChange'])?.( + String(newValue) + ); + } + }, + [onValueChange, multiple] + ); + + const setInputValue = useCallback( + (newValue: string) => { + if (!multiple && newValue.length === 0) setValue(''); + setInternalInputValue(newValue); + onInputValueChange?.(newValue); + }, + [onInputValueChange, setValue, multiple] + ); + + const setOpen = useCallback( + (newOpen: boolean) => { + setInternalOpen(newOpen); + onOpenChange?.(newOpen); + }, + [onOpenChange] + ); + + return ( + + + + {children} + + + + ); +}; diff --git a/packages/raystack/components/combobox/combobox.module.css b/packages/raystack/components/combobox/combobox.module.css new file mode 100644 index 000000000..7a1a67533 --- /dev/null +++ b/packages/raystack/components/combobox/combobox.module.css @@ -0,0 +1,72 @@ +.content { + z-index: var(--rs-z-index-portal); + font-size: var(--rs-font-size-small); + line-height: var(--rs-line-height-small); + letter-spacing: var(--rs-letter-spacing-small); + box-sizing: border-box; + background-color: var(--rs-color-background-base-primary); + border-radius: var(--rs-radius-2); + box-shadow: var(--rs-shadow-soft); + border: 1px solid var(--rs-color-border-base-primary); + max-height: 320px; + min-width: var(--radix-popover-trigger-width); + overflow: auto; +} + +.list { + padding: var(--rs-space-2); +} + +.itemIcon { + display: flex; + align-items: center; + justify-content: center; + flex-shrink: 0; +} + +.itemIcon svg { + width: 16px; + height: 16px; +} + +.menuitem { + display: flex; + align-items: center; + position: relative; + gap: var(--rs-space-3); + padding: var(--rs-space-3); + color: var(--rs-color-foreground-base-primary); + white-space: normal; + word-break: break-word; + border-radius: var(--rs-radius-2); +} + +.menuitem[data-highlighted], +.menuitem[data-active-item="true"] { + outline: none; + cursor: pointer; + background: var(--rs-color-background-base-primary-hover); +} +.menuitem[data-disabled] { + opacity: 0.6; + pointer-events: none; +} + +.list:empty { + padding: 0; +} +.content:has(.list:empty) { + border: none; +} + +.label { + padding: var(--rs-space-2) var(--rs-space-3); + font-weight: var(--rs-font-weight-medium); + font-size: var(--rs-font-size-mini); +} + +.separator { + height: 1px; + margin: var(--rs-space-2) calc(var(--rs-space-3) * -1); + background: var(--rs-color-border-base-primary); +} diff --git a/packages/raystack/components/combobox/combobox.tsx b/packages/raystack/components/combobox/combobox.tsx new file mode 100644 index 000000000..3ae662e2e --- /dev/null +++ b/packages/raystack/components/combobox/combobox.tsx @@ -0,0 +1,18 @@ +import { ComboboxContent } from './combobox-content'; +import { ComboboxInput } from './combobox-input'; +import { ComboboxItem } from './combobox-item'; +import { + ComboboxGroup, + ComboboxLabel, + ComboboxSeparator +} from './combobox-misc'; +import { ComboboxRoot } from './combobox-root'; + +export const Combobox = Object.assign(ComboboxRoot, { + Input: ComboboxInput, + Content: ComboboxContent, + Item: ComboboxItem, + Group: ComboboxGroup, + Label: ComboboxLabel, + Separator: ComboboxSeparator +}); diff --git a/packages/raystack/components/combobox/index.ts b/packages/raystack/components/combobox/index.ts new file mode 100644 index 000000000..a9e4295d5 --- /dev/null +++ b/packages/raystack/components/combobox/index.ts @@ -0,0 +1 @@ +export { Combobox } from './combobox'; diff --git a/packages/raystack/components/input-field/input-field.tsx b/packages/raystack/components/input-field/input-field.tsx index a58ec262b..b63c4609e 100644 --- a/packages/raystack/components/input-field/input-field.tsx +++ b/packages/raystack/components/input-field/input-field.tsx @@ -1,11 +1,10 @@ 'use client'; import { InfoCircledIcon } from '@radix-ui/react-icons'; -import { type VariantProps, cva, cx } from 'class-variance-authority'; -import { ComponentPropsWithoutRef, ReactNode, forwardRef } from 'react'; -import { Tooltip } from '../tooltip'; - +import { cva, cx, type VariantProps } from 'class-variance-authority'; +import { ComponentPropsWithoutRef, forwardRef, ReactNode } from 'react'; import { Chip } from '../chip'; +import { Tooltip } from '../tooltip'; import styles from './input-field.module.css'; // Todo: Add a dropdown support diff --git a/packages/raystack/components/select/__tests__/select.test.tsx b/packages/raystack/components/select/__tests__/select.test.tsx index 93c04d390..c1d9de884 100644 --- a/packages/raystack/components/select/__tests__/select.test.tsx +++ b/packages/raystack/components/select/__tests__/select.test.tsx @@ -243,232 +243,4 @@ describe('Select', () => { expect(options[0].textContent).toBe('Apple'); expect(options[1].textContent).toBe('Pineapple'); }); - // const user = userEvent.setup(); - // render(); - - // const trigger = screen.getByRole('combobox'); - // await user.click(trigger); - - // await waitFor(() => { - // expect(screen.getByRole('dialog')).toBeInTheDocument(); - // }); - - // const searchInput = screen.getByPlaceholderText('Search...'); - // await user.type(searchInput, 'app'); - // await user.clear(searchInput); - - // await waitFor(() => { - // FRUIT_OPTIONS.forEach(option => { - // expect(screen.getByText(option.label)).toBeInTheDocument(); - // }); - // }); - // }); - // }); - - // describe('Grouping and Labels', () => { - // it('renders grouped options', async () => { - // const user = userEvent.setup(); - // render( - // - // ); - - // const trigger = screen.getByRole('combobox'); - // await user.click(trigger); - - // await waitFor(() => { - // expect(screen.getByText('Fruits')).toBeInTheDocument(); - // expect(screen.getByText('Vegetables')).toBeInTheDocument(); - // expect(screen.getByText('Apple')).toBeInTheDocument(); - // expect(screen.getByText('Carrot')).toBeInTheDocument(); - // }); - // }); - // }); - - // describe('Controlled Mode', () => { - // it('works as controlled component', () => { - // const { rerender } = render( - // - // ); - - // expect(screen.queryByRole('listbox')).not.toBeInTheDocument(); - - // rerender( - // - // ); - - // expect(screen.getByRole('listbox')).toBeInTheDocument(); - // }); - - // it('calls onOpenChange when state changes', async () => { - // const user = userEvent.setup(); - // const onOpenChange = vi.fn(); - // render(); - - // const trigger = screen.getByRole('combobox'); - // await user.click(trigger); - - // expect(onOpenChange).toHaveBeenCalledWith(true); - // }); - // }); - - // describe('Accessibility', () => { - // it('has correct ARIA roles', async () => { - // const user = userEvent.setup(); - // render(); - - // const trigger = screen.getByRole('combobox'); - // await user.click(trigger); - - // await waitFor(() => { - // const listbox = screen.getByRole('listbox'); - // expect(listbox).toBeInTheDocument(); - // expect(listbox).toHaveAttribute('aria-multiselectable', 'false'); - // }); - // }); - - // it('has correct ARIA roles for multiple selection', async () => { - // const user = userEvent.setup(); - // render(); - - // const trigger = screen.getByRole('combobox'); - // await user.click(trigger); - - // await waitFor(() => { - // const listbox = screen.getByRole('listbox'); - // expect(listbox).toBeInTheDocument(); - // expect(listbox).toHaveAttribute('aria-multiselectable', 'true'); - // }); - // }); - - // it('has correct ARIA roles for autocomplete', async () => { - // const user = userEvent.setup(); - // render(); - - // const trigger = screen.getByRole('combobox'); - // await user.click(trigger); - - // await waitFor(() => { - // const dialog = screen.getByRole('dialog'); - // expect(dialog).toBeInTheDocument(); - // }); - // }); - - // it('uses custom ARIA label when provided', () => { - // render( - // - // ); - - // const trigger = screen.getByRole('combobox'); - // expect(trigger).toHaveAttribute('aria-label', 'Custom select label'); - // }); - - // it('marks selected items correctly', async () => { - // const user = userEvent.setup(); - // render(); - - // const trigger = screen.getByRole('combobox'); - // await user.click(trigger); - - // await waitFor(() => { - // const appleOption = screen.getByText('Apple'); - // expect(appleOption).toHaveAttribute('aria-selected', 'true'); - // expect(appleOption).toHaveAttribute('data-checked', 'true'); - // }); - // }); - // }); - - // describe('Disabled State', () => { - // it('disables individual items', async () => { - // const user = userEvent.setup(); - // render( - // - // ); - - // const trigger = screen.getByRole('combobox'); - // await user.click(trigger); - - // await waitFor(() => { - // const bananaOption = screen.getByText('Banana'); - // expect(bananaOption).toHaveAttribute('disabled'); - // }); - // }); - - // it('prevents selection of disabled items', async () => { - // const user = userEvent.setup(); - // const handleValueChange = vi.fn(); - // render( - // - // ); - - // const trigger = screen.getByRole('combobox'); - // await user.click(trigger); - - // await waitFor(() => { - // expect(screen.getByRole('listbox')).toBeInTheDocument(); - // }); - - // const bananaOption = screen.getByText('Banana'); - // await user.click(bananaOption); - - // expect(handleValueChange).not.toHaveBeenCalled(); - // }); - // }); }); diff --git a/packages/raystack/components/select/select-item.tsx b/packages/raystack/components/select/select-item.tsx index f9ed7f478..a499d5ee7 100644 --- a/packages/raystack/components/select/select-item.tsx +++ b/packages/raystack/components/select/select-item.tsx @@ -7,8 +7,8 @@ import { ElementRef, forwardRef, useLayoutEffect } from 'react'; import { Checkbox } from '../checkbox'; import { getMatch } from '../dropdown-menu/utils'; import { Text } from '../text'; -import { useSelectContext } from './select-root'; import styles from './select.module.css'; +import { useSelectContext } from './select-root'; export const SelectItem = forwardRef< ElementRef, diff --git a/packages/raystack/components/select/select-misc.tsx b/packages/raystack/components/select/select-misc.tsx index e8b5bfedc..01d3a0ea4 100644 --- a/packages/raystack/components/select/select-misc.tsx +++ b/packages/raystack/components/select/select-misc.tsx @@ -3,8 +3,8 @@ import { cx } from 'class-variance-authority'; import { Select as SelectPrimitive } from 'radix-ui'; import { ElementRef, Fragment, forwardRef } from 'react'; -import { useSelectContext } from './select-root'; import styles from './select.module.css'; +import { useSelectContext } from './select-root'; export const SelectGroup = forwardRef< ElementRef, diff --git a/packages/raystack/components/select/select-trigger.tsx b/packages/raystack/components/select/select-trigger.tsx index 7592f85d3..cacd2dbc0 100644 --- a/packages/raystack/components/select/select-trigger.tsx +++ b/packages/raystack/components/select/select-trigger.tsx @@ -1,12 +1,12 @@ 'use client'; import { ChevronDownIcon } from '@radix-ui/react-icons'; -import { VariantProps, cva } from 'class-variance-authority'; +import { cva, VariantProps } from 'class-variance-authority'; import { Select as SelectPrimitive, Slot } from 'radix-ui'; -import { ElementRef, SVGAttributes, forwardRef } from 'react'; +import { ElementRef, forwardRef, SVGAttributes } from 'react'; import { Flex } from '../flex'; -import { useSelectContext } from './select-root'; import styles from './select.module.css'; +import { useSelectContext } from './select-root'; export interface IconProps extends SVGAttributes { children?: never; diff --git a/packages/raystack/index.tsx b/packages/raystack/index.tsx index 80c8eacac..f7eed35ef 100644 --- a/packages/raystack/index.tsx +++ b/packages/raystack/index.tsx @@ -14,6 +14,8 @@ export { Callout } from './components/callout'; export { Checkbox } from './components/checkbox'; export { Chip } from './components/chip'; export { CodeBlock } from './components/code-block'; +export * from './components/color-picker'; +export { Combobox } from './components/combobox'; export { Command } from './components/command'; export { Container } from './components/container'; export { CopyButton } from './components/copy-button'; @@ -22,14 +24,15 @@ export { DataTableColumnDef, DataTableQuery, DataTableSort, - useDataTable, - EmptyFilterValue + EmptyFilterValue, + useDataTable } from './components/data-table'; export { Dialog } from './components/dialog'; export { DropdownMenu } from './components/dropdown-menu'; export { EmptyState } from './components/empty-state'; export { FilterChip } from './components/filter-chip'; export { Flex } from './components/flex'; +export { Grid } from './components/grid'; export { Headline } from './components/headline'; export { IconButton } from './components/icon-button'; export { Image } from './components/image'; @@ -46,12 +49,12 @@ export { ScrollArea } from './components/scroll-area'; export { Select } from './components/select'; export { Separator } from './components/separator'; export { Sheet } from './components/sheet'; +export { SidePanel } from './components/side-panel'; export { Sidebar } from './components/sidebar'; export { Skeleton } from './components/skeleton'; export { Slider } from './components/slider'; export { Spinner } from './components/spinner'; export { Switch } from './components/switch'; -export { SidePanel } from './components/side-panel'; export { Table } from './components/table'; export { Tabs } from './components/tabs'; export { Text } from './components/text'; @@ -62,7 +65,5 @@ export { ThemeSwitcher, useTheme } from './components/theme-provider'; -export { toast, ToastContainer } from './components/toast'; +export { ToastContainer, toast } from './components/toast'; export { Tooltip } from './components/tooltip'; -export { Grid } from './components/grid'; -export * from './components/color-picker';