Skip to content

Conversation

@ldelhommeau
Copy link
Contributor

@ldelhommeau ldelhommeau commented Dec 23, 2025

🔗 Linked issue

#1667

This PR only addresses the FocusScope issue, not the dismissible layer issue mentioned in the original issue.

When using an input field inside a Dialog that dynamically updates content within the Dialog, the focus keeps switching back to DialogContent, disrupting the user experience

❓ Type of change

  • 📖 Documentation (updates to the documentation, readme or JSdoc annotations)
  • 🐞 Bug fix (a non-breaking change that fixes an issue)
  • 👌 Enhancement (improving an existing functionality like performance)
  • ✨ New feature (a non-breaking change that adds functionality) <-- not sure if it's a feature or an enhancement
  • 🧹 Chore (updates to the build process or auxiliary tools and libraries)
  • ⚠️ Breaking change (fix or feature that would cause existing functionality to change)

📚 Description

Resolves #1667 partially, just about the focus scope issue:

When using an input field inside a Dialog that dynamically updates content within the Dialog, the focus keeps switching back to DialogContent, disrupting the user experience

📸 Screenshots (if appropriate)

Now the FocusScope behaves correctly for the dialog components when they are teleported inside the shadow root:
https://github.com/user-attachments/assets/084fa28d-9cb6-4d82-be6b-7bfe9845e1f8

📝 Checklist

  • I have linked an issue or discussion.
  • I have updated the documentation accordingly. <-- I don't know if I should add any documentation on this, the only doc I found was docs/content/docs/utilities/focus-scope.md, but I don't think it's useful to add implementation details about the shadow root inside.

Summary by CodeRabbit

  • New Features

    • FocusScope now respects Shadow DOM boundaries: focus trapping, Tab navigation, and looping work across light and shadow roots and include tabbable elements inside shadow roots.
  • Documentation

    • New Storybook examples demonstrating dialogs and focusable elements in both light DOM and shadow roots.
  • Tests

    • Comprehensive shadow DOM test coverage, including mixed light/shadow scenarios and focus-loop validation.

✏️ Tip: You can customize this high-level summary in your review settings.

@pkg-pr-new
Copy link

pkg-pr-new bot commented Dec 23, 2025

Open in StackBlitz

npm i https://pkg.pr.new/reka-ui@2352

commit: e57629c

@coderabbitai
Copy link

coderabbitai bot commented Jan 14, 2026

📝 Walkthrough

Walkthrough

FocusScope gained Shadow DOM compatibility: event listeners and MutationObserver now resolve and bind to the element's root (Document or ShadowRoot); tabbable discovery recurses into shadow roots; Tab handling and focus trapping operate relative to the resolved root; tests and Storybook examples for mixed light/shadow scenarios were added.

Changes

Cohort / File(s) Summary
Core FocusScope Implementation
packages/core/src/FocusScope/FocusScope.vue
Add getEventRoot() to resolve Document vs ShadowRoot; bind/unbind focusin/focusout and MutationObserver to the resolved root; narrow event typing and make Tab handling root-aware for focus looping.
Tabbable Candidate Discovery
packages/core/src/FocusScope/utils.ts
getTabbableCandidates(container: HTMLElement | ShadowRoot) now recurses into encountered shadowRoots and merges their tabbable nodes into the candidate set.
Shadow DOM Tests
packages/core/src/FocusScope/FocusScope.test.ts
Add renderInShadowRoot, getActiveElement helpers and extensive tests covering shadowDomOnly, mixedBodyAndShadowDom, and bodyOnly focus-loop scenarios; ensure cleanup (unmount, host removal).
Storybook & Shadow Helpers
packages/core/src/FocusScope/story/FocusScopeShadowRoot.story.vue, packages/core/src/FocusScope/story/shadowDom/ShadowRootContainer.vue, packages/core/src/FocusScope/story/shadowDom/_Dialog.vue, packages/core/src/FocusScope/story/shadowDom/FocusableLayersElements.vue
Add Story demonstrating body vs shadow vs mixed cases; add ShadowRootContainer to mount shadow-scoped app and copy styles; add dialog variant and simple focusable elements for demo/testing.

Sequence Diagram(s)

sequenceDiagram
    participant User
    participant FocusScope
    participant getEventRoot
    participant Root as Root(ShadowRoot/Document)
    participant TabHandler
    participant getTabbable

    User->>FocusScope: focusin/focusout event or Tab key
    FocusScope->>getEventRoot: resolve root for container
    getEventRoot-->>FocusScope: return Root
    FocusScope->>Root: attach/remove listeners / observe mutations
    Root-->>FocusScope: emit focus event or keydown

    alt Tab pressed
        FocusScope->>TabHandler: handle Tab within resolved root
        TabHandler->>getTabbable: collect tabbable candidates from container/root
        getTabbable->>getTabbable: recurse into shadowRoots
        getTabbable-->>TabHandler: merged candidates
        TabHandler->>FocusScope: move focus to next candidate (support looping)
    else focusin/focusout
        FocusScope->>FocusScope: update internal focus state
    end
