Skip to content

Commit e5dab27

Browse files
committed
Improvement - BaseDraggableDialog - Add accessibility features
1 parent 2653660 commit e5dab27

File tree

1 file changed

+109
-24
lines changed

1 file changed

+109
-24
lines changed

src/atoms/BaseDraggableDialog.vue

Lines changed: 109 additions & 24 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
<script setup>
22
import { ref, reactive, computed, onUnmounted, nextTick, onMounted } from "vue";
33
import BaseIcon from "./BaseIcon.vue";
4-
import { XMLNS } from "../lib";
4+
import { createUid, XMLNS } from "../lib";
55
66
const props = defineProps({
77
backgroundColor: { type: String },
@@ -19,6 +19,12 @@ const emit = defineEmits(["close"]);
1919
const isOpen = ref(false);
2020
const hasBeenOpened = ref(false);
2121
const instanceKey = ref(0);
22+
const draggableDialog = ref(null);
23+
const closeButtonElement = ref(null);
24+
const previouslyFocusedElement = ref(null);
25+
const dialogId = `vue-ui-draggable-dialog-${createUid()}`;
26+
const dialogTitleId = `${dialogId}-title`;
27+
const dialogBodyId = `${dialogId}-body`;
2228
2329
function bringToFront() {
2430
instanceKey.value += 1;
@@ -40,18 +46,31 @@ const modal = reactive({
4046
});
4147
4248
function open() {
49+
previouslyFocusedElement.value =
50+
document.activeElement instanceof HTMLElement ? document.activeElement : null;
51+
4352
isOpen.value = true;
4453
nextTick(() => {
4554
if (!hasBeenOpened.value) {
4655
modal.left = Math.max(0, window.innerWidth / 2 - modal.width / 2);
4756
modal.top = Math.max(0, window.innerHeight / 2 - modal.height / 2);
4857
hasBeenOpened.value = true;
4958
}
59+
60+
const targetToFocus = closeButtonElement.value || draggableDialog.value;
61+
if (targetToFocus && typeof targetToFocus.focus === "function") {
62+
targetToFocus.focus();
63+
}
5064
});
5165
}
66+
5267
function close() {
5368
isOpen.value = false;
5469
emit("close");
70+
71+
if (previouslyFocusedElement.value && typeof previouslyFocusedElement.value.focus === "function") {
72+
previouslyFocusedElement.value.focus();
73+
}
5574
}
5675
5776
defineExpose({ open, close });
@@ -169,7 +188,7 @@ function resizeLeft(e) {
169188
let dx = pointer.x - modal.pointerStartX;
170189
let newLeft = Math.min(
171190
Math.max(0, modal.resizeStartLeft + dx),
172-
modal.resizeStartLeft + modal.resizeStartW - 240 // min width
191+
modal.resizeStartLeft + modal.resizeStartW - 240
173192
);
174193
let newWidth = modal.resizeStartW - (newLeft - modal.resizeStartLeft);
175194
let dy = pointer.y - modal.pointerStartY;
@@ -188,11 +207,11 @@ function endResizeLeft() {
188207
}
189208
190209
onMounted(() => {
191-
document.addEventListener('keydown', onEscape);
210+
document.addEventListener("keydown", onEscape);
192211
});
193212
194213
function onEscape(e) {
195-
if (e.key && e.key === 'Escape') {
214+
if (e.key && e.key === "Escape") {
196215
close();
197216
}
198217
}
@@ -201,27 +220,72 @@ onUnmounted(() => {
201220
endDrag();
202221
endResize();
203222
endResizeLeft();
204-
document.removeEventListener('keydown', onEscape);
223+
document.removeEventListener("keydown", onEscape);
205224
});
225+
226+
function handleDialogKeydown(event) {
227+
if (event.key === "Tab") {
228+
trapFocus(event);
229+
}
230+
}
231+
232+
function trapFocus(event) {
233+
if (!draggableDialog.value) return;
234+
235+
const focusableSelector =
236+
'a[href], area[href], input:not([disabled]), select:not([disabled]), ' +
237+
'textarea:not([disabled]), button:not([disabled]), iframe, object, embed, ' +
238+
'[tabindex]:not([tabindex="-1"]), [contenteditable="true"]';
239+
240+
const focusableElements = draggableDialog.value.querySelectorAll(focusableSelector);
241+
if (!focusableElements.length) return;
242+
243+
const firstElement = focusableElements[0];
244+
const lastElement = focusableElements[focusableElements.length - 1];
245+
246+
if (event.shiftKey) {
247+
if (document.activeElement === firstElement) {
248+
event.preventDefault();
249+
lastElement.focus();
250+
}
251+
} else {
252+
if (document.activeElement === lastElement) {
253+
event.preventDefault();
254+
firstElement.focus();
255+
}
256+
}
257+
}
206258
</script>
207259
208260
<template>
209261
<Teleport :to="isFullscreen ? fullscreenParent : 'body'" :key="instanceKey">
210-
<div
211-
v-if="isOpen"
212-
data-cy="draggable-dialog"
213-
class="vue-ui-draggable-dialog"
262+
<div
263+
v-if="isOpen"
264+
ref="draggableDialog"
265+
data-cy="draggable-dialog"
266+
class="vue-ui-draggable-dialog"
214267
:style="modalStyle"
268+
role="dialog"
269+
:aria-modal="true"
270+
:aria-labelledby="dialogTitleId"
271+
:aria-describedby="dialogBodyId"
272+
tabindex="-1"
215273
@click.stop
274+
@keydown="handleDialogKeydown"
216275
>
217-
<div
276+
<div
218277
class="vue-ui-draggable-dialog-header"
219278
:style="{
220279
backgroundColor: headerBg,
221280
color: headerColor
222281
}"
223282
>
224-
<span class="drag-handle" @mousedown.stop.prevent="initDrag" @touchstart.stop.prevent="initDrag">
283+
<span
284+
class="drag-handle"
285+
aria-hidden="true"
286+
@mousedown.stop.prevent="initDrag"
287+
@touchstart.stop.prevent="initDrag"
288+
>
225289
<svg
226290
:xmlns="XMLNS"
227291
width="20"
@@ -240,25 +304,46 @@ onUnmounted(() => {
240304
<path d="M19 9m-1 0a1 1 0 1 0 2 0a1 1 0 1 0 -2 0" />
241305
<path d="M19 15m-1 0a1 1 0 1 0 2 0a1 1 0 1 0 -2 0" />
242306
</svg>
243-
244-
245307
</span>
246-
<span class="vue-ui-draggable-dialog-title">
247-
<slot name="title"/>
308+
<span
309+
class="vue-ui-draggable-dialog-title"
310+
:id="dialogTitleId"
311+
>
312+
<slot name="title" />
248313
</span>
249314
<div class="draggable-dialog-actions">
250-
<slot name="actions"/>
251-
<button data-cy="draggable-dialog-close" class="close" @click="close">
252-
<BaseIcon name="close" :stroke="headerColor"/>
315+
<slot name="actions" />
316+
<button
317+
ref="closeButtonElement"
318+
data-cy="draggable-dialog-close"
319+
class="close"
320+
type="button"
321+
aria-label="Close dialog"
322+
@click="close"
323+
>
324+
<BaseIcon name="close" :stroke="headerColor" />
253325
</button>
254326
</div>
255327
</div>
256-
<div :class="{ 'vue-ui-draggable-dialog-body': !withPadding, 'vue-ui-draggable-dialog-body-pad': withPadding}">
328+
<div
329+
:id="dialogBodyId"
330+
role="document"
331+
:class="{
332+
'vue-ui-draggable-dialog-body': !withPadding,
333+
'vue-ui-draggable-dialog-body-pad': withPadding
334+
}"
335+
>
257336
<slot name="content" />
258337
</div>
259-
<div class="resize-handle" @mousedown.stop.prevent="initResize" @touchstart.stop.prevent="initResize" />
338+
<div
339+
class="resize-handle"
340+
aria-hidden="true"
341+
@mousedown.stop.prevent="initResize"
342+
@touchstart.stop.prevent="initResize"
343+
/>
260344
<div
261345
class="resize-handle resize-handle-left"
346+
aria-hidden="true"
262347
@mousedown.stop.prevent="initResizeLeft"
263348
@touchstart.stop.prevent="initResizeLeft"
264349
/>
@@ -280,8 +365,8 @@ onUnmounted(() => {
280365
display: flex;
281366
flex-direction: row;
282367
gap: 6px;
283-
align-items:center;
284-
justify-content:center;
368+
align-items: center;
369+
justify-content: center;
285370
}
286371
287372
.drag-handle {
@@ -306,7 +391,7 @@ onUnmounted(() => {
306391
border: none;
307392
cursor: pointer;
308393
display: flex;
309-
align-items:center;
394+
align-items: center;
310395
justify-content: center;
311396
}
312397
@@ -388,4 +473,4 @@ onUnmounted(() => {
388473
.vue-ui-user-options-button:focus-visible {
389474
outline: 1px solid #CCCCCC;
390475
}
391-
</style>
476+
</style>

0 commit comments

Comments
 (0)