nextcloud-libraries / nextcloud-libraries/nextcloud-vue

NcModal: focus trap is activated on mount even when show=false, swallowing Tab on the entire page

Open
#8,723 0 comments 0 reactions 0 assignees View on GitHub

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
  1. Mount a modal permanently and control it only via the show prop (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>
    
  2. Load the page, do not open the modal.
  3. 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.vue activates the trap in the Transition hook onAfterEnter (and clears it in onBeforeLeave / onUnmounted).
  • The Transition has the appear attribute and the mask uses v-show, so onAfterEnter also fires on the initial mount while the mask is display: none.
  • The trap's keydown handler calls findNextNavNode → the hidden container has no tabbable nodes → destination falls back to mostRecentlyFocusedNodeevent.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

Open the contributing guide

First steps

  1. Read the whole issue, then the project's contributing guide.
  2. Comment on the issue to say you are picking it up — it saves two people doing the same work.
  3. Fork the repository and make your change on a branch.
  4. 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

Get new issues in your inbox

A short digest of beginner-friendly GitHub issues.