Loading

Estimated code review effort

🎯 4 (Complex) | ⏱️ ~45 minutes

Possibly related PRs

Poem

🐰 I dug a tiny tunnel bright,
through shadow roots and soft moonlight.
Tabs now hop without a fight,
focus finds the way just right,
joy in dark and day—what a sight! ✨

🚥 Pre-merge checks | ✅ 4 | ❌ 1
❌ Failed checks (1 warning)
Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 14.29% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (4 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title directly describes the main change: adding shadow root support to the FocusScope component, which aligns with all modified files and the PR objective.
Linked Issues check ✅ Passed The PR implements shadow DOM support for FocusScope as required by issue #1667, including focus event handling rooted to shadow DOM, tabbable candidate recursion through shadow boundaries, and comprehensive testing.
Out of Scope Changes check ✅ Passed All changes are scoped to FocusScope shadow DOM support; test utilities, story files, and helper components are necessary supporting infrastructure for the feature.

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

✨ Finishing touches
  • 📝 Generate docstrings

🧹 Recent nitpick comments
packages/core/src/FocusScope/FocusScope.vue (1)

233-264: Silent no-op when focused element is outside tabbable candidates in shadow DOM.

When currentIndex !== -1 but nextIndex goes out of bounds (e.g., due to DOM mutations between edge checks and candidate retrieval), nextElement will be undefined. Since event.preventDefault() was already called at line 241, the tab key press becomes a no-op with no focus change.

Consider adding a fallback or guard:

♻️ Suggested defensive handling
         const nextElement = allTabbable[nextIndex]
         if (nextElement) {
           focus(nextElement, { select: true })
         }
+        else if (props.loop) {
+          // Fallback to edge looping if nextIndex went out of bounds
+          focus(event.shiftKey ? last : first, { select: true })
+        }
       }
packages/core/src/FocusScope/story/shadowDom/ShadowRootContainer.vue (1)

9-9: Missing cleanup on component unmount may leak the shadow app.

The shadowApp is stored at module level and the watch doesn't provide an onCleanup callback. When the ShadowRootContainer component unmounts, the Vue app inside the shadow root is not cleaned up, potentially leaking event listeners and memory.

For a test/story component this is likely acceptable, but consider adding explicit cleanup:

♻️ Suggested fix with onUnmounted
+import { createApp, onUnmounted, useTemplateRef, watch } from 'vue'
-import { createApp, useTemplateRef, watch } from 'vue'

 const container = useTemplateRef('container')

