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

feat(web, a11y): focus management for modals and popups #8298

Merged
merged 3 commits into from
Mar 27, 2024

Conversation

ben-basten
Copy link
Member

@ben-basten ben-basten commented Mar 27, 2024

Description

Creating a new FocusTrap component, that helps maintain accessible focus in modals and popup menus. See the "Keyboard Interaction" section of the WCAG spec for more information about the recommended focus management:

https://www.w3.org/WAI/ARIA/apg/patterns/dialog-modal/#keyboardinteraction

New functionality in the focus trap:

  1. Automatically focus the first focusable element when a modal is opened.
  2. Traps focus within the modal, to prevent interacting with elements that are in the background.
  3. Focus loops if it reaches the beginning or the end of the dialog.
  4. When the dialog is closed, return focus to the triggering element if it still exists.

Also in this PR:

  • Trap focus in the asset viewer, to prevent interacting with elements under the full screen overlay
  • When multiple modals are opened, prevent the escape key closing both
    • Example 1: "SELECT AVATAR COLOR" in the account info panel
    • Example 2: Choosing "Reset password" when editing a user in the Administration settings
    • Example 3: Using escape to close any open modal (Change Location, Create link to share, Add to album, etc.) while the asset viewer overlay is open
  • Handling escape keypress in more modals within the asset viewer

Caveats:

  • This doesn't cover the scenario where a modal is launched, but the triggering element is gone when the modal gets closed. This scenario works the same as prior to this change.
    • Example: "Set as profile picture" in the asset viewer dropdown menu
  • Input elements with the autofocus attribute break the ability of the focus trap to focus the first element, and return focus back to the triggering element.
    • Example: "Add to shared album" in the asset viewer dropdown menu

Screenshots

Not applicable - everything visually looks the same.

How Has This Been Tested?

Navigate the UI entirely by keyboard. Any time a modal is opened, focus should automatically be moved to the modal without need for a click, and return back to where it was when closed.

Focus should never go underneath the asset viewer if a photo is clicked.

Checklist:

  • npm run lint (linting via ESLint)
  • npm run format (formatting via Prettier)
  • npm run check:svelte (Type checking via SvelteKit)
  • npm test (unit tests)

@@ -43,12 +43,12 @@ export function clickOutside(node: HTMLElement, options: Options = {}): ActionRe
};

document.addEventListener('click', handleClick, true);
document.addEventListener('keydown', handleKey, true);
node.addEventListener('keydown', handleKey, false);
Copy link
Member Author

Choose a reason for hiding this comment

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

Setting the listener on the document was causing both modals to close at once in scenarios where 2 overlapping modals are open at once. Setting the listener on the element itself and letting the event bubble up in the DOM solves this problem.

This does mean that modals now need tabindex="-1" on the body - if a mouse user is navigating the modal and presses escape without a form element focus, this guarantees that the modal will still close.

});

const getFocusableElements = () => {
return Array.from(
Copy link
Member Author

Choose a reason for hiding this comment

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

For reference, another example implementation for finding focusable elements is the tabbable package.

https://github.com/focus-trap/tabbable

@@ -182,7 +182,12 @@
{#if !asset.isReadOnly || !asset.isExternal}
<CircleIconButton isOpacity={true} icon={mdiDeleteOutline} on:click={() => dispatch('delete')} title="Delete" />
{/if}
<div use:clickOutside on:outclick={() => (isShowAssetOptions = false)}>
<div
use:clickOutside={{
Copy link
Contributor

Choose a reason for hiding this comment

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

I didn't know you could do this! Amazing!

Copy link
Member Author

Choose a reason for hiding this comment

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

It's from the cool work done in #7677!

Copy link
Contributor

@alextran1502 alextran1502 left a comment

Choose a reason for hiding this comment

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

Thank you for the PR!

@@ -44,7 +44,7 @@
};
</script>

<BaseModal on:close={() => dispatch('close')}>
<BaseModal on:close on:escape>
Copy link
Contributor

Choose a reason for hiding this comment

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

I don't think we use on:escape here, correct? Which event's reaction does it do?

Copy link
Member Author

Choose a reason for hiding this comment

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

This is needed now, since I added escape key handling for a couple of the modals inside of the asset viewer.

on:escape={() => (isShowAlbumPicker = false)}

Copy link
Contributor

Choose a reason for hiding this comment

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

Ah, so this propagates the event out for this component?

Copy link
Member Author

Choose a reason for hiding this comment

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

Yes exactly!

@@ -0,0 +1,62 @@
<script lang="ts">
Copy link
Contributor

Choose a reason for hiding this comment

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

What is the rule of thumb to wrap other component with FocusTrap?

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 don't have a hard and fast rule, but it's something to consider any time you're making an element appear over the screen and have a use:clickOutside to dismiss it. The idea is that if there's an element on the screen that's preventing mouse users from interacting with elements behind it, keyboard users should also have the same experience.

Copy link
Contributor

Choose a reason for hiding this comment

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

Thank you!

@alextran1502 alextran1502 merged commit e1c2135 into immich-app:main Mar 27, 2024
24 of 27 checks passed
@ben-basten ben-basten deleted the feat/focus-trap branch March 27, 2024 22:16
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.

None yet

3 participants