diff --git a/.changeset/spicy-needles-shave.md b/.changeset/spicy-needles-shave.md new file mode 100644 index 00000000000..123aebd98ec --- /dev/null +++ b/.changeset/spicy-needles-shave.md @@ -0,0 +1,5 @@ +--- +"@aws-amplify/ui-react": patch +--- + +Refocus `SearchField` input field on clear button click diff --git a/packages/react/src/hooks/__tests__/useComposeRefsCallback.test.tsx b/packages/react/src/hooks/__tests__/useComposeRefsCallback.test.tsx new file mode 100644 index 00000000000..6e96a07bed4 --- /dev/null +++ b/packages/react/src/hooks/__tests__/useComposeRefsCallback.test.tsx @@ -0,0 +1,37 @@ +import * as React from 'react'; +import { renderHook } from '@testing-library/react-hooks'; +import { useComposeRefsCallback } from '../useComposeRefsCallback'; + +const externalRef = React.createRef(); +const internalRef = React.createRef(); + +let externalRefCallbackNode: HTMLInputElement; +const externalRefCallback = (node: HTMLInputElement) => { + externalRefCallbackNode = node; +}; + +describe('useComposeRefsCallback', () => { + it('should compose both internal RefObject and external RefObject', () => { + const callback = renderHook(() => + useComposeRefsCallback({ externalRef, internalRef }) + ); + + const inputNode = document.createElement('input'); + callback.result.current(inputNode); + + expect(internalRef.current).toBe(inputNode); + expect(externalRef.current).toBe(inputNode); + }); + + it('should compose both internal RefObject and external callback', () => { + const callback = renderHook(() => + useComposeRefsCallback({ externalRef: externalRefCallback, internalRef }) + ); + + const inputNode = document.createElement('input'); + callback.result.current(inputNode); + + expect(internalRef.current).toBe(inputNode); + expect(externalRefCallbackNode).toBe(inputNode); + }); +}); diff --git a/packages/react/src/hooks/useComposeRefsCallback.tsx b/packages/react/src/hooks/useComposeRefsCallback.tsx new file mode 100644 index 00000000000..7aa88ff0236 --- /dev/null +++ b/packages/react/src/hooks/useComposeRefsCallback.tsx @@ -0,0 +1,27 @@ +import * as React from 'react'; + +export interface UseComposeRefsCallbackProps { + externalRef: React.ForwardedRef; + internalRef: React.MutableRefObject; +} + +export type UseComposeRefsCallbackReturn = (node: RefType) => void; + +/** + * Creates ref callback to compose together external and internal refs + */ +export const useComposeRefsCallback = ({ + externalRef, + internalRef, +}: UseComposeRefsCallbackProps): UseComposeRefsCallbackReturn => { + return React.useCallback((node) => { + // Handle callback ref + if (typeof externalRef === 'function') { + externalRef(node); + } else if (externalRef != null) { + externalRef.current = node; + } + + internalRef.current = node; + }, []); +}; diff --git a/packages/react/src/primitives/SearchField/SearchField.tsx b/packages/react/src/primitives/SearchField/SearchField.tsx index b388b63217e..f73c1aae3d6 100644 --- a/packages/react/src/primitives/SearchField/SearchField.tsx +++ b/packages/react/src/primitives/SearchField/SearchField.tsx @@ -3,74 +3,11 @@ import * as React from 'react'; import { ComponentClassNames } from '../shared/constants'; import { FieldClearButton } from '../Field'; -import { isFunction, strHasLength } from '../shared/utils'; +import { strHasLength } from '../shared/utils'; import { SearchFieldButton } from './SearchFieldButton'; import { SearchFieldProps, Primitive } from '../types'; import { TextField } from '../TextField'; - -const ESCAPE_KEY = 'Escape'; -const ENTER_KEY = 'Enter'; -const DEFAULT_KEYS = [ESCAPE_KEY, ENTER_KEY]; - -export const useSearchField = ({ - onSubmit, - onClear, -}: Partial) => { - const [value, setValue] = React.useState(''); - - const onClearHandler = React.useCallback(() => { - setValue(''); - - if (isFunction(onClear)) { - onClear(); - } - }, [setValue, onClear]); - - const onSubmitHandler = React.useCallback( - (value: string) => { - if (isFunction(onSubmit)) { - onSubmit(value); - } - }, - [onSubmit] - ); - - const onKeyDown = React.useCallback( - (event) => { - const key = event.key; - - if (DEFAULT_KEYS.includes(key)) { - event.preventDefault(); - } - - if (key === ESCAPE_KEY) { - onClearHandler(); - } else if (key === ENTER_KEY) { - onSubmitHandler(value); - } - }, - [value, onClearHandler, onSubmitHandler] - ); - - const onInput = React.useCallback( - (event) => { - setValue(event.target.value); - }, - [setValue] - ); - - const onClick = React.useCallback(() => { - onSubmitHandler(value); - }, [onSubmitHandler, value]); - - return { - value, - onClearHandler, - onKeyDown, - onInput, - onClick, - }; -}; +import { useSearchField } from './useSearchField'; const SearchFieldPrimitive: Primitive = ( { @@ -86,9 +23,8 @@ const SearchFieldPrimitive: Primitive = ( }, ref ) => { - const { value, onClearHandler, onInput, onKeyDown, onClick } = useSearchField( - { onSubmit, onClear } - ); + const { value, onClearHandler, onInput, onKeyDown, onClick, composedRefs } = + useSearchField({ onSubmit, onClear, externalRef: ref }); return ( = ( size={size} /> } - ref={ref} + ref={composedRefs} size={size} value={value} {...rest} diff --git a/packages/react/src/primitives/SearchField/__tests__/SearchField.test.tsx b/packages/react/src/primitives/SearchField/__tests__/SearchField.test.tsx index 70f7568a633..798d022ac86 100644 --- a/packages/react/src/primitives/SearchField/__tests__/SearchField.test.tsx +++ b/packages/react/src/primitives/SearchField/__tests__/SearchField.test.tsx @@ -50,6 +50,26 @@ describe('SearchField component', () => { expect(searchButtonRef.current.nodeName).toBe('BUTTON'); }); + it('should forward callback ref to DOM element', async () => { + let ref: HTMLInputElement; + + const setRef = (node) => (ref = node); + + render( + + ); + + await screen.findByRole('button'); + + expect(ref.nodeName).toBe('INPUT'); + }); + it('should be text input type', async () => { render(); @@ -137,7 +157,7 @@ describe('SearchField component', () => { }); describe(' - clear button', () => { - it('should clear text when clicked', async () => { + it('should clear text and refocus input when clicked', async () => { render(); const searchField = (await screen.findByLabelText( @@ -150,6 +170,7 @@ describe('SearchField component', () => { expect(searchField).toHaveValue(searchQuery); userEvent.click(clearButton); expect(searchField).toHaveValue(''); + expect(searchField).toHaveFocus(); }); }); }); diff --git a/packages/react/src/primitives/SearchField/useSearchField.tsx b/packages/react/src/primitives/SearchField/useSearchField.tsx new file mode 100644 index 00000000000..08f6beb88c2 --- /dev/null +++ b/packages/react/src/primitives/SearchField/useSearchField.tsx @@ -0,0 +1,75 @@ +import * as React from 'react'; + +import { isFunction } from '../shared/utils'; +import { UseSearchFieldProps } from '../types'; +import { useComposeRefsCallback } from '../../hooks/useComposeRefsCallback'; + +const ESCAPE_KEY = 'Escape'; +const ENTER_KEY = 'Enter'; +const DEFAULT_KEYS = new Set([ESCAPE_KEY, ENTER_KEY]); + +export const useSearchField = ({ + onSubmit, + onClear, + externalRef, +}: UseSearchFieldProps) => { + const [value, setValue] = React.useState(''); + const internalRef = React.useRef(null); + const composedRefs = useComposeRefsCallback({ externalRef, internalRef }); + + const onClearHandler = React.useCallback(() => { + setValue(''); + internalRef?.current?.focus(); + if (isFunction(onClear)) { + onClear(); + } + }, [setValue, onClear]); + + const onSubmitHandler = React.useCallback( + (value: string) => { + if (isFunction(onSubmit)) { + onSubmit(value); + } + }, + [onSubmit] + ); + + const onKeyDown = React.useCallback( + (event) => { + const { key } = event; + + if (!DEFAULT_KEYS.has(key)) { + return; + } + + event.preventDefault(); + + if (key === ESCAPE_KEY) { + onClearHandler(); + } else if (key === ENTER_KEY) { + onSubmitHandler(value); + } + }, + [value, onClearHandler, onSubmitHandler] + ); + + const onInput = React.useCallback( + (event) => { + setValue(event.target.value); + }, + [setValue] + ); + + const onClick = React.useCallback(() => { + onSubmitHandler(value); + }, [onSubmitHandler, value]); + + return { + value, + onClearHandler, + onKeyDown, + onInput, + onClick, + composedRefs, + }; +}; diff --git a/packages/react/src/primitives/types/searchField.ts b/packages/react/src/primitives/types/searchField.ts index 4f4e44a888c..4b11a2751e3 100644 --- a/packages/react/src/primitives/types/searchField.ts +++ b/packages/react/src/primitives/types/searchField.ts @@ -27,3 +27,7 @@ export interface SearchFieldProps extends TextInputFieldProps { export interface SearchFieldButtonProps extends Partial {} + +export type UseSearchFieldProps = Partial & { + externalRef?: React.ForwardedRef; +};