+onUnmounted(() => {
+  if (shadowApp) {
+    shadowApp.unmount()
+    shadowApp = null
+  }
+})
+
 watch(
   container,

Also applies to: 46-55

packages/core/src/FocusScope/FocusScope.test.ts (1)

320-346: Test name could be more accurate for the bodyOnly case.

The test is named "should loop focus within the FocusScope in the ShadowRoot" but it also runs for the bodyOnly case which has no shadow root involvement. Consider a more generic name.

♻️ Suggested rename
-        it('should loop focus within the FocusScope in the ShadowRoot', async () => {
+        it('should loop focus within the FocusScope', async () => {

📜 Recent review details

Configuration used: defaults

Review profile: CHILL

Plan: Pro

📥 Commits

Reviewing files that changed from the base of the PR and between 755008e and e57629c.

📒 Files selected for processing (7)
  • packages/core/src/FocusScope/FocusScope.test.ts
  • packages/core/src/FocusScope/FocusScope.vue
  • packages/core/src/FocusScope/story/FocusScopeShadowRoot.story.vue
  • packages/core/src/FocusScope/story/shadowDom/FocusableLayersElements.vue
  • packages/core/src/FocusScope/story/shadowDom/ShadowRootContainer.vue
  • packages/core/src/FocusScope/story/shadowDom/_Dialog.vue
  • packages/core/src/FocusScope/utils.ts
🚧 Files skipped from review as they are similar to previous changes (4)
  • packages/core/src/FocusScope/story/FocusScopeShadowRoot.story.vue
  • packages/core/src/FocusScope/story/shadowDom/FocusableLayersElements.vue
  • packages/core/src/FocusScope/story/shadowDom/_Dialog.vue
  • packages/core/src/FocusScope/utils.ts
🧰 Additional context used
🧬 Code graph analysis (1)
packages/core/src/FocusScope/FocusScope.test.ts (2)
packages/core/src/shared/index.ts (1)
  • getActiveElement (6-6)
packages/plugins/src/namespaced/index.ts (1)
  • Dialog (275-293)
🪛 ast-grep (0.40.5)
packages/core/src/FocusScope/FocusScope.test.ts

[warning] 262-262: Direct modification of innerHTML or outerHTML properties detected. Modifying these properties with unsanitized user input can lead to XSS vulnerabilities. Use safe alternatives or sanitize content first.
Context: document.body.innerHTML = ''
Note: [CWE-79] Improper Neutralization of Input During Web Page Generation [REFERENCES]
- https://owasp.org/www-community/xss-filter-evasion-cheatsheet
- https://cwe.mitre.org/data/definitions/79.html

(dom-content-modification)

🔇 Additional comments (7)
packages/core/src/FocusScope/FocusScope.vue (2)

71-76: LGTM - Clean helper for determining event root.

The getEventRoot function correctly returns the ShadowRoot when the container is within a shadow DOM, falling back to document otherwise. This enables proper event listener scoping.


86-96: LGTM - Type guards enable dual Document/ShadowRoot event handling.

The Event type parameter with instanceof FocusEvent guard is the correct approach since ShadowRoot.addEventListener accepts EventListener (generic Event), while Document.addEventListener for focus events provides FocusEvent. This avoids type casting issues while maintaining runtime safety.

Also applies to: 98-123

packages/core/src/FocusScope/story/shadowDom/ShadowRootContainer.vue (1)

11-31: LGTM - Shadow root mounting logic is well-structured.

The function correctly handles existing shadow roots, clones document styles into the shadow DOM for proper styling, and creates appropriate mount points. The portal target setup enables teleporting content within the shadow boundary.

packages/core/src/FocusScope/FocusScope.test.ts (4)

143-159: LGTM - Well-designed shadow root test utilities.

The renderInShadowRoot helper properly creates an isolated shadow DOM environment for testing, and the local getActiveElement correctly retrieves focus state from the shadow root context. This mirrors the production getActiveElement behavior for shadow DOM.


161-197: LGTM - Critical regression test for the original issue.

This test directly validates the bug reported in issue #1667: focus staying on an input while dynamically adding/removing elements in shadow DOM. The try/finally cleanup pattern ensures the shadow host is removed even if assertions fail.


262-263: Static analysis false positive - safe DOM clearing for test isolation.

The document.body.innerHTML = '' warning is a false positive. This is a test file using a literal empty string to reset the DOM between test cases, not user-controlled input. No action needed.


204-218: LGTM - Comprehensive test matrix for shadow DOM scenarios.

The three-case matrix (shadowDomOnly, mixedBodyAndShadowDom, bodyOnly) provides excellent coverage of the various DOM configurations where FocusScope must operate. This ensures the shadow root changes don't regress light DOM behavior.

✏️ Tip: You can disable this entire section by setting review_details to false in your review settings.


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

Copy link

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

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

Actionable comments posted: 2

🤖 Fix all issues with AI agents
In `@packages/core/src/FocusScope/FocusScope.test.ts`:
- Around line 348-357: The test uses un-awaited waitFor calls after
userEvent.tab() which can cause flakiness; update the FocusScope.test assertions
to await each waitFor invocation (the ones asserting queryRoot.activeElement,
document.activeElement/shadowRoot.activeElement, and the final loop-back check)
so that the async assertions complete before proceeding—specifically add await
before each waitFor that wraps expect(...) to ensure the test waits for the
focus changes triggered by userEvent.tab().

In `@packages/core/src/FocusScope/story/shadowDom/DialogShadowRoot.vue`:
- Line 46: The template passes a :portal-target="portalTarget" to
FocusableLayersElements but FocusableLayersElements.vue declares no props, so
this attribute is a fallthrough; either remove the :portal-target binding from
DialogShadowRoot.vue if nested portals don’t need it, or add a prop definition
named portalTarget (kebab portal-target / camel portalTarget) to
FocusableLayersElements.vue (with an appropriate type like Element|String|null
and default null) and forward it where needed for nested portal mounting; update
usages inside FocusableLayersElements.vue to read this.portalTarget when
rendering portals.
🧹 Nitpick comments (7)
packages/core/src/FocusScope/utils.ts (1)

52-56: Side-effect in acceptNode filter is unconventional but works.

The recursive collection of shadow DOM nodes inside the acceptNode callback is a side-effect pattern that works but can be surprising. Consider extracting this to a separate traversal step for clarity.

Also, the cast as unknown as HTMLElement is technically imprecise since shadowRoot is a ShadowRoot (extends DocumentFragment), but it works because createTreeWalker accepts any Node.

packages/core/src/FocusScope/FocusScope.vue (2)

132-154: Consider reusing the stored root reference for cleanup.

The cleanup function recomputes getEventRoot(container) at line 146. While unlikely in practice, if the DOM structure changed during the component lifecycle, this could remove listeners from a different root than where they were added.

♻️ Suggested refactor to reuse root reference
   const root = getEventRoot(container)
   if (root instanceof Document) {
     root.addEventListener('focusin', handleFocusIn)
     root.addEventListener('focusout', handleFocusOut)
   }
   else {
     root.addEventListener('focusin', handleFocusIn as EventListener)
     root.addEventListener('focusout', handleFocusOut as EventListener)
   }
   const mutationObserver = new MutationObserver(handleMutations)
   if (container)
     mutationObserver.observe(container, { childList: true, subtree: true })

   cleanupFn(() => {
-    const cleanupRoot = getEventRoot(container)
-    if (cleanupRoot instanceof Document) {
-      cleanupRoot.removeEventListener('focusin', handleFocusIn)
-      cleanupRoot.removeEventListener('focusout', handleFocusOut)
+    if (root instanceof Document) {
+      root.removeEventListener('focusin', handleFocusIn)
+      root.removeEventListener('focusout', handleFocusOut)
     }
     else {
-      cleanupRoot.removeEventListener('focusin', handleFocusIn as EventListener)
-      cleanupRoot.removeEventListener('focusout', handleFocusOut as EventListener)
+      root.removeEventListener('focusin', handleFocusIn as EventListener)
+      root.removeEventListener('focusout', handleFocusOut as EventListener)
     }
     mutationObserver.disconnect()
   })

195-197: Fallback to document.body on unmount may be suboptimal in shadow DOM contexts.

When unmounting within a shadow root, if previouslyFocusedElement is null, falling back to document.body might not provide the best UX. Consider whether the shadow host or a designated element would be more appropriate.

This is a minor edge case and acceptable for now.

packages/core/src/FocusScope/FocusScope.test.ts (2)

155-160: Consider handling regular document root for robustness.

The getActiveElement helper returns null when the root is not a ShadowRoot. While this works for current shadow DOM-only usage, returning document.activeElement for regular document roots would make the helper more versatile.

♻️ Optional improvement
 function getActiveElement(container: Element): Element | null {
   const root = container.getRootNode()
   if ((root as ShadowRoot).host)
     return (root as ShadowRoot).activeElement
-  return null
+  return (root as Document).activeElement
 }

339-339: Test description may be misleading for bodyOnly case.

The test name "should loop focus within the FocusScope in the ShadowRoot" doesn't accurately describe the bodyOnly case, which tests focus looping in the regular document body without a ShadowRoot.

♻️ Suggested improvement
-      it('should loop focus within the FocusScope in the ShadowRoot', async () => {
+      it('should loop focus within the FocusScope', async () => {
packages/core/src/FocusScope/story/shadowDom/ShadowRootContainer.vue (2)

25-27: Vue app instance not unmounted on cleanup - potential memory leak.

The createApp creates a new Vue app instance, but it's never stored or explicitly unmounted when resetShadowRoot is called or when the component unmounts. For story/demo purposes this is likely acceptable, but in production usage this could cause memory leaks.

♻️ Suggested fix to properly manage app lifecycle
+import type { App, Component } from 'vue'
-import type { Component } from 'vue'
-import { createApp, useTemplateRef, watch } from 'vue'
+import { createApp, onBeforeUnmount, useTemplateRef, watch } from 'vue'
 import DialogShadowRoot from './DialogShadowRoot.vue'
 import FocusableLayersElements from './FocusableLayersElements.vue'

 const props = defineProps<{ withDialog?: boolean }>()

+let currentApp: App | null = null
+
 function mountShadowRoot(container: HTMLDivElement, component: Component) {
   const elementWithShadow = container as Element & { shadowRoot: ShadowRoot | null }
   const shadowRoot
     = elementWithShadow.shadowRoot || elementWithShadow.attachShadow({ mode: 'open' })

   document.querySelectorAll('style, link[rel="stylesheet"]').forEach((node) => {
     shadowRoot.appendChild(node.cloneNode(true))
   })

   const shadowMountPoint = document.createElement('div')
   shadowRoot.appendChild(shadowMountPoint)

   const shadowPortalTarget = document.createElement('div')
   shadowPortalTarget.id = `portal-shadow-root`
   shadowRoot.appendChild(shadowPortalTarget)

-  createApp(component, {
+  currentApp = createApp(component, {
     portalTarget: shadowPortalTarget,
   }).mount(shadowMountPoint)
 }

 function resetShadowRoot(container: HTMLDivElement) {
+  if (currentApp) {
+    currentApp.unmount()
+    currentApp = null
+  }
   const elementWithShadow = container as Element & { shadowRoot: ShadowRoot | null }
   if (elementWithShadow.shadowRoot) {
     elementWithShadow.shadowRoot.innerHTML = ''
   }
 }

+onBeforeUnmount(() => {
+  if (currentApp) {
+    currentApp.unmount()
+    currentApp = null
+  }
+})

21-23: Static portal target ID may cause conflicts with multiple containers.

The portal target uses a hardcoded ID portal-shadow-root. If multiple ShadowRootContainer instances exist in the same document, this could cause ID conflicts. Since this is a story/demo component, this is likely acceptable, but consider using a unique ID if this pattern is reused elsewhere.

📜 Review details

Configuration used: defaults

Review profile: CHILL

Plan: Pro

📥 Commits

Reviewing files that changed from the base of the PR and between 4022163 and 11cff99.

📒 Files selected for processing (9)
  • packages/core/src/FocusScope/FocusScope.test.ts
  • packages/core/src/FocusScope/FocusScope.vue
  • packages/core/src/FocusScope/story/FocusScopeShadowRoot.story.vue
  • packages/core/src/FocusScope/story/shadowDom/DialogBodyOnly.vue
  • packages/core/src/FocusScope/story/shadowDom/DialogMixedBodyAndShadowRoot.vue
  • packages/core/src/FocusScope/story/shadowDom/DialogShadowRoot.vue
  • packages/core/src/FocusScope/story/shadowDom/FocusableLayersElements.vue
  • packages/core/src/FocusScope/story/shadowDom/ShadowRootContainer.vue
  • packages/core/src/FocusScope/utils.ts
🧰 Additional context used
🪛 ast-grep (0.40.5)
packages/core/src/FocusScope/FocusScope.test.ts

[warning] 263-263: Direct modification of innerHTML or outerHTML properties detected. Modifying these properties with unsanitized user input can lead to XSS vulnerabilities. Use safe alternatives or sanitize content first.
Context: document.body.innerHTML = ''
Note: [CWE-79] Improper Neutralization of Input During Web Page Generation [REFERENCES]
- https://owasp.org/www-community/xss-filter-evasion-cheatsheet
- https://cwe.mitre.org/data/definitions/79.html

(dom-content-modification)

⏰ Context from checks skipped due to timeout of 90000ms. You can increase the timeout in your CodeRabbit configuration to a maximum of 15 minutes (900000ms). (1)
  • GitHub Check: test
🔇 Additional comments (11)
packages/core/src/FocusScope/FocusScope.vue (1)

71-76: LGTM!

The getEventRoot helper correctly uses getRootNode() to determine the appropriate event target for shadow DOM vs. document contexts. The fallback to document when not in a shadow root is appropriate.

packages/core/src/FocusScope/story/shadowDom/FocusableLayersElements.vue (1)

1-15: LGTM!

Simple and clean story component for testing focus behavior within shadow DOM contexts.

packages/core/src/FocusScope/story/FocusScopeShadowRoot.story.vue (1)

1-15: LGTM!

Clean Storybook story setup for demonstrating FocusScope behavior with shadow DOM.

packages/core/src/FocusScope/story/shadowDom/DialogShadowRoot.vue (1)

22-59: LGTM!

The Dialog composition correctly uses DialogPortal with a custom target for shadow DOM rendering. The structure follows established patterns and properly demonstrates focus management in shadow DOM contexts.

packages/core/src/FocusScope/utils.ts (1)

80-92: isHidden limitation with shadow boundaries is an existing design constraint.

The parentElement traversal in isHidden doesn't cross shadow DOM boundaries. However, this is intentional by design: the function is always called with an upTo parameter (from findVisible()) that explicitly stops visibility checks at the container boundary. Elements are only checked for visibility within their containing scope, and the container itself (whether in light DOM or shadow DOM) serves as the boundary—not something this function checks beyond.

Extensive shadow DOM test coverage confirms this design works correctly across all scenarios (shadow-only, mixed body/shadow, and body-only cases).

packages/core/src/FocusScope/story/shadowDom/DialogBodyOnly.vue (1)

1-18: LGTM - Story component for light DOM dialog testing.

The component correctly demonstrates dialog usage with proper accessibility attributes and test IDs. Minor note: there's a trailing empty line in the import block (line 12) that could be cleaned up for consistency.

packages/core/src/FocusScope/FocusScope.test.ts (3)

1-12: LGTM - Appropriate imports for shadow DOM testing.

The new imports correctly support the shadow DOM test scenarios being added.


30-30: Good clarity improvement.

Renaming the test suite to explicitly indicate "(light DOM)" improves readability and distinguishes it from the new shadow DOM tests.


263-264: Static analysis false positive - safe test cleanup.

The document.body.innerHTML = '' on line 264 is safe test cleanup code that resets the DOM between tests. No user input is involved, so there's no XSS risk.

packages/core/src/FocusScope/story/shadowDom/DialogMixedBodyAndShadowRoot.vue (1)

1-57: LGTM - Well-structured mixed DOM story component.

The component correctly demonstrates the mixed scenario where the dialog lives in the document body while the form content is rendered inside a shadow root via ShadowRootContainer. This is an important test case for validating focus management across DOM boundaries.

packages/core/src/FocusScope/story/shadowDom/ShadowRootContainer.vue (1)

51-56: LGTM - Clean template structure.

The template is straightforward and correctly sets up the container element for shadow root attachment.

✏️ Tip: You can disable this entire section by setting review_details to false in your review settings.

@ldelhommeau ldelhommeau force-pushed the feat/focus-scope-shadow-root branch from 3624989 to c221d9a Compare January 15, 2026 11:08
Copy link

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

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

Actionable comments posted: 2

🤖 Fix all issues with AI agents
In `@packages/core/src/FocusScope/FocusScope.vue`:
- Around line 132-154: Capture the event root once after calling
getEventRoot(container) (the existing const root) and close over that value in
the cleanupFn instead of calling getEventRoot(container) again; update cleanupFn
to reference the previously computed root when removing listeners (for both
Document and non-Document branches) so handleFocusIn/handleFocusOut are removed
from the same root they were added to, and do not re-resolve container which can
be detached and return a different root.

In `@packages/core/src/FocusScope/story/shadowDom/ShadowRootContainer.vue`:
- Around line 25-27: createApp(...).mount(...) is called without keeping the
returned app instance so the Vue app isn't unmounted before resetShadowRoot
clears innerHTML; store the result of createApp(...) in a variable (e.g.,
shadowApp) when mounting to shadowMountPoint, and call shadowApp.unmount()
inside resetShadowRoot (or before setting shadowRoot.innerHTML = '') to properly
teardown the Vue app and avoid leaks; apply the same change for the other
occurrences around lines 30-35 that mount into
shadowPortalTarget/shadowMountPoint.
♻️ Duplicate comments (2)
packages/core/src/FocusScope/story/shadowDom/DialogShadowRoot.vue (1)

46-46: FocusableLayersElements does not accept a portal-target prop.

This was flagged in a previous review. The prop will be ignored or treated as a fallthrough attribute.

packages/core/src/FocusScope/FocusScope.test.ts (1)

348-356: Missing await on waitFor calls will cause flaky tests.

The waitFor calls are not awaited, which means assertions may complete after the test proceeds. This can lead to false positives or intermittent failures.

🐛 Proposed fix
       await userEvent.tab()
-      waitFor(() => expect(queryRoot.activeElement).toBe(emailInput))
+      await waitFor(() => expect(queryRoot.activeElement).toBe(emailInput))
       await userEvent.tab()
-      waitFor(() => expect(queryRoot.activeElement).toBe(submitButton))
+      await waitFor(() => expect(queryRoot.activeElement).toBe(submitButton))
       await userEvent.tab()
-      waitFor(() => expect(testCase === 'shadowDomOnly' ? shadowRoot.activeElement : document.activeElement).toBe(closeDialogButton))
+      await waitFor(() => expect(testCase === 'shadowDomOnly' ? shadowRoot.activeElement : document.activeElement).toBe(closeDialogButton))
       // Tab again should loop back to the first focusable element
       await userEvent.tab()
-      waitFor(() => expect(queryRoot.activeElement).toBe(nameInput))
+      await waitFor(() => expect(queryRoot.activeElement).toBe(nameInput))
🧹 Nitpick comments (1)
packages/core/src/FocusScope/FocusScope.test.ts (1)

339-339: Test description is misleading for the bodyOnly case.

The description "should loop focus within the FocusScope in the ShadowRoot" doesn't accurately describe the bodyOnly test case, which operates entirely in the light DOM.

✏️ Suggested improvement
-      it('should loop focus within the FocusScope in the ShadowRoot', async () => {
+      it('should loop focus within the FocusScope', async () => {
📜 Review details

Configuration used: defaults

Review profile: CHILL

Plan: Pro

📥 Commits

Reviewing files that changed from the base of the PR and between 3624989 and c221d9a.

📒 Files selected for processing (9)
  • packages/core/src/FocusScope/FocusScope.test.ts
  • packages/core/src/FocusScope/FocusScope.vue
  • packages/core/src/FocusScope/story/FocusScopeShadowRoot.story.vue
  • packages/core/src/FocusScope/story/shadowDom/DialogBodyOnly.vue
  • packages/core/src/FocusScope/story/shadowDom/DialogMixedBodyAndShadowRoot.vue
  • packages/core/src/FocusScope/story/shadowDom/DialogShadowRoot.vue
  • packages/core/src/FocusScope/story/shadowDom/FocusableLayersElements.vue
  • packages/core/src/FocusScope/story/shadowDom/ShadowRootContainer.vue
  • packages/core/src/FocusScope/utils.ts
🚧 Files skipped from review as they are similar to previous changes (3)
  • packages/core/src/FocusScope/story/shadowDom/DialogBodyOnly.vue
  • packages/core/src/FocusScope/story/FocusScopeShadowRoot.story.vue
  • packages/core/src/FocusScope/story/shadowDom/FocusableLayersElements.vue
🧰 Additional context used
🪛 ast-grep (0.40.5)
packages/core/src/FocusScope/FocusScope.test.ts

[warning] 263-263: Direct modification of innerHTML or outerHTML properties detected. Modifying these properties with unsanitized user input can lead to XSS vulnerabilities. Use safe alternatives or sanitize content first.
Context: document.body.innerHTML = ''
Note: [CWE-79] Improper Neutralization of Input During Web Page Generation [REFERENCES]
- https://owasp.org/www-community/xss-filter-evasion-cheatsheet
- https://cwe.mitre.org/data/definitions/79.html

(dom-content-modification)

⏰ Context from checks skipped due to timeout of 90000ms. You can increase the timeout in your CodeRabbit configuration to a maximum of 15 minutes (900000ms). (1)
  • GitHub Check: test
🔇 Additional comments (9)
packages/core/src/FocusScope/utils.ts (1)

42-65: LGTM! Shadow DOM traversal implemented correctly.

The recursive handling properly collects tabbable candidates from shadow roots while still evaluating the host element's tabbability independently. The approach correctly handles nested shadow DOMs.

packages/core/src/FocusScope/story/shadowDom/DialogMixedBodyAndShadowRoot.vue (1)

1-57: LGTM!

The story component is well-structured and demonstrates the mixed body/shadow-root dialog scenario effectively. Accessibility is properly handled with the aria-label on the close button.

packages/core/src/FocusScope/story/shadowDom/DialogShadowRoot.vue (1)

1-45: LGTM!

The dialog structure and accessibility handling look correct. The component properly uses the portalTarget prop to render the dialog portal inside the shadow root.

Also applies to: 47-60

packages/core/src/FocusScope/FocusScope.vue (1)

71-76: LGTM on the helper function.

getEventRoot correctly determines whether to use the ShadowRoot or document based on the container's root node.

packages/core/src/FocusScope/FocusScope.test.ts (5)

1-12: LGTM!

The new imports are well-organized and all appear to be utilized in the shadow DOM test suite.


30-30: LGTM!

Good practice to distinguish the existing light DOM tests from the new shadow root tests.


145-160: LGTM!

The helper functions are well-designed for shadow DOM testing:

  • renderInShadowRoot properly creates and manages the shadow DOM container
  • getActiveElement correctly retrieves the active element from the shadow root context

162-198: LGTM!

This test directly validates the core issue from the PR objectives - ensuring focus remains on an input inside a Shadow DOM when content dynamically updates. Good use of try/finally for cleanup.


263-264: Test cleanup pattern is safe here.

The static analysis warning about innerHTML modification is a false positive in this test context. Clearing document.body.innerHTML before each test is a standard practice for test isolation and doesn't involve user input.

✏️ Tip: You can disable this entire section by setting review_details to false in your review settings.

@ldelhommeau ldelhommeau marked this pull request as draft January 15, 2026 11:26
@ldelhommeau ldelhommeau force-pushed the feat/focus-scope-shadow-root branch 2 times, most recently from 932bfbd to 6971453 Compare January 15, 2026 12:50
@ldelhommeau ldelhommeau marked this pull request as ready for review January 15, 2026 12:53
Copy link

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

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

Actionable comments posted: 0

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (1)
packages/core/src/FocusScope/FocusScope.test.ts (1)

65-65: Missing await on waitFor may cause flaky test.

This waitFor call is not awaited, which means the assertion may not complete before the test ends. This pattern also appears at lines 108 and 138.

🐛 Proposed fix
-      waitFor(() => expect(tabbableLast).toBe(document.activeElement))
+      await waitFor(() => expect(tabbableLast).toBe(document.activeElement))
🧹 Nitpick comments (3)
packages/core/src/FocusScope/story/shadowDom/ShadowRootContainer.vue (2)

9-9: Module-level shadowApp may cause issues with multiple instances.

The shadowApp variable is scoped to the module rather than the component instance. If multiple ShadowRootContainer components are mounted simultaneously, they would share this variable, causing the second instance to overwrite the first's reference.

For a story/demo component with single-instance usage, this is likely acceptable. However, if you anticipate multiple simultaneous instances, consider moving this into the component's reactive state.


44-55: Consider adding cleanup on component unmount.

The watch handles mounting and resetting when the container ref changes, but there's no cleanup when the component itself is unmounted. If the parent component destroys ShadowRootContainer, the Vue app inside the shadow root will remain mounted.

♻️ Suggested fix
-import { createApp, useTemplateRef, watch } from 'vue'
+import { createApp, onUnmounted, useTemplateRef, watch } from 'vue'

Then add after the watch:

onUnmounted(() => {
  if (shadowApp) {
    shadowApp.unmount()
    shadowApp = null
  }
})
packages/core/src/FocusScope/FocusScope.test.ts (1)

270-276: Consider replacing sleep(1) with explicit wait conditions.

Fixed delays can cause flaky tests. The sleep(1) calls after clicking the trigger could be replaced with waitFor on the expected dialog state.

♻️ Suggested alternative
-            await fireEvent.click(trigger)
-            await sleep(1)
-            const dialogOverlay = getDialogOverlay()
-            expect(dialogOverlay).toBeTruthy()
+            await fireEvent.click(trigger)
+            await waitFor(() => expect(getDialogOverlay()).toBeTruthy())
+            const dialogOverlay = getDialogOverlay()
📜 Review details

Configuration used: defaults

Review profile: CHILL

Plan: Pro

📥 Commits

Reviewing files that changed from the base of the PR and between c221d9a and 6971453.

📒 Files selected for processing (7)
  • packages/core/src/FocusScope/FocusScope.test.ts
  • packages/core/src/FocusScope/FocusScope.vue
  • packages/core/src/FocusScope/story/FocusScopeShadowRoot.story.vue
  • packages/core/src/FocusScope/story/shadowDom/FocusableLayersElements.vue
  • packages/core/src/FocusScope/story/shadowDom/ShadowRootContainer.vue
  • packages/core/src/FocusScope/story/shadowDom/_Dialog.vue
  • packages/core/src/FocusScope/utils.ts
🚧 Files skipped from review as they are similar to previous changes (3)
  • packages/core/src/FocusScope/FocusScope.vue
  • packages/core/src/FocusScope/story/shadowDom/FocusableLayersElements.vue
  • packages/core/src/FocusScope/story/FocusScopeShadowRoot.story.vue
🧰 Additional context used
🧬 Code graph analysis (1)
packages/core/src/FocusScope/FocusScope.test.ts (2)
packages/core/src/shared/index.ts (1)
  • getActiveElement (6-6)
packages/plugins/src/namespaced/index.ts (1)
  • Dialog (275-293)
🪛 ast-grep (0.40.5)
packages/core/src/FocusScope/FocusScope.test.ts

[warning] 262-262: Direct modification of innerHTML or outerHTML properties detected. Modifying these properties with unsanitized user input can lead to XSS vulnerabilities. Use safe alternatives or sanitize content first.
Context: document.body.innerHTML = ''
Note: [CWE-79] Improper Neutralization of Input During Web Page Generation [REFERENCES]
- https://owasp.org/www-community/xss-filter-evasion-cheatsheet
- https://cwe.mitre.org/data/definitions/79.html

(dom-content-modification)

🔇 Additional comments (4)
packages/core/src/FocusScope/utils.ts (1)

42-66: Shadow DOM recursion approach looks correct for typical use cases.

The recursive traversal into shadow roots is a good approach for discovering tabbable elements across shadow boundaries. The signature expansion to accept ShadowRoot is appropriate since TreeWalker accepts any Node as root.

One edge case to be aware of: if a shadow host element itself has tabIndex >= 0, the shadow children will be added to the array before the host element (due to the push inside acceptNode happening before the walker loop's push). This would produce [shadowChild1, shadowChild2, host] instead of [host, shadowChild1, shadowChild2]. In practice, tabbable shadow hosts are rare, so this is likely acceptable.

packages/core/src/FocusScope/story/shadowDom/_Dialog.vue (1)

1-67: LGTM!

The dialog story component is well-structured with clear conditional rendering logic for demonstrating both shadow DOM and light DOM scenarios. The component properly integrates with the existing Dialog primitives.

packages/core/src/FocusScope/FocusScope.test.ts (2)

143-197: Well-structured shadow DOM test utilities.

The renderInShadowRoot helper and getActiveElement function provide clean abstractions for testing within shadow DOM contexts. The test properly validates focus behavior during dynamic content updates within a shadow root.


199-349: Comprehensive shadow DOM focus loop test coverage.

The test matrix covering shadowDomOnly, mixedBodyAndShadowDom, and bodyOnly scenarios provides excellent coverage for the FocusScope changes. The helper functions handle query root differences cleanly, and the focus loop assertions properly await waitFor calls.

Note: The static analysis warning about document.body.innerHTML = '' on line 263 is a false positive—this is standard test cleanup code, not user input handling.

✏️ Tip: You can disable this entire section by setting review_details to false in your review settings.

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.

[Feature]: support Shadow DOM compatibility

1 participant