Skip to content

[#1660] Handle focus when focused node ancestor is removed#1861

Merged
stefcameron merged 2 commits into
masterfrom
fix-1660
May 3, 2026
Merged

[#1660] Handle focus when focused node ancestor is removed#1861
stefcameron merged 2 commits into
masterfrom
fix-1660

Conversation

@stefcameron

@stefcameron stefcameron commented May 3, 2026

Copy link
Copy Markdown
Member

Fixes #1660

Detect focused-node removal when an ancestor subtree is removed, and only force refocus when at least one trap container remains connected.

Add demo + Cypress regression coverage for the remove-parent path in the DOM-removal scenario.

PR Checklist

Please leave this checklist in your PR.

  • Issue being fixed is referenced.
  • Source changes maintain stated browser compatibility.
  • Web APIs introduced have deep browser coverage, including Safari (often very late to adopt new APIs).
  • Includes updated docs demo bundle if source/docs code was changed (run npm run demo-bundle in your branch and include the /docs/demo-bundle.js file that gets generated in your PR).
  • Unit test coverage added/updated.
  • E2E (i.e. demos) test coverage added/updated.
    • ⚠️ Non-covered demos (look for // TEST MANUALLY comments here) that can't be fully tested in Cypress have been manually verified.
  • Typings added/updated.
  • Changes do not break SSR:
    • Careful to test typeof document/window !== 'undefined' before using it in code that gets executed on load.
  • README updated (API changes, instructions, etc.).
  • Changes to dependencies explained.
  • Changeset added (run npm run changeset locally to add one, and follow the prompts).
    • EXCEPTION: A Changeset is not required if the change does not affect any of the source files that produce the package bundle. For example, demo changes, tooling changes, test updates, or a new dev-only dependency to run tests more efficiently should not have a Changeset since it will not affect package consumers.

Summary by CodeRabbit

  • Bug Fixes

    • Fixed focus-management so focus stays inside a trap when a focused element’s ancestor is removed.
  • New Features

    • Added a configurable delay for return-focus during deactivation.
    • Lifecycle callbacks now receive a context object with trap info.
  • Documentation

    • Demo updated to show removing a parent wrapper via a new button.
  • Tests

    • Demo test extended to cover ancestor-removal focus behavior.

Fixes #1660

Detect focused-node removal when an ancestor subtree is removed, and only force refocus when at least one trap container remains connected.

Add demo + Cypress regression coverage for the remove-parent path in the DOM-removal scenario.
@changeset-bot

changeset-bot Bot commented May 3, 2026

Copy link
Copy Markdown

🦋 Changeset detected

Latest commit: 4ac943a

The changes in this PR will be included in the next version bump.

This PR includes changesets to release 1 package
Name Type
focus-trap Patch

Not sure what this means? Click here to learn what changesets are.

Click here if you're a maintainer who wants to add another changeset to this PR

@coderabbitai

coderabbitai Bot commented May 3, 2026

Copy link
Copy Markdown

No actionable comments were generated in the recent review. 🎉

ℹ️ Recent review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro

Run ID: b063b0a9-6fea-4bdd-839c-d4d8fd2a0bdb

📥 Commits

Reviewing files that changed from the base of the PR and between 325d318 and 4ac943a.

📒 Files selected for processing (1)
  • index.js
🚧 Files skipped from review as they are similar to previous changes (1)
  • index.js

Walkthrough

This PR fixes a focus-management bug where removing an ancestor of the currently focused element inside a focus trap could cause focus to move to the document body. It updates DOM-removal detection and focus restoration logic, adjusts lifecycle callback signatures and return-focus timing, and extends demos/tests to cover ancestor removal.

Ancestor-Removal Bug Fix

Layer / File(s) Summary
Core Detection & Restoration
index.js
checkDomRemoval() now no-ops if mostRecentlyFocusedNode is unset, treats a removed node as matching when a removed node equals or contains the focused node, gates restoration on at least one connected container, refreshes tabbable state, and then calls tryFocus(getInitialFocusNode()) when appropriate.
Core Defaults & Deactivate Flow
docs/demo-bundle.js
Adds delayReturnFocus: true to defaults; deactivate() reads delayReturnFocus from deactivateOptions and conditionally delays return-focus via a completeDeactivation() helper.
Lifecycle Callback Signatures
docs/demo-bundle.js
Lifecycle callbacks (onActivate, onPostActivate, onDeactivate, onPostDeactivate, onPause, onPostPause, onUnpause, onPostUnpause) are invoked with an argument object { trap } instead of no args.
Demo DOM & Wiring
docs/index.html, docs/js/dom-remove.js, docs/demo-bundle.js
Adds #dom-remove-parent-wrapper and #dom-remove-parent-button to demo HTML; adds click handler removing the wrapper; demo bundle registers listener for dom-remove-parent-button.
Tests
cypress/e2e/focus-trap-demo.cy.js
Extends the "demo: removing elements" scenario to also remove the focused element's ancestor and assert focus remains inside the trap (first focusable element).
Release Metadata
.changeset/soft-socks-eat.md
New changeset entry documenting a patch release fixing issue #1660.

Sequence Diagram

sequenceDiagram
    participant User as User (click)
    participant DOM as DOM
    participant MO as MutationObserver
    participant Trap as FocusTrap
    participant Logic as checkDomRemoval / restore flow

    User->>DOM: click remove-parent or remove element
    DOM->>MO: mutation (removedNodes includes node/ancestor)
    MO->>Trap: notify mutation
    Trap->>Logic: checkDomRemoval()
    Logic->>Logic: if focusedNode absent -> return
    Logic->>Logic: detect removedNode === focusedNode OR removedNode.contains(focusedNode)
    Logic->>Trap: if containers.some(isConnected) then updateTabbableNodes()
    Logic->>Trap: compute getInitialFocusNode()
    Logic->>Trap: tryFocus(initialFocusNode)
    Trap-->>DOM: focus moves to initial focusable element
Loading

Estimated code review effort

🎯 3 (Moderate) | ⏱️ ~25 minutes

(Concise and slightly cheeky: the bug ran off to play hide-and-seek with focus; now it gets politely guided back.)

🚥 Pre-merge checks | ✅ 5
✅ Passed checks (5 passed)
Check name Status Explanation
Title check ✅ Passed Title clearly and specifically identifies the fix for issue #1660, describing the core change: handling focus when a focused node's ancestor is removed.
Description check ✅ Passed Description references the issue, summarizes the key changes, mentions demo and test coverage, and includes a completed checklist covering critical areas like browser compatibility, test coverage, and changeset addition.
Linked Issues check ✅ Passed The PR addresses issue #1660 by detecting ancestor-subtree removal via improved checkDomRemoval logic [index.js], ensuring focus stays within trap by checking if containers are connected, and adding demo/test coverage [docs files, cypress test] for the remove-parent scenario.
Out of Scope Changes check ✅ Passed All changes are directly scoped to fixing #1660: core logic updates, demo enhancements, test coverage, and changeset documentation. Callback signature changes in demo-bundle appear to be supporting refactoring for the fix.
Docstring Coverage ✅ Passed No functions found in the changed files to evaluate docstring coverage. Skipping docstring coverage check.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ Finishing Touches
📝 Generate docstrings
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch fix-1660

Tip

💬 Introducing Slack Agent: The best way for teams to turn conversations into code.

Slack Agent is built on CodeRabbit's deep understanding of your code, so your team can collaborate across the entire SDLC without losing context.

  • Generate code and open pull requests
  • Plan features and break down work
  • Investigate incidents and troubleshoot customer tickets together
  • Automate recurring tasks and respond to alerts with triggers
  • Summarize progress and report instantly

Built for teams:

  • Shared memory across your entire org—no repeating context
  • Per-thread sandboxes to safely plan and execute work
  • Governance built-in—scoped access, auditability, and budget controls

One agent for your entire SDLC. Right inside Slack.

👉 Get started


Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share
Review rate limit: 7/8 reviews remaining, refill in 7 minutes and 30 seconds.

Comment @coderabbitai help to get the list of available commands and usage tips.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 1

🧹 Nitpick comments (1)
cypress/e2e/focus-trap-demo.cy.js (1)

1849-1850: ⚡ Quick win

Tighten this assertion so it can’t pass on the “right text, wrong element.”

Line 1850 validates focused text, but not that focus stayed inside #demo-dom-remove. Since "with" appears in multiple demos, this can mask an escape. Add a containment check on the focused node.

Suggested tweak
 cy.get('@testRoot').find('#dom-remove-parent-button').click();
-cy.focused().should('not.be.undefined').and('have.text', 'with');
+cy.focused()
+  .should('not.be.undefined')
+  .and('have.text', 'with')
+  .and(($el) => {
+    expect($el.closest('#demo-dom-remove').length).to.equal(1);
+  });
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@cypress/e2e/focus-trap-demo.cy.js` around lines 1849 - 1850, The current
assertion checks only the focused element's text and can pass if another element
elsewhere contains "with"; modify the assertion so cy.focused() is also asserted
to be inside the demo container (`#demo-dom-remove`). Update the chain after
clicking '#dom-remove-parent-button' to assert both
cy.focused().should('have.text', 'with') and that the focused node is contained
within '#demo-dom-remove' (e.g., add an assertion like .and('be.descendantOf',
'#demo-dom-remove') or equivalent) so focus is verified to remain inside the
correct demo root referenced by '@testRoot' and '#demo-dom-remove'.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In `@index.js`:
- Around line 999-1005: The recovery path uses getInitialFocusNode() before
refreshing tabbable state, so if the removed ancestor contained the trap’s first
tabbable or the configured initial focus the call can return a detached element;
update the logic in the block that checks isFocusedNodeRemoved and
state.containers (the code that then calls tryFocus(getInitialFocusNode())) to
recompute/refresh tabbables/tabbableGroups first (invoke whatever function
updates state.tabbableGroups or recomputeTabbables used elsewhere) and only then
call getInitialFocusNode() and tryFocus so the recovery target is always a
current, connected element.

---

Nitpick comments:
In `@cypress/e2e/focus-trap-demo.cy.js`:
- Around line 1849-1850: The current assertion checks only the focused element's
text and can pass if another element elsewhere contains "with"; modify the
assertion so cy.focused() is also asserted to be inside the demo container
(`#demo-dom-remove`). Update the chain after clicking '#dom-remove-parent-button'
to assert both cy.focused().should('have.text', 'with') and that the focused
node is contained within '#demo-dom-remove' (e.g., add an assertion like
.and('be.descendantOf', '#demo-dom-remove') or equivalent) so focus is verified
to remain inside the correct demo root referenced by '@testRoot' and
'#demo-dom-remove'.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro

Run ID: af0435f7-1218-4202-a532-5e112a96cc35

📥 Commits

Reviewing files that changed from the base of the PR and between 567dbe1 and 325d318.

⛔ Files ignored due to path filters (1)
  • docs/demo-bundle.js.map is excluded by !**/*.map
📒 Files selected for processing (6)
  • .changeset/soft-socks-eat.md
  • cypress/e2e/focus-trap-demo.cy.js
  • docs/demo-bundle.js
  • docs/index.html
  • docs/js/dom-remove.js
  • index.js

Comment thread index.js Outdated
@stefcameron stefcameron merged commit b70e8d9 into master May 3, 2026
3 checks passed
@stefcameron stefcameron deleted the fix-1660 branch May 3, 2026 21:43
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Focus jumps to body when focused element parent is removed

1 participant