mirror of
https://github.com/KevinMidboe/seasoned.git
synced 2026-03-11 11:55:38 +00:00
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:
@@ -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;
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|||||||
Reference in New Issue
Block a user