Skip to content

Commit cf1a639

Browse files
authored
Fix focus trap on the last element in a modal (#5406)
* Fix focus trap on the last element in a modal * Fix trap escaping edge cases
1 parent e52e1df commit cf1a639

2 files changed

Lines changed: 56 additions & 23 deletions

File tree

‎.changeset/moody-poems-speak.md‎

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
1+
---
2+
"apostrophe": minor
3+
---
4+
5+
Fixed an issue where using the Tab key to navigate within modals could incorrectly jump focus to a wrong element instead of the next input field.
6+
7+
Fixed Tab navigation escaping out of modals when the form contained hidden sections or elements that became disabled after editing.

‎packages/apostrophe/modules/@apostrophecms/modal/ui/apos/components/AposModal.vue‎

Lines changed: 49 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -16,7 +16,7 @@
1616
:data-apos-graph-key="props.graphKey || undefined"
1717
tabindex="0"
1818
@focus.capture="captureFocus"
19-
@keyup.tab="onKeyup"
19+
@keydown.tab="onKeydownTab"
2020
@keyup.esc="onKeyup"
2121
>
2222
<transition :name="transitionType">
@@ -216,6 +216,22 @@ const nonDraggableElements = [
216216
'.apos-input-array-inline-table'
217217
];
218218
219+
// Selector for focusable elements inside the modal. Used both at trap setup
220+
// and on every Tab keydown to refresh the cycle list, so that elements that
221+
// became disabled/hidden (e.g. Save when validation fails) or newly visible
222+
// are reflected.
223+
const focusableSelector = [
224+
'[tabindex]',
225+
'[href]',
226+
'input',
227+
'select',
228+
'textarea',
229+
'button',
230+
'[data-apos-focus-priority]'
231+
]
232+
.map(s => `${s}:not([tabindex="-1"]):not([disabled]):not([type="hidden"]):not([aria-hidden]):not(.apos-sr-only)`)
233+
.join(', ');
234+
219235
const resizeSides = [
220236
{
221237
edge: 'top',
@@ -476,13 +492,30 @@ onUnmounted(() => {
476492
}
477493
});
478494
479-
function onKeyup(event) {
495+
// Handle Tab on keydown — before the browser moves focus.
496+
// Handling Tab on keyup is too late: the browser has already moved focus,
497+
// so the cycling logic sees the wrong activeElement.
498+
//
499+
// We also recompute the focusable list here on every Tab, scoped to
500+
// modalEl, instead of relying on the snapshot taken at trapFocus() time.
501+
function onKeydownTab(event) {
502+
if (!shouldTrapFocus.value) {
503+
return;
504+
}
480505
if (!store.isOnTop(modalEl.value)) {
481506
return;
482507
}
508+
if (event.target?.nodeName?.toLowerCase() === 'textarea') {
509+
return;
510+
}
511+
const elements = getFocusableElements(modalEl.value);
512+
// Keep the store snapshot consistent for other consumers.
513+
store.updateModalData(props.modalData.id, { elementsToFocus: elements });
514+
cycleElementsToFocus(event, elements);
515+
}
483516
484-
if (event.key === 'Tab') {
485-
cycleElementsToFocus(event, props.modalData.elementsToFocus);
517+
function onKeyup(event) {
518+
if (!store.isOnTop(modalEl.value)) {
486519
return;
487520
}
488521
@@ -517,23 +550,20 @@ function captureFocus(e) {
517550
store.updateModalData(props.modalData.id, { focusedElement: e.target });
518551
}
519552
553+
function getFocusableElements(rootEl) {
554+
if (!rootEl) {
555+
return [];
556+
}
557+
return [ ...rootEl.querySelectorAll(focusableSelector) ]
558+
// a cheap "visible and in the DOM" condition,
559+
// false positive expected for position: fixed and
560+
// visually hidden elements (visible: hidden, opacity: 0, etc.)
561+
.filter(el => el.offsetParent !== null);
562+
}
563+
520564
async function trapFocus() {
521565
if (modalEl?.value) {
522-
const elementSelectors = [
523-
'[tabindex]',
524-
'[href]',
525-
'input',
526-
'select',
527-
'textarea',
528-
'button',
529-
'[data-apos-focus-priority]'
530-
];
531-
532-
const selector = elementSelectors
533-
.map(addExcludingAttributes)
534-
.join(', ');
535-
536-
const elementsToFocus = [ ...modalEl.value.querySelectorAll(selector) ];
566+
const elementsToFocus = getFocusableElements(modalEl.value);
537567
538568
store.updateModalData(props.modalData.id, { elementsToFocus });
539569
@@ -556,10 +586,6 @@ async function trapFocus() {
556586
await nextTick();
557587
focusElement(props.modalData.focusedElement, firstElementToFocus);
558588
}
559-
560-
function addExcludingAttributes(element) {
561-
return `${element}:not([tabindex="-1"]):not([disabled]):not([type="hidden"]):not([aria-hidden]):not(.apos-sr-only)`;
562-
}
563589
}
564590
565591
function close() {

0 commit comments

Comments
 (0)