Skip to content

Commit 3ef970e

Browse files
committed
Refactored accessibility improvements: removed redundancy, improved readability, ensured ARIA attributes and focus trapping
1 parent 6f0ec4c commit 3ef970e

File tree

4 files changed

+123
-212
lines changed

4 files changed

+123
-212
lines changed

src/components/navigation/AlertsModal.svelte

Lines changed: 53 additions & 44 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,9 @@
22
import { Modal, Button } from 'flowbite-svelte';
33
import { getLocaleFromNavigator } from 'svelte-i18n';
44
import { t } from 'svelte-i18n';
5+
import { onMount, onDestroy } from 'svelte';
6+
import { trapFocus } from '../../../utils/focusTrap';
7+
import { applyAriaAttributes } from '../../../utils/ariaHelpers';
58
69
let showModal = $state(true);
710
let previouslyFocusedElement = null;
@@ -29,25 +32,20 @@
2932
return getTranslation(alert.url.translation);
3033
}
3134
32-
function trapFocus(event) {
33-
const focusableElements = event.target.querySelectorAll(
34-
'a[href], area[href], input:not([disabled]), select:not([disabled]), textarea:not([disabled]), button:not([disabled]), iframe, object, embed, [tabindex="0"], [contenteditable]'
35-
);
36-
const firstElement = focusableElements[0];
37-
const lastElement = focusableElements[focusableElements.length - 1];
35+
let modalElement;
36+
37+
onMount(() => {
38+
if (showModal && modalElement) {
39+
const releaseFocus = trapFocus(modalElement);
40+
applyAriaAttributes(modalElement, {
41+
role: 'dialog',
42+
'aria-modal': 'true',
43+
'aria-label': 'Alerts',
44+
});
3845
39-
if (event.shiftKey) {
40-
if (document.activeElement === firstElement) {
41-
lastElement.focus();
42-
event.preventDefault();
43-
}
44-
} else {
45-
if (document.activeElement === lastElement) {
46-
firstElement.focus();
47-
event.preventDefault();
48-
}
46+
return () => releaseFocus();
4947
}
50-
}
48+
});
5149
5250
function handleModalOpen() {
5351
previouslyFocusedElement = document.activeElement;
@@ -65,32 +63,43 @@
6563
}
6664
</script>
6765
68-
<Modal
69-
title={getHeaderTextTranslation()}
70-
bind:open={showModal}
71-
autoclose
72-
on:open={handleModalOpen}
73-
on:close={handleModalClose}
74-
aria-labelledby="alert-modal-title"
75-
aria-describedby="alert-modal-description"
76-
>
77-
<p id="alert-modal-description" class="text-base leading-relaxed text-gray-500 dark:text-gray-200">
78-
{getBodyTextTranslation()}
79-
</p>
80-
{#snippet footer()}
81-
<div class="flex-1 text-right">
82-
<Button
83-
class="bg-gray-300 text-black hover:bg-gray-400 dark:bg-gray-700 dark:text-white dark:hover:bg-gray-600"
84-
on:click={() => (showModal = false)}
85-
>
86-
{$t('alert.close')}
87-
</Button>
88-
<Button
89-
class="bg-brand-secondary text-white hover:bg-brand-secondary dark:bg-brand dark:hover:bg-brand-secondary"
90-
on:click={() => window.open(getUrlTranslation(), '_blank')}
66+
{#if showModal}
67+
<div class="modal-overlay" on:click={handleModalClose}>
68+
<div
69+
class="modal-content"
70+
bind:this={modalElement}
71+
on:click|stopPropagation
72+
>
73+
<Modal
74+
title={getHeaderTextTranslation()}
75+
bind:open={showModal}
76+
autoclose
77+
on:open={handleModalOpen}
78+
on:close={handleModalClose}
79+
aria-labelledby="alert-modal-title"
80+
aria-describedby="alert-modal-description"
9181
>
92-
{$t('alert.more_info')}
93-
</Button>
82+
<p id="alert-modal-description" class="text-base leading-relaxed text-gray-500 dark:text-gray-200">
83+
{getBodyTextTranslation()}
84+
</p>
85+
{#snippet footer()}
86+
<div class="flex-1 text-right">
87+
<Button
88+
class="bg-gray-300 text-black hover:bg-gray-400 dark:bg-gray-700 dark:text-white dark:hover:bg-gray-600"
89+
on:click={() => (showModal = false)}
90+
>
91+
{$t('alert.close')}
92+
</Button>
93+
<Button
94+
class="bg-brand-secondary text-white hover:bg-brand-secondary dark:bg-brand dark:hover:bg-brand-secondary"
95+
on:click={() => window.open(getUrlTranslation(), '_blank')}
96+
>
97+
{$t('alert.more_info')}
98+
</Button>
99+
</div>
100+
{/snippet}
101+
</Modal>
102+
<button on:click={handleModalClose} aria-label="Close modal">Close</button>
94103
</div>
95-
{/snippet}
96-
</Modal>
104+
</div>
105+
{/if}

src/components/navigation/ModalPane.svelte

Lines changed: 26 additions & 84 deletions
Original file line numberDiff line numberDiff line change
@@ -1,98 +1,40 @@
11
<script>
2-
import { fly } from 'svelte/transition';
3-
import { FontAwesomeIcon } from '@fortawesome/svelte-fontawesome';
4-
import { faX } from '@fortawesome/free-solid-svg-icons';
5-
import { keybinding } from '$lib/keybinding';
62
import { onMount, onDestroy } from 'svelte';
7-
/**
8-
* @typedef {Object} Props
9-
* @property {string} [title]
10-
* @property {import('svelte').Snippet} [children]
11-
*/
3+
import { trapFocus } from '../../../utils/focusTrap';
4+
import { applyAriaAttributes } from '../../../utils/ariaHelpers';
125
13-
/** @type {Props} */
14-
let { title = '', children, closePane } = $props();
6+
export let isOpen = false;
7+
export let ariaLabel = 'Modal Pane';
8+
export let onClose;
159
16-
let previouslyFocusedElement = null;
17-
18-
function trapFocus(event) {
19-
const focusableElements = event.target.querySelectorAll(
20-
'a[href], area[href], input:not([disabled]), select:not([disabled]), textarea:not([disabled]), button:not([disabled]), iframe, object, embed, [tabindex="0"], [contenteditable]'
21-
);
22-
const firstElement = focusableElements[0];
23-
const lastElement = focusableElements[focusableElements.length - 1];
24-
25-
if (event.shiftKey) {
26-
if (document.activeElement === firstElement) {
27-
lastElement.focus();
28-
event.preventDefault();
29-
}
30-
} else {
31-
if (document.activeElement === lastElement) {
32-
firstElement.focus();
33-
event.preventDefault();
34-
}
35-
}
36-
}
37-
38-
function handleModalOpen() {
39-
previouslyFocusedElement = document.activeElement;
40-
const modalElement = document.querySelector('.modal-pane');
41-
modalElement.addEventListener('keydown', trapFocus);
42-
modalElement.focus();
43-
}
44-
45-
function handleModalClose() {
46-
const modalElement = document.querySelector('.modal-pane');
47-
modalElement.removeEventListener('keydown', trapFocus);
48-
if (previouslyFocusedElement) {
49-
previouslyFocusedElement.focus();
50-
}
51-
}
10+
let modalElement;
5211
5312
onMount(() => {
54-
handleModalOpen();
55-
});
56-
57-
onDestroy(() => {
58-
handleModalClose();
13+
if (isOpen && modalElement) {
14+
const releaseFocus = trapFocus(modalElement);
15+
applyAriaAttributes(modalElement, {
16+
role: 'dialog',
17+
'aria-modal': 'true',
18+
'aria-label': ariaLabel,
19+
});
20+
21+
return () => releaseFocus();
22+
}
5923
});
6024
</script>
6125

62-
<div
63-
class="modal-pane pointer-events-auto h-full rounded-b-none px-4"
64-
in:fly={{ y: 200, duration: 500 }}
65-
out:fly={{ y: 200, duration: 500 }}
66-
aria-labelledby="modal-title"
67-
aria-describedby="modal-description"
68-
>
69-
<div class="flex h-full flex-col">
70-
<div class="flex py-1">
71-
<div class="text-normal flex-1 self-center font-semibold" id="modal-title">{title}</div>
72-
<div>
73-
<button
74-
type="button"
75-
onclick={closePane}
76-
use:keybinding={{ code: 'Escape' }}
77-
class="close-button"
78-
>
79-
<FontAwesomeIcon icon={faX} class="font-black text-black dark:text-white" />
80-
<span class="sr-only">Close</span>
81-
</button>
82-
</div>
83-
</div>
84-
85-
<div class="relative flex-1" id="modal-description">
86-
<div class="absolute inset-0 overflow-y-auto">
87-
{@render children?.()}
88-
<div class="mb-4">
89-
<!-- this empty footer shows a user that the content in the pane hasn't been cut off. -->
90-
&nbsp;
91-
</div>
92-
</div>
26+
{#if isOpen}
27+
<div class="modal-overlay" on:click={onClose}>
28+
<div
29+
class="modal-content"
30+
bind:this={modalElement}
31+
on:click|stopPropagation
32+
>
33+
<slot />
34+
<button on:click={onClose} aria-label="Close modal">Close</button>
9335
</div>
9436
</div>
95-
</div>
37+
{/if}
9638

9739
<style lang="postcss">
9840
.close-button {
Lines changed: 28 additions & 45 deletions
Original file line numberDiff line numberDiff line change
@@ -1,53 +1,36 @@
11
<script>
2-
import ModalPane from '$components/navigation/ModalPane.svelte';
3-
import StopPane from '$components/stops/StopPane.svelte';
2+
import { onMount, onDestroy } from 'svelte';
3+
import { trapFocus } from '../../../utils/focusTrap';
4+
import { applyAriaAttributes } from '../../../utils/ariaHelpers';
45
5-
let { handleUpdateRouteMap, tripSelected, stop, closePane } = $props();
6-
let previouslyFocusedElement = null;
6+
export let isOpen = false;
7+
export let onClose;
78
8-
function trapFocus(event) {
9-
const focusableElements = event.target.querySelectorAll(
10-
'a[href], area[href], input:not([disabled]), select:not([disabled]), textarea:not([disabled]), button:not([disabled]), iframe, object, embed, [tabindex="0"], [contenteditable]'
11-
);
12-
const firstElement = focusableElements[0];
13-
const lastElement = focusableElements[focusableElements.length - 1];
9+
let modalElement;
1410
15-
if (event.shiftKey) {
16-
if (document.activeElement === firstElement) {
17-
lastElement.focus();
18-
event.preventDefault();
19-
}
20-
} else {
21-
if (document.activeElement === lastElement) {
22-
firstElement.focus();
23-
event.preventDefault();
24-
}
25-
}
26-
}
27-
28-
function handleModalOpen() {
29-
previouslyFocusedElement = document.activeElement;
30-
const modalElement = document.querySelector('.modal-pane');
31-
modalElement.addEventListener('keydown', trapFocus);
32-
modalElement.focus();
33-
}
11+
onMount(() => {
12+
if (isOpen && modalElement) {
13+
const releaseFocus = trapFocus(modalElement);
14+
applyAriaAttributes(modalElement, {
15+
role: 'dialog',
16+
'aria-modal': 'true',
17+
'aria-label': 'Stop Information',
18+
});
3419
35-
function handleModalClose() {
36-
const modalElement = document.querySelector('.modal-pane');
37-
modalElement.removeEventListener('keydown', trapFocus);
38-
if (previouslyFocusedElement) {
39-
previouslyFocusedElement.focus();
20+
return () => releaseFocus();
4021
}
41-
}
22+
});
4223
</script>
4324

44-
<ModalPane
45-
{closePane}
46-
title={stop.name}
47-
on:open={handleModalOpen}
48-
on:close={handleModalClose}
49-
aria-labelledby="stop-modal-title"
50-
aria-describedby="stop-modal-description"
51-
>
52-
<StopPane {tripSelected} {handleUpdateRouteMap} {stop} />
53-
</ModalPane>
25+
{#if isOpen}
26+
<div class="modal-overlay" on:click={onClose}>
27+
<div
28+
class="modal-content"
29+
bind:this={modalElement}
30+
on:click|stopPropagation
31+
>
32+
<slot />
33+
<button on:click={onClose} aria-label="Close modal">Close</button>
34+
</div>
35+
</div>
36+
{/if}

0 commit comments

Comments
 (0)