nextcloud-libraries / nextcloud-libraries/nextcloud-vue
NcModal: focus trap is activated on mount even when show=false, swallowing Tab on the entire page
Nobody has claimed this yet.
- Dominant language
- Vue
- Stars
- 246
- Forks
- 99
- Avg merge
- 1d 20h
- Merged PRs (30d)
- 103
Description
Summary
NcModal creates and activates its focus trap on mount, even when the modal is hidden (show=false). A permanently mounted, hidden NcModal therefore registers an active focus trap whose container is display: none. Since tabbable finds 0 tabbable nodes in that container, focus-trap answers every Tab keypress on the page with preventDefault without moving focus — keyboard navigation is dead for the entire page, not just the modal.
Steps to reproduce
- Mount a modal permanently and control it only via the
showprop (a pattern that was harmless with @nextcloud/vue 8 / Vue 2):<template> <div> <input placeholder="first" /> <input placeholder="second" /> <NcModal :show="showModal" name="Demo" @close="showModal = false"> <p>content</p> </NcModal> </div> </template> <script> export default { data: () => ({ showModal: false }) } </script> - Load the page, do not open the modal.
- Focus the first input and press Tab.
Expected behaviour
Focus moves to the second input. The focus trap should not exist while the modal is hidden.
Actual behaviour
Focus stays where it is; the Tab keydown is preventDefaulted. getTrapStack() (i.e. window._nc_focus_trap) already contains one active, unpaused trap right after page load, although no modal was ever opened. With two hidden modals mounted (e.g. an edit dialog and a view dialog), the stack contains two traps.
Analysis
NcModal.vueactivates the trap in theTransitionhookonAfterEnter(and clears it inonBeforeLeave/onUnmounted).- The
Transitionhas theappearattribute and the mask usesv-show, soonAfterEnteralso fires on the initial mount while the mask isdisplay: none. - The trap's keydown handler calls
findNextNavNode→ the hidden container has no tabbable nodes → destination falls back tomostRecentlyFocusedNode→event.preventDefault()+ focus stays. This happens for every Tab on the whole document because the trap is document-global.
Verified with @nextcloud/vue 9.8.0, Vue 3.5.34, focus-trap 8.2.1 (bundled), Nextcloud 34, reproduced in Chromium and Firefox/macOS. Pausing the trap manually (window._nc_focus_trap.at(-1).pause()) immediately restores normal Tab behaviour, and tabbable(visibleMask) finds all nodes — confirming the trap on the hidden container is the culprit.
Real-world impact: this shipped as an app bug in contractmanager 1.2.0–1.2.2 (keyboard navigation broken app-wide, reported by end users): cpcMomentum/contractmanager#266, fixed by mounting the modal with v-if (cpcMomentum/contractmanager#267).
Suggested fix
Only activate the focus trap when the modal is actually shown, e.g. guard useFocusTrap() with showModal.value, or watch showModal instead of relying on transition hooks that also fire for the initial hidden render.
Workaround for apps
Mount NcModal with v-if instead of keeping it mounted with :show only.
Related observation
Once the trap works correctly, it auto-focuses the first tabbable element (usually an input) on open — and useHotKey's shouldIgnoreEvent drops keyboard events originating from HTMLInputElement regardless of allowInModal, so Escape effectively never closes a freshly opened NcModal with form fields. That seems to be in scope of #7967, so I am not filing it separately; happy to do so if preferred.
Contributor guide
First steps
- Read the whole issue, then the project's contributing guide.
- Comment on the issue to say you are picking it up — it saves two people doing the same work.
- Fork the repository and make your change on a branch.
- Open a pull request that references the issue number.
Research direction
Start in NcModal.vue, tracing the Transition hooks and focus-trap setup described in the issue, then reproduce the permanently mounted modal with show=false and verify that Tab remains usable. Add coverage for the hidden-mount case and confirm that the trap activates only when the modal is shown, while still trapping focus after opening.
Written by the indexing model from the issue text.
Assessment
- Tech stack
- javascript
- Domain
- accessibility, frontend
- Issue type
- Bug
- Difficulty
- 3/5
- Estimated time
- 1-2 days
- Activity status
- Quiet
- Clarity
- Clearly specified
- Newbie friendliness
- 68/100