DEV Community

Cover image for Fixing Focus Management in Nested Web Components
Rob Levin
Rob Levin

Posted on

Fixing Focus Management in Nested Web Components

The Problem

While building a drawer component system, I encountered a peculiar focus management bug. When opening a drawer, the initial focus was supposed to land on the Close button. Instead, the ag-drawer element itself was receiving focus, and our focus detection utility was returning an empty arrayeven though the Close button was clearly visible and focusable.

The Architecture

Our drawer component has an interesting architecture:

<ag-drawer>
  <p>Drawer content</p>
  <ag-button>Close</ag-button>
</ag-drawer>
Enter fullscreen mode Exit fullscreen mode

Internally, ag-drawer renders an ag-dialog in its Shadow DOM:

// ag-drawer's shadow DOM
render() {
  return html`
    <ag-dialog
      .open=${this.open}
      .heading=${this.heading}
      // ... other props
    >
      <slot></slot>
    </ag-dialog>
  `;
}
Enter fullscreen mode Exit fullscreen mode

So the component hierarchy looks like this:

ag-drawer (light DOM: <ag-button>Close</ag-button>)
   shadow root
       ag-dialog
           shadow root
               <slot> (projects ag-drawer's light DOM)
Enter fullscreen mode Exit fullscreen mode

The First Bug: Custom Element Visibility Detection

Our getFocusableElements utility was filtering out the ag-button because of this check:

// Exclude elements that are not visible
if (el.offsetParent === null && window.getComputedStyle(el).position !== 'fixed') {
  return false;
}
Enter fullscreen mode Exit fullscreen mode

Custom elements can have offsetParent === null even when visible, especially:

  • During render cycles
  • After parent transitions
  • In Shadow DOM contexts

Fix: Skip the offsetParent check for custom elements:

const isCustomElement = el.tagName.includes('-');
if (!isCustomElement && el.offsetParent === null && window.getComputedStyle(el).position !== 'fixed') {
  return false;
}
Enter fullscreen mode Exit fullscreen mode

The Second Bug: Wrong Light DOM Search Scope

Even after fixing the custom element detection, the Close button still wasn't found. The issue? We were searching the wrong light DOM container.

ag-dialog was calling:

getFocusableElements(this.shadowRoot, this) // 'this' is ag-dialog
Enter fullscreen mode Exit fullscreen mode

But the slotted content (Close button) lives in ag-drawer's light DOM, not ag-dialog's. ag-dialog's light DOM is emptyit's just a slot that projects content from its parent host.

Fix: Find the parent host element when inside a shadow root:

private _setInitialFocus() {
  // For drawers, the slotted content is in the parent ag-drawer's light DOM
  const lightDomContainer = (this.getRootNode() as ShadowRoot).host as HTMLElement || this;
  const focusableElements = getFocusableElements(this.shadowRoot, lightDomContainer);

  if (focusableElements.length > 0) {
    focusableElements[0].focus();
  }
}
Enter fullscreen mode Exit fullscreen mode

Now ag-dialog correctly searches ag-drawer's light DOM for focusable elements.

Key Takeaways

  1. Custom elements need special visibility handling - Standard DOM visibility checks like offsetParent === null can incorrectly filter out custom elements.

  2. Understand your Shadow DOM hierarchy - When searching for slotted content, you need to search the correct host's light DOM, not the component doing the rendering.

  3. getRootNode().host is your friend - It helps you traverse up the Shadow DOM tree to find parent custom elements.

The Result

Focus management now works correctly:

  • Close button receives initial focus when drawer opens
  • Focus trap properly cycles through all focusable elements
  • Custom elements are correctly identified as focusable

This was a great reminder that Shadow DOM creates encapsulation boundaries that require careful thought when implementing cross-boundary features like focus management.

Top comments (1)

Collapse
 
jumbei profile image
Jumbei

Thanks !!!!