Can Angular dynamically loaded components maintain focus trapping for screen readers?
0 reputation · 21 Aug 2023, 21:24 UTC
0 reputation · 21 Aug 2023, 21:24 UTC
To ensure that when a component is injected into the DOM via ViewContainerRef, keyboard focus is automatically trapped within that component and returned to the original trigger when the component is removed.
Angular Material dialogs provide automatic focus trapping, but custom components lack a built‑in mechanism. The OnPush change‑detection strategy can delay updates to aria-live regions, and reordering elements with ngFor may disrupt the tab order.
There is no documented, generic service that applies focus trapping and live‑region announcements to arbitrary dynamically loaded components. Developers must decide whether to implement custom ARIA attributes, use the LiveAnnouncer service, or rely on third‑party libraries.
1. What is the recommended approach to automatically trap focus when using ViewContainerRef to load a custom component?
2. How can we ensure that focus is restored to the triggering element after the component is destroyed?
3. Does Angular provide a built‑in service for managing live‑region announcements when components are added dynamically?
Yes, Angular dynamically loaded components can maintain focus trapping for screen readers, but Angular does not provide a single built-in service that automatically handles focus trapping, live-region announcements, and focus restoration together. You must compose FocusTrapFactory (from @angular/cdk/a11y), LiveAnnouncer, and manual trigger-element tracking yourself — typically in a reusable wrapper directive or service.
Apply the CDK cdkTrapFocus directive (or use FocusTrapFactory programmatically) to the host element of the dynamically created component. The trap activates once the component is inserted and a focusable descendant exists.
// In a wrapper directive or component that manages the ViewContainerRef
import { FocusTrapFactory } from '@angular/cdk/a11y';
import { ViewContainerRef, ComponentRef, AfterViewInit, OnDestroy } from '@angular/core';
export class DynamicHostDirective implements AfterViewInit, OnDestroy {
private focusTrap: FocusTrap | null = null;
private triggerElement: HTMLElement | null = null;
constructor(
private vcr: ViewContainerRef,
private focusTrapFactory: FocusTrapFactory
) {}
loadComponent<T>(component: Type<T>): ComponentRef<T> {
this.triggerElement = document.activeElement as HTMLElement;
this.vcr.clear();
const ref = this.vcr.createComponent(component);
// Apply focus trap to the component's host element
this.focusTrap = this.focusTrapFactory.create(ref.location.nativeElement);
this.focusTrap.focusInitialElementWhenReady();
return ref;
}
ngAfterViewInit() {}
ngOnDestroy() {
this.focusTrap?.destroy();
this.restoreFocus();
}
private restoreFocus() {
this.triggerElement?.focus();
}
}
Focus restoration is not automatic. Capture the triggering element before opening the dynamic component (using document.activeElement or an ElementRef passed from the trigger), then call focus() on that reference after destroying the focus trap and clearing the ViewContainerRef.
const trigger = document.activeElement as HTMLElement; immediately before vcr.createComponent().ngOnDestroy, a close callback, or after ref.destroy()), call trigger?.focus().FocusTrap instance (focusTrap.destroy()) before clearing the container to avoid leaving focus in a detached node.Angular provides LiveAnnouncer (from @angular/cdk/a11y) as the built-in mechanism for live-region announcements. It operates outside Angular's change detection, so OnPush does not block it. You must call it manually when the dynamic component is added or removed:
import { LiveAnnouncer } from '@angular/cdk/a11y';
constructor(private announcer: LiveAnnouncer) {}
openComponent() {
// ... create component ...
this.announcer.announce('Dialog opened', 'polite');
}
closeComponent() {
this.announcer.announce('Dialog closed', 'polite');
// ... destroy component and restore focus ...
}
tabindex="0", etc.). Without one, the trap appears broken.focusInitialElementWhenReady() or move focus in a setTimeout/AfterViewInit after the component is inserted and change detection runs.trackBy with stable identities to prevent DOM recreation that disrupts tab order while a trap is active.ViewContainerRef.createComponent() with cdkTrapFocus on its host; verify with NVDA/VoiceOver that focus enters and stays within.document.activeElement before opening, destroy the component, and assert focus returns to the stored element (automate with Cypress + tab navigation).LiveAnnouncer.announce() on insert/remove; confirm announcements via browser devtools live-region logging.ngFor items with trackBy inside the dynamic component; verify logical tab order via keyboard-only navigation.Does your dynamic component always contain at least one natively focusable element (or an element with tabindex="0") at the time the focus trap activates? If the component may render empty or conditionally hide all focusable children, the trap will not move focus and you'll need a fallback (e.g., a hidden <div tabindex="-1"> as first child).
Use comments to ask for clarification. Post a solution as an answer.
No question comments on this page.