-
Notifications
You must be signed in to change notification settings - Fork 1.3k
(RSP-1596 and RSP-1592) Update onFocus and onBlur behavior of FocusScope containment #281
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
Conversation
| restoreFocus?: boolean, | ||
| autoFocus?: boolean | ||
| autoFocus?: boolean, | ||
| setActiveScope?: boolean |
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.
API change?
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.
also, isActiveScope?
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.
replaceScope?
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.
suggesting other names because setActiveScope collides with hook usage in my head, sounds like setState conventions
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.
yep, added this api in since the issue is that the nested Dialog's FocusScope isn't set as the active scope on mount. Then when it attempts to focus the nested dialog, the first FocusScope detects that the element is not in its scope and resets focus back to the button that triggered the nested dialog.
I like isActiveScope, I'll go and use that
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.
I believe the reason autofocus made this work is because it updated the activeScope to be equal to the nested dialog's scopeRef so that when useDialog tries to focus the nested dialog on mount, the focus isn't perceived as a event outside of the active scope, thus it doesn't reset the focus to the previously focused element (i.e. the dialog trigger button)
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.
can we find out why the active scope isn't set properly? we do auto focus the dialog itself, which is inside the scope, so it should handle that.
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.
So the activeScope is only updated in two cases, the first being if autoFocus is set on one of the FocusScope (inapplicable in this case) and the second being from onFocus when a focused element is found in the current scope and that scope is not the active scope. The problematic flow is as follows:
- The first dialog is opened and focus is called on it via
useDialoguseEffect. - Its
FocusScope'sonFocusis called. Since the dialog is within scope and there isn't anactiveScopeset, the first dialog'sFocusScopeis set as theactiveScope. - User clicks on the action button to open the nested dialog.
onFocusfor the firstFocusScopeis called and it saves the action button as thefocusedNode.activeScopedoesn't change. - The nested dialog renders and focus is called on it via
useDialoguseEffect.onFocusfor the firstFocusScopetriggers. It determines that the focus target (the nested dialog) is not in it's scope and since it is the currentactiveScopeit resets focus back to the lastfocusedNodeaka the action button
If the onFocus for the nested Dialog's FocusScope triggered first then the activeScope would get updated appropriately to be the nested Dialog's FocusScope. Is there a way to make it trigger first? Dunno if that was possible hence why I opted for the current solution
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.
Ah I see. I think we need to make child focus scopes take precedence over parent focus scopes. If focus occurs in a child focus scope, that scope should always become the active scope rather than the parent taking back focus.
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.
Mmmm, ok. I'll see if I can check the focus context and set the focus scope based on whether or not there is anything returned (indicating if it is a child focus scope)
|
Build successful! 🎉 |
| function ChildComponent(props) { | ||
| return ReactDOM.createPortal(props.children, document.body); | ||
| } |
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.
Rendered the child FocusScope in a portal to test the case where the child is not contained within the parent FocusScope in the DOM but is still a child of the parent FocusScope (nested Dialog test case basically)
|
Build successful! 🎉 |
| }, [children]); | ||
|
|
||
| if (prevContext) { | ||
| activeScope = scopeRef; |
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.
Not sure this will work correctly in all cases. The FocusScope might be re-rendered while a child scope is active, and take focus away. I think this needs to be done in the focus handler. The active scope needs to not take focus back if the element being focused is in a different scope.
re your earlier comment about order of events, if we attached the focusin/focusout events at the scope level rather than the document level, we could rely on bubbling to handle the order. Inner scopes would receive events first and we could stopPropagation() to prevent them being handled in outer scopes. Also, if events occurred completely outside the scope (e.g. portals), non-active scopes wouldn't even receive events.
We may also need to track not only the active scope but a set of all currently mounted scopes. Then, in the blur event, we could determine if the target was within another scope and ignore it in that case. If the event occurred outside any scope at all (e.g. the body), only then would we move focus back to the active scope.
In this dialog scenario:
- The child dialog would mount and focus itself.
- The previous scope would handle the focusout event, and check if the related target is outside any other scope. In this case it's not, so it would do nothing and allow the focus to move to the child scope. Otherwise, it would restore focus to itself.
- The new focus scope would handle the focusin event on its node, make itself the active focus scope, and stop propagation to any parent scopes.
Let me know if this makes sense. We can chat about it over vc if needed.
|
Build successful! 🎉 |
|
Build successful! 🎉 |
| let focusedNode = useRef<HTMLElement>(); | ||
|
|
||
| useEffect(() => { | ||
| let scope = scopeRef.current; |
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.
Did this cuz lint warning regarding hooks
|
|
||
| let activeScope: RefObject<HTMLElement[]> = null; | ||
| let counter = 0; | ||
| let scopesMap: Map<number, RefObject<HTMLElement[]>> = new Map(); |
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.
You could use a Set for this instead of a Map since it doesn't look like you're using the keys anywhere. Then you could get rid of the counter too.
| } | ||
| if (!isInAnyScope) { | ||
| activeScope = scopeRef; | ||
| focusedNode.current = e.target; |
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.
Looks like you could get rid of focusedNode entirely. It's not used anywhere else.
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.
using it now for the document level focusin events, thanks for the catch
|
One more issue I noticed while testing: if you open a dialog, move focus to the browser location bar, and then tab back into the page (past all the chrome toolbar items), it will focus the input behind the dialog. It used to restore focus to the previously focused node in the active scope. We may still need a document level focusin handler to detect that. If a focus event occurs outside the active scope, then move it back into the active scope. |
In v2 this was one of the rare cases where the Dialog used |
|
Build successful! 🎉 |
| return () => { | ||
| document.removeEventListener('keydown', onKeyDown, false); | ||
| document.removeEventListener('focusin', onFocus, false); | ||
| scope.forEach(element => element.removeEventListener('focusin', onFocus, false)); |
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.
just to make sure, but the element's we are looping over, they can't change between effects right? I ask because scope is from a ref, which can change without causing useEffect
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.
A lint error previously mentioned this possibility when I had scopeRef.current here previously, and it suggested that I do let scope = scopeRef.current; on line 158 so I think we are good here
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.
o, yep, that'll do the trick.
hmm... though is there any chance that elements might be null now? so maybe we should element && element.remove...?
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.
Think we are ok, tested by adding a div and a button that removes said div inside the Dialog's FocusScope. Removing the div and then closing the dialog didn't trigger any console errors, scope preserved all 3 element (div, button, and Dialog section) while scopeRef.current updated correctly to show button and section only
|
Build successful! 🎉 |
Closes https://jira.corp.adobe.com/browse/RSP-1596 and https://jira.corp.adobe.com/browse/RSP-1592
✅ Pull Request Checklist:
📝 Test Instructions:
Go to DialogTrigger nested modal/dialog story and open the nested modal. Note that you can hit escape to close the top most modal/dialog
Go to the DialogTrigger modal story and try opening the modal, clicking the underlay, and hitting escape key. Note that the modal properly closes