Skip to content

Commit 90bdb6a

Browse files
authored
Add focus trap to studio immersive modal
1 parent 3818aaa commit 90bdb6a

2 files changed

Lines changed: 154 additions & 48 deletions

File tree

Lines changed: 46 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,46 @@
1+
/*
2+
* Checks if the element is focusable.
3+
* @param {HTMLElement} el - The element to check.
4+
* @returns {boolean} - True if the element is focusable, false otherwise.
5+
*/
6+
export const isFocusable = el => {
7+
if (el.tabIndex < 0) {
8+
return false;
9+
}
10+
11+
if (el.offsetParent === null && window.getComputedStyle(el).position !== 'fixed') {
12+
// If the element or any of its ancestors is set display none,
13+
// it will have offsetParent set to null. If the element is fixed, it will also
14+
// have offsetParent set to null, but this doesnt means it has display none.
15+
// https://developer.mozilla.org/en-US/docs/Web/API/HTMLElement/offsetParent
16+
return false;
17+
}
18+
switch (el.tagName) {
19+
case 'A':
20+
return !!el.href || el.tabIndex >= 0;
21+
case 'INPUT':
22+
return el.type !== 'hidden' && !el.disabled;
23+
case 'SELECT':
24+
case 'TEXTAREA':
25+
case 'BUTTON':
26+
return !el.disabled;
27+
default:
28+
return false;
29+
}
30+
};
31+
32+
const focusableSelectors = ['button', 'a', 'input', 'select', 'textarea'];
33+
34+
export const getFirstFocusableElement = el => {
35+
if (!el) return null;
36+
37+
return Array.from(el.querySelectorAll(focusableSelectors.join(','))).find(isFocusable);
38+
};
39+
40+
export const getLastFocusableElement = el => {
41+
if (!el) return null;
42+
43+
return Array.from(el.querySelectorAll(focusableSelectors.join(',')))
44+
.reverse()
45+
.find(isFocusable);
46+
};

contentcuration/contentcuration/frontend/shared/views/StudioImmersiveModal.vue

Lines changed: 108 additions & 48 deletions
Original file line numberDiff line numberDiff line change
@@ -1,46 +1,58 @@
11
<template>
22

3-
<div
3+
<KFocusTrap
44
v-if="value"
5-
class="modal-wrapper"
6-
data-testid="modal-wrapper"
7-
:style="{ backgroundColor: $themeTokens.surface }"
5+
@shouldFocusFirstEl="focusFirstEl"
6+
@shouldFocusLastEl="focusLastEl"
87
>
9-
<KToolbar
10-
textColor="white"
11-
:style="{ backgroundColor: $themeTokens.appBarDark }"
8+
<div
9+
ref="modalRef"
10+
class="modal-wrapper"
11+
data-testid="modal-wrapper"
12+
role="dialog"
13+
aria-modal="true"
14+
aria-labelledby="immersive-modal-title"
15+
:style="{ backgroundColor: $themeTokens.surface }"
1216
>
13-
<template #icon>
14-
<KIconButton
15-
icon="close"
16-
:ariaLabel="$tr('close')"
17-
:color="$themeTokens.textInverted"
18-
data-test="close"
19-
@click="$emit('input', false)"
20-
/>
21-
</template>
22-
23-
<template #default>
24-
<span class="toolbar-title">
25-
<slot name="header">{{ title }}</slot>
26-
</span>
27-
</template>
28-
29-
<template #actions>
30-
<slot name="action"></slot>
31-
</template>
32-
</KToolbar>
33-
34-
<StudioOfflineAlert :offset="46" />
35-
36-
<StudioPage
37-
:offline="offline"
38-
:marginTop="0"
39-
:centered="true"
40-
>
41-
<slot></slot>
42-
</StudioPage>
43-
</div>
17+
<KToolbar
18+
textColor="white"
19+
:style="{ backgroundColor: $themeTokens.appBarDark }"
20+
>
21+
<template #icon>
22+
<KIconButton
23+
icon="close"
24+
:ariaLabel="$tr('close')"
25+
:color="$themeTokens.textInverted"
26+
data-test="close"
27+
@click="$emit('input', false)"
28+
/>
29+
</template>
30+
31+
<template #default>
32+
<span
33+
id="immersive-modal-title"
34+
class="toolbar-title"
35+
>
36+
<slot name="header">{{ title }}</slot>
37+
</span>
38+
</template>
39+
40+
<template #actions>
41+
<slot name="action"></slot>
42+
</template>
43+
</KToolbar>
44+
45+
<StudioOfflineAlert :offset="46" />
46+
47+
<StudioPage
48+
:offline="offline"
49+
:marginTop="0"
50+
:centered="true"
51+
>
52+
<slot></slot>
53+
</StudioPage>
54+
</div>
55+
</KFocusTrap>
4456

4557
</template>
4658

@@ -50,6 +62,7 @@
5062
import { mapState } from 'vuex';
5163
import StudioOfflineAlert from './StudioOfflineAlert';
5264
import StudioPage from './StudioPage';
65+
import { getFirstFocusableElement, getLastFocusableElement } from 'shared/utils/focusUtils';
5366
5467
export default {
5568
name: 'StudioImmersiveModal',
@@ -73,18 +86,65 @@
7386
offline: state => !state.connection.online,
7487
}),
7588
},
76-
mounted() {
77-
document.documentElement.classList.add('modal-open');
78-
const handleKeyDown = event => {
79-
if (event.key === 'Escape') {
80-
this.$emit('input', false);
89+
watch: {
90+
value: {
91+
handler(newValue) {
92+
if (newValue) {
93+
this.onModalOpen();
94+
} else {
95+
this.onModalClose();
96+
}
97+
},
98+
immediate: true,
99+
},
100+
},
101+
beforeDestroy() {
102+
this.onModalClose();
103+
},
104+
methods: {
105+
onModalOpen() {
106+
if (!this.handleKeyDown) {
107+
this.handleKeyDown = event => {
108+
if (event.key === 'Escape') {
109+
this.$emit('input', false);
110+
}
111+
};
112+
document.addEventListener('keydown', this.handleKeyDown);
113+
}
114+
115+
document.documentElement.classList.add('modal-open');
116+
117+
this.lastFocus = document.activeElement;
118+
this.$nextTick(() => {
119+
this.focusFirstEl();
120+
});
121+
},
122+
onModalClose() {
123+
if (this.handleKeyDown) {
124+
document.removeEventListener('keydown', this.handleKeyDown);
125+
this.handleKeyDown = null;
81126
}
82-
};
83-
document.addEventListener('keydown', handleKeyDown);
84-
this.$once('hook:beforeDestroy', () => {
85127
document.documentElement.classList.remove('modal-open');
86-
document.removeEventListener('keydown', handleKeyDown);
87-
});
128+
129+
if (this.lastFocus) {
130+
this.lastFocus.focus();
131+
}
132+
},
133+
focusLastEl() {
134+
const modalRef = this.$refs['modalRef'];
135+
const lastEl = getLastFocusableElement(modalRef);
136+
if (lastEl) {
137+
lastEl.focus();
138+
}
139+
},
140+
141+
focusFirstEl() {
142+
const modalRef = this.$refs['modalRef'];
143+
const firstEl = getFirstFocusableElement(modalRef);
144+
if (firstEl) {
145+
firstEl.focus();
146+
}
147+
},
88148
},
89149
$trs: {
90150
close: 'Close',

0 commit comments

Comments
 (0)