mirror of
https://github.com/bitwarden/browser
synced 2025-12-10 05:13:29 +00:00
[CL-806] Focus main content after SPA navigation occurs (#17112)
This commit is contained in:
@@ -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() {
|
||||
|
||||
@@ -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<FeatureFlag, AllowedFeatureFlagTypes>;
|
||||
|
||||
export type DefaultFeatureFlagValueType = typeof DefaultFeatureFlagValue;
|
||||
|
||||
@@ -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";
|
||||
|
||||
65
libs/components/src/a11y/router-focus-manager.service.ts
Normal file
65
libs/components/src/a11y/router-focus-manager.service.ts
Normal file
@@ -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:
|
||||
* `<a [routerLink]="route()" [info]="{ focusMainAfterNav: false }"></a>`
|
||||
*
|
||||
* 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<HTMLElement>("main");
|
||||
|
||||
if (mainEl) {
|
||||
mainEl.focus();
|
||||
}
|
||||
}),
|
||||
);
|
||||
}
|
||||
@@ -5,6 +5,7 @@
|
||||
[routerLinkActiveOptions]="routerLinkMatchOptions"
|
||||
#rla="routerLinkActive"
|
||||
[active]="rla.isActive"
|
||||
[info]="{ focusMainAfterNav: false }"
|
||||
[disabled]="disabled"
|
||||
[attr.aria-disabled]="disabled"
|
||||
ariaCurrentWhenActive="page"
|
||||
|
||||
Reference in New Issue
Block a user