Improve modal accessibility with focus trapping and ARIA attributes

- Implement focus trapping in Popup component for keyboard navigation
- Add tabindex and ARIA attributes to ActionButton for screen readers
- Ensure tab navigation cycles through modal elements properly
- Enhance keyboard-only user experience
This commit is contained in:
2026-02-27 18:49:38 +01:00
parent f7cf2e4508
commit e84ba1c40b
2 changed files with 101 additions and 4 deletions

View File

@@ -1,16 +1,26 @@
<template> <template>
<div v-if="isOpen" class="movie-popup" @click="close" @keydown.enter="close"> <div
v-if="isOpen"
ref="popupContainer"
class="movie-popup"
role="dialog"
aria-modal="true"
tabindex="-1"
@click="close"
@keydown.enter="close"
@keydown="handleKeydown"
>
<div class="movie-popup__box" @click.stop> <div class="movie-popup__box" @click.stop>
<person v-if="type === 'person'" :id="id" type="person" /> <person v-if="type === 'person'" :id="id" type="person" />
<movie v-else :id="id" :type="type"></movie> <movie v-else :id="id" :type="type"></movie>
<button class="movie-popup__close" @click="close"></button> <button class="movie-popup__close" @click="close" tabindex="0"></button>
</div> </div>
<i class="loader"></i> <i class="loader"></i>
</div> </div>
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import { ref, onMounted, onBeforeUnmount } from "vue"; import { ref, onMounted, onBeforeUnmount, watch, nextTick } from "vue";
import { useStore } from "vuex"; import { useStore } from "vuex";
import Movie from "@/components/popup/Movie.vue"; import Movie from "@/components/popup/Movie.vue";
import Person from "@/components/popup/Person.vue"; import Person from "@/components/popup/Person.vue";
@@ -26,6 +36,8 @@
const isOpen: Ref<boolean> = ref(); const isOpen: Ref<boolean> = ref();
const id: Ref<string> = ref(); const id: Ref<string> = ref();
const type: Ref<MediaTypes> = ref(); const type: Ref<MediaTypes> = ref();
const popupContainer = ref<HTMLElement | null>(null);
let previouslyFocusedElement: HTMLElement | null = null;
const unsubscribe = store.subscribe((mutation, state) => { const unsubscribe = store.subscribe((mutation, state) => {
if (!mutation.type.includes("popup")) return; if (!mutation.type.includes("popup")) return;
@@ -76,6 +88,75 @@
close(); close();
} }
function getFocusableElements(): HTMLElement[] {
if (!popupContainer.value) return [];
const focusableSelectors = [
"button:not([disabled])",
"a[href]",
"input:not([disabled])",
"select:not([disabled])",
"textarea:not([disabled])",
'[tabindex]:not([tabindex="-1"])'
].join(", ");
return Array.from(
popupContainer.value.querySelectorAll(focusableSelectors)
) as HTMLElement[];
}
function trapFocus(event: KeyboardEvent) {
if (event.key !== "Tab") return;
const focusableElements = getFocusableElements();
if (focusableElements.length === 0) return;
const firstElement = focusableElements[0];
const lastElement = focusableElements[focusableElements.length - 1];
if (event.shiftKey) {
// Shift + Tab
if (document.activeElement === firstElement) {
event.preventDefault();
lastElement.focus();
}
} else {
// Tab
if (document.activeElement === lastElement) {
event.preventDefault();
firstElement.focus();
}
}
}
function handleKeydown(event: KeyboardEvent) {
trapFocus(event);
}
function setInitialFocus() {
nextTick(() => {
// Focus the popup container itself instead of a specific element
// This allows tab to start fresh without any element being focused
if (popupContainer.value) {
popupContainer.value.focus();
}
});
}
watch(isOpen, newValue => {
if (newValue) {
// Store the previously focused element
previouslyFocusedElement = document.activeElement as HTMLElement;
// Set focus to popup
setInitialFocus();
} else {
// Restore focus to previously focused element
if (previouslyFocusedElement) {
previouslyFocusedElement.focus();
}
}
});
window.addEventListener("keyup", checkEventForEscapeKey); window.addEventListener("keyup", checkEventForEscapeKey);
onMounted(() => { onMounted(() => {
@@ -104,6 +185,10 @@
-webkit-overflow-scrolling: touch; -webkit-overflow-scrolling: touch;
overflow: auto; overflow: auto;
&:focus {
outline: none;
}
&__box { &__box {
max-width: 768px; max-width: 768px;
position: relative; position: relative;

View File

@@ -2,8 +2,12 @@
<li <li
class="sidebar-list-element" class="sidebar-list-element"
:class="{ active, disabled }" :class="{ active, disabled }"
:tabindex="disabled ? -1 : 0"
role="button"
:aria-disabled="disabled"
@click="emit('click')" @click="emit('click')"
@keydown.enter="emit('click')" @keydown.enter.prevent="emit('click')"
@keydown.space.prevent="emit('click')"
> >
<slot></slot> <slot></slot>
</li> </li>
@@ -53,8 +57,10 @@
} }
&:hover, &:hover,
&:focus,
&.active { &.active {
color: var(--text-color); color: var(--text-color);
outline: none;
div > svg, div > svg,
svg { svg {
@@ -63,6 +69,12 @@
} }
} }
&:focus-visible {
outline: 2px solid var(--highlight-color);
outline-offset: 2px;
border-radius: 4px;
}
&.active > div > svg, &.active > div > svg,
&.active > svg { &.active > svg {
fill: var(--highlight-color); fill: var(--highlight-color);