From 539f4209a7149401367eb1edf345c1c544789ee4 Mon Sep 17 00:00:00 2001 From: Miro Yovchev <2827783+myovchev@users.noreply.github.com> Date: Wed, 13 May 2026 15:41:16 +0300 Subject: [PATCH 1/2] Fix focus trap on the last element in a modal --- .changeset/moody-poems-speak.md | 5 +++++ .../modal/ui/apos/components/AposModal.vue | 20 +++++++++++++++---- 2 files changed, 21 insertions(+), 4 deletions(-) create mode 100644 .changeset/moody-poems-speak.md diff --git a/.changeset/moody-poems-speak.md b/.changeset/moody-poems-speak.md new file mode 100644 index 0000000000..83702b37cd --- /dev/null +++ b/.changeset/moody-poems-speak.md @@ -0,0 +1,5 @@ +--- +"apostrophe": minor +--- + +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. diff --git a/packages/apostrophe/modules/@apostrophecms/modal/ui/apos/components/AposModal.vue b/packages/apostrophe/modules/@apostrophecms/modal/ui/apos/components/AposModal.vue index a83770ff35..d425cc50e9 100644 --- a/packages/apostrophe/modules/@apostrophecms/modal/ui/apos/components/AposModal.vue +++ b/packages/apostrophe/modules/@apostrophecms/modal/ui/apos/components/AposModal.vue @@ -16,7 +16,7 @@ :data-apos-graph-key="props.graphKey || undefined" tabindex="0" @focus.capture="captureFocus" - @keyup.tab="onKeyup" + @keydown.tab="onKeydownTab" @keyup.esc="onKeyup" > @@ -476,13 +476,25 @@ onUnmounted(() => { } }); -function onKeyup(event) { +// Handle Tab on keydown — before the browser moves focus. +// Handling Tab on keyup is too late: the browser +// has already moved focus, so the cycling logic sees the wrong +// activeElement. +function onKeydownTab(event) { + if (!shouldTrapFocus.value) { + return; + } if (!store.isOnTop(modalEl.value)) { return; } + if (event.target?.nodeName?.toLowerCase() === 'textarea') { + return; + } + cycleElementsToFocus(event, props.modalData.elementsToFocus); +} - if (event.key === 'Tab') { - cycleElementsToFocus(event, props.modalData.elementsToFocus); +function onKeyup(event) { + if (!store.isOnTop(modalEl.value)) { return; } From 9ddd31a137ff44fd02a3fb837451f5537b7e97e6 Mon Sep 17 00:00:00 2001 From: Miro Yovchev <2827783+myovchev@users.noreply.github.com> Date: Wed, 13 May 2026 16:24:34 +0300 Subject: [PATCH 2/2] Fix trap escaping edge cases --- .changeset/moody-poems-speak.md | 2 + .../modal/ui/apos/components/AposModal.vue | 60 ++++++++++++------- 2 files changed, 39 insertions(+), 23 deletions(-) diff --git a/.changeset/moody-poems-speak.md b/.changeset/moody-poems-speak.md index 83702b37cd..8582885ec2 100644 --- a/.changeset/moody-poems-speak.md +++ b/.changeset/moody-poems-speak.md @@ -3,3 +3,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. + +Fixed Tab navigation escaping out of modals when the form contained hidden sections or elements that became disabled after editing. diff --git a/packages/apostrophe/modules/@apostrophecms/modal/ui/apos/components/AposModal.vue b/packages/apostrophe/modules/@apostrophecms/modal/ui/apos/components/AposModal.vue index d425cc50e9..055434d48e 100644 --- a/packages/apostrophe/modules/@apostrophecms/modal/ui/apos/components/AposModal.vue +++ b/packages/apostrophe/modules/@apostrophecms/modal/ui/apos/components/AposModal.vue @@ -216,6 +216,22 @@ const nonDraggableElements = [ '.apos-input-array-inline-table' ]; +// Selector for focusable elements inside the modal. Used both at trap setup +// and on every Tab keydown to refresh the cycle list, so that elements that +// became disabled/hidden (e.g. Save when validation fails) or newly visible +// are reflected. +const focusableSelector = [ + '[tabindex]', + '[href]', + 'input', + 'select', + 'textarea', + 'button', + '[data-apos-focus-priority]' +] + .map(s => `${s}:not([tabindex="-1"]):not([disabled]):not([type="hidden"]):not([aria-hidden]):not(.apos-sr-only)`) + .join(', '); + const resizeSides = [ { edge: 'top', @@ -477,9 +493,11 @@ onUnmounted(() => { }); // Handle Tab on keydown — before the browser moves focus. -// Handling Tab on keyup is too late: the browser -// has already moved focus, so the cycling logic sees the wrong -// activeElement. +// Handling Tab on keyup is too late: the browser has already moved focus, +// so the cycling logic sees the wrong activeElement. +// +// We also recompute the focusable list here on every Tab, scoped to +// modalEl, instead of relying on the snapshot taken at trapFocus() time. function onKeydownTab(event) { if (!shouldTrapFocus.value) { return; @@ -490,7 +508,10 @@ function onKeydownTab(event) { if (event.target?.nodeName?.toLowerCase() === 'textarea') { return; } - cycleElementsToFocus(event, props.modalData.elementsToFocus); + const elements = getFocusableElements(modalEl.value); + // Keep the store snapshot consistent for other consumers. + store.updateModalData(props.modalData.id, { elementsToFocus: elements }); + cycleElementsToFocus(event, elements); } function onKeyup(event) { @@ -529,23 +550,20 @@ function captureFocus(e) { store.updateModalData(props.modalData.id, { focusedElement: e.target }); } +function getFocusableElements(rootEl) { + if (!rootEl) { + return []; + } + return [ ...rootEl.querySelectorAll(focusableSelector) ] + // a cheap "visible and in the DOM" condition, + // false positive expected for position: fixed and + // visually hidden elements (visible: hidden, opacity: 0, etc.) + .filter(el => el.offsetParent !== null); +} + async function trapFocus() { if (modalEl?.value) { - const elementSelectors = [ - '[tabindex]', - '[href]', - 'input', - 'select', - 'textarea', - 'button', - '[data-apos-focus-priority]' - ]; - - const selector = elementSelectors - .map(addExcludingAttributes) - .join(', '); - - const elementsToFocus = [ ...modalEl.value.querySelectorAll(selector) ]; + const elementsToFocus = getFocusableElements(modalEl.value); store.updateModalData(props.modalData.id, { elementsToFocus }); @@ -568,10 +586,6 @@ async function trapFocus() { await nextTick(); focusElement(props.modalData.focusedElement, firstElementToFocus); } - - function addExcludingAttributes(element) { - return `${element}:not([tabindex="-1"]):not([disabled]):not([type="hidden"]):not([aria-hidden]):not(.apos-sr-only)`; - } } function close() {