Close search bar in Connect on blur#25186
Conversation
As the comment in SearchBar explains, the focus wasn't actually achieving anything. I added it only because I was testing SearchContext in separation and depended on the input getting focused in the test. This doesn't reflect how the input is actually used so I adjusted the test.
| isOpen: true, | ||
| open: () => {}, | ||
| close: () => {}, | ||
| closeAndResetInput: () => {}, |
There was a problem hiding this comment.
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.
| // 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( |
There was a problem hiding this comment.
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:
teleport/web/packages/teleterm/src/ui/Search/pickers/ActionPicker.tsx
Lines 217 to 220 in 5bf72af
There was a problem hiding this comment.
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
There was a problem hiding this comment.
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.
There was a problem hiding this comment.
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.
| const closeWithoutRestoringFocus = useCallback(() => { | ||
| previouslyActive.current = undefined; | ||
| close(); | ||
| }, [close]); |
There was a problem hiding this comment.
I wish I didn't have to implement it like this, I'd rather have close which doesn't restore focus and then another version of close which does. This way we wouldn't have to depend on some state interactions to achieve the effect we want.
But given that in all but one case we want to restore focus, I just couldn't find a way to do this without making the code unnecessarily complicated.
| const close = useCallback(() => { | ||
| setIsOpen(false); | ||
| setActivePicker(actionPicker); | ||
| if (previouslyActive.current instanceof HTMLElement) { |
There was a problem hiding this comment.
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.
teleport/web/packages/teleterm/src/ui/Search/SearchContext.test.tsx
Lines 185 to 187 in 058cb0a
There was a problem hiding this comment.
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?
There was a problem hiding this comment.
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.
There was a problem hiding this comment.
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. ( .__.)
| * 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( |
There was a problem hiding this comment.
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.
gzdunek
left a comment
There was a problem hiding this comment.
Looks good, but I think we should revert checking the instance of previouslyActive, so the app won't crash in the runtime.
| !containerRef.current.contains(elementReceivingFocus); | ||
|
|
||
| if (isElementReceivingFocusOutsideOfSearchBar) { | ||
| closeWithoutRestoringFocus(); // without restoring focus |
There was a problem hiding this comment.
Nit: this comment just repeats the function name
There was a problem hiding this comment.
Ah, good catch, I forgot to remove it when I was still prototyping since I originally called just close there. ;)
| const close = useCallback(() => { | ||
| setIsOpen(false); | ||
| setActivePicker(actionPicker); | ||
| if (previouslyActive.current instanceof HTMLElement) { |
There was a problem hiding this comment.
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?
| // 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( |
There was a problem hiding this comment.
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.
|
@ravicious See the table below for backport results.
|
Adding
onBlurto the search input fixes two scenarios:onFocuson the overall search bar container and not the input itself, I imagine it might have went like this:onFocuson the search bar container gets called.opengets called, rendering a new input withautoFocus.onFocusof the search bar container gets triggered again.