mirror of
https://github.com/bitwarden/browser
synced 2025-12-11 22:03:36 +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 { CipherService } from "@bitwarden/common/vault/abstractions/cipher.service";
|
||||||
import { InternalFolderService } from "@bitwarden/common/vault/abstractions/folder/folder.service.abstraction";
|
import { InternalFolderService } from "@bitwarden/common/vault/abstractions/folder/folder.service.abstraction";
|
||||||
import { SearchService } from "@bitwarden/common/vault/abstractions/search.service";
|
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";
|
import { KeyService, BiometricStateService } from "@bitwarden/key-management";
|
||||||
|
|
||||||
const BroadcasterSubscriptionId = "AppComponent";
|
const BroadcasterSubscriptionId = "AppComponent";
|
||||||
@@ -76,11 +76,17 @@ export class AppComponent implements OnDestroy, OnInit {
|
|||||||
private readonly destroy: DestroyRef,
|
private readonly destroy: DestroyRef,
|
||||||
private readonly documentLangSetter: DocumentLangSetter,
|
private readonly documentLangSetter: DocumentLangSetter,
|
||||||
private readonly tokenService: TokenService,
|
private readonly tokenService: TokenService,
|
||||||
|
private readonly routerFocusManager: RouterFocusManagerService,
|
||||||
) {
|
) {
|
||||||
this.deviceTrustToastService.setupListeners$.pipe(takeUntilDestroyed()).subscribe();
|
this.deviceTrustToastService.setupListeners$.pipe(takeUntilDestroyed()).subscribe();
|
||||||
|
|
||||||
const langSubscription = this.documentLangSetter.start();
|
const langSubscription = this.documentLangSetter.start();
|
||||||
this.destroy.onDestroy(() => langSubscription.unsubscribe());
|
|
||||||
|
this.routerFocusManager.start$.pipe(takeUntilDestroyed()).subscribe();
|
||||||
|
|
||||||
|
this.destroy.onDestroy(() => {
|
||||||
|
langSubscription.unsubscribe();
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
ngOnInit() {
|
ngOnInit() {
|
||||||
|
|||||||
@@ -72,6 +72,9 @@ export enum FeatureFlag {
|
|||||||
|
|
||||||
/* Innovation */
|
/* Innovation */
|
||||||
PM19148_InnovationArchive = "pm-19148-innovation-archive",
|
PM19148_InnovationArchive = "pm-19148-innovation-archive",
|
||||||
|
|
||||||
|
/* UIF */
|
||||||
|
RouterFocusManagement = "router-focus-management",
|
||||||
}
|
}
|
||||||
|
|
||||||
export type AllowedFeatureFlagTypes = boolean | number | string;
|
export type AllowedFeatureFlagTypes = boolean | number | string;
|
||||||
@@ -150,6 +153,9 @@ export const DefaultFeatureFlagValue = {
|
|||||||
|
|
||||||
/* Innovation */
|
/* Innovation */
|
||||||
[FeatureFlag.PM19148_InnovationArchive]: FALSE,
|
[FeatureFlag.PM19148_InnovationArchive]: FALSE,
|
||||||
|
|
||||||
|
/* UIF */
|
||||||
|
[FeatureFlag.RouterFocusManagement]: FALSE,
|
||||||
} satisfies Record<FeatureFlag, AllowedFeatureFlagTypes>;
|
} satisfies Record<FeatureFlag, AllowedFeatureFlagTypes>;
|
||||||
|
|
||||||
export type DefaultFeatureFlagValueType = typeof DefaultFeatureFlagValue;
|
export type DefaultFeatureFlagValueType = typeof DefaultFeatureFlagValue;
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
export * from "./a11y-title.directive";
|
export * from "./a11y-title.directive";
|
||||||
export * from "./aria-disabled-click-capture.service";
|
export * from "./aria-disabled-click-capture.service";
|
||||||
export * from "./aria-disable.directive";
|
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"
|
[routerLinkActiveOptions]="routerLinkMatchOptions"
|
||||||
#rla="routerLinkActive"
|
#rla="routerLinkActive"
|
||||||
[active]="rla.isActive"
|
[active]="rla.isActive"
|
||||||
|
[info]="{ focusMainAfterNav: false }"
|
||||||
[disabled]="disabled"
|
[disabled]="disabled"
|
||||||
[attr.aria-disabled]="disabled"
|
[attr.aria-disabled]="disabled"
|
||||||
ariaCurrentWhenActive="page"
|
ariaCurrentWhenActive="page"
|
||||||
|
|||||||
Reference in New Issue
Block a user