Skip to content

Commit e98603c

Browse files
committed
🐛 fix(ui): trap security chooser focus
1 parent 05c264c commit e98603c

2 files changed

Lines changed: 102 additions & 1 deletion

File tree

ui/src/views/security/SecurityContainerChooser.vue

Lines changed: 70 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
<script setup lang="ts">
2+
import { nextTick, onBeforeUnmount, onMounted, ref } from 'vue';
23
import { useI18n } from 'vue-i18n';
34
import AppBadge from '../../components/AppBadge.vue';
45
import type { ContainerChoice } from './securityViewTypes';
@@ -14,14 +15,82 @@ const emit = defineEmits<{
1415
}>();
1516
1617
const { t } = useI18n();
18+
const modalRoot = ref<HTMLElement | null>(null);
19+
let previouslyFocusedElement: HTMLElement | null = null;
20+
21+
const focusableSelector = [
22+
'a[href]',
23+
'button:not([disabled])',
24+
'input:not([disabled])',
25+
'select:not([disabled])',
26+
'textarea:not([disabled])',
27+
'[tabindex]:not([tabindex="-1"])',
28+
].join(',');
29+
30+
function getFocusableElements() {
31+
return Array.from(modalRoot.value?.querySelectorAll<HTMLElement>(focusableSelector) ?? []).filter(
32+
(element) => element.tabIndex >= 0,
33+
);
34+
}
35+
36+
function focusFirstElement() {
37+
getFocusableElements()[0]?.focus();
38+
}
39+
40+
function handleModalKeydown(event: KeyboardEvent) {
41+
if (event.key !== 'Tab') {
42+
return;
43+
}
44+
45+
const focusableElements = getFocusableElements();
46+
if (focusableElements.length === 0) {
47+
event.preventDefault();
48+
modalRoot.value?.focus();
49+
return;
50+
}
51+
52+
const firstElement = focusableElements[0];
53+
const lastElement = focusableElements.at(-1);
54+
if (!lastElement) {
55+
return;
56+
}
57+
58+
if (event.shiftKey && document.activeElement === firstElement) {
59+
event.preventDefault();
60+
lastElement.focus();
61+
return;
62+
}
63+
64+
if (!event.shiftKey && document.activeElement === lastElement) {
65+
event.preventDefault();
66+
firstElement.focus();
67+
}
68+
}
69+
70+
onMounted(() => {
71+
previouslyFocusedElement =
72+
document.activeElement instanceof HTMLElement ? document.activeElement : null;
73+
void nextTick(() => focusFirstElement());
74+
});
75+
76+
onBeforeUnmount(() => {
77+
if (previouslyFocusedElement && document.body.contains(previouslyFocusedElement)) {
78+
previouslyFocusedElement.focus();
79+
}
80+
});
1781
</script>
1882

1983
<template>
2084
<Teleport to="body">
2185
<div
86+
ref="modalRoot"
2287
class="fixed inset-0 z-overlay"
88+
role="dialog"
89+
aria-modal="true"
90+
tabindex="-1"
2391
@pointerdown.self="emit('close')"
24-
@keydown.escape="emit('close')">
92+
@keydown.escape="emit('close')"
93+
@keydown="handleModalKeydown">
2594
<div
2695
class="fixed left-1/2 top-1/3 -translate-x-1/2 w-full max-w-xs mx-4 dd-rounded-lg overflow-hidden shadow-lg"
2796
:style="{

ui/tests/views/security/SecurityContainerChooser.spec.ts

Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -92,4 +92,36 @@ describe('SecurityContainerChooser', () => {
9292
expect(wrapper.emitted('viewAll')).toHaveLength(1);
9393
wrapper.unmount();
9494
});
95+
96+
it('traps tab focus inside the modal controls', async () => {
97+
const outsideButton = document.createElement('button');
98+
outsideButton.textContent = 'Outside';
99+
document.body.appendChild(outsideButton);
100+
101+
const wrapper = factory();
102+
await nextTick();
103+
104+
const focusableButtons = [
105+
...document.body.querySelectorAll<HTMLButtonElement>(
106+
'[data-test="security-chooser-item"]:not(:disabled), [data-test="security-chooser-view-all"], .z-overlay button:not(:disabled)',
107+
),
108+
];
109+
const firstButton = focusableButtons[0];
110+
const lastButton = focusableButtons.at(-1);
111+
112+
expect(document.activeElement).toBe(firstButton);
113+
114+
firstButton.dispatchEvent(
115+
new KeyboardEvent('keydown', { key: 'Tab', shiftKey: true, bubbles: true }),
116+
);
117+
await nextTick();
118+
expect(document.activeElement).toBe(lastButton);
119+
120+
lastButton?.dispatchEvent(new KeyboardEvent('keydown', { key: 'Tab', bubbles: true }));
121+
await nextTick();
122+
expect(document.activeElement).toBe(firstButton);
123+
expect(document.activeElement).not.toBe(outsideButton);
124+
125+
wrapper.unmount();
126+
});
95127
});

0 commit comments

Comments
 (0)