From f51ba32f9a58f9109eb796168b060ef9defc7713 Mon Sep 17 00:00:00 2001 From: Scott Rees <6165315+reesscot@users.noreply.github.com> Date: Tue, 7 Dec 2021 11:25:15 -0800 Subject: [PATCH 1/4] Refocus search field on clear button click --- .../__tests__/useComposeRefsCallback.test.tsx | 37 +++++++++ .../src/hooks/useComposeRefsCallback.tsx | 27 +++++++ .../primitives/SearchField/SearchField.tsx | 74 ++---------------- .../__tests__/SearchField.test.tsx | 23 +++++- .../primitives/SearchField/useSearchField.tsx | 75 +++++++++++++++++++ 5 files changed, 166 insertions(+), 70 deletions(-) create mode 100644 packages/react/src/hooks/__tests__/useComposeRefsCallback.test.tsx create mode 100644 packages/react/src/hooks/useComposeRefsCallback.tsx create mode 100644 packages/react/src/primitives/SearchField/useSearchField.tsx 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..6446e2ea08e 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, composeRefs } = + useSearchField({ onSubmit, onClear, externalRef: ref }); return ( = ( size={size} /> } - ref={ref} + ref={composeRefs} 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..719a6f21382 --- /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 { SearchFieldProps } from '../types'; +import { useComposeRefsCallback } from '../../hooks/useComposeRefsCallback'; + +const ESCAPE_KEY = 'Escape'; +const ENTER_KEY = 'Enter'; +const DEFAULT_KEYS = [ESCAPE_KEY, ENTER_KEY]; + +export const useSearchField = ({ + onSubmit, + onClear, + externalRef, +}: Partial< + SearchFieldProps & { externalRef: React.ForwardedRef } +>) => { + const [value, setValue] = React.useState(''); + const internalRef = React.useRef(null); + const composeRefs = 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.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, + composeRefs, + }; +}; From 4e86f717ea81adbcb5347f26d9ceb5c9edbbb8ad Mon Sep 17 00:00:00 2001 From: Scott Rees <6165315+reesscot@users.noreply.github.com> Date: Thu, 9 Dec 2021 16:07:39 -0800 Subject: [PATCH 2/4] Address feedback --- .../primitives/SearchField/SearchField.tsx | 4 ++-- .../primitives/SearchField/useSearchField.tsx | 20 +++++++++---------- .../react/src/primitives/types/searchField.ts | 4 ++++ 3 files changed, 16 insertions(+), 12 deletions(-) diff --git a/packages/react/src/primitives/SearchField/SearchField.tsx b/packages/react/src/primitives/SearchField/SearchField.tsx index 6446e2ea08e..f73c1aae3d6 100644 --- a/packages/react/src/primitives/SearchField/SearchField.tsx +++ b/packages/react/src/primitives/SearchField/SearchField.tsx @@ -23,7 +23,7 @@ const SearchFieldPrimitive: Primitive = ( }, ref ) => { - const { value, onClearHandler, onInput, onKeyDown, onClick, composeRefs } = + const { value, onClearHandler, onInput, onKeyDown, onClick, composedRefs } = useSearchField({ onSubmit, onClear, externalRef: ref }); return ( @@ -51,7 +51,7 @@ const SearchFieldPrimitive: Primitive = ( size={size} /> } - ref={composeRefs} + ref={composedRefs} size={size} value={value} {...rest} diff --git a/packages/react/src/primitives/SearchField/useSearchField.tsx b/packages/react/src/primitives/SearchField/useSearchField.tsx index 719a6f21382..f2379a388bc 100644 --- a/packages/react/src/primitives/SearchField/useSearchField.tsx +++ b/packages/react/src/primitives/SearchField/useSearchField.tsx @@ -1,23 +1,21 @@ import * as React from 'react'; import { isFunction } from '../shared/utils'; -import { SearchFieldProps } from '../types'; +import { SearchFieldProps, UseSearchFieldProps } from '../types'; import { useComposeRefsCallback } from '../../hooks/useComposeRefsCallback'; const ESCAPE_KEY = 'Escape'; const ENTER_KEY = 'Enter'; -const DEFAULT_KEYS = [ESCAPE_KEY, ENTER_KEY]; +const DEFAULT_KEYS = new Set([ESCAPE_KEY, ENTER_KEY]); export const useSearchField = ({ onSubmit, onClear, externalRef, -}: Partial< - SearchFieldProps & { externalRef: React.ForwardedRef } ->) => { +}: UseSearchFieldProps) => { const [value, setValue] = React.useState(''); const internalRef = React.useRef(null); - const composeRefs = useComposeRefsCallback({ externalRef, internalRef }); + const composedRefs = useComposeRefsCallback({ externalRef, internalRef }); const onClearHandler = React.useCallback(() => { setValue(''); @@ -38,12 +36,14 @@ export const useSearchField = ({ const onKeyDown = React.useCallback( (event) => { - const key = event.key; + const { key } = event; - if (DEFAULT_KEYS.includes(key)) { - event.preventDefault(); + if (!DEFAULT_KEYS.has(key)) { + return; } + event.preventDefault(); + if (key === ESCAPE_KEY) { onClearHandler(); } else if (key === ENTER_KEY) { @@ -70,6 +70,6 @@ export const useSearchField = ({ onKeyDown, onInput, onClick, - composeRefs, + 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; +}; From db13a4e1f672fbe978e70ee936c3305dfabf695c Mon Sep 17 00:00:00 2001 From: Scott Rees <6165315+reesscot@users.noreply.github.com> Date: Thu, 9 Dec 2021 16:33:34 -0800 Subject: [PATCH 3/4] Remove unneeded type --- packages/react/src/primitives/SearchField/useSearchField.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/react/src/primitives/SearchField/useSearchField.tsx b/packages/react/src/primitives/SearchField/useSearchField.tsx index f2379a388bc..08f6beb88c2 100644 --- a/packages/react/src/primitives/SearchField/useSearchField.tsx +++ b/packages/react/src/primitives/SearchField/useSearchField.tsx @@ -1,7 +1,7 @@ import * as React from 'react'; import { isFunction } from '../shared/utils'; -import { SearchFieldProps, UseSearchFieldProps } from '../types'; +import { UseSearchFieldProps } from '../types'; import { useComposeRefsCallback } from '../../hooks/useComposeRefsCallback'; const ESCAPE_KEY = 'Escape'; From 94120780d4f387a6b1211bf15b7b736d58600a39 Mon Sep 17 00:00:00 2001 From: Scott Rees <6165315+reesscot@users.noreply.github.com> Date: Thu, 9 Dec 2021 16:37:51 -0800 Subject: [PATCH 4/4] Create spicy-needles-shave.md --- .changeset/spicy-needles-shave.md | 5 +++++ 1 file changed, 5 insertions(+) create mode 100644 .changeset/spicy-needles-shave.md 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