diff --git a/packages/react/components/calendar/range-picker.tsx b/packages/react/components/calendar/range-picker.tsx index a4e690bfb..ff07830a1 100644 --- a/packages/react/components/calendar/range-picker.tsx +++ b/packages/react/components/calendar/range-picker.tsx @@ -1,5 +1,6 @@ 'use client'; +import { useControlled } from '@base-ui/utils/useControlled'; import { cx } from 'class-variance-authority'; import dayjs from 'dayjs'; import { useCallback, useEffect, useMemo, useState } from 'react'; @@ -110,11 +111,16 @@ export function RangePicker({ const [currentRangeField, setCurrentRangeField] = useState('from'); - const [internalValue, setInternalValue] = useState( - value ?? defaultValue - ); + const [computedValue, setValueUnwrapped] = useControlled< + DateRange | undefined + >({ + controlled: value, + default: defaultValue, + name: 'RangePicker', + state: 'value' + }); const [currentMonth, setCurrentMonth] = useState( - internalValue?.from + computedValue?.from ); /* @@ -132,8 +138,7 @@ export function RangePicker({ }, [valueFromTime, isControlled]); // Empty-range fallback so downstream `.from`/`.to` reads don't need guards. - const selectedRange: DateRange = value ?? - internalValue ?? { from: undefined }; + const selectedRange: DateRange = computedValue ?? { from: undefined }; const startDate = selectedRange.from ? dayjs(selectedRange.from).format(dateFormat) @@ -208,8 +213,7 @@ export function RangePicker({ } if (newField !== currentRangeField) setCurrentRangeField(newField); - // Only update internal state when uncontrolled, since controlled consumers own `value`. - if (!isControlled) setInternalValue(newRange); + setValueUnwrapped(newRange); onSelect(newRange); if (shouldClose) popover.disengage(); }; diff --git a/packages/react/components/code-block/__tests__/code-block.test.tsx b/packages/react/components/code-block/__tests__/code-block.test.tsx index 4bd22c031..ac739451d 100644 --- a/packages/react/components/code-block/__tests__/code-block.test.tsx +++ b/packages/react/components/code-block/__tests__/code-block.test.tsx @@ -1,6 +1,6 @@ import { render, screen } from '@testing-library/react'; import userEvent from '@testing-library/user-event'; -import { ComponentProps } from 'react'; +import { ComponentProps, StrictMode } from 'react'; import { describe, expect, it, vi } from 'vitest'; import { CodeBlock } from '../code-block'; @@ -147,6 +147,31 @@ describe('CodeBlock', () => { expect(screen.getByText('def')).toBeInTheDocument(); expect(screen.queryByText('function')).not.toBeInTheDocument(); }); + + it('shows the language of the code when no value is set', () => { + render( + + + + + + + JavaScript + + + Python + + + + + + {PYTHON_CODE} + + + ); + + expect(screen.getByRole('combobox')).toHaveTextContent('Python'); + }); }); describe('Copy Button', () => { @@ -197,6 +222,45 @@ describe('CodeBlock', () => { ).toHaveLength(2); }); + it('reports the next state when collapsed is controlled', async () => { + const user = userEvent.setup(); + const onCollapseChange = vi.fn(); + render( + + ); + + await user.click(screen.getByText('Hide Code')); + + expect(onCollapseChange).toHaveBeenCalledTimes(1); + expect(onCollapseChange).toHaveBeenCalledWith(true); + expect(screen.getByText('Hide Code')).toBeInTheDocument(); + }); + + it('calls onCollapseChange once per click in StrictMode', async () => { + const user = userEvent.setup(); + const onCollapseChange = vi.fn(); + render( + + + + ); + + await user.click(screen.getByText('Show Code')); + + expect(onCollapseChange).toHaveBeenCalledTimes(1); + expect(onCollapseChange).toHaveBeenCalledWith(false); + expect(screen.getByText('Hide Code')).toBeInTheDocument(); + }); + it('hides collapse trigger when code fits in maxLines', () => { render(); diff --git a/packages/react/components/code-block/code-block-language-select.tsx b/packages/react/components/code-block/code-block-language-select.tsx index 96a321bc0..055054da3 100644 --- a/packages/react/components/code-block/code-block-language-select.tsx +++ b/packages/react/components/code-block/code-block-language-select.tsx @@ -18,7 +18,11 @@ export const CodeBlockLanguageSelect = ( const handleValueChange = (value: string) => { setValue(value as Language); }; - return + ); }; CodeBlockLanguageSelect.displayName = 'CodeBlock.LanguageSelect'; diff --git a/packages/react/components/code-block/code-block-root.tsx b/packages/react/components/code-block/code-block-root.tsx index 3d0d43e30..c0097d621 100644 --- a/packages/react/components/code-block/code-block-root.tsx +++ b/packages/react/components/code-block/code-block-root.tsx @@ -1,5 +1,6 @@ 'use client'; +import { useControlled } from '@base-ui/utils/useControlled'; import { cx } from 'class-variance-authority'; import { ComponentProps, @@ -60,32 +61,33 @@ export const CodeBlockRoot = ({ onCollapseChange, ...props }: CodeBlockProps) => { - const [internalValue, setInternalValue] = useState( - defaultValue - ); + const [value, setValueUnwrapped] = useControlled({ + controlled: providedValue, + default: defaultValue, + name: 'CodeBlock', + state: 'value' + }); const [code, setCode] = useState(); - const [internalCollapsed, setInternalCollapsed] = useState( - defaultCollapsed ?? false - ); - - const collapsed = providedCollapsed ?? internalCollapsed; - - const value = providedValue ?? internalValue ?? defaultValue; + const [collapsed, setCollapsedUnwrapped] = useControlled({ + controlled: providedCollapsed, + default: defaultCollapsed, + name: 'CodeBlock', + state: 'collapsed' + }); const setValue = useCallback( (newValue: string) => { - setInternalValue(newValue); + setValueUnwrapped(newValue); onValueChange?.(newValue); }, - [onValueChange] + [onValueChange, setValueUnwrapped] ); const toggleCollapsed = useCallback(() => { - setInternalCollapsed(_internalCollapsed => { - onCollapseChange?.(!_internalCollapsed); - return !_internalCollapsed; - }); - }, [onCollapseChange]); + const next = !collapsed; + setCollapsedUnwrapped(next); + onCollapseChange?.(next); + }, [collapsed, onCollapseChange, setCollapsedUnwrapped]); return ( { expect(modeSelector).toHaveTextContent('HEX'); }); + it('switches an uncontrolled mode and calls onModeChange', async () => { + const user = userEvent.setup(); + const onModeChange = vi.fn(); + render( + + + + + ); + + await user.click(screen.getByTestId('mode-selector')); + await user.click(await screen.findByRole('option', { name: 'RGB' })); + + expect(onModeChange).toHaveBeenCalledWith('rgb'); + expect(screen.getByTestId('mode-selector')).toHaveTextContent('RGB'); + expect(screen.getByTestId('color-input')).toHaveValue('rgb(255, 0, 0)'); + }); + it('accepts custom options', () => { const customOptions: Array<'hex' | 'rgb'> = ['hex', 'rgb']; diff --git a/packages/react/components/color-picker/color-picker-root.tsx b/packages/react/components/color-picker/color-picker-root.tsx index dc408b31c..e6b8e522c 100644 --- a/packages/react/components/color-picker/color-picker-root.tsx +++ b/packages/react/components/color-picker/color-picker-root.tsx @@ -1,13 +1,13 @@ 'use client'; +import { useControlled } from '@base-ui/utils/useControlled'; import { ComponentProps, createContext, useCallback, useContext, useMemo, - useRef, - useState + useRef } from 'react'; import { Flex } from '../flex'; import { @@ -64,25 +64,26 @@ export const ColorPickerRoot = ({ [value] ); - const [internalColor, setInternalColor] = useState(() => - parseColor(defaultValue) - ); - const [internalMode, setInternalMode] = useState(defaultMode); - - const mode = providedMode ?? internalMode; + const [color, setColorUnwrapped] = useControlled({ + controlled: providedColor, + default: parseColor(defaultValue), + name: 'ColorPicker', + state: 'value' + }); + const [mode, setModeUnwrapped] = useControlled({ + controlled: providedMode, + default: defaultMode, + name: 'ColorPicker', + state: 'mode' + }); // In non-oklch modes, derive a display value clamped to the sRGB gamut so // the pad / thumb / input only reflect colors the active output mode can // actually represent. Internal state stays raw so switching back to oklch // restores any wide-gamut pick the user hadn't yet overwritten. const rawColor = useMemo( - () => ({ - l: providedColor ? providedColor.l : internalColor.l, - c: providedColor ? providedColor.c : internalColor.c, - h: providedColor ? providedColor.h : internalColor.h, - alpha: (providedColor ? providedColor.alpha : internalColor.alpha) ?? 1 - }), - [providedColor, internalColor] + () => ({ ...color, alpha: color.alpha ?? 1 }), + [color] ); // clampToSrgb wraps culori's clampChroma, which is iterative, so memoize // it doesn't re-run on unrelated re-renders during a drag. @@ -98,7 +99,7 @@ export const ColorPickerRoot = ({ // Mirror the current effective color in a ref so setColor can compute the // next value synchronously, without putting onValueChange inside the - // setInternalColor updater (where StrictMode would fire it twice). + // state updater (where StrictMode would fire it twice). const rawColorRef = useRef(rawColor); rawColorRef.current = rawColor; @@ -106,18 +107,18 @@ export const ColorPickerRoot = ({ value => { const next = { ...rawColorRef.current, ...value }; rawColorRef.current = next; - setInternalColor(next); + setColorUnwrapped(next); onValueChange?.(getColorString(next, mode), mode); }, - [mode, onValueChange] + [mode, onValueChange, setColorUnwrapped] ); const setMode = useCallback( value => { - setInternalMode(value); + setModeUnwrapped(value); onModeChange?.(value); }, - [onModeChange] + [onModeChange, setModeUnwrapped] ); // Memoize the context value so consumers (Area, Hue, Alpha, Input, Mode) only diff --git a/packages/react/components/combobox/combobox-root.tsx b/packages/react/components/combobox/combobox-root.tsx index 5f27a616d..73ff964e2 100644 --- a/packages/react/components/combobox/combobox-root.tsx +++ b/packages/react/components/combobox/combobox-root.tsx @@ -1,14 +1,14 @@ 'use client'; import { Combobox as ComboboxPrimitive } from '@base-ui/react'; +import { useControlled } from '@base-ui/utils/useControlled'; import { createContext, RefObject, useCallback, useContext, useMemo, - useRef, - useState + useRef } from 'react'; import { useFieldContext } from '../field'; @@ -71,6 +71,8 @@ export const ComboboxRoot = ({ children, onValueChange, onInputValueChange, + inputValue: providedInputValue, + defaultInputValue, value: providedValue, defaultValue, items, @@ -81,23 +83,32 @@ export const ComboboxRoot = ({ const fieldContext = useFieldContext(); const resolvedRequired = required ?? fieldContext?.required; - const [inputValue, setInputValue] = useState(''); - const [internalValue, setInternalValue] = useState< - Value | Value[] | null | undefined - >(defaultValue ?? null); + const [inputValue, setInputValueUnwrapped] = useControlled({ + controlled: + providedInputValue === undefined ? undefined : String(providedInputValue), + default: defaultInputValue === undefined ? '' : String(defaultInputValue), + name: 'Combobox', + state: 'inputValue' + }); + const [computedValue, setValueUnwrapped] = useControlled< + Value | Value[] | null + >({ + controlled: providedValue, + default: defaultValue ?? null, + name: 'Combobox', + state: 'value' + }); const inputContainerRef = useRef(null); - const computedValue = providedValue ?? internalValue; - const handleInputValueChange = useCallback( ( value: string, eventDetails: ComboboxPrimitive.Root.ChangeEventDetails ) => { - setInputValue(value); + setInputValueUnwrapped(value); onInputValueChange?.(value); }, - [onInputValueChange] + [onInputValueChange, setInputValueUnwrapped] ); const handleValueChange = useCallback( @@ -105,7 +116,7 @@ export const ComboboxRoot = ({ value: Value | Value[] | null, eventDetails: ComboboxPrimitive.Root.ChangeEventDetails ) => { - setInternalValue(value); + setValueUnwrapped(value); if (multiple) { (onValueChange as MultipleComboboxProps['onValueChange'])?.( value as Value[] @@ -116,7 +127,7 @@ export const ComboboxRoot = ({ ); } }, - [onValueChange, multiple] + [onValueChange, multiple, setValueUnwrapped] ); // Same fallback order that Base UI uses for the input text. @@ -151,6 +162,8 @@ export const ComboboxRoot = ({ multiple={multiple} onValueChange={handleValueChange} onInputValueChange={handleInputValueChange} + inputValue={providedInputValue} + defaultInputValue={defaultInputValue} items={items} itemToStringLabel={itemToStringLabel} value={providedValue} diff --git a/packages/react/components/command/command-root.tsx b/packages/react/components/command/command-root.tsx index 5f85097b4..3e4f43ed7 100644 --- a/packages/react/components/command/command-root.tsx +++ b/packages/react/components/command/command-root.tsx @@ -1,6 +1,7 @@ 'use client'; import { Autocomplete as AutocompletePrimitive } from '@base-ui/react/autocomplete'; +import { useControlled } from '@base-ui/utils/useControlled'; import { cx } from 'class-variance-authority'; import { createContext, @@ -8,8 +9,7 @@ import { useCallback, useContext, useMemo, - useRef, - useState + useRef } from 'react'; import styles from './command.module.css'; @@ -56,23 +56,23 @@ export const CommandRoot = ({ children, ...props }: CommandRootProps) => { - const [internalValue, setInternalValue] = useState( - typeof defaultValue === 'string' ? defaultValue : '' - ); + const [computedValue, setValueUnwrapped] = useControlled({ + controlled: typeof providedValue === 'string' ? providedValue : undefined, + default: typeof defaultValue === 'string' ? defaultValue : '', + name: 'Command', + state: 'value' + }); const inputContainerRef = useRef(null); - const computedValue = - typeof providedValue === 'string' ? providedValue : internalValue; - const handleValueChange = useCallback( ( value: string, eventDetails: AutocompletePrimitive.Root.ChangeEventDetails ) => { - setInternalValue(value); + setValueUnwrapped(value); onValueChange?.(value, eventDetails); }, - [onValueChange] + [onValueChange, setValueUnwrapped] ); const contextValue = useMemo( diff --git a/packages/react/components/context-menu/__tests__/context-menu.test.tsx b/packages/react/components/context-menu/__tests__/context-menu.test.tsx index bb7e2b360..3e22c9556 100644 --- a/packages/react/components/context-menu/__tests__/context-menu.test.tsx +++ b/packages/react/components/context-menu/__tests__/context-menu.test.tsx @@ -1,4 +1,4 @@ -import { fireEvent, render, screen } from '@testing-library/react'; +import { fireEvent, render, screen, waitFor } from '@testing-library/react'; import { ReactNode } from 'react'; import { describe, expect, it, vi } from 'vitest'; import { ContextMenu } from '../context-menu'; @@ -143,5 +143,29 @@ describe('ContextMenu', () => { expect(onOpenChange).toHaveBeenCalled(); }); + + it('stays open until the parent closes a controlled menu', async () => { + const onOpenChange = vi.fn(); + const renderMenu = (open: boolean) => ( + + {TRIGGER_TEXT} + + Profile + + + ); + const { rerender } = render(renderMenu(true)); + + fireEvent.keyDown(screen.getByRole('menu'), { key: 'Escape' }); + + expect(onOpenChange).toHaveBeenCalledWith(false, expect.anything()); + expect(screen.getByText('Profile')).toBeInTheDocument(); + + rerender(renderMenu(false)); + + await waitFor(() => { + expect(screen.queryByText('Profile')).not.toBeInTheDocument(); + }); + }); }); }); diff --git a/packages/react/components/context-menu/context-menu-root.tsx b/packages/react/components/context-menu/context-menu-root.tsx index f9b00cb65..d3e38aac5 100644 --- a/packages/react/components/context-menu/context-menu-root.tsx +++ b/packages/react/components/context-menu/context-menu-root.tsx @@ -1,7 +1,8 @@ 'use client'; import { ContextMenu as ContextMenuPrimitive } from '@base-ui/react'; -import { useCallback, useRef, useState } from 'react'; +import { useControlled } from '@base-ui/utils/useControlled'; +import { useCallback, useRef } from 'react'; import { MenuContext, useMenuContext } from '../menu/menu-root'; export interface NormalContextMenuRootProps @@ -37,23 +38,28 @@ export const ContextMenuRoot = ({ defaultOpen = false, ...props }: ContextMenuRootProps) => { - const [internalInputValue, setInternalInputValue] = - useState(defaultInputValue); - - const [internalOpen, setInternalOpen] = useState(defaultOpen); - const open = providedOpen ?? internalOpen; + const [inputValue, setInputValueUnwrapped] = useControlled({ + controlled: providedInputValue, + default: defaultInputValue, + name: 'ContextMenu', + state: 'inputValue' + }); + const [open, setOpenUnwrapped] = useControlled({ + controlled: providedOpen, + default: defaultOpen, + name: 'ContextMenu', + state: 'open' + }); const inputRef = useRef(null); const contentRef = useRef(null); const isInitialRender = useRef(true); - const inputValue = providedInputValue ?? internalInputValue; - const setValue = useCallback( (value: string) => { - setInternalInputValue(value); + setInputValueUnwrapped(value); onInputValueChange?.(value); }, - [onInputValueChange] + [onInputValueChange, setInputValueUnwrapped] ); const handleOpenChange: ContextMenuPrimitive.Root.Props['onOpenChange'] = @@ -66,10 +72,10 @@ export const ContextMenuRoot = ({ setValue(''); isInitialRender.current = true; } - setInternalOpen(value); + setOpenUnwrapped(value); onOpenChange?.(value, eventDetails); }, - [onOpenChange, setValue, autocomplete] + [onOpenChange, setValue, autocomplete, setOpenUnwrapped] ); return ( @@ -129,22 +135,29 @@ export const ContextMenuSubMenu = ({ defaultOpen = false, ...props }: ContextMenuSubMenuProps) => { - const [internalInputValue, setInternalInputValue] = - useState(defaultInputValue); - const [internalOpen, setInternalOpen] = useState(defaultOpen); - const open = providedOpen ?? internalOpen; + const [inputValue, setInputValueUnwrapped] = useControlled({ + controlled: providedInputValue, + default: defaultInputValue, + name: 'ContextMenu.SubMenu', + state: 'inputValue' + }); + const [open, setOpenUnwrapped] = useControlled({ + controlled: providedOpen, + default: defaultOpen, + name: 'ContextMenu.SubMenu', + state: 'open' + }); const parentContext = useMenuContext(); const inputRef = useRef(null); const isInitialRender = useRef(true); const contentRef = useRef(null); - const inputValue = providedInputValue ?? internalInputValue; const setValue = useCallback( (value: string) => { - setInternalInputValue(value); + setInputValueUnwrapped(value); onInputValueChange?.(value); }, - [onInputValueChange] + [onInputValueChange, setInputValueUnwrapped] ); const handleOpenChange: ContextMenuPrimitive.SubmenuRoot.Props['onOpenChange'] = @@ -157,10 +170,10 @@ export const ContextMenuSubMenu = ({ setValue(''); isInitialRender.current = true; } - setInternalOpen(value); + setOpenUnwrapped(value); onOpenChange?.(value, eventDetails); }, - [onOpenChange, setValue, autocomplete] + [onOpenChange, setValue, autocomplete, setOpenUnwrapped] ); return ( diff --git a/packages/react/components/data-view/data-view.tsx b/packages/react/components/data-view/data-view.tsx index 8857f3fd7..3d90946e6 100644 --- a/packages/react/components/data-view/data-view.tsx +++ b/packages/react/components/data-view/data-view.tsx @@ -1,5 +1,6 @@ 'use client'; +import { useControlled } from '@base-ui/utils/useControlled'; import { getCoreRowModel, getExpandedRowModel, @@ -74,17 +75,20 @@ function DataViewRoot({ ); // Active view (controlled / uncontrolled). - const isViewControlled = view !== undefined; - const [internalActiveView, setInternalActiveView] = useState< + const [activeView, setActiveViewUnwrapped] = useControlled< string | undefined - >(defaultView ?? views?.[0]?.value); - const activeView = isViewControlled ? view : internalActiveView; + >({ + controlled: view, + default: defaultView ?? views?.[0]?.value, + name: 'DataView', + state: 'view' + }); const setActiveView = useCallback( (next: string) => { - if (!isViewControlled) setInternalActiveView(next); + setActiveViewUnwrapped(next); onViewChange?.(next); }, - [isViewControlled, onViewChange] + [onViewChange, setActiveViewUnwrapped] ); // Per-view field overrides registered by mounted renderers. diff --git a/packages/react/components/menu/__tests__/menu.test.tsx b/packages/react/components/menu/__tests__/menu.test.tsx index 02574cad0..3455b9c39 100644 --- a/packages/react/components/menu/__tests__/menu.test.tsx +++ b/packages/react/components/menu/__tests__/menu.test.tsx @@ -1,4 +1,4 @@ -import { fireEvent, render, screen } from '@testing-library/react'; +import { fireEvent, render, screen, waitFor } from '@testing-library/react'; import { describe, expect, it, vi } from 'vitest'; import { Button } from '../../button/button'; import { Menu } from '../menu'; @@ -132,5 +132,83 @@ describe('Menu', () => { expect(onOpenChange).toHaveBeenCalled(); }); + + it('stays open until the parent closes a controlled menu', async () => { + const onOpenChange = vi.fn(); + const { rerender } = render( + + ); + + fireEvent.keyDown(screen.getByRole('menu'), { key: 'Escape' }); + + expect(onOpenChange).toHaveBeenCalledWith(false, expect.anything()); + expect(screen.getByText('Profile')).toBeInTheDocument(); + + rerender(); + + await waitFor(() => { + expect(screen.queryByText('Profile')).not.toBeInTheDocument(); + }); + }); + }); + + describe('Autocomplete', () => { + const FRUITS = ( + + Apple + Banana + + ); + + it('filters items by a controlled inputValue', () => { + const onInputValueChange = vi.fn(); + render( + + {TRIGGER_TEXT} + {FRUITS} + + ); + const input = screen.getByPlaceholderText('Search...'); + + fireEvent.change(input, { target: { value: 'app' } }); + + expect(onInputValueChange).toHaveBeenCalledWith('app'); + expect(input).toHaveValue('ban'); + expect(screen.queryByText('Apple')).not.toBeInTheDocument(); + expect(screen.getByText('Banana')).toBeInTheDocument(); + }); + + it('clears an uncontrolled search when the menu closes', async () => { + const onInputValueChange = vi.fn(); + render( + + {TRIGGER_TEXT} + {FRUITS} + + ); + expect(screen.queryByText('Apple')).not.toBeInTheDocument(); + + fireEvent.keyDown(screen.getByPlaceholderText('Search...'), { + key: 'Escape' + }); + await waitFor(() => { + expect(screen.queryByText('Banana')).not.toBeInTheDocument(); + }); + fireEvent.click(screen.getByText(TRIGGER_TEXT)); + + expect(onInputValueChange).toHaveBeenCalledWith(''); + expect(await screen.findByText('Apple')).toBeInTheDocument(); + expect(screen.getByText('Banana')).toBeInTheDocument(); + }); }); }); diff --git a/packages/react/components/menu/menu-root.tsx b/packages/react/components/menu/menu-root.tsx index 3af2ef47e..849abc7a9 100644 --- a/packages/react/components/menu/menu-root.tsx +++ b/packages/react/components/menu/menu-root.tsx @@ -1,13 +1,8 @@ 'use client'; import { Menu as MenuPrimitive } from '@base-ui/react'; -import { - createContext, - useCallback, - useContext, - useRef, - useState -} from 'react'; +import { useControlled } from '@base-ui/utils/useControlled'; +import { createContext, useCallback, useContext, useRef } from 'react'; interface CommonProps { autocomplete?: boolean; @@ -102,23 +97,28 @@ export const MenuRoot = ({ defaultOpen = false, ...props }: MenuRootProps) => { - const [internalInputValue, setInternalInputValue] = - useState(defaultInputValue); - - const [internalOpen, setInternalOpen] = useState(defaultOpen); - const open = providedOpen ?? internalOpen; + const [inputValue, setInputValueUnwrapped] = useControlled({ + controlled: providedInputValue, + default: defaultInputValue, + name: 'Menu', + state: 'inputValue' + }); + const [open, setOpenUnwrapped] = useControlled({ + controlled: providedOpen, + default: defaultOpen, + name: 'Menu', + state: 'open' + }); const inputRef = useRef(null); const contentRef = useRef(null); const isInitialRender = useRef(true); - const inputValue = providedInputValue ?? internalInputValue; - const setValue = useCallback( (value: string) => { - setInternalInputValue(value); + setInputValueUnwrapped(value); onInputValueChange?.(value); }, - [onInputValueChange] + [onInputValueChange, setInputValueUnwrapped] ); const handleOpenChange: MenuPrimitive.Root.Props['onOpenChange'] = @@ -128,10 +128,10 @@ export const MenuRoot = ({ setValue(''); isInitialRender.current = true; } - setInternalOpen(value); + setOpenUnwrapped(value); onOpenChange?.(value, eventDetails); }, - [onOpenChange, setValue, autocomplete] + [onOpenChange, setValue, autocomplete, setOpenUnwrapped] ); return ( @@ -192,22 +192,29 @@ export const MenuSubMenu = ({ defaultOpen = false, ...props }: MenuSubMenuProps) => { - const [internalInputValue, setInternalInputValue] = - useState(defaultInputValue); - const [internalOpen, setInternalOpen] = useState(defaultOpen); - const open = providedOpen ?? internalOpen; + const [inputValue, setInputValueUnwrapped] = useControlled({ + controlled: providedInputValue, + default: defaultInputValue, + name: 'Menu.Submenu', + state: 'inputValue' + }); + const [open, setOpenUnwrapped] = useControlled({ + controlled: providedOpen, + default: defaultOpen, + name: 'Menu.Submenu', + state: 'open' + }); const parentContext = useMenuContext(); const inputRef = useRef(null); const isInitialRender = useRef(true); const contentRef = useRef(null); - const inputValue = providedInputValue ?? internalInputValue; const setValue = useCallback( (value: string) => { - setInternalInputValue(value); + setInputValueUnwrapped(value); onInputValueChange?.(value); }, - [onInputValueChange] + [onInputValueChange, setInputValueUnwrapped] ); const handleOpenChange: MenuPrimitive.Root.Props['onOpenChange'] = @@ -217,10 +224,10 @@ export const MenuSubMenu = ({ setValue(''); isInitialRender.current = true; } - setInternalOpen(value); + setOpenUnwrapped(value); onOpenChange?.(value, eventDetails); }, - [onOpenChange, setValue, autocomplete] + [onOpenChange, setValue, autocomplete, setOpenUnwrapped] ); return ( diff --git a/packages/react/components/select/__tests__/select.test.tsx b/packages/react/components/select/__tests__/select.test.tsx index ebf572071..0a48a178f 100644 --- a/packages/react/components/select/__tests__/select.test.tsx +++ b/packages/react/components/select/__tests__/select.test.tsx @@ -298,5 +298,36 @@ describe('Select', () => { expect(options[0]).toHaveTextContent('Apple'); expect(options[1]).toHaveTextContent('Pineapple'); }); + + it('filters options by a controlled searchValue', async () => { + const onSearch = vi.fn(); + render( + + ); + + fireEvent.click(screen.getByLabelText('Select option')); + await flushMicrotasks(); + fireEvent.change(screen.getByPlaceholderText('Search...'), { + target: { value: 'b' } + }); + await flushMicrotasks(); + + expect(onSearch).toHaveBeenCalledWith('b'); + const options = screen.getAllByRole('option'); + expect(options.length).toBe(2); + expect(options[0]).toHaveTextContent('Apple'); + expect(options[1]).toHaveTextContent('Pineapple'); + }); }); }); diff --git a/packages/react/components/select/select-root.tsx b/packages/react/components/select/select-root.tsx index 62e87e5cd..3e3819ddb 100644 --- a/packages/react/components/select/select-root.tsx +++ b/packages/react/components/select/select-root.tsx @@ -4,6 +4,7 @@ import { Combobox as ComboboxPrimitive, Select as SelectPrimitive } from '@base-ui/react'; +import { useControlled } from '@base-ui/utils/useControlled'; import { createContext, ReactNode, @@ -110,21 +111,27 @@ export const SelectRoot = (props: SelectRootProps) => { const fieldContext = useFieldContext(); const resolvedRequired = required ?? fieldContext?.required; - const [internalValue, setInternalValue] = useState< + const [computedValue, setValueUnwrapped] = useControlled< string | string[] | undefined - >(defaultValue); - const [internalSearchValue, setInternalSearchValue] = - useState(defaultSearchValue); + >({ + controlled: providedValue, + default: defaultValue, + name: 'Select', + state: 'value' + }); + const [searchValue, setSearchValueUnwrapped] = useControlled({ + controlled: providedSearchValue, + default: defaultSearchValue, + name: 'Select', + state: 'searchValue' + }); const [registeredItems, setRegisteredItems] = useState< SelectContextValue['items'] >({}); - const computedValue = providedValue ?? internalValue; - const searchValue = providedSearchValue ?? internalSearchValue; - const handleValueChange = useCallback( (value: string | string[] | null) => { - setInternalValue(value ?? undefined); + setValueUnwrapped(value ?? undefined); if (multiple) { (onValueChange as MultipleSelectProps['onValueChange'])?.( value as string[] @@ -135,15 +142,15 @@ export const SelectRoot = (props: SelectRootProps) => { ); } }, - [multiple, onValueChange] + [multiple, onValueChange, setValueUnwrapped] ); const handleSearchValueChange = useCallback( (value: string) => { - setInternalSearchValue(value); + setSearchValueUnwrapped(value); onSearch?.(value); }, - [onSearch] + [onSearch, setSearchValueUnwrapped] ); const handleOpenChange = useCallback( diff --git a/packages/react/components/sidebar/sidebar-group.tsx b/packages/react/components/sidebar/sidebar-group.tsx index 4545e34f8..076e4acc3 100644 --- a/packages/react/components/sidebar/sidebar-group.tsx +++ b/packages/react/components/sidebar/sidebar-group.tsx @@ -1,8 +1,9 @@ 'use client'; import { Accordion as AccordionPrimitive } from '@base-ui/react'; +import { useControlled } from '@base-ui/utils/useControlled'; import { cx } from 'class-variance-authority'; -import { ComponentProps, ReactNode, useCallback, useState } from 'react'; +import { ComponentProps, ReactNode, useCallback } from 'react'; import { ChevronDownIcon } from '~/icons'; import { Flex } from '../flex'; import styles from './sidebar.module.css'; @@ -56,17 +57,22 @@ export function SidebarNavigationGroup({ ...props }: SidebarGroupProps) { const { isCollapsed } = useSidebarSafe(); - const [internalOpen, setInternalOpen] = useState(defaultOpen); - const isOpen = isCollapsed || (providedOpen ?? internalOpen); + const [open, setOpenUnwrapped] = useControlled({ + controlled: providedOpen, + default: defaultOpen, + name: 'Sidebar.Group', + state: 'open' + }); + const isOpen = isCollapsed || open; const handleOpenChange = useCallback( (value: unknown[]) => { if (isCollapsed) return; const nextOpen = value.length > 0; - setInternalOpen(nextOpen); + setOpenUnwrapped(nextOpen); onOpenChange?.(nextOpen); }, - [isCollapsed, onOpenChange] + [isCollapsed, onOpenChange, setOpenUnwrapped] ); const labelContent = ( diff --git a/packages/react/components/sidebar/sidebar-root.tsx b/packages/react/components/sidebar/sidebar-root.tsx index de6374c8b..a94f5a1e5 100644 --- a/packages/react/components/sidebar/sidebar-root.tsx +++ b/packages/react/components/sidebar/sidebar-root.tsx @@ -1,5 +1,6 @@ 'use client'; +import { useControlled } from '@base-ui/utils/useControlled'; import { cx } from 'class-variance-authority'; import { ComponentProps, @@ -128,8 +129,12 @@ export function SidebarRoot({ }: SidebarRootProps) { const generatedId = useId(); const sidebarId = providedId ?? generatedId; - const [internalOpen, setInternalOpen] = useState(defaultOpen); - const open = providedOpen ?? internalOpen; + const [open, setOpenUnwrapped] = useControlled({ + controlled: providedOpen, + default: defaultOpen, + name: 'Sidebar', + state: 'open' + }); const [isPeeking, setIsPeeking] = useState(false); const peekTimeoutRef = useRef>(undefined); const openPopupCountRef = useRef(0); @@ -137,10 +142,10 @@ export function SidebarRoot({ const handleOpenChange = useCallback( (value: boolean) => { - setInternalOpen(value); + setOpenUnwrapped(value); onOpenChange?.(value); }, - [onOpenChange] + [onOpenChange, setOpenUnwrapped] ); const canCollapse = collapsible !== 'none';