diff --git a/web/packages/teleterm/src/ui/Search/SearchBar.test.tsx b/web/packages/teleterm/src/ui/Search/SearchBar.test.tsx index 02c7a8f5e69bd..2f2eeecdec33d 100644 --- a/web/packages/teleterm/src/ui/Search/SearchBar.test.tsx +++ b/web/packages/teleterm/src/ui/Search/SearchBar.test.tsx @@ -16,7 +16,7 @@ import React from 'react'; import userEvent from '@testing-library/user-event'; -import { render, screen, waitFor } from 'design/utils/testing'; +import { render, screen, waitFor, act } from 'design/utils/testing'; import { makeSuccessAttempt } from 'shared/hooks/useAsync'; import Logger, { NullService } from 'teleterm/logger'; @@ -315,6 +315,43 @@ it('shows a login modal when a request to a cluster from the current workspace f expect(screen.getByRole('menu')).toBeInTheDocument(); }); +it('closes on a click on an unfocusable element outside of the search bar', async () => { + const user = userEvent.setup(); + const cluster = makeRootCluster(); + const resourceSearchResult = { + results: [], + errors: [], + search: 'foo', + }; + const resourceSearch = async () => resourceSearchResult; + jest + .spyOn(useSearch, 'useResourceSearch') + .mockImplementation(() => resourceSearch); + + const appContext = new MockAppContext(); + appContext.workspacesService.setState(draft => { + draft.rootClusterUri = cluster.uri; + }); + appContext.clustersService.setState(draftState => { + draftState.clusters.set(cluster.uri, cluster); + }); + + render( + + +

Lorem ipsum

+
+ ); + + await user.type(screen.getByRole('searchbox'), 'foo'); + expect(screen.getByRole('menu')).toBeInTheDocument(); + + act(() => { + screen.getByTestId('unfocusable-element').click(); + }); + expect(screen.queryByRole('menu')).not.toBeInTheDocument(); +}); + const getMockedSearchContext = (): SearchContext.SearchContext => ({ inputValue: 'foo', filters: [], @@ -323,10 +360,10 @@ const getMockedSearchContext = (): SearchContext.SearchContext => ({ isOpen: true, open: () => {}, close: () => {}, - closeAndResetInput: () => {}, + closeWithoutRestoringFocus: () => {}, resetInput: () => {}, changeActivePicker: () => {}, - onInputValueChange: () => {}, + setInputValue: () => {}, activePicker: pickers.actionPicker, inputRef: undefined, pauseUserInteraction: async cb => { @@ -335,4 +372,5 @@ const getMockedSearchContext = (): SearchContext.SearchContext => ({ addWindowEventListener: () => ({ cleanup: () => {}, }), + makeEventListener: cb => cb, }); diff --git a/web/packages/teleterm/src/ui/Search/SearchBar.tsx b/web/packages/teleterm/src/ui/Search/SearchBar.tsx index dcd0c37c58be5..52f811887edb1 100644 --- a/web/packages/teleterm/src/ui/Search/SearchBar.tsx +++ b/web/packages/teleterm/src/ui/Search/SearchBar.tsx @@ -53,12 +53,14 @@ function SearchBar() { const { activePicker, inputValue, - onInputValueChange, + setInputValue, inputRef, isOpen, open, close, + closeWithoutRestoringFocus, addWindowEventListener, + makeEventListener, } = useSearchContext(); const ctx = useAppContext(); ctx.clustersService.useState(); @@ -69,23 +71,53 @@ function SearchBar() { }, }); + // Handle outside click when the search bar is open. useEffect(() => { + if (!isOpen) { + return; + } + const onClickOutside = e => { if (!e.composedPath().includes(containerRef.current)) { close(); } }; - if (isOpen) { - const { cleanup } = addWindowEventListener('click', onClickOutside, { - capture: true, - }); - return cleanup; - } + + const { cleanup } = addWindowEventListener('click', onClickOutside, { + capture: true, + }); + return cleanup; }, [close, isOpen, addWindowEventListener]); - function handleOnFocus(e: React.FocusEvent) { - open(e.relatedTarget); - } + // closeIfAnotherElementReceivedFocus handles a scenario where the focus shifts from the search + // input to another element on page. It does nothing if there's no other element that receives + // focus, i.e. the user clicks on an unfocusable element (for example, the empty space between the + // search bar and the profile selector). + // + // If that element is present though, onBlur takes precedence over onClickOutside. For example, + // clicking on a button outside of the search bar will trigger onBlur and will not trigger + // onClickOutside. + const closeIfAnotherElementReceivedFocus = makeEventListener( + (event: FocusEvent) => { + const elementReceivingFocus = event.relatedTarget; + + if (!(elementReceivingFocus instanceof Node)) { + // event.relatedTarget might be undefined if the user clicked on an element that is not + // focusable. The element might or might not be inside the search bar, however we have no way + // of knowing that. Instead of closing the search bar, we defer this responsibility to the + // onClickOutside handler and return early. + // + return; + } + + const isElementReceivingFocusOutsideOfSearchBar = + !containerRef.current.contains(elementReceivingFocus); + + if (isElementReceivingFocusOutsideOfSearchBar) { + closeWithoutRestoringFocus(); // without restoring focus + } + } + ); const defaultInputProps = { ref: inputRef, @@ -93,8 +125,12 @@ function SearchBar() { placeholder: activePicker.placeholder, value: inputValue, onChange: e => { - onInputValueChange(e.target.value); + setInputValue(e.target.value); + }, + onFocus: (e: React.FocusEvent) => { + open(e.relatedTarget); }, + onBlur: closeIfAnotherElementReceivedFocus, spellCheck: false, }; @@ -118,7 +154,6 @@ function SearchBar() { `} justifyContent="center" ref={containerRef} - onFocus={handleOnFocus} > {!isOpen && ( <> @@ -128,7 +163,11 @@ function SearchBar() { )} {isOpen && ( } /> )} diff --git a/web/packages/teleterm/src/ui/Search/SearchContext.test.tsx b/web/packages/teleterm/src/ui/Search/SearchContext.test.tsx index 31cce8c34b2f5..86f6eb58bc50f 100644 --- a/web/packages/teleterm/src/ui/Search/SearchContext.test.tsx +++ b/web/packages/teleterm/src/ui/Search/SearchContext.test.tsx @@ -146,11 +146,12 @@ describe('addWindowEventListener', () => { describe('open', () => { it('manages the focus properly when called with no arguments', () => { const SearchInput = () => { - const { inputRef, open, close } = useSearchContext(); + const { inputRef, isOpen, open, close } = useSearchContext(); return ( <> +
{String(isOpen)}