Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 8 additions & 2 deletions apps/web/src/app/app.component.ts
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We should move this logic into a service in libs/components/a11y

Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -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());
const routerFocusManagerSubscription = this.routerFocusManager.start();

this.destroy.onDestroy(() => {
langSubscription.unsubscribe();
routerFocusManagerSubscription.unsubscribe();
});
}

ngOnInit() {
Expand Down
1 change: 1 addition & 0 deletions libs/components/src/a11y/index.ts
Original file line number Diff line number Diff line change
@@ -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";
61 changes: 61 additions & 0 deletions libs/components/src/a11y/router-focus-manager.service.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,61 @@
import { inject, Injectable } from "@angular/core";
import { takeUntilDestroyed } from "@angular/core/rxjs-interop";
import { NavigationEnd, Router } from "@angular/router";
import { skip, filter, map } from "rxjs";

@Injectable({ providedIn: "root" })
export class RouterFocusManagerService {
private router = inject(Router);

/**
* 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() {
return 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),
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;
}),
)
.subscribe(() => {
const mainEl = document.querySelector<HTMLElement>("main");

if (mainEl) {
mainEl.focus();
}
});
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@
[routerLinkActiveOptions]="routerLinkMatchOptions"
#rla="routerLinkActive"
[active]="rla.isActive"
[info]="{ focusMainAfterNav: false }"
[disabled]="disabled"
[attr.aria-disabled]="disabled"
ariaCurrentWhenActive="page"
Expand Down
Loading