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)}