diff --git a/apps/www/src/content/docs/components/search/index.mdx b/apps/www/src/content/docs/components/search/index.mdx index 6536c9ab7..53cacb7a1 100644 --- a/apps/www/src/content/docs/components/search/index.mdx +++ b/apps/www/src/content/docs/components/search/index.mdx @@ -34,6 +34,14 @@ The Search component can include a clear button that appears when there is input +### Clearing + +Escape clears the input when it has a value. After a clear, from the clear button or from Escape, focus is on the input and `onClear` receives the triggering event. + +When you pass `value` and `onClear`, `onClear` must reset `value`. In every other case, a clear empties the input and fires `onChange` and `onValueChange` with an empty value. Escape clears whether or not `showClearButton` is set. + +When the input is empty, Escape is not handled, so it can close an enclosing Dialog, Popover, or Menu. + ### Controlled value Use `onValueChange` to receive only the new query string, or `onChange` for the full React change event. The Search component forwards both to the underlying [Input](/docs/components/input). @@ -84,3 +92,4 @@ The component supports keyboard navigation: - Tab to focus on the search input - Tab again to focus on the clear button (when visible) - Enter or Space to trigger the clear button +- Escape to clear the input when it has a value diff --git a/apps/www/src/content/docs/components/search/props.ts b/apps/www/src/content/docs/components/search/props.ts index 8d04db533..b1dcbcc36 100644 --- a/apps/www/src/content/docs/components/search/props.ts +++ b/apps/www/src/content/docs/components/search/props.ts @@ -26,8 +26,20 @@ export interface SearchProps { */ onValueChange?: (value: string, eventDetails: unknown) => void; - /** Callback when clear button is clicked. */ - onClear?: () => void; + /** + * Called when the clear button is clicked or Escape clears the input. Receives the triggering event. + */ + onClear?: ( + event: + | React.MouseEvent + | React.KeyboardEvent + ) => void; + + /** + * Native input type. The default gives the input the `searchbox` role. + * @default "search" + */ + type?: string; /** Additional CSS class names. */ className?: string; diff --git a/packages/raystack/components/data-view/__tests__/data-view.test.tsx b/packages/raystack/components/data-view/__tests__/data-view.test.tsx index 9e98859d4..58423963e 100644 --- a/packages/raystack/components/data-view/__tests__/data-view.test.tsx +++ b/packages/raystack/components/data-view/__tests__/data-view.test.tsx @@ -357,7 +357,7 @@ describe('DataView', () => { ); // Search input - expect(screen.getByRole('textbox')).toBeInTheDocument(); + expect(screen.getByRole('searchbox')).toBeInTheDocument(); // Filter button expect( screen.getByRole('button', { name: /filter/i }) @@ -374,7 +374,7 @@ describe('DataView', () => { ); - const search = screen.getByRole('textbox') as HTMLInputElement; + const search = screen.getByRole('searchbox') as HTMLInputElement; await user.type(search, 'jane'); expect(search.value).toBe('jane'); // John row should no longer appear (client-mode global filter) @@ -794,7 +794,7 @@ describe('DataView', () => { ); - await user.type(screen.getByRole('textbox'), 'jane'); + await user.type(screen.getByRole('searchbox'), 'jane'); expect(onTableQueryChange).not.toHaveBeenCalled(); }); @@ -815,7 +815,7 @@ describe('DataView', () => { ); - await user.type(screen.getByRole('textbox'), 'a'); + await user.type(screen.getByRole('searchbox'), 'a'); expect(onTableQueryChange).toHaveBeenCalled(); const calls = onTableQueryChange.mock.calls; const lastCall = calls[calls.length - 1]?.[0]; diff --git a/packages/raystack/components/search/__tests__/search.test.tsx b/packages/raystack/components/search/__tests__/search.test.tsx index 6321f2fdd..5b69ec72a 100644 --- a/packages/raystack/components/search/__tests__/search.test.tsx +++ b/packages/raystack/components/search/__tests__/search.test.tsx @@ -14,7 +14,7 @@ describe('Search', () => { it('renders input field', () => { render(); - const input = screen.getByRole('textbox'); + const input = screen.getByRole('searchbox'); expect(input).toBeInTheDocument(); }); @@ -44,7 +44,7 @@ describe('Search', () => { it('sets aria-label from placeholder', () => { render(); - const input = screen.getByRole('textbox'); + const input = screen.getByRole('searchbox'); expect(input).toHaveAttribute('aria-label', 'Search items'); }); }); @@ -52,14 +52,14 @@ describe('Search', () => { describe('Value and Change', () => { it('displays value', () => { render(); - const input = screen.getByRole('textbox') as HTMLInputElement; + const input = screen.getByRole('searchbox') as HTMLInputElement; expect(input.value).toBe('test query'); }); it('calls onChange when typing', () => { const handleChange = vi.fn(); render(); - const input = screen.getByRole('textbox'); + const input = screen.getByRole('searchbox'); fireEvent.change(input, { target: { value: 'new value' } }); expect(handleChange).toHaveBeenCalled(); @@ -67,7 +67,7 @@ describe('Search', () => { it('works as uncontrolled component', () => { render(); - const input = screen.getByRole('textbox') as HTMLInputElement; + const input = screen.getByRole('searchbox') as HTMLInputElement; fireEvent.change(input, { target: { value: 'search term' } }); expect(input.value).toBe('search term'); @@ -133,18 +133,34 @@ describe('Search', () => { fireEvent.click(clearButton); expect(handleClear).not.toHaveBeenCalled(); }); + + it('does not clear or call onClear when readOnly', () => { + const handleClear = vi.fn(); + render( + + ); + + fireEvent.click(screen.getByLabelText('Clear search')); + expect(screen.getByRole('searchbox')).toHaveValue('test'); + expect(handleClear).not.toHaveBeenCalled(); + }); }); describe('Sizes', () => { it('renders default size', () => { render(); - const input = screen.getByRole('textbox'); + const input = screen.getByRole('searchbox'); expect(input).toBeInTheDocument(); }); it('renders small size', () => { render(); - const input = screen.getByRole('textbox'); + const input = screen.getByRole('searchbox'); expect(input).toBeInTheDocument(); }); }); @@ -152,13 +168,13 @@ describe('Search', () => { describe('Variants', () => { it('defaults to default variant', () => { render(); - const input = screen.getByRole('textbox'); + const input = screen.getByRole('searchbox'); expect(input).toBeInTheDocument(); }); it('renders borderless variant', () => { render(); - const input = screen.getByRole('textbox'); + const input = screen.getByRole('searchbox'); expect(input).toBeInTheDocument(); }); }); @@ -180,7 +196,7 @@ describe('Search', () => { describe('Disabled State', () => { it('disables input when disabled', () => { render(); - const input = screen.getByRole('textbox'); + const input = screen.getByRole('searchbox'); expect(input).toBeDisabled(); }); @@ -188,7 +204,7 @@ describe('Search', () => { const user = userEvent.setup(); const handleChange = vi.fn(); render(); - const input = screen.getByRole('textbox'); + const input = screen.getByRole('searchbox'); await user.type(input, 'test'); @@ -199,14 +215,14 @@ describe('Search', () => { describe('Input Props', () => { it('passes className to input', () => { render(); - const input = screen.getByRole('textbox'); + const input = screen.getByRole('searchbox'); expect(input).toHaveClass('custom-search'); }); it('supports onFocus event', () => { const handleFocus = vi.fn(); render(); - const input = screen.getByRole('textbox'); + const input = screen.getByRole('searchbox'); fireEvent.focus(input); expect(handleFocus).toHaveBeenCalled(); @@ -215,7 +231,7 @@ describe('Search', () => { it('supports onBlur event', () => { const handleBlur = vi.fn(); render(); - const input = screen.getByRole('textbox'); + const input = screen.getByRole('searchbox'); fireEvent.blur(input); expect(handleBlur).toHaveBeenCalled(); @@ -224,7 +240,7 @@ describe('Search', () => { it('supports onKeyDown event', () => { const handleKeyDown = vi.fn(); render(); - const input = screen.getByRole('textbox'); + const input = screen.getByRole('searchbox'); fireEvent.keyDown(input, { key: 'Enter' }); expect(handleKeyDown).toHaveBeenCalled(); @@ -232,14 +248,195 @@ describe('Search', () => { it('supports name attribute', () => { render(); - const input = screen.getByRole('textbox'); + const input = screen.getByRole('searchbox'); expect(input).toHaveAttribute('name', 'search-field'); }); it('supports id attribute', () => { render(); - const input = screen.getByRole('textbox'); + const input = screen.getByRole('searchbox'); expect(input).toHaveAttribute('id', 'search-input'); }); }); + + describe('Type', () => { + it('defaults to type="search"', () => { + render(); + expect(screen.getByRole('searchbox')).toHaveAttribute('type', 'search'); + }); + + it('lets type be overridden', () => { + render(); + expect(screen.getByRole('textbox')).toHaveAttribute('type', 'text'); + }); + }); + + describe('Ref', () => { + it('passes the input element to the consumer ref', () => { + let node: HTMLInputElement | null = null; + render( + { + node = el; + }} + /> + ); + expect(node).toBe(screen.getByRole('searchbox')); + }); + }); + + describe('Clear with Escape', () => { + it('clears, calls onClear with the keyboard event, and stops propagation', async () => { + const user = userEvent.setup(); + const handleClear = vi.fn(); + const parentKeyDown = vi.fn(); + render( +
+ +
+ ); + const input = screen.getByRole('searchbox'); + + await user.click(input); + await user.keyboard('{Escape}'); + + expect(handleClear).toHaveBeenCalledTimes(1); + expect(handleClear.mock.calls[0][0].type).toBe('keydown'); + expect(handleClear.mock.calls[0][0].key).toBe('Escape'); + expect(input).toHaveFocus(); + expect(parentKeyDown).not.toHaveBeenCalled(); + }); + + it('lets Escape bubble when the input is empty', async () => { + const user = userEvent.setup(); + const handleClear = vi.fn(); + const parentKeyDown = vi.fn(); + render( +
+ +
+ ); + + await user.click(screen.getByRole('searchbox')); + await user.keyboard('{Escape}'); + + expect(handleClear).not.toHaveBeenCalled(); + expect(parentKeyDown).toHaveBeenCalledTimes(1); + }); + + it('does nothing when disabled', () => { + const handleClear = vi.fn(); + render(); + + fireEvent.keyDown(screen.getByRole('searchbox'), { key: 'Escape' }); + expect(handleClear).not.toHaveBeenCalled(); + }); + + it('does nothing when readOnly', async () => { + const user = userEvent.setup(); + const handleClear = vi.fn(); + render(); + + await user.click(screen.getByRole('searchbox')); + await user.keyboard('{Escape}'); + expect(handleClear).not.toHaveBeenCalled(); + }); + + it('does nothing when the consumer onKeyDown prevents default', async () => { + const user = userEvent.setup(); + const handleClear = vi.fn(); + render( + e.preventDefault()} + /> + ); + + await user.click(screen.getByRole('searchbox')); + await user.keyboard('{Escape}'); + expect(handleClear).not.toHaveBeenCalled(); + }); + + it('clears an uncontrolled input and fires onChange', async () => { + const user = userEvent.setup(); + const handleChange = vi.fn(); + render(); + const input = screen.getByRole('searchbox') as HTMLInputElement; + + await user.type(input, 'abc'); + handleChange.mockClear(); + await user.keyboard('{Escape}'); + + expect(input.value).toBe(''); + expect(handleChange).toHaveBeenCalledTimes(1); + }); + }); + + describe('Clear button focus and events', () => { + it('passes the mouse event to onClear and focuses the input', async () => { + const user = userEvent.setup(); + const handleClear = vi.fn(); + render(); + + await user.click(screen.getByLabelText('Clear search')); + + expect(handleClear).toHaveBeenCalledTimes(1); + expect(handleClear.mock.calls[0][0].type).toBe('click'); + expect(document.activeElement).toBe(screen.getByRole('searchbox')); + }); + + it('clears an uncontrolled input and fires onChange', async () => { + const user = userEvent.setup(); + const handleChange = vi.fn(); + render(); + const input = screen.getByRole('searchbox') as HTMLInputElement; + + await user.type(input, 'abc'); + handleChange.mockClear(); + await user.click(screen.getByLabelText('Clear search')); + + expect(input.value).toBe(''); + expect(handleChange).toHaveBeenCalledTimes(1); + expect(input).toHaveFocus(); + }); + }); + + describe('Controlled without onClear', () => { + it('clears on Escape through onValueChange and stops propagation', async () => { + const user = userEvent.setup(); + const handleValueChange = vi.fn(); + const parentKeyDown = vi.fn(); + render( +
+ +
+ ); + + await user.click(screen.getByRole('searchbox')); + await user.keyboard('{Escape}'); + + expect(handleValueChange).toHaveBeenCalledTimes(1); + expect(handleValueChange.mock.calls[0][0]).toBe(''); + expect(parentKeyDown).not.toHaveBeenCalled(); + }); + + it('clears on clear click through onValueChange', async () => { + const user = userEvent.setup(); + const handleValueChange = vi.fn(); + render( + + ); + + await user.click(screen.getByLabelText('Clear search')); + + expect(handleValueChange).toHaveBeenCalledTimes(1); + expect(handleValueChange.mock.calls[0][0]).toBe(''); + expect(screen.getByRole('searchbox')).toHaveFocus(); + }); + }); }); diff --git a/packages/raystack/components/search/search.module.css b/packages/raystack/components/search/search.module.css index 199d2d240..c9621a01b 100644 --- a/packages/raystack/components/search/search.module.css +++ b/packages/raystack/components/search/search.module.css @@ -7,6 +7,14 @@ padding: 0 var(--rs-space-3); } +/* Search renders its own clear button, so hide the native one. */ +.container input[type="search"]::-webkit-search-cancel-button, +.container input[type="search"]::-webkit-search-decoration { + -webkit-appearance: none; + appearance: none; + display: none; +} + .clearButtonWrapper { display: flex; align-items: center; diff --git a/packages/raystack/components/search/search.tsx b/packages/raystack/components/search/search.tsx index ced3a5ab5..515d4d9e6 100644 --- a/packages/raystack/components/search/search.tsx +++ b/packages/raystack/components/search/search.tsx @@ -1,5 +1,7 @@ 'use client'; +import { useMergedRefs } from '@base-ui/utils/useMergedRefs'; +import { KeyboardEvent, MouseEvent, useRef } from 'react'; import { ClearIcon, SearchIcon } from '~/icons'; import { IconButton } from '../icon-button'; import { Input } from '../input'; @@ -9,30 +11,81 @@ import styles from './search.module.css'; export interface SearchProps extends Omit { showClearButton?: boolean; - onClear?: () => void; + onClear?: ( + event: MouseEvent | KeyboardEvent + ) => void; variant?: 'default' | 'borderless'; } +// Uses the native setter and an `input` event so React's onChange and +// Base UI's onValueChange fire as if the user cleared the field. +function clearInputValue(input: HTMLInputElement) { + const setValue = Object.getOwnPropertyDescriptor( + HTMLInputElement.prototype, + 'value' + )?.set; + setValue?.call(input, ''); + input.dispatchEvent(new Event('input', { bubbles: true })); +} + export function Search({ disabled, placeholder = 'Search', size, showClearButton, onClear, + onKeyDown, value, width = '100%', variant = 'default', + type = 'search', + ref, ...props }: SearchProps) { + const inputRef = useRef(null); + const mergedRef = useMergedRefs(inputRef, ref); + // A controlled Search with `onClear` resets `value` itself. Otherwise the + // clear goes through `onChange` and `onValueChange`. + const clear = () => { + if (value !== undefined && onClear) return; + if (inputRef.current) clearInputValue(inputRef.current); + }; + + const handleKeyDown: InputProps['onKeyDown'] = event => { + onKeyDown?.(event); + if (event.defaultPrevented) return; + + const input = event.currentTarget; + if ( + event.key !== 'Escape' || + event.nativeEvent.isComposing || + input.disabled || + input.readOnly || + input.value === '' + ) { + return; + } + + // Stop only when there is a value to clear, so an empty field lets + // Escape reach an enclosing Dialog, Popover or Menu. + event.preventDefault(); + event.stopPropagation(); + clear(); + onClear?.(event); + }; + const trailingIconWithClear = showClearButton ? (
{ e.stopPropagation(); - if (!disabled && onClear) { - onClear(); - } + if (disabled || inputRef.current?.readOnly) return; + clear(); + onClear?.(e); + // The button hides once the input is empty, so focus would + // otherwise fall back to . + inputRef.current?.focus(); }} disabled={disabled} aria-label='Clear search' @@ -61,6 +114,9 @@ export function Search({ size={size} aria-label={placeholder} variant={variant} + type={type} + ref={mergedRef} + onKeyDown={handleKeyDown} {...props} />