Skip to content

Fix some cases where FocusScope focus restoration and tabbing misbehaves #2416

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

Closed
wants to merge 7 commits into from

Conversation

kherock
Copy link
Contributor

@kherock kherock commented Oct 2, 2021

Closes #2415

✅ Pull Request Checklist:

  • Included link to corresponding React Spectrum GitHub 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:

The unit tests added in this PR are hopefully self explanatory, let me know if I need to clarify anything else!

🧢 Your Project:

N/A

@kherock
Copy link
Contributor Author

kherock commented Oct 4, 2021

There's actually one more issue I'm facing with FocusScope, but it actually seems to be by design?

it('should move focus to the previous element after the previously focused node on Shift+Tab', function () {
function Test({show}) {
return (
<div>
<input data-testid="before" />
<button data-testid="trigger" />
<input data-testid="after" />
{show &&
<FocusScope restoreFocus autoFocus>
<input data-testid="input1" />
<input data-testid="input2" />
<input data-testid="input3" />
</FocusScope>
}
</div>
);
}

Here, after seems like the logical target for moving focus backwards since that's the native behavior. FocusScope's implementation says that Shift+Tabing out of the scope should focus the element before the trigger, which in general feels incorrect. There's an annoying bug that happens in this case:

          <div>
            <button data-testid="trigger" />
            {show &&
              <FocusScope restoreFocus autoFocus>
                <input data-testid="input1" />
                <input data-testid="input2" />
                <input data-testid="input3" />
              </FocusScope>
            }
            <input data-testid="after" />
          </div>

The current implementation says that Shift+Tabing out of the scope should focus the element before the trigger. No element is defined, so the FocusScope overrides the native behavior of focusing the trigger and blurs the active element.

@kherock
Copy link
Contributor Author

kherock commented Oct 4, 2021

Could I suggest refactoring the current code dealing with focus restoration to only run when some element anchoring it to the tab order is provided? e.g.

          <div>
            <input data-testid="before" />
            <button data-testid="trigger" />
            <input data-testid="after" ref={focusDomRef} /> 
            {show &&
              <OverlayContainer>
                <FocusScope domRef={focusDomRef} restoreFocus autoFocus>
                  <input data-testid="input1" />
                  <input data-testid="input2" />
                  <input data-testid="input3" />
                </FocusScope>
              </OverlayContainer>
            }
          </div>

Here, domRef would be documented along the lines of

The position in the DOM where this component's descendants should be inserted into the tab order. This has no effect when focus is contained.

Its default value would be the node to restore if restoreFocus is enabled.

This way, when the domRef prop is omitted, developers can use the intuitive (in most cases) behavior of native tab ordering. Then in more complicated cases such as the one above, they can provide a ref to the position where they'd like focus to be restored when focus leaves the scope.

edit: a prototype of this is implemented in #2459

@kherock
Copy link
Contributor Author

kherock commented Oct 23, 2021

@LFDanLu any feedback on these changes in particular?

Copy link
Member

@LFDanLu LFDanLu left a comment

Choose a reason for hiding this comment

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

Apologies for the late review, here are a couple things I noticed with the behavior if contain is removed. Behavior with contain seems pretty good though

Comment on lines 261 to 267
} else if (domRef?.current) {
walker.currentNode = domRef.current;

// Skip over elements within the scope, in case the scope immediately follows the domRef.
do {
nextElement = (e.shiftKey ? walker.previousNode() : walker.nextNode()) as HTMLElement;
} while (isElementInScope(nextElement, scope));
Copy link
Member

Choose a reason for hiding this comment

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

Was testing this locally and noticed that for FocusScope w/ contain=false and restoreFocus, shift tabbing in a FocusScope would always cause the focus to leave the FocusScope, even if there are preceeding elements to focus in the FocusScope. You can reproduce this via removing the contain here and testing the FocusScope stories in the storybook.

Should this conditional for this block check if nextElement is an element in scope and if so proceed to line 227 instead?

Copy link
Contributor Author

@kherock kherock Oct 26, 2021

Choose a reason for hiding this comment

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

Yep, good catch. Things behave nearly as expected for nested scopes now. However, there's a quirk with the focus scope stories where getScopeRoot resolves to the same element for all nested scopes due to them rendering as siblings:

image

The causes tabbing between children of nested scopes to behave as if they were all part of the same scope. Should I create a new variant of the story for testing when the focus scopes nest within the DOM?

Copy link
Member

Choose a reason for hiding this comment

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

Ah yes, I see what you are talking about, makes those stories pretty confusing since I was expecting tabbing out of the child dialog to move focus to the input after the trigger button in the parent dialog. Yeah I think it would be a good idea to create a separate set of stories that nests the "dialogs" in a parent/child fashion rather than siblings. Mind creating adding a toggle in those stories/creating another set of stories that removes the contain from the FocusScope as well so it is easier to compare and contrast the behavior?

Copy link
Member

Choose a reason for hiding this comment

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

Actually the nested scopes only render as siblings for the "Keyboard Navigation Inside Portal" right? The "Keyboard Navigation" story renders the nested scopes in a nested fashion within the DOM so why is scope in useTabOrderSplice returning the entire div tree (all FocusScopes) and not just the child dialog FocusScope?

Copy link
Contributor Author

Choose a reason for hiding this comment

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

I think some heavier refactoring is needed here. I added a short-circuit when e.defaultPrevented is true, but this is causing only the parent focus scope to run their handler. The reason why scope contains the entire div is because the parent handler runs first and it calls e.preventDefault() before it exits.

Contained scopes behave fine because the parent short-circuits when contain && scope !== containedScope fails.

Without the e.defaultPrevented check for uncontained scopes, the keydown handler runs once for every level of nesting. You can test this by removing the check and creating a dialog with two levels of nesting. A dialog nested 2 levels deep will skip 1 element for each tab press. We need a way to ensure that the parent scope handlers don't run if a child scope can handle the event.

useAutoFocus(scopeRef, autoFocus);
useAutoFocus(scopeRef.current, autoFocus);
let nodeToRestoreRef = useRestoreFocus(scopeRef.current, restoreFocus);
useTabOrderSplice(scopeRef.current, contain, nodeToRestoreRef);
Copy link
Member

Choose a reason for hiding this comment

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

nitpick:
Can we keep the useFocusContainment name here?

Copy link
Contributor Author

Choose a reason for hiding this comment

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

I can revert, but I do think there's a better name out there for it since the hook now handles some of the tab ordering stuff that useRestoreFocus was doing before.

Copy link
Member

Choose a reason for hiding this comment

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

That's true, I personally still lean towards useFocusContainment. Names are hard though, so I'm fine waiting for a third opinion.

Comment on lines +318 to +325
if (contain) {
document.addEventListener('focusin', onFocus, false);
document.addEventListener('focusout', onBlur, false);
}
Copy link
Member

Choose a reason for hiding this comment

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

Do we really only want to add these listeners if contain is true? Won't this mean we don't properly update the containedScope if focus moves into a child FocusScope?

I'm seeing an issue locally where you can't tab forward through the child dialog's element in the FocusScope storybook "Keyboard Navigation Inside Portal" story if you remove contain from the FocusScope.
Repro steps:

  1. Remove contain from https://github.com/adobe/react-spectrum/blob/main/packages/@react-aria/focus/stories/FocusScope.stories.tsx#L49
  2. Go to the "Keyboard Navigation Inside Portal" story. Open the first dialog via Enter on "open dialog"
  3. Tab to the next "open dialog" button and open the child dialog.
  4. Attempt to tab through the child dialog element. Note that it moves focus to the close button of the first dialog

Copy link
Contributor Author

Choose a reason for hiding this comment

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

I'm confident that those listeners should only be added when contain is true as the previous version of uncontained code only relied on keyboard events:

if (!contain) {
document.addEventListener('keydown', onKeyDown, true);
}
return () => {
if (!contain) {
document.removeEventListener('keydown', onKeyDown, true);
}

Additionally, the implementations of onFocus and onBlur don't have additional responsibility outside of containing focus.

Copy link
Contributor Author

@kherock kherock Oct 26, 2021

Choose a reason for hiding this comment

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

The issue you found above is a tough case. However, it's technically behaving as intended, and I think altering this behavior in any way will break unit tests. Though I was missing an isElementInScope check which was causing asymmetric behavior with shift+tab. I'll try to illustrate:

<x> = nodeToRestore
>x< = focusedElement

/* case: one dialog */
1 <2>
3  4  >5<

/*
 * Pressing tab will exit focus from the scope and past the end of the document. We intercept
 * this and move focus to the element immediately following <2>, which is 3.  We have a loop
 * preventing focus from re-entering in this manner, and it will continue iterating until we
 * focus the element following >5<. This is the end of the document body, so it ultimately wraps
 * to focus 1.
 * 
 * Shift+tabbing from >3< moves focus to the element preceding <2>, so focus also moves to 1 as expected
 * (this was broken during the initial review).
 */


/* case: two dialogs, nested */
1 <2>
3 <4>  5
6  7  >8<

/*
 * Pressing tab now will again attempt to move focus past the end of the document. We
 * focus the element immediately following <4>. So 5 receives focus, and this matches the
 * behavior seen where the close button is focused after tabbing out of the innermost dialog.
 */

Copy link
Member

@LFDanLu LFDanLu Oct 26, 2021

Choose a reason for hiding this comment

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

I'm confident that those listeners should only be added when contain is true

Yep, you're right, I overlooked that old useFocusContainment early returns when contain was false and thus never adds focusin/focusout for the elements in scope.

Pressing tab will exit focus from the scope and past the end of the document. We intercept
this and move focus to the element immediately following [2], which is 3. We have a loop
preventing focus from re-entering in this manner, and it will continue iterating until we
focus the element following {5}. This is the end of the document body, so it ultimately wraps
to focus 1.

Yeah this is a strange case for sure, will need to discuss with the team on what the correct behavior here should be (We definitely wouldn't want to focus element 3 but it IS technically the element that follows the nodeToRestore...)

@kherock kherock force-pushed the fix-focus-restore branch 2 times, most recently from 44cebf1 to a8c0dcc Compare October 26, 2021 04:55
Copy link
Contributor Author

@kherock kherock left a comment

Choose a reason for hiding this comment

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

Appreciate the feedback, thank you. I rebased and and fixed the shift+tab behavior.

I compared behavior of the FocusScope stories to main and there's some slightly different behavior where tabbing past the end of the innermost dialog blurs and focuses the body. My branch focuses the element following the trigger of the parent FocusScope. This feels more correct, but let me know if the old behavior is intended.

Comment on lines 261 to 267
} else if (domRef?.current) {
walker.currentNode = domRef.current;

// Skip over elements within the scope, in case the scope immediately follows the domRef.
do {
nextElement = (e.shiftKey ? walker.previousNode() : walker.nextNode()) as HTMLElement;
} while (isElementInScope(nextElement, scope));
Copy link
Contributor Author

@kherock kherock Oct 26, 2021

Choose a reason for hiding this comment

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

Yep, good catch. Things behave nearly as expected for nested scopes now. However, there's a quirk with the focus scope stories where getScopeRoot resolves to the same element for all nested scopes due to them rendering as siblings:

image

The causes tabbing between children of nested scopes to behave as if they were all part of the same scope. Should I create a new variant of the story for testing when the focus scopes nest within the DOM?

Comment on lines +318 to +325
if (contain) {
document.addEventListener('focusin', onFocus, false);
document.addEventListener('focusout', onBlur, false);
}
Copy link
Contributor Author

Choose a reason for hiding this comment

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

I'm confident that those listeners should only be added when contain is true as the previous version of uncontained code only relied on keyboard events:

if (!contain) {
document.addEventListener('keydown', onKeyDown, true);
}
return () => {
if (!contain) {
document.removeEventListener('keydown', onKeyDown, true);
}

Additionally, the implementations of onFocus and onBlur don't have additional responsibility outside of containing focus.

Comment on lines +318 to +325
if (contain) {
document.addEventListener('focusin', onFocus, false);
document.addEventListener('focusout', onBlur, false);
}
Copy link
Contributor Author

@kherock kherock Oct 26, 2021

Choose a reason for hiding this comment

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

The issue you found above is a tough case. However, it's technically behaving as intended, and I think altering this behavior in any way will break unit tests. Though I was missing an isElementInScope check which was causing asymmetric behavior with shift+tab. I'll try to illustrate:

<x> = nodeToRestore
>x< = focusedElement

/* case: one dialog */
1 <2>
3  4  >5<

/*
 * Pressing tab will exit focus from the scope and past the end of the document. We intercept
 * this and move focus to the element immediately following <2>, which is 3.  We have a loop
 * preventing focus from re-entering in this manner, and it will continue iterating until we
 * focus the element following >5<. This is the end of the document body, so it ultimately wraps
 * to focus 1.
 * 
 * Shift+tabbing from >3< moves focus to the element preceding <2>, so focus also moves to 1 as expected
 * (this was broken during the initial review).
 */


/* case: two dialogs, nested */
1 <2>
3 <4>  5
6  7  >8<

/*
 * Pressing tab now will again attempt to move focus past the end of the document. We
 * focus the element immediately following <4>. So 5 receives focus, and this matches the
 * behavior seen where the close button is focused after tabbing out of the innermost dialog.
 */

useAutoFocus(scopeRef, autoFocus);
useAutoFocus(scopeRef.current, autoFocus);
let nodeToRestoreRef = useRestoreFocus(scopeRef.current, restoreFocus);
useTabOrderSplice(scopeRef.current, contain, nodeToRestoreRef);
Copy link
Contributor Author

Choose a reason for hiding this comment

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

I can revert, but I do think there's a better name out there for it since the hook now handles some of the tab ordering stuff that useRestoreFocus was doing before.

@LFDanLu
Copy link
Member

LFDanLu commented Oct 26, 2021

@kherock

I compared behavior of the FocusScope stories to main and there's some slightly different behavior where tabbing past the end of the innermost dialog blurs and focuses the body. My branch focuses the element following the trigger of the parent FocusScope. This feels more correct, but let me know if the old behavior is intended.

From my understanding of how the FocusScopes should work, I believe it should focus the element following the trigger of the parent FocusScope. The behavior of the first FocusScope story on main w/ contain removed from the FocusScope seems wrong to me, lemme bring it up with the team

EDIT: To be more specific, the behavior on main is focusing the body because there isn't an element following the first trigger button that isn't an element within the FocusScope. Adding an input element after the first trigger button rectifies this behavior. Perhaps focusing the body in that specific case (contain=false and no following element after the trigger) is correct since that would allow the user to tab out of the window and into the browser tabs hmm...

@kherock
Copy link
Contributor Author

kherock commented Oct 27, 2021

It didn't occur to me that preventing focus from leaving the window into the browser's chrome is potentially inaccessible. I feel like there should be some special handling for how tabbing past the ends of the a focus scope should work:

  1. when focus is contained, should tabbing past the last element allow focus to move outside of the window?
    • I think "no" is an acceptable answer provided that containment is released once Esc is pressed
  2. when focus isn't contained, how do we ensure focus properly leaves the window if the the scope's trigger is the first/final element on the page?

@kherock kherock changed the title Fix focus restore Fix some cases where FocusScope focus restoration and tabbing misbehaves Oct 29, 2021
Copy link
Member

@LFDanLu LFDanLu left a comment

Choose a reason for hiding this comment

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

Thanks for the updates, behavior seems to be correct to me, definitely much more consistent. I've gone ahead and added some stories for testing the original restoreFocus issue as well as the overall FocusScope refactor, hope you don't mind.

To be quite frank I am a bit concerned about the amount of changes happening here but things seem to work from my testing and the logic seems fine. Will see if we can get a second pair of eyes on this

@kherock
Copy link
Contributor Author

kherock commented Nov 20, 2021

Anything further I can do with this PR? I've been especially in need of the autoFocus fix in these changes.

@LFDanLu
Copy link
Member

LFDanLu commented Nov 22, 2021

I'll bump the team again, may take some time since we are prepping a release + holiday season.

Copy link
Member

@ktabors ktabors left a comment

Choose a reason for hiding this comment

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

the focus scope in /focusscope--keyboard-navigation-no-contain seem wonky. If I tab I stay (cycle) in the most recent dialog and the previous close button. If I "shift+tab" I go back through the previous "dialogs", starting with the "replace focusscope children", but I can tab forward with that again, until I get to the last dialog and then it cycles on that.

Still not a fan of the contain stories and being able to click on a previous dialog's buttons when the keyboard navigation is contained away from them.

expect(document.activeElement).toBe(outside);
});

it('should restore focus to the previously focused node when tabbing away from a child with autoFocus', function () {
Copy link
Member

Choose a reason for hiding this comment

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

based on this description I'd expect the final check on active element to be "outside" not "after".

I feel like all three of these tests should check the activeElement before adding the and checking that the focus is within it since they all say "restore focus".

Copy link
Contributor Author

Choose a reason for hiding this comment

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

Yes the phrasing here is off, I'll fix the description. In this case focus is "moving after" rather than "restoring".

And just so I understand you (I think you're missing a word), in this case are you suggesting to check if activeElement is outside before rerender(<Test show />)?

let input3 = getByTestId('input3');
expect(document.activeElement).toBe(input3);

userEvent.tab();
Copy link
Member

Choose a reason for hiding this comment

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

Do we need a test that looks at shift+tabbing? I ask because there was a lot of discussion about that case.

Copy link
Contributor Author

Choose a reason for hiding this comment

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

Yeah there were a couple times we found regressions that weren't caught by tests, I can add some scenarios for those.

@beamery-tomht
Copy link
Contributor

Waiting eagerly for this fix! 🥺

@njsfield
Copy link

We're blocked by this too, keen to see it get released 🙏

@LFDanLu
Copy link
Member

LFDanLu commented Jan 29, 2022

@kherock Apologies for the long delay, @snowystinger and I have spent some time looking at this today and will be discussing the behavior with another team member early next week. Depending on the outcome of that discussion, we may be looking into splitting this PR so the tab order fixes are separated from the fix for the original linked issue. I know we've been quite tardy in getting to this PR so we'd be happy to perform the code splitting if you'd like.

@snowystinger
Copy link
Member

Thanks @kherock, we've distilled your changes into this PR #2791
We can discuss the oddities of tabbing in/out of un-contained focus scopes elsewhere if it is causing a problem
We can also discuss the refactor now that we won't be changing behavior
Again, thanks for all your hard work on this!

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.

FocusScope restoreFocus doesn't work when its immediate children change
6 participants