diff --git a/packages/raystack/components/calendar/calendar.tsx b/packages/raystack/components/calendar/calendar.tsx index 3fb94e441..0f3096c22 100644 --- a/packages/raystack/components/calendar/calendar.tsx +++ b/packages/raystack/components/calendar/calendar.tsx @@ -1,11 +1,6 @@ 'use client'; -import { - ChevronDownIcon, - ChevronLeftIcon, - ChevronRightIcon, - ChevronUpIcon -} from '@radix-ui/react-icons'; +import { ChevronLeftIcon, ChevronRightIcon } from '@radix-ui/react-icons'; import { cva, cx } from 'class-variance-authority'; import { ChangeEvent, ReactNode, useEffect, useState } from 'react'; import { @@ -15,7 +10,6 @@ import { dateLib } from 'react-day-picker'; -import { Flex } from '../flex/flex'; import { IconButton } from '../icon-button'; import { Select } from '../select'; import { Skeleton } from '../skeleton'; @@ -82,27 +76,15 @@ function DropDown({ - - - - - - - {options.map(opt => ( - - {opt.label} - - ))} - - - - - - + {options.map(opt => ( + + {opt.label} + + ))} ); diff --git a/packages/raystack/components/code-block/__tests__/code-block.test.tsx b/packages/raystack/components/code-block/__tests__/code-block.test.tsx index 6561ad3fa..66d462a04 100644 --- a/packages/raystack/components/code-block/__tests__/code-block.test.tsx +++ b/packages/raystack/components/code-block/__tests__/code-block.test.tsx @@ -1,8 +1,7 @@ import { render, screen } from '@testing-library/react'; import userEvent from '@testing-library/user-event'; -import { describe, expect, it, vi } from 'vitest'; - import { ComponentProps } from 'react'; +import { describe, expect, it, vi } from 'vitest'; import { CodeBlock } from '../code-block'; // Mock the clipboard API @@ -146,13 +145,14 @@ describe('CodeBlock', () => { it('updates language when language select is changed', () => { const { rerender } = render(); - expect(screen.getByText('JavaScript')).toBeInTheDocument(); + const trigger = screen.getByRole('combobox'); + expect(trigger).toHaveTextContent('JavaScript'); expect(screen.getByText('function')).toBeInTheDocument(); expect(screen.queryByText('def')).not.toBeInTheDocument(); rerender(); - expect(screen.getByText('Python')).toBeInTheDocument(); + expect(trigger).toHaveTextContent('Python'); expect(screen.getByText('def')).toBeInTheDocument(); expect(screen.queryByText('function')).not.toBeInTheDocument(); }); diff --git a/packages/raystack/components/filter-chip/__tests__/filter-chip.test.tsx b/packages/raystack/components/filter-chip/__tests__/filter-chip.test.tsx index 44fd9a801..9fce07bd2 100644 --- a/packages/raystack/components/filter-chip/__tests__/filter-chip.test.tsx +++ b/packages/raystack/components/filter-chip/__tests__/filter-chip.test.tsx @@ -82,7 +82,9 @@ describe('FilterChip', () => { ); - const input = container.querySelector('input'); + const input = container.querySelector( + `.${styles.inputFieldWrapper} input` + ); expect(input).toBeInTheDocument(); }); @@ -96,7 +98,9 @@ describe('FilterChip', () => { /> ); - const input = container.querySelector('input') as HTMLInputElement; + const input = container.querySelector( + `.${styles.inputFieldWrapper} input` + ) as HTMLInputElement; fireEvent.change(input, { target: { value: 'test value' } }); expect(onValueChange).toHaveBeenCalledWith( @@ -114,7 +118,9 @@ describe('FilterChip', () => { /> ); - const input = container.querySelector('input'); + const input = container.querySelector( + `.${styles.inputFieldWrapper} input` + ); expect(input).toHaveValue('initial value'); }); }); diff --git a/packages/raystack/components/select/__tests__/select.test.tsx b/packages/raystack/components/select/__tests__/select.test.tsx index c1d9de884..ec423241b 100644 --- a/packages/raystack/components/select/__tests__/select.test.tsx +++ b/packages/raystack/components/select/__tests__/select.test.tsx @@ -1,4 +1,4 @@ -import { fireEvent, render, screen } from '@testing-library/react'; +import { act, fireEvent, render, screen } from '@testing-library/react'; import userEvent from '@testing-library/user-event'; import { describe, expect, it, vi } from 'vitest'; import { Select } from '../select'; @@ -10,6 +10,12 @@ Object.defineProperty(Element.prototype, 'scrollIntoView', { writable: true }); +const flushMicrotasks = async () => { + await act(async () => { + await new Promise(r => setTimeout(r, 0)); + }); +}; + const TRIGGER_TEXT = 'Select a fruit'; const FRUIT_OPTIONS = [ { value: 'apple', label: 'Apple' }, @@ -37,8 +43,11 @@ const BasicSelect = ({ ...props }: SelectRootProps) => { ); }; -const renderAndOpenSelect = async (Select: any) => { - await fireEvent.click(render(Select).getByRole('combobox')); + +const openSelect = async () => { + const trigger = screen.getByRole('combobox'); + fireEvent.click(trigger); + await flushMicrotasks(); }; describe('Select', () => { @@ -63,63 +72,63 @@ describe('Select', () => { expect(trigger).toHaveClass('custom-trigger'); }); - it('does not show content initially', () => { - render(); - FRUIT_OPTIONS.forEach(option => { - expect(screen.queryByText(option.label)).not.toBeInTheDocument(); - }); - }); - it('shows content when trigger is clicked', async () => { - await renderAndOpenSelect(); + render(); + await openSelect(); expect(screen.getByRole('listbox')).toBeInTheDocument(); FRUIT_OPTIONS.forEach(option => { expect(screen.getByText(option.label)).toBeInTheDocument(); }); }); - - it('renders in portal', async () => { - await renderAndOpenSelect(); - - const content = screen.getByRole('listbox'); - expect(content.closest('body')).toBe(document.body); - }); }); describe('Single Selection', () => { - it('displays selected value', () => { + it('displays selected value in trigger', async () => { render(); - expect(screen.getByText('Apple')).toBeInTheDocument(); + await flushMicrotasks(); + + const trigger = screen.getByRole('combobox'); + expect(trigger).toHaveTextContent('Apple'); }); - it('works as controlled component', () => { + it('works as controlled component', async () => { const handleValueChange = vi.fn(); render(); - expect(screen.getByText('Apple')).toBeInTheDocument(); + await flushMicrotasks(); + + const trigger = screen.getByRole('combobox'); + expect(trigger).toHaveTextContent('Apple'); }); it('selects option when clicked', async () => { const handleValueChange = vi.fn(); - renderAndOpenSelect( + render( ); + await openSelect(); - const options = await screen.findAllByRole('option'); - fireEvent.click(options[1]); + const options = screen.getAllByRole('option'); + await act(async () => { + await userEvent.click(options[1]); + }); + await flushMicrotasks(); expect(handleValueChange).toHaveBeenCalledWith('banana'); expect(handleValueChange).toHaveBeenCalledTimes(1); - expect(screen.getByText('Banana')).toBeInTheDocument(); }); it('closes content after selection', async () => { - renderAndOpenSelect(); + render(); + await openSelect(); expect(screen.getByRole('listbox')).toBeInTheDocument(); - const options = await screen.findAllByRole('option'); - fireEvent.click(options[1]); + const options = screen.getAllByRole('option'); + await act(async () => { + await userEvent.click(options[1]); + }); + await flushMicrotasks(); expect(screen.queryByRole('listbox')).not.toBeInTheDocument(); }); @@ -128,15 +137,21 @@ describe('Select', () => { describe('Multiple Selection', () => { it('supports multiple selection', async () => { const handleValueChange = vi.fn(); - renderAndOpenSelect( - - ); - const options = await screen.findAllByRole('option'); + render(); + await openSelect(); + + const options = screen.getAllByRole('option'); - fireEvent.click(options[1]); + await act(async () => { + await userEvent.click(options[1]); + }); + await flushMicrotasks(); expect(handleValueChange).toHaveBeenCalledWith(['banana']); - fireEvent.click(options[4]); + await act(async () => { + await userEvent.click(options[4]); + }); + await flushMicrotasks(); expect(handleValueChange).toHaveBeenCalledWith(['banana', 'pineapple']); expect(options[1]).toHaveAttribute('aria-selected', 'true'); @@ -145,15 +160,21 @@ describe('Select', () => { it('allows deselecting items in multiple mode', async () => { const handleValueChange = vi.fn(); - renderAndOpenSelect( - - ); - const options = await screen.findAllByRole('option'); + render(); + await openSelect(); - fireEvent.click(options[1]); + const options = screen.getAllByRole('option'); + + await act(async () => { + await userEvent.click(options[1]); + }); + await flushMicrotasks(); expect(handleValueChange).toHaveBeenCalledWith(['banana']); - fireEvent.click(options[1]); + await act(async () => { + await userEvent.click(options[1]); + }); + await flushMicrotasks(); expect(handleValueChange).toHaveBeenCalledWith([]); }); }); @@ -183,7 +204,8 @@ describe('Select', () => { it('closes with Escape key', async () => { const user = userEvent.setup(); - renderAndOpenSelect(); + render(); + await openSelect(); await user.keyboard('{Escape}'); @@ -193,54 +215,64 @@ describe('Select', () => { it('selects option with Enter key', async () => { const user = userEvent.setup(); const handleValueChange = vi.fn(); - renderAndOpenSelect( + render( ); + await openSelect(); - const options = await screen.findAllByRole('option'); - options[1].focus(); + const options = screen.getAllByRole('option'); + await act(() => options[1].focus()); await user.keyboard('{Enter}'); + await flushMicrotasks(); expect(handleValueChange).toHaveBeenCalledWith('banana'); expect(handleValueChange).toHaveBeenCalledTimes(1); - expect(screen.getByText('Banana')).toBeInTheDocument(); }); it('navigates options with arrow keys', async () => { const user = userEvent.setup(); - renderAndOpenSelect(); + const handleValueChange = vi.fn(); + render( + + ); + await openSelect(); await user.keyboard('{ArrowDown}{ArrowDown}{Enter}'); + await flushMicrotasks(); - expect(screen.getByText('Blueberry')).toBeInTheDocument(); + expect(handleValueChange).toHaveBeenCalledWith('blueberry'); }); }); describe('Autocomplete Mode', () => { it('renders search input in autocomplete mode', async () => { - renderAndOpenSelect(); + render(); - expect(screen.getByRole('dialog')).toBeInTheDocument(); - expect(screen.getByRole('combobox')).toBeInTheDocument(); - expect(screen.getByRole('combobox')).toHaveAttribute( - 'placeholder', - 'Search...' - ); + const trigger = screen.getByLabelText('Select option'); + fireEvent.click(trigger); + await flushMicrotasks(); + + expect(screen.getByRole('listbox')).toBeInTheDocument(); + const searchInput = screen.getByPlaceholderText('Search...'); + expect(searchInput).toBeInTheDocument(); }); - }); - it('filters options based on search', async () => { - const user = userEvent.setup(); - renderAndOpenSelect(); + it('filters options based on search', async () => { + const user = userEvent.setup(); + render(); - expect(screen.getByRole('dialog')).toBeInTheDocument(); + const trigger = screen.getByLabelText('Select option'); + fireEvent.click(trigger); + await flushMicrotasks(); - const searchInput = screen.getByPlaceholderText('Search...'); - await user.type(searchInput, 'app'); + const searchInput = screen.getByPlaceholderText('Search...'); + await user.type(searchInput, 'app'); + await flushMicrotasks(); - 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]).toHaveTextContent('Apple'); + expect(options[1]).toHaveTextContent('Pineapple'); + }); }); }); diff --git a/packages/raystack/components/select/select-content.tsx b/packages/raystack/components/select/select-content.tsx index e01815b79..51645699b 100644 --- a/packages/raystack/components/select/select-content.tsx +++ b/packages/raystack/components/select/select-content.tsx @@ -1,106 +1,82 @@ '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 { forwardRef, ReactNode } from 'react'; import styles from './select.module.css'; +import { useSelectContext } from './select-root'; -export interface SelectContentProps extends SelectPrimitive.SelectContentProps { +export interface SelectContentProps { + className?: string; + children?: ReactNode; searchPlaceholder?: string; + sideOffset?: number; + 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 { autocomplete, multiple } = useSelectContext(); - const onEscapeKeyDown = useCallback< - NonNullable - >( - event => { - updateSelectionInProgress(false); - providedOnEscapeKeyDown?.(event); - }, - [updateSelectionInProgress, providedOnEscapeKeyDown] - ); + if (autocomplete) { + return ( + + + + + + {children} + + + + + ); + } return ( - - + - - {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 6676b655a..b0d12629a 100644 --- a/packages/raystack/components/select/select-item.tsx +++ b/packages/raystack/components/select/select-item.tsx @@ -1,21 +1,26 @@ '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 '../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 { + className?: string; + children?: ReactNode; + value: string; + leadingIcon?: ReactNode; + disabled?: boolean; +} + +export const SelectItem = forwardRef( ( { className, @@ -35,6 +40,7 @@ export const SelectItem = forwardRef< searchValue, value: selectValue, shouldFilter, + hasItems, multiple } = useSelectContext(); @@ -42,7 +48,7 @@ export const SelectItem = forwardRef< ? selectValue?.includes(value) : value === selectValue; const isMatched = getMatch(value, children, searchValue); - const isHidden = shouldFilter && isSelected && !isMatched; + const isHidden = shouldFilter && !hasItems && isSelected && !isMatched; const element = typeof children === 'string' ? ( @@ -61,42 +67,30 @@ export const SelectItem = forwardRef< }; }, [value, children, registerItem, unregisterItem, leadingIcon]); - if (shouldFilter && !isMatched && !isSelected) { - // Not selected and doesn't match search, so don't render at all + if (shouldFilter && !hasItems && !isMatched && !isSelected) { return null; } + const ItemPrimitive = autocomplete + ? ComboboxPrimitive.Item + : SelectPrimitive.Item; + return ( - - {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..289f64d79 100644 --- a/packages/raystack/components/select/select-misc.tsx +++ b/packages/raystack/components/select/select-misc.tsx @@ -1,62 +1,89 @@ '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 { Fragment, forwardRef, ReactNode } from 'react'; import styles from './select.module.css'; import { useSelectContext } from './select-root'; -export const SelectGroup = forwardRef< - ElementRef, - SelectPrimitive.SelectGroupProps ->(({ className, children, ...props }, ref) => { - const { shouldFilter } = useSelectContext(); - - if (shouldFilter) return {children}; - - return ( - - {children} - - ); -}); -SelectGroup.displayName = SelectPrimitive.Group.displayName; - -export const SelectLabel = forwardRef< - ElementRef, - SelectPrimitive.SelectLabelProps ->(({ className, ...props }, ref) => { - const { shouldFilter } = useSelectContext(); - - if (shouldFilter) return null; - - return ( - - ); -}); -SelectLabel.displayName = SelectPrimitive.Label.displayName; - -export const SelectSeparator = forwardRef< - ElementRef, - SelectPrimitive.SelectSeparatorProps ->(({ className, ...props }, ref) => { - const { shouldFilter } = useSelectContext(); - - if (shouldFilter) return null; - return ( - - ); -}); -SelectSeparator.displayName = SelectPrimitive.Separator.displayName; +export interface SelectGroupProps { + className?: string; + children?: ReactNode; +} + +export const SelectGroup = forwardRef( + ({ className, children, ...props }, ref) => { + const { shouldFilter, autocomplete } = useSelectContext(); + + if (shouldFilter) return {children}; + + const GroupPrimitive = autocomplete + ? ComboboxPrimitive.Group + : SelectPrimitive.Group; + + return ( + + {children} + + ); + } +); +SelectGroup.displayName = 'Select.Group'; + +export interface SelectLabelProps { + className?: string; + children?: ReactNode; +} + +export const SelectLabel = forwardRef( + ({ className, ...props }, ref) => { + const { shouldFilter, autocomplete } = useSelectContext(); + + if (shouldFilter) return null; + + const LabelPrimitive = autocomplete + ? ComboboxPrimitive.GroupLabel + : SelectPrimitive.GroupLabel; + + return ( + + ); + } +); +SelectLabel.displayName = 'Select.Label'; + +export interface SelectSeparatorProps { + className?: string; +} + +export const SelectSeparator = forwardRef( + ({ className, ...props }, ref) => { + const { shouldFilter, autocomplete } = useSelectContext(); + + if (shouldFilter) return null; + + const SeparatorPrimitive = autocomplete + ? ComboboxPrimitive.Separator + : SelectPrimitive.Separator; + + return ( + + ); + } +); +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..f80978049 100644 --- a/packages/raystack/components/select/select-multiple-value.tsx +++ b/packages/raystack/components/select/select-multiple-value.tsx @@ -1,20 +1,13 @@ 'use client'; import { cx } from 'class-variance-authority'; -import { Select as SelectPrimitive } from 'radix-ui'; -import { - ElementRef, - forwardRef, - useLayoutEffect, - useRef, - useState -} from 'react'; +import { 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[]; } @@ -27,10 +20,9 @@ const calculateTextWidth = (text: string, fontSize: number = 11): number => { return text.length * avgCharWidth; }; -export const SelectMultipleValue = forwardRef< - ElementRef, - SelectMultipleValueProps ->(({ data = [], ...props }, ref) => { +export const SelectMultipleValue = ({ + data = [] +}: SelectMultipleValueProps) => { const containerRef = useRef(null); const [visibleCount, setVisibleCount] = useState(data.length); const [containerWidth, setContainerWidth] = useState(0); @@ -51,7 +43,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 +53,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,18 +73,12 @@ export const SelectMultipleValue = forwardRef< return (
- -
- {data.slice(0, visibleCount).map(item => ( - - {typeof item.children === 'string' ? item.children : item.value} - - ))} - {data.length > visibleCount && ( - +{data.length - visibleCount} - )} -
-
+ {data.slice(0, visibleCount).map(item => ( + + {typeof item.children === 'string' ? item.children : item.value} + + ))} + {data.length > visibleCount && +{data.length - visibleCount}}
); -}); +}; diff --git a/packages/raystack/components/select/select-root.tsx b/packages/raystack/components/select/select-root.tsx index 62a058337..120f64a9f 100644 --- a/packages/raystack/components/select/select-root.tsx +++ b/packages/raystack/components/select/select-root.tsx @@ -1,14 +1,15 @@ '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, + ReactNode, useCallback, useContext, - useId, useMemo, - useRef, useState } from 'react'; import { ItemType } from './types'; @@ -27,8 +28,7 @@ interface SelectContextValue extends CommonProps { unregisterItem: (value: string) => void; multiple: boolean; items: Record; - updateSelectionInProgress: (value: boolean) => void; - setValue: (value: string) => void; + hasItems?: boolean; } interface UseSelectContext extends SelectContextValue { @@ -57,27 +57,17 @@ export const useSelectContext = (): UseSelectContext => { }; }; -interface NormalSelectRootProps extends SelectPrimitive.SelectProps { - autocomplete?: false; - autocompleteMode?: never; - searchValue?: never; - onSearch?: never; - defaultSearchValue?: never; -} - -interface AutocompleteSelectRootProps - extends SelectPrimitive.SelectProps, - CommonProps { - autocomplete: true; +interface BaseSelectProps extends CommonProps { + children?: ReactNode; + open?: boolean; + defaultOpen?: boolean; + onOpenChange?: (open: boolean) => void; + disabled?: boolean; + required?: boolean; + name?: string; + items?: string[]; } -export type BaseSelectProps = Omit< - NormalSelectRootProps | AutocompleteSelectRootProps, - 'autoComplete' | 'value' | 'onValueChange' | 'defaultValue' -> & { - htmlAutoComplete?: string; -}; - export interface SingleSelectProps extends BaseSelectProps { multiple?: false; value?: string; @@ -94,8 +84,6 @@ export interface MultipleSelectProps extends BaseSelectProps { export type SelectRootProps = SingleSelectProps | MultipleSelectProps; -const SELECT_INTERNAL_VALUE = 'SELECT_INTERNAL_VALUE'; - export const SelectRoot = (props: SelectRootProps) => { const { children, @@ -110,8 +98,11 @@ export const SelectRoot = (props: SelectRootProps) => { open: providedOpen, defaultOpen = false, onOpenChange, - htmlAutoComplete, multiple = false, + disabled, + required, + name, + items: itemsProp, ...rest } = props; @@ -120,52 +111,31 @@ export const SelectRoot = (props: SelectRootProps) => { >(defaultValue); 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< + SelectContextValue['items'] + >({}); const computedValue = providedValue ?? internalValue; const searchValue = providedSearchValue ?? internalSearchValue; - const open = providedOpen ?? internalOpen; - - const updateSelectionInProgress = useCallback((value: boolean) => { - isSelectionInProgress.current = value; - }, []); - - 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; + const handleValueChange = useCallback( + (value: any, _eventDetails?: any) => { + setInternalValue(value); if (multiple) { - updateSelectionInProgress(true); - const set = new Set( - Array.isArray(computedValue) - ? computedValue - : [computedValue ?? ''].filter(Boolean) + (onValueChange as MultipleSelectProps['onValueChange'])?.( + value as string[] ); - - 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); + (onValueChange as SingleSelectProps['onValueChange'])?.( + value as string + ); } }, - [multiple, onValueChange, computedValue, updateSelectionInProgress] + [multiple, onValueChange] ); - const setSearchValue = useCallback( - (value: string) => { + const handleSearchValueChange = useCallback( + (value: string, _eventDetails?: any) => { setInternalSearchValue(value); onSearch?.(value); }, @@ -173,21 +143,19 @@ export const SelectRoot = (props: SelectRootProps) => { ); const handleOpenChange = useCallback( - (value: boolean) => { - if (isSelectionInProgress.current) return; - setInternalOpen(value); - onOpenChange?.(value); + (open: boolean, _eventDetails?: any) => { + onOpenChange?.(open); }, [onOpenChange] ); const registerItem = useCallback(item => { - setItems(prev => ({ ...prev, [item.value]: item })); + setRegisteredItems(prev => ({ ...prev, [item.value]: item })); }, []); const unregisterItem = useCallback( value => { - setItems(prev => { + setRegisteredItems(prev => { const { [value]: _, ...rest } = prev; return rest; }); @@ -195,56 +163,66 @@ export const SelectRoot = (props: SelectRootProps) => { [] ); - /* - * 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 contextValue = useMemo( + () => ({ + value: computedValue, + registerItem, + unregisterItem, + autocomplete, + autocompleteMode, + searchValue, + multiple, + items: registeredItems, + hasItems: !!itemsProp + }), + [ + computedValue, + registerItem, + unregisterItem, + autocomplete, + autocompleteMode, + searchValue, + multiple, + registeredItems, + itemsProp + ] ); + const commonProps = { + value: providedValue as any, + defaultValue: defaultValue as any, + onValueChange: handleValueChange, + open: providedOpen, + defaultOpen, + onOpenChange: handleOpenChange, + multiple: multiple as any, + disabled, + modal: true as const, + ...rest + }; + + if (autocomplete) { + 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..2d74bb936 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 { ComponentPropsWithoutRef, forwardRef, SVGAttributes } from 'react'; import { Flex } from '../flex'; import styles from './select.module.css'; import { useSelectContext } from './select-root'; @@ -31,15 +34,12 @@ const trigger = cva(styles.trigger, { }); export interface SelectTriggerProps - extends SelectPrimitive.SelectTriggerProps, + extends ComponentPropsWithoutRef<'button'>, VariantProps { iconProps?: IconProps; } -export const SelectTrigger = forwardRef< - ElementRef, - SelectTriggerProps ->( +export const SelectTrigger = forwardRef( ( { size, @@ -47,34 +47,35 @@ export const SelectTrigger = forwardRef< className, children, iconProps = {}, - asChild, 'aria-label': ariaLabel, ...props }, ref ) => { const { multiple, autocomplete } = useSelectContext(); + + const TriggerPrimitive = autocomplete + ? ComboboxPrimitive.Trigger + : SelectPrimitive.Trigger; + return ( - - {asChild ? {children} : children} + {children} - - - +