Skip to content
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

Keyboard navigation in mobile web (Issue #2159) #2166

Conversation

LMulvey
Copy link
Contributor

@LMulvey LMulvey commented Oct 11, 2022

Description

Few a11y fixes related to the Header component

  • Fixed bug where toggle was being called every time onKeyDown was triggered on the hamburger button

navigation-bug-fixed

  • Introduced focus trapping for the mobile navigation bar while open - you can no longer focus out of the nav via the keyboard without closing it

trap-focus

  • Upgraded @testing-library/jest-dom, @testing-library/react and @testing-library/user-event - the dependency update was largely to use the updated userEvent.keyboard event support from @testing-library/user-event but I wanted to ensure the dependencies were in sync.

Corresponding Issue

#2159


Reviewing this pull request? Check out our Code Review Practices guide if you haven't already!

@LMulvey
Copy link
Contributor Author

LMulvey commented Oct 11, 2022

Obviously broke some things with the test upgrades. I'm working through the changes - there were a few API updates with @testing-library over some versions.

@julianguyen - happy to update all existing tests to still function. The benefit here is that we get a more realistic testing environment from user-event (See: Differences from fireEvent) but I definitely don't want to disrupt things too much so let me know if you'd like me to step back here and I can update the Header.spec tests I've added to try and get them to work with the older APIs.

@julianguyen
Copy link
Member

@LMulvey Thanks for the summary and for taking this on 🎉 ! Yeah, let's update the existing tests like you said.

@LMulvey
Copy link
Contributor Author

LMulvey commented Oct 11, 2022

Okay! Tests are updated but there are a few caveats.

I've had to skip three tests for now - I need to think about an approach a bit. I left a comment on all three tests describing why they were skipped. Still, in summary, @testing-library/user-event v14 dropped support for event.keyCode (See: testing-library/user-event#842).

react-autosuggest is pretty outdated and still exclusively looks for event.keyCode when detecting whether or not a user hit the Enter key. Because of this, our Enter inputs from tests are not working with any components that use react-autosuggest.

I've tried manually dispatching an event with JSDom (you can see my scraps in the InputTag.spec tests) but it wasn't working. I can try to submit a PR to react-autosuggest but it seems to be unmaintained at this point.

Does anyone have any ideas? 🤔

cc: @julianguyen

Copy link
Member

@julianguyen julianguyen left a comment

Choose a reason for hiding this comment

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

Thanks so much for taking this on! Thanks for also clarifying your approach and debugging! 🎉 Hope you're well :)

await user.keyboard('[Space]');
expect(container.querySelector('#headerMobile')).toBeInTheDocument();
await user.keyboard('[Space]');
expect(container.querySelector('#headerMobile')).not.toBeInTheDocument();
Copy link
Member

Choose a reason for hiding this comment

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

Could you explain what exactly we're testing when we hit the Space key twice?

Copy link
Contributor Author

Choose a reason for hiding this comment

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

Ensuring that it opens AND closes the navbar with the space key!

await user.keyboard('{Tab}');
expect(screen.getByRole('link', { name: /link 1/i })).toHaveFocus();

// Shift-tab back to the hamburger and close the mobile menu
Copy link
Member

Choose a reason for hiding this comment

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

Nice!


if (event.key === 'Tab') {
if (event.shiftKey) {
if (document.activeElement === firstFocusableElement) {
Copy link
Member

Choose a reason for hiding this comment

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

I think the if-locks on lines 42 and 43 can be combined with an && to reduce nesting.

client/app/components/Header/index.jsx Outdated Show resolved Hide resolved
@@ -9,6 +9,13 @@ const getComponent = (extraProps = {}) => createInput(inputTagProps, extraProps)
const component = getComponent();
const { checkboxes } = inputTagProps;

function setup(jsx) {
Copy link
Member

Choose a reason for hiding this comment

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

Would it be useful to create some kind of utility function so that it doesn't have to be re-created in multiple tests files?

userEvent.click(button);
expect(pell.exec).toHaveBeenCalledWith(...expectedArgs);
});
await Promise.all(
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 use waitFor instead?

Copy link
Contributor Author

Choose a reason for hiding this comment

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

What's the rationale for waitFor vs Promise.all? I'm not sure I follow! My larger gut feeling is to break out of doing loops here entirely and write out all the Button conditions explicitly – just to avoid any hack-y-ness 🤔

Copy link
Member

Choose a reason for hiding this comment

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

Could you do something like this instead with waitFor?

buttons.forEach(({ title, expectedArgs }) => {
  const button = screen.getByTitle(title);
  userEvent.click(button);
  
  await waitFor(() => expect(pell.exec).toHaveBeenCalledWith(...expectedArgs));
});

Copy link
Contributor Author

Choose a reason for hiding this comment

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

Wahoo! Worked like a charm 🎉

userEvent.click(button);
expect(pell.exec).toHaveBeenCalledWith(...expectedArgs);
});
await Promise.all(
Copy link
Member

Choose a reason for hiding this comment

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

Similar question as above.

@@ -144,5 +144,6 @@
},
"browserslist": [
"defaults"
]
],
"packageManager": "[email protected]"
Copy link
Member

Choose a reason for hiding this comment

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

What's the rationale for this? Is it necessary?

Copy link
Contributor Author

Choose a reason for hiding this comment

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

🤦🏻 Not at all. My setup uses Yarn v3 and to avoid changing the lockfile formatting, I ran yarn set-version classic - which introduced a bunch of random changes throughout the repo - I thought I had caught them all. Turns out it's not super intuitive to run a separate version of Yarn in a specific workspace. I'll uncommit this and watch for it again!

const firstFocusableElement = focusableElements[0];
const lastFocusableElement = focusableElements[focusableElements.length - 1];

if (event.key === 'Tab') {
Copy link
Member

Choose a reason for hiding this comment

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

When I do the following, the focus trap does not work:

  1. Tab into the hamburger icon
  2. Hit Enter to open the mobile menu
  3. Tab through the menu items
  4. When the last item of the menu is reached, and I hit Tab again, the focus trap does not work and the contents behind the menu are hit.

The expected behaviour is for the focus to go back to logo at the top of the menu.

Oct-11-2022 21-21-15

Copy link
Member

@julianguyen julianguyen Oct 12, 2022

Choose a reason for hiding this comment

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

I think the problem is that document.activeElement is not giving you the active element that is being focused on. It's giving the element before it in the DOM. I think this is happening because it needs to be in a useEffect().

I think we want to make this focus trap code we're using in the Modal component a custom hook that we can reuse here.

Copy link
Member

Choose a reason for hiding this comment

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

Another issue I see is that lastFocusableElement is the "Sign out" button when it should be the "Resources" link.

Copy link
Contributor Author

Choose a reason for hiding this comment

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

@julianguyen Very interesting! I was testing it in an unauthenticated state on the homepage–I feel like this is a separate state. I'll take a look (and look at lifting the Modal component useEffect into a reusable hook! 💪🏻 )

Copy link
Contributor Author

Choose a reason for hiding this comment

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

This is fixed now! It was related to a display: none element in the Links - it isn't technically focusable by the browser but still appeared in our focusableElements array. I've added an extra step to filter out non-visible elements here – the Focus Trap now works as expected while authenticated or unauthenticated.


checkbox = screen.getByRole('checkbox', queryOptions);
expect(checkbox).toBeInTheDocument();
});

