-
Notifications
You must be signed in to change notification settings - Fork 2.1k
Close search bar in Connect on blur #25186
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
1abc27b
bf97cdd
058cb0a
41d7d02
7b55bea
4f9077e
2b4773f
e50de42
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change | ||||||||
|---|---|---|---|---|---|---|---|---|---|---|
|
|
@@ -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,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( | ||||||||||
|
Member
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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 That's the same reason why I renamed the prop in the following piece of code from teleport/web/packages/teleterm/src/ui/Search/pickers/ActionPicker.tsx Lines 217 to 220 in 5bf72af
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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,
I suppose I understand this from a prop -> passed func readability (as in, the prop name is
Member
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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 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 Matter of fact, the
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. I think it makes sense to use more specific names when possible. |
||||||||||
| (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 | ||||||||||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Nit: this comment just repeats the function name
Member
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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 |
||||||||||
| } | ||||||||||
| } | ||||||||||
| ); | ||||||||||
|
|
||||||||||
| 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, | ||||||||||
| }; | ||||||||||
|
|
||||||||||
|
|
@@ -118,7 +154,6 @@ function SearchBar() { | |||||||||
| `} | ||||||||||
| justifyContent="center" | ||||||||||
| ref={containerRef} | ||||||||||
| onFocus={handleOnFocus} | ||||||||||
| > | ||||||||||
| {!isOpen && ( | ||||||||||
| <> | ||||||||||
|
|
@@ -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} />} | ||||||||||
| /> | ||||||||||
| )} | ||||||||||
|
|
||||||||||
| Original file line number | Diff line number | Diff line change | ||||||
|---|---|---|---|---|---|---|---|---|
|
|
@@ -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 = ( | ||||||||
|
|
@@ -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); | ||||||||
|
|
@@ -77,33 +81,39 @@ export const SearchContextProvider: FC = props => { | |||||||
| const close = useCallback(() => { | ||||||||
| setIsOpen(false); | ||||||||
| setActivePicker(actionPicker); | ||||||||
| if (previouslyActive.current instanceof HTMLElement) { | ||||||||
|
Member
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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 I replaced it with a simple truthiness check because I wanted to use a simple mock in tests instead. teleport/web/packages/teleterm/src/ui/Search/SearchContext.test.tsx Lines 185 to 187 in 058cb0a
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. AFAIK I think you can still have that simple mock in a test, but only revert the
Member
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
I cannot use the MDN doesn't explain how it's possible for For now, I'll revert
Member
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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 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); | ||||||||
| } | ||||||||
|
|
||||||||
|
|
@@ -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( | ||||||||
|
Member
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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 So we could have a more generic name but since this function would be used only for event listeners, I feel like I tried coming up with something 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]); | ||||||||
|
|
@@ -187,7 +213,7 @@ export const SearchContextProvider: FC = props => { | |||||||
| value={{ | ||||||||
| inputRef, | ||||||||
| inputValue, | ||||||||
| onInputValueChange: setInputValue, | ||||||||
| setInputValue, | ||||||||
| changeActivePicker, | ||||||||
| activePicker, | ||||||||
| filters, | ||||||||
|
|
@@ -197,9 +223,10 @@ export const SearchContextProvider: FC = props => { | |||||||
| isOpen, | ||||||||
| open, | ||||||||
| close, | ||||||||
| closeAndResetInput, | ||||||||
| closeWithoutRestoringFocus, | ||||||||
| pauseUserInteraction, | ||||||||
| addWindowEventListener, | ||||||||
| makeEventListener, | ||||||||
| }} | ||||||||
| children={props.children} | ||||||||
| /> | ||||||||
|
|
||||||||
There was a problem hiding this comment.
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 differentclosevariants and I didn't want to haveclose({restoreFocus: boolean, resetInput: boolean})either. So I decided to get rid ofcloseAndResetInputas it was used only in a single specific situation which already also calledresetInputanyway.