Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
44 changes: 41 additions & 3 deletions web/packages/teleterm/src/ui/Search/SearchBar.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -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(
<MockAppContextProvider appContext={appContext}>
<SearchBarConnected />
<p data-testid="unfocusable-element">Lorem ipsum</p>
</MockAppContextProvider>
);

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: [],
Expand All @@ -323,10 +360,10 @@ const getMockedSearchContext = (): SearchContext.SearchContext => ({
isOpen: true,
open: () => {},
close: () => {},
closeAndResetInput: () => {},
Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

In the upcoming commits I'm going to add closeWithoutRestoringFocus. I didn't want to have three different close variants and I didn't want to have close({restoreFocus: boolean, resetInput: boolean}) either. So I decided to get rid of closeAndResetInput as it was used only in a single specific situation which already also called resetInput anyway.

closeWithoutRestoringFocus: () => {},
resetInput: () => {},
changeActivePicker: () => {},
onInputValueChange: () => {},
setInputValue: () => {},
activePicker: pickers.actionPicker,
inputRef: undefined,
pauseUserInteraction: async cb => {
Expand All @@ -335,4 +372,5 @@ const getMockedSearchContext = (): SearchContext.SearchContext => ({
addWindowEventListener: () => ({
cleanup: () => {},
}),
makeEventListener: cb => cb,
});
65 changes: 52 additions & 13 deletions web/packages/teleterm/src/ui/Search/SearchBar.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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();
Expand All @@ -69,32 +71,66 @@ 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(
Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is an unorthodox name, but I don't like listeners being called like onBlur and handleBlur if they do something more complex because then you have no idea what's happening in them.

That's the same reason why I renamed the prop in the following piece of code from onShowDetails to showErrorsInModal:

<ExtraTopComponents
status={actionPickerStatus}
getClusterName={getClusterName}
showErrorsInModal={showErrorsInModal}

Copy link
Copy Markdown
Contributor

@avatus avatus Apr 26, 2023

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

What do you mean by "more complex". More complex than what? To me, handleBlur makes sense because it sounds like "when this event happens, we are going to handle it like this" and that seems very open ended in terms of complexity and viable to me, imo. I'd love to know your thoughts a bit deeper.

because then you have no idea what's happening in them

I suppose I understand this from a prop -> passed func readability (as in, the prop name is onBlur and you pass it a function that describes what it does). If I were to read it outloud as a normal sentence I could see it like "on blur, close if another element received focus" so that makes sense the way you're using it here

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Hmm maybe the complexity of the handler is the wrong way to look at it. What I should've said is that if I'm writing a generic component that's going to be used in many different contexts, say a <Button> or <Input>, then onClick, onBlur accepted through props all make sense because <Button> doesn't care what the handler does.

But if I'm working on a specific component in a specific context, then IMHO giving specific names to handlers makes it easier to understand what's going on, as you described. Otherwise all I see is just onBlur or handleBlur and if I want to know what actually happens on blur, I have to go to the function and I have to read through it because there's no variable name which would summarize what the function does.

Matter of fact, the onInputValueChange function exported from SearchContext has this problem too, I'm going to rename it in the next commit.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think it makes sense to use more specific names when possible.
If I needed to run a few unrelated actions in a handler, then probably I would just call it handleBlur.

(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
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Nit: this comment just repeats the function name

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Ah, good catch, I forgot to remove it when I was still prototyping since I originally called just close there. ;)

}
}
);

const defaultInputProps = {
ref: inputRef,
role: 'searchbox',
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,
};

Expand All @@ -118,7 +154,6 @@ function SearchBar() {
`}
justifyContent="center"
ref={containerRef}
onFocus={handleOnFocus}
>
{!isOpen && (
<>
Expand All @@ -128,7 +163,11 @@ function SearchBar() {
)}
{isOpen && (
<activePicker.picker
// autofocusing cannot be done in `open` function as it would focus the input from closed state
// When the search bar transitions from closed to open state, `inputRef.current` within
// the `open` function refers to the input element from when the search bar was closed.
//
// Thus, calling `focus()` on it would have no effect. Instead, we add `autoFocus` on the
// input when the search bar is open.
input={<Input {...defaultInputProps} autoFocus={true} />}
/>
)}
Expand Down
53 changes: 50 additions & 3 deletions web/packages/teleterm/src/ui/Search/SearchContext.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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 (
<>
<input data-testid="search-input" ref={inputRef} />
<div data-testid="is-open">{String(isOpen)}</div>
<button data-testid="open" onClick={() => open()} />
<button data-testid="close" onClick={() => close()} />
</>
Expand All @@ -168,13 +169,59 @@ describe('open', () => {
);

const otherInput = screen.getByTestId('other-input');
const searchInput = screen.getByTestId('search-input');
otherInput.focus();

expect(screen.getByTestId('is-open')).toHaveTextContent('false');
screen.getByTestId('open').click();
expect(searchInput).toHaveFocus();
expect(screen.getByTestId('is-open')).toHaveTextContent('true');

screen.getByTestId('close').click();
expect(otherInput).toHaveFocus();
});
});

describe('close', () => {
it('restores focus on the previously active element', () => {
const previouslyActive = {
focus: jest.fn(),
} as unknown as HTMLInputElement;
const { result } = renderHook(() => useSearchContext(), {
wrapper: ({ children }) => (
<SearchContextProvider>{children}</SearchContextProvider>
),
});

act(() => {
result.current.open(previouslyActive);
});

act(() => {
result.current.close();
});

expect(previouslyActive.focus).toHaveBeenCalledTimes(1);
});
});

describe('closeWithoutRestoringFocus', () => {
it('does not restore focus on the previously active element', () => {
const previouslyActive = {
focus: jest.fn(),
} as unknown as HTMLInputElement;
const { result } = renderHook(() => useSearchContext(), {
wrapper: ({ children }) => (
<SearchContextProvider>{children}</SearchContextProvider>
),
});

act(() => {
result.current.open(previouslyActive);
});

act(() => {
result.current.closeWithoutRestoringFocus();
});

expect(previouslyActive.focus).not.toHaveBeenCalled();
});
});
51 changes: 39 additions & 12 deletions web/packages/teleterm/src/ui/Search/SearchContext.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -33,17 +33,20 @@ export interface SearchContext {
inputValue: string;
filters: SearchFilter[];
activePicker: SearchPicker;
onInputValueChange(value: string): void;
setInputValue(value: string): void;
changeActivePicker(picker: SearchPicker): void;
isOpen: boolean;
open(fromElement?: Element): void;
close(): void;
closeAndResetInput(): void;
closeWithoutRestoringFocus(): void;
resetInput(): void;
setFilter(filter: SearchFilter): void;
removeFilter(filter: SearchFilter): void;
pauseUserInteraction(action: () => Promise<any>): Promise<void>;
addWindowEventListener: AddWindowEventListener;
makeEventListener: <EventListener>(
eventListener: EventListener
) => EventListener | undefined;
}

export type AddWindowEventListener = (
Expand All @@ -55,6 +58,7 @@ export type AddWindowEventListener = (
const SearchContext = createContext<SearchContext>(null);

export const SearchContextProvider: FC = props => {
// The type of the ref is Element to adhere to the type of document.activeElement.
const previouslyActive = useRef<Element>();
const inputRef = useRef<HTMLInputElement>();
const [isOpen, setIsOpen] = useState(false);
Expand All @@ -77,33 +81,39 @@ export const SearchContextProvider: FC = props => {
const close = useCallback(() => {
setIsOpen(false);
setActivePicker(actionPicker);
if (previouslyActive.current instanceof HTMLElement) {
Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Grzegorz, do you remember if this check was only to satisfy TypeScript? Or if previouslyActive can actually be some other kind of element and we want to execute this only when it's HTMLElement?

I replaced it with a simple truthiness check because I wanted to use a simple mock in tests instead.

const previouslyActive = {
focus: jest.fn(),
} as unknown as HTMLInputElement;

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

AFAIK previouslyActive can be for example an SVGElement that doesn't have focus method, so we want to run it only for HTMLElement. That's why I checked an instance of the element.
https://stackoverflow.com/a/47198823

I think you can still have that simple mock in a test, but only revert the instanceof check?

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

SVGElement extends HTMLOrSVGElement which has focus, though I've seen people mention what you said (microsoft/TypeScript#5901 (comment)), but I suppose it has changed since 2015.

I cannot use the instanceof check together with a simple mock because the instanceof check is done in runtime and the object is not an instance of HTMLElement.

MDN doesn't explain how it's possible for document.activeElement to be an Element that doesn't have focus function. I assume this is just due to the internal browser engine hierarchy of DOM objects.

For now, I'll revert previouslyActive back to just Element to adhere to the browser API. Before calling focus I'll check if previouslyActive has the focus property which is a function. I think this is better than an instanceof check because after all we're not interested in the element being an instance of a specific class, we just want to call focus on it.

Copy link
Copy Markdown
Member Author

@ravicious ravicious May 5, 2023

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I have to use a less type-safe check anyway because the current code won't pass CI until #25683 gets merged and I don't want to be blocked by it. I'll add a TODO comment to address it later.

Edit: I actually didn't change the check, I just temporarily replaced previouslyActive.current.focus() with previouslyActive.current['focus'](). We do the manualy "type check" on this property beforehand so it's safe to call it without null checks.

Edit 2: nvm, I had to change the check after all. ( .__.)

previouslyActive.current.focus();
if (
// The Element type is not guaranteed to have the focus function so we're forced to manually
// perform the type check.
previouslyActive.current
) {
// TODO(ravicious): Revert to a regular `focus()` call (#25186@4f9077eb7) once #25683 gets in.
previouslyActive.current['focus']?.();
}
}, []);

const closeAndResetInput = useCallback(() => {
const closeWithoutRestoringFocus = useCallback(() => {
previouslyActive.current = undefined;
close();
setInputValue('');
}, [close]);

const resetInput = useCallback(() => {
setInputValue('');
}, []);

function open(fromElement?: Element): void {
function open(fromElement?: HTMLElement): void {
if (isOpen) {
// Even if the search bar is already open, we want to focus on the input anyway. The search
// input might lose focus due to user interaction while the search bar stays open. Focusing
// here again makes it possible to use the shortcut to grant the focus to the input again.
//
// Also note that SearchBar renders two distinct input elements, one when the search bar is
// closed and another when its open. During the initial call to this function,
// inputRef.current is equal to the element from when the search bar was closed.
inputRef.current?.focus();
return;
}

// In case `open` was called without `fromElement` (e.g. when using the keyboard shortcut), we
// must read `document.activeElement` before we focus the input.
previouslyActive.current = fromElement || document.activeElement;
inputRef.current?.focus();
setIsOpen(true);
}

Expand Down Expand Up @@ -163,6 +173,22 @@ export const SearchContextProvider: FC = props => {
[isUserInteractionPaused]
);

/**
* makeEventListener is similar to addWindowEventListener but meant for situations where you want
* to add a listener to an element directly. By wrapping the listener in makeEventListener, you
* make sure that the listener will be removed when the interaction with the search bar is paused.
*/
const makeEventListener = useCallback(
Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Technically the function has nothing to do with event listeners, it just simply does not return the passed value if isUserInteractionPaused is true.

So we could have a more generic name but since this function would be used only for event listeners, I feel like makeEventListener is a better name than a generic one.

I tried coming up with something like makePausableEventListener but I didn't think of anything that I'd like.

eventListener => {
if (isUserInteractionPaused) {
return;
}

return eventListener;
},
[isUserInteractionPaused]
);

function setFilter(filter: SearchFilter) {
// UI prevents adding more than one filter of the same type
setFilters(prevState => [...prevState, filter]);
Expand All @@ -187,7 +213,7 @@ export const SearchContextProvider: FC = props => {
value={{
inputRef,
inputValue,
onInputValueChange: setInputValue,
setInputValue,
changeActivePicker,
activePicker,
filters,
Expand All @@ -197,9 +223,10 @@ export const SearchContextProvider: FC = props => {
isOpen,
open,
close,
closeAndResetInput,
closeWithoutRestoringFocus,
pauseUserInteraction,
addWindowEventListener,
makeEventListener,
}}
children={props.children}
/>
Expand Down
Loading