Skip to content

Conversation

@LFDanLu
Copy link
Member

@LFDanLu LFDanLu commented Mar 13, 2020

Closes https://jira.corp.adobe.com/browse/RSP-1596 and https://jira.corp.adobe.com/browse/RSP-1592

✅ Pull Request Checklist:

  • Included link to corresponding Issue.
  • Added/updated unit tests and storybook for this change (for new code or code which already has tests).
  • Filled out test instructions.
  • Updated documentation (if it already exists for this component).
  • Looked at the Accessibility Practices for this feature - Aria Practices

📝 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

restoreFocus?: boolean,
autoFocus?: boolean
autoFocus?: boolean,
setActiveScope?: boolean
Copy link
Member

Choose a reason for hiding this comment

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

API change?

Copy link
Member

Choose a reason for hiding this comment

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

also, isActiveScope?

Copy link
Member

Choose a reason for hiding this comment

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

replaceScope?

Copy link
Member

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

Copy link
Member Author

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

Copy link
Member Author

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)

Copy link
Member

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.

Copy link
Member Author

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:

  1. The first dialog is opened and focus is called on it via useDialog useEffect.
  2. Its FocusScope's onFocus is called. Since the dialog is within scope and there isn't an activeScope set, the first dialog's FocusScope is set as the activeScope.
  3. User clicks on the action button to open the nested dialog. onFocus for the first FocusScope is called and it saves the action button as the focusedNode. activeScope doesn't change.
  4. The nested dialog renders and focus is called on it via useDialog useEffect. onFocus for the first FocusScope triggers. It determines that the focus target (the nested dialog) is not in it's scope and since it is the current activeScope it resets focus back to the last focusedNode aka 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

Copy link
Member

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.

Copy link
Member Author

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)

@adobe-bot
Copy link

Build successful! 🎉

@LFDanLu LFDanLu changed the title (WIP) (RSP-1596) Preserve focus on nested dialog on mount (RSP-1596) Preserve focus on nested dialog on mount Mar 16, 2020
Comment on lines +532 to +534
function ChildComponent(props) {
return ReactDOM.createPortal(props.children, document.body);
}
Copy link
Member Author

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)

@adobe-bot
Copy link

Build successful! 🎉

@LFDanLu LFDanLu changed the title (RSP-1596) Preserve focus on nested dialog on mount (RSP-1596) Make child focus scopes take "activeScope" precedent over parent focus scopes Mar 16, 2020
}, [children]);

if (prevContext) {
activeScope = scopeRef;
Copy link
Member

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:

  1. The child dialog would mount and focus itself.
  2. 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.
  3. 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.

@adobe-bot
Copy link

Build successful! 🎉

@LFDanLu LFDanLu changed the title (RSP-1596) Make child focus scopes take "activeScope" precedent over parent focus scopes (RSP-1596 and RSP-1592) Update onFocus and onBlur behavior of FocusScope containment Mar 18, 2020
@adobe-bot
Copy link

Build successful! 🎉

let focusedNode = useRef<HTMLElement>();

useEffect(() => {
let scope = scopeRef.current;
Copy link
Member Author

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();
Copy link
Member

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;
Copy link
Member

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.

Copy link
Member Author

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

@devongovett
Copy link
Member

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.

@majornista
Copy link
Collaborator

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 tabindex={1} so that it would receive focus first when tabbing into the document from the browser chrome. But I agree, a more robust handling of document level focusin to marshall focus to the dialog may be better.

@adobe-bot
Copy link

Build successful! 🎉

return () => {
document.removeEventListener('keydown', onKeyDown, false);
document.removeEventListener('focusin', onFocus, false);
scope.forEach(element => element.removeEventListener('focusin', onFocus, false));
Copy link
Member

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

Copy link
Member Author

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

Copy link
Member

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...?

Copy link
Member Author

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

snowystinger
snowystinger previously approved these changes Mar 19, 2020
devongovett
devongovett previously approved these changes Mar 24, 2020
@devongovett devongovett dismissed stale reviews from snowystinger and themself via 28c7c6a March 24, 2020 01:35
@adobe-bot
Copy link

Build successful! 🎉

@devongovett devongovett merged commit 4091406 into master Mar 24, 2020
@devongovett devongovett deleted the RSP-1596 branch March 24, 2020 01:46
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

6 participants