it('selects a value with keydown without specifying text', () => {
/**
* Skipping this one for now. react-autosuggest still checks for event.keyCode, which
Copy link
Member

Choose a reason for hiding this comment

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

Have you tried using fireEvent instead in these skipped tests? I know it's not ideal since we want to simulate a complete interaction via userEvent.

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 did try that - turns out it also writes the keyCode is 0 so we end up with the same issue 😭

Copy link
Member

Choose a reason for hiding this comment

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

Thanks for trying all this! Hmm, I'm okay with skipping this test for now, but we should have a fast follow to fix this. Since react-autosuggest is deprecated, I think we'll need to migrate to another library. Could you create an issue for this? Are you able to take this on?

I think we can just keep the original contents of this test since it's being skipped anyways.

Copy link
Member

@julianguyen julianguyen left a comment

Choose a reason for hiding this comment

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

Awesome work! 🎉 Thanks for the updates. Tested things locally, and the keyboard navigation works as expected.

Shared some feedback around moving forward with react-autosuggest and also the tests


checkbox = screen.getByRole('checkbox', queryOptions);
expect(checkbox).toBeInTheDocument();
});

it('selects a value with keydown without specifying text', () => {
/**
* Skipping this one for now. react-autosuggest still checks for event.keyCode, which
Copy link
Member

Choose a reason for hiding this comment

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

Thanks for trying all this! Hmm, I'm okay with skipping this test for now, but we should have a fast follow to fix this. Since react-autosuggest is deprecated, I think we'll need to migrate to another library. Could you create an issue for this? Are you able to take this on?

I think we can just keep the original contents of this test since it's being skipped anyways.

@@ -35,7 +35,7 @@ describe('InputSwitch', () => {
expect(screen.getByRole('checkbox')).not.toBeChecked();

/**
* TODO: Follow up on `userEvent.type(inputSwitch, '{enter}')` in v12.1.7.
* TODO: Follow up on `await userEvent.type(inputSwitch, '{enter}')` in v12.1.7.
Copy link
Member

Choose a reason for hiding this comment

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

Let's also make a GitHub issue for this, so it doesn't get lost in comments.

Copy link
Contributor Author

Choose a reason for hiding this comment

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

This is an old comment that I was able to just resolve & remove as part of this upgrade 🎉 !

userEvent.click(button);
expect(pell.exec).toHaveBeenCalledWith(...expectedArgs);
});
await Promise.all(
Copy link
Member

Choose a reason for hiding this comment

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

Could you do something like this instead with waitFor?

buttons.forEach(({ title, expectedArgs }) => {
  const button = screen.getByTitle(title);
  userEvent.click(button);
  
  await waitFor(() => expect(pell.exec).toHaveBeenCalledWith(...expectedArgs));
});

useEffect(() => {
const focusableElements = 'button, [href], input, select, textarea, [tabindex]:not([tabindex="-1"])';
const modal = modalEl.current;
useFocusTrap(modalEl, open);
Copy link
Member

Choose a reason for hiding this comment

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

Woot!

@@ -135,7 +147,19 @@ describe('QuickCreate', () => {
});

describe('when the form', () => {
it('is submitted it adds a new checkbox from data', async () => {
/**
Copy link
Member

Choose a reason for hiding this comment

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

Similar comment around keeping original test like stated above!

@LMulvey
Copy link
Contributor Author

LMulvey commented Oct 13, 2022

@julianguyen Thank you for all the feedback! I've pushed up another round of fixes and created #2170 to cover off the react-autosuggest changeover task. I should be able to take that on next week once this PR is wrapped up 🤝

Copy link
Member

@julianguyen julianguyen left a comment

Choose a reason for hiding this comment

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

Awesome work! 🎉 Thanks so much for taking this on and navigating all the gotchas in a good way.

@julianguyen julianguyen merged commit 72f396e into ifmeorg:main Oct 13, 2022
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Projects
None yet
Development

Successfully merging this pull request may close these issues.

2 participants