From 2128894152d422168aa77cc62e06b6778adc25ed Mon Sep 17 00:00:00 2001 From: Vicki League Date: Wed, 26 Nov 2025 12:30:10 -0500 Subject: [PATCH] [CL-806] Focus main content after SPA navigation occurs (#17112) --- apps/web/src/app/app.component.ts | 10 ++- libs/common/src/enums/feature-flag.enum.ts | 6 ++ libs/components/src/a11y/index.ts | 1 + .../src/a11y/router-focus-manager.service.ts | 65 +++++++++++++++++++ .../tabs/tab-nav-bar/tab-link.component.html | 1 + 5 files changed, 81 insertions(+), 2 deletions(-) create mode 100644 libs/components/src/a11y/router-focus-manager.service.ts diff --git a/apps/web/src/app/app.component.ts b/apps/web/src/app/app.component.ts index 30dbee9fac5..c312b4edd7e 100644 --- a/apps/web/src/app/app.component.ts +++ b/apps/web/src/app/app.component.ts @@ -27,7 +27,7 @@ import { StateEventRunnerService } from "@bitwarden/common/platform/state"; import { CipherService } from "@bitwarden/common/vault/abstractions/cipher.service"; import { InternalFolderService } from "@bitwarden/common/vault/abstractions/folder/folder.service.abstraction"; import { SearchService } from "@bitwarden/common/vault/abstractions/search.service"; -import { DialogService, ToastService } from "@bitwarden/components"; +import { DialogService, RouterFocusManagerService, ToastService } from "@bitwarden/components"; import { KeyService, BiometricStateService } from "@bitwarden/key-management"; const BroadcasterSubscriptionId = "AppComponent"; @@ -76,11 +76,17 @@ export class AppComponent implements OnDestroy, OnInit { private readonly destroy: DestroyRef, private readonly documentLangSetter: DocumentLangSetter, private readonly tokenService: TokenService, + private readonly routerFocusManager: RouterFocusManagerService, ) { this.deviceTrustToastService.setupListeners$.pipe(takeUntilDestroyed()).subscribe(); const langSubscription = this.documentLangSetter.start(); - this.destroy.onDestroy(() => langSubscription.unsubscribe()); + + this.routerFocusManager.start$.pipe(takeUntilDestroyed()).subscribe(); + + this.destroy.onDestroy(() => { + langSubscription.unsubscribe(); + }); } ngOnInit() { diff --git a/libs/common/src/enums/feature-flag.enum.ts b/libs/common/src/enums/feature-flag.enum.ts index 56a25cb213c..cf60dca5d24 100644 --- a/libs/common/src/enums/feature-flag.enum.ts +++ b/libs/common/src/enums/feature-flag.enum.ts @@ -72,6 +72,9 @@ export enum FeatureFlag { /* Innovation */ PM19148_InnovationArchive = "pm-19148-innovation-archive", + + /* UIF */ + RouterFocusManagement = "router-focus-management", } export type AllowedFeatureFlagTypes = boolean | number | string; @@ -150,6 +153,9 @@ export const DefaultFeatureFlagValue = { /* Innovation */ [FeatureFlag.PM19148_InnovationArchive]: FALSE, + + /* UIF */ + [FeatureFlag.RouterFocusManagement]: FALSE, } satisfies Record; export type DefaultFeatureFlagValueType = typeof DefaultFeatureFlagValue; diff --git a/libs/components/src/a11y/index.ts b/libs/components/src/a11y/index.ts index 2a723f14c93..ff53375ab0c 100644 --- a/libs/components/src/a11y/index.ts +++ b/libs/components/src/a11y/index.ts @@ -1,3 +1,4 @@ export * from "./a11y-title.directive"; export * from "./aria-disabled-click-capture.service"; export * from "./aria-disable.directive"; +export * from "./router-focus-manager.service"; diff --git a/libs/components/src/a11y/router-focus-manager.service.ts b/libs/components/src/a11y/router-focus-manager.service.ts new file mode 100644 index 00000000000..27c4e0f9b1e --- /dev/null +++ b/libs/components/src/a11y/router-focus-manager.service.ts @@ -0,0 +1,65 @@ +import { inject, Injectable } from "@angular/core"; +import { takeUntilDestroyed } from "@angular/core/rxjs-interop"; +import { NavigationEnd, Router } from "@angular/router"; +import { skip, filter, map, combineLatestWith, tap } from "rxjs"; + +import { FeatureFlag } from "@bitwarden/common/enums/feature-flag.enum"; +import { ConfigService } from "@bitwarden/common/platform/abstractions/config/config.service"; + +@Injectable({ providedIn: "root" }) +export class RouterFocusManagerService { + private router = inject(Router); + + private configService = inject(ConfigService); + + /** + * Handles SPA route focus management. SPA apps don't automatically notify screenreader + * users that navigation has occured or move the user's focus to the content they are + * navigating to, so we need to do it. + * + * By default, we focus the `main` after an internal route navigation. + * + * Consumers can opt out of the passing the following to the `info` input: + * `` + * + * Or, consumers can use the autofocus directive on an applicable interactive element. + * The autofocus directive will take precedence over this route focus pipeline. + * + * Example of where you might want to manually opt out: + * - Tab component causes a route navigation, but the tab content should be focused, + * not the whole `main` + * + * Note that router events that cause a fully new page to load (like switching between + * products) will not follow this pipeline. Instead, those will automatically bring + * focus to the top of the html document as if it were a full page load. So those links + * do not need to manually opt out of this pipeline. + */ + start$ = this.router.events.pipe( + takeUntilDestroyed(), + filter((navEvent) => navEvent instanceof NavigationEnd), + /** + * On first page load, we do not want to skip the user over the navigation content, + * so we opt out of the default focus management behavior. + */ + skip(1), + combineLatestWith(this.configService.getFeatureFlag$(FeatureFlag.RouterFocusManagement)), + filter(([_navEvent, flagEnabled]) => flagEnabled), + map(() => { + const currentNavData = this.router.getCurrentNavigation()?.extras; + + const info = currentNavData?.info as { focusMainAfterNav?: boolean } | undefined; + + return info; + }), + filter((currentNavInfo) => { + return currentNavInfo === undefined ? true : currentNavInfo?.focusMainAfterNav !== false; + }), + tap(() => { + const mainEl = document.querySelector("main"); + + if (mainEl) { + mainEl.focus(); + } + }), + ); +} diff --git a/libs/components/src/tabs/tab-nav-bar/tab-link.component.html b/libs/components/src/tabs/tab-nav-bar/tab-link.component.html index 7a8c0619987..f05ed31547b 100644 --- a/libs/components/src/tabs/tab-nav-bar/tab-link.component.html +++ b/libs/components/src/tabs/tab-nav-bar/tab-link.component.html @@ -5,6 +5,7 @@ [routerLinkActiveOptions]="routerLinkMatchOptions" #rla="routerLinkActive" [active]="rla.isActive" + [info]="{ focusMainAfterNav: false }" [disabled]="disabled" [attr.aria-disabled]="disabled" ariaCurrentWhenActive="page"