From cd31bb747b08f6d2ff6afd1abdf2969f4748b979 Mon Sep 17 00:00:00 2001 From: Vicki League Date: Thu, 15 Jan 2026 13:52:18 -0500 Subject: [PATCH] [PM-24178] Handle focus when routed dialog closes in vault table --- .../collections/vault.component.ts | 39 ++- .../navigation-switcher.component.html | 1 + .../product-switcher-content.component.html | 3 + .../vault-cipher-row.component.html | 5 + .../vault/individual-vault/vault.component.ts | 40 ++- .../src/a11y/router-focus-manager.mdx | 69 ++++ .../src/a11y/router-focus-manager.service.ts | 64 ++-- .../src/a11y/router-focus-manager.spec.ts | 320 ++++++++++++++++++ .../src/navigation/nav-item.component.html | 3 + .../src/navigation/nav-item.component.ts | 12 + .../tabs/tab-nav-bar/tab-link.component.html | 2 +- .../services/routed-vault-filter.service.ts | 2 +- 12 files changed, 514 insertions(+), 46 deletions(-) create mode 100644 libs/components/src/a11y/router-focus-manager.mdx create mode 100644 libs/components/src/a11y/router-focus-manager.spec.ts diff --git a/apps/web/src/app/admin-console/organizations/collections/vault.component.ts b/apps/web/src/app/admin-console/organizations/collections/vault.component.ts index 5f952fa8b4a..8955af84e33 100644 --- a/apps/web/src/app/admin-console/organizations/collections/vault.component.ts +++ b/apps/web/src/app/admin-console/organizations/collections/vault.component.ts @@ -1,6 +1,6 @@ import { ChangeDetectorRef, Component, NgZone, OnDestroy, OnInit, ViewChild } from "@angular/core"; import { takeUntilDestroyed } from "@angular/core/rxjs-interop"; -import { ActivatedRoute, Params, Router } from "@angular/router"; +import { ActivatedRoute, NavigationExtras, Params, Router } from "@angular/router"; import { BehaviorSubject, combineLatest, @@ -588,7 +588,7 @@ export class VaultComponent implements OnInit, OnDestroy { queryParamsHandling: "merge", replaceUrl: true, state: { - focusMainAfterNav: false, + focusAfterNav: false, }, }), ); @@ -812,7 +812,7 @@ export class VaultComponent implements OnInit, OnDestroy { async editCipherAttachments(cipher: CipherView) { if (cipher.reprompt !== 0 && !(await this.passwordRepromptService.showPasswordPrompt())) { - this.go({ cipherId: null, itemId: null }); + this.go({ cipherId: null, itemId: null }, this.configureRouterFocusToCipher(cipher.id)); return; } @@ -869,7 +869,7 @@ export class VaultComponent implements OnInit, OnDestroy { !(await this.passwordRepromptService.showPasswordPrompt()) ) { // didn't pass password prompt, so don't open add / edit modal - this.go({ cipherId: null, itemId: null }); + this.go({ cipherId: null, itemId: null }, this.configureRouterFocusToCipher(cipher.id)); return; } @@ -893,7 +893,10 @@ export class VaultComponent implements OnInit, OnDestroy { !(await this.passwordRepromptService.showPasswordPrompt()) ) { // Didn't pass password prompt, so don't open add / edit modal. - await this.go({ cipherId: null, itemId: null, action: null }); + await this.go( + { cipherId: null, itemId: null, action: null }, + this.configureRouterFocusToCipher(cipher.id), + ); return; } @@ -948,7 +951,10 @@ export class VaultComponent implements OnInit, OnDestroy { } // Clear the query params when the dialog closes - await this.go({ cipherId: null, itemId: null, action: null }); + await this.go( + { cipherId: null, itemId: null, action: null }, + this.configureRouterFocusToCipher(formConfig.originalCipher?.id), + ); } async cloneCipher(cipher: CipherView) { @@ -1427,7 +1433,25 @@ export class VaultComponent implements OnInit, OnDestroy { } } - private go(queryParams: any = null) { + /** + * Helper function to set up the `state.focusAfterNav` property for dialog router navigation if + * the cipherId exists. If it doesn't exist, returns undefined. + * + * This ensures that when the routed dialog is closed, the focus returns to the cipher button in + * the vault table, which allows keyboard users to continue navigating uninterrupted. + * + * @param cipherId id of cipher + * @returns Partial, specifically the state.focusAfterNav property, or undefined + */ + private configureRouterFocusToCipher(cipherId?: string): Partial | undefined { + if (cipherId) { + return { + state: { focusAfterNav: `#cipher-btn-${cipherId}` }, + }; + } + } + + private go(queryParams: any = null, navigateOptions?: NavigationExtras) { if (queryParams == null) { queryParams = { type: this.activeFilter.cipherType, @@ -1441,6 +1465,7 @@ export class VaultComponent implements OnInit, OnDestroy { queryParams: queryParams, queryParamsHandling: "merge", replaceUrl: true, + ...navigateOptions, }); } diff --git a/apps/web/src/app/layouts/product-switcher/navigation-switcher/navigation-switcher.component.html b/apps/web/src/app/layouts/product-switcher/navigation-switcher/navigation-switcher.component.html index 9c8f2125614..b6e06e7d06f 100644 --- a/apps/web/src/app/layouts/product-switcher/navigation-switcher/navigation-switcher.component.html +++ b/apps/web/src/app/layouts/product-switcher/navigation-switcher/navigation-switcher.component.html @@ -9,6 +9,7 @@ [route]="product.appRoute" [attr.icon]="product.icon" [forceActiveStyles]="product.isActive" + focusAfterNavTarget="body" > } diff --git a/apps/web/src/app/layouts/product-switcher/product-switcher-content.component.html b/apps/web/src/app/layouts/product-switcher/product-switcher-content.component.html index f2154ec74a3..290e07c932a 100644 --- a/apps/web/src/app/layouts/product-switcher/product-switcher-content.component.html +++ b/apps/web/src/app/layouts/product-switcher/product-switcher-content.component.html @@ -19,6 +19,9 @@ " class="tw-group/product-link tw-flex tw-h-24 tw-w-28 tw-flex-col tw-items-center tw-justify-center tw-rounded tw-p-1 tw-text-primary-600 tw-outline-none hover:tw-bg-background-alt hover:tw-text-primary-700 hover:tw-no-underline focus-visible:!tw-ring-2 focus-visible:!tw-ring-primary-700" ariaCurrentWhenActive="page" + [state]="{ + focusAfterNav: 'body', + }" >
+ diff --git a/apps/web/src/app/vault/individual-vault/vault.component.ts b/apps/web/src/app/vault/individual-vault/vault.component.ts index cad2c97557b..1db4053190e 100644 --- a/apps/web/src/app/vault/individual-vault/vault.component.ts +++ b/apps/web/src/app/vault/individual-vault/vault.component.ts @@ -1,7 +1,7 @@ // FIXME: Update this file to be type safe and remove this and next line // @ts-strict-ignore import { ChangeDetectorRef, Component, NgZone, OnDestroy, OnInit, ViewChild } from "@angular/core"; -import { ActivatedRoute, Params, Router } from "@angular/router"; +import { ActivatedRoute, NavigationExtras, Params, Router } from "@angular/router"; import { BehaviorSubject, combineLatest, @@ -424,7 +424,7 @@ export class VaultComponent implements OnInit, OnDestr queryParamsHandling: "merge", replaceUrl: true, state: { - focusMainAfterNav: false, + focusAfterNav: false, }, }), ); @@ -971,7 +971,10 @@ export class VaultComponent implements OnInit, OnDestr } // Clear the query params when the dialog closes - await this.go({ cipherId: null, itemId: null, action: null }); + await this.go( + { cipherId: null, itemId: null, action: null }, + this.configureRouterFocusToCipher(formConfig.originalCipher?.id), + ); } /** @@ -1031,7 +1034,10 @@ export class VaultComponent implements OnInit, OnDestr !(await this.passwordRepromptService.showPasswordPrompt()) ) { // didn't pass password prompt, so don't open add / edit modal - await this.go({ cipherId: null, itemId: null, action: null }); + await this.go( + { cipherId: null, itemId: null, action: null }, + this.configureRouterFocusToCipher(cipher.id), + ); return; } @@ -1073,7 +1079,10 @@ export class VaultComponent implements OnInit, OnDestr !(await this.passwordRepromptService.showPasswordPrompt()) ) { // Didn't pass password prompt, so don't open add / edit modal. - await this.go({ cipherId: null, itemId: null, action: null }); + await this.go( + { cipherId: null, itemId: null, action: null }, + this.configureRouterFocusToCipher(cipher.id), + ); return; } @@ -1552,7 +1561,25 @@ export class VaultComponent implements OnInit, OnDestr this.vaultItemsComponent?.clearSelection(); } - private async go(queryParams: any = null) { + /** + * Helper function to set up the `state.focusAfterNav` property for dialog router navigation if + * the cipherId exists. If it doesn't exist, returns undefined. + * + * This ensures that when the routed dialog is closed, the focus returns to the cipher button in + * the vault table, which allows keyboard users to continue navigating uninterrupted. + * + * @param cipherId id of cipher + * @returns Partial, specifically the state.focusAfterNav property, or undefined + */ + private configureRouterFocusToCipher(cipherId?: string): Partial | undefined { + if (cipherId) { + return { + state: { focusAfterNav: `#cipher-btn-${cipherId}` }, + }; + } + } + + private async go(queryParams: any = null, navigateOptions?: NavigationExtras) { if (queryParams == null) { queryParams = { favorites: this.activeFilter.isFavorites || null, @@ -1568,6 +1595,7 @@ export class VaultComponent implements OnInit, OnDestr queryParams: queryParams, queryParamsHandling: "merge", replaceUrl: true, + ...navigateOptions, }); } diff --git a/libs/components/src/a11y/router-focus-manager.mdx b/libs/components/src/a11y/router-focus-manager.mdx new file mode 100644 index 00000000000..aa882f9deac --- /dev/null +++ b/libs/components/src/a11y/router-focus-manager.mdx @@ -0,0 +1,69 @@ +import { Meta } from "@storybook/addon-docs/blocks"; + + + +# Router Focus Management + +On a normal non-SPA (Single Page Application) webpage, a page navigation / route change will cause +the full page to reload, and a user's focus is placed at the top of the page when the new page +loads. + +Bitwarden's Angular apps are SPAs using the Angular router to manage internal routing and page +navigation. When the Angular router performs a page navigation / route change to another internal +SPA route, the full page does not reload, and the user's focus does not move from the trigger +element unless the trigger element no longer exists. There is no other built-in notification to a +screenreader user that a navigation has occured, if the focus is not moved. + +## Web + +We handle router focus management in the web app by moving the user's focus at the end of a SPA +Angular router navigation. + +See `router-focus-manager.service.ts` for the implementation. + +### Default behavior + +By default, we focus the `main` element. + +Consumers can change or opt out of the focus management using the `state` input to the +[Angular route](https://angular.dev/api/router/RouterLink). Using `state` allows us to access the +value between browser back/forward arrows. + +### Change focus target + +In template: `` + +In typescript: `this.router.navigate([], { state: { focusAfterNav: '#selector' }})` + +Any valid `querySelector` selector can be passed. If the element is not found, no focus management +occurs as we cannot make the assumption that the default `main` element is the next best option. + +Examples of where you might want to change the target: + +- A full page navigation occurs where you do want the user to be placed at the top of the page (aka + the body element) like a non-SPA app, such as going from Password Manager to Secrets Manager +- A routed dialog needs to manually specify where the focus should return to once it is closed + +### Opt out of focus management + +In template: `` + +In typescript: `this.router.navigate([], { state: { focusAfterNav: false }})` + +Example of where you might want to manually opt out: + +- Tab component causes a route navigation, and the focus will be handled by the tab component itself + +### Autofocus directive + +Consumers can use the autofocus directive on an applicable interactive element. The autofocus +directive will take precedence over the router focus management system. See the +[Autofocus Directive docs](?path=/docs/component-library-form-autofocus-directive--docs). + +## Browser + +Not implemented yet. + +## Desktop + +Not implemented yet. diff --git a/libs/components/src/a11y/router-focus-manager.service.ts b/libs/components/src/a11y/router-focus-manager.service.ts index f7371e02a17..86bdf91e7aa 100644 --- a/libs/components/src/a11y/router-focus-manager.service.ts +++ b/libs/components/src/a11y/router-focus-manager.service.ts @@ -1,7 +1,7 @@ -import { inject, Injectable } from "@angular/core"; +import { inject, Injectable, NgZone } from "@angular/core"; import { takeUntilDestroyed } from "@angular/core/rxjs-interop"; import { NavigationEnd, Router } from "@angular/router"; -import { skip, filter, combineLatestWith, tap } from "rxjs"; +import { skip, filter, combineLatestWith, tap, map, take } from "rxjs"; import { FeatureFlag } from "@bitwarden/common/enums/feature-flag.enum"; import { ConfigService } from "@bitwarden/common/platform/abstractions/config/config.service"; @@ -9,32 +9,13 @@ import { ConfigService } from "@bitwarden/common/platform/abstractions/config/co @Injectable({ providedIn: "root" }) export class RouterFocusManagerService { private router = inject(Router); + private ngZone = inject(NgZone); 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 `state` input. Using `state` - * allows us to access the value between browser back/forward arrows. - * In template: `` - * In typescript: `this.router.navigate([], { state: { focusMainAfterNav: false }})` - * - * 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. + * See associated router-focus-manager.mdx page for documentation on what this pipeline does and + * how to customize focus behavior. */ start$ = this.router.events.pipe( takeUntilDestroyed(), @@ -46,19 +27,40 @@ export class RouterFocusManagerService { skip(1), combineLatestWith(this.configService.getFeatureFlag$(FeatureFlag.RouterFocusManagement)), filter(([_navEvent, flagEnabled]) => flagEnabled), - filter(() => { + map(() => { const currentNavExtras = this.router.currentNavigation()?.extras; - const focusMainAfterNav: boolean | undefined = currentNavExtras?.state?.focusMainAfterNav; + const focusAfterNav: boolean | string | undefined = currentNavExtras?.state?.focusAfterNav; - return focusMainAfterNav !== false; + return focusAfterNav; }), - tap(() => { - const mainEl = document.querySelector("main"); + filter((focusAfterNav) => { + return focusAfterNav !== false; + }), + tap((focusAfterNav) => { + let elSelector: string = "main"; - if (mainEl) { - mainEl.focus(); + if (typeof focusAfterNav === "string" && focusAfterNav.length > 0) { + elSelector = focusAfterNav; + } + + if (this.ngZone.isStable) { + this.focusTargetEl(elSelector); + } else { + this.ngZone.onStable.pipe(take(1)).subscribe(() => { + this.focusTargetEl(elSelector); + }); } }), ); + + private focusTargetEl(elSelector: string) { + const targetEl = document.querySelector(elSelector); + if (targetEl) { + targetEl.focus(); + } else { + // eslint-disable-next-line no-console + console.warn(`RouterFocusManager: Could not find element with selector "${elSelector}"`); + } + } } diff --git a/libs/components/src/a11y/router-focus-manager.spec.ts b/libs/components/src/a11y/router-focus-manager.spec.ts new file mode 100644 index 00000000000..9e99fd91ffc --- /dev/null +++ b/libs/components/src/a11y/router-focus-manager.spec.ts @@ -0,0 +1,320 @@ +import { + computed, + DestroyRef, + EventEmitter, + Injectable, + NgZone, + Signal, + signal, +} from "@angular/core"; +import { TestBed } from "@angular/core/testing"; +import { Event, Navigation, NavigationEnd, Router } from "@angular/router"; +import { mock } from "jest-mock-extended"; +import { BehaviorSubject, Subject } from "rxjs"; + +import { FeatureFlag } from "@bitwarden/common/enums/feature-flag.enum"; +import { ConfigService } from "@bitwarden/common/platform/abstractions/config/config.service"; + +import { RouterFocusManagerService } from "./router-focus-manager.service"; + +describe("RouterFocusManagerService", () => { + @Injectable() + class MockNgZone extends NgZone { + onStable: EventEmitter = new EventEmitter(false); + constructor() { + super({ enableLongStackTrace: false }); + } + run(fn: any): any { + return fn(); + } + runOutsideAngular(fn: any): any { + return fn(); + } + simulateZoneExit(): void { + this.onStable.emit(null); + } + + isStable: boolean = true; + } + + @Injectable() + class MockRouter extends Router { + readonly currentNavigationExtras = signal({}); + + readonly currentNavigation: Signal = computed(() => ({ + ...mock(), + extras: this.currentNavigationExtras(), + })); + + // eslint-disable-next-line rxjs/no-exposed-subjects + readonly routerEventsSubject = new Subject(); + + override get events() { + return this.routerEventsSubject.asObservable(); + } + } + + let service: RouterFocusManagerService; + let featureFlagSubject: BehaviorSubject; + let mockRouter: MockRouter; + let mockConfigService: Partial; + let mockNgZoneRef: MockNgZone; + + let querySelectorSpy: jest.SpyInstance; + let consoleWarnSpy: jest.SpyInstance; + + beforeEach(() => { + // Mock ConfigService + featureFlagSubject = new BehaviorSubject(true); + mockConfigService = { + getFeatureFlag$: jest.fn((flag: FeatureFlag) => { + if (flag === FeatureFlag.RouterFocusManagement) { + return featureFlagSubject.asObservable(); + } + return new BehaviorSubject(false).asObservable(); + }), + }; + + // Spy on document.querySelector and console.warn + querySelectorSpy = jest.spyOn(document, "querySelector"); + consoleWarnSpy = jest.spyOn(console, "warn").mockImplementation(); + + TestBed.configureTestingModule({ + providers: [ + RouterFocusManagerService, + { provide: Router, useClass: MockRouter }, + { provide: ConfigService, useValue: mockConfigService }, + { provide: NgZone, useClass: MockNgZone }, + { provide: DestroyRef, useValue: { onDestroy: jest.fn() } }, + ], + }); + + service = TestBed.inject(RouterFocusManagerService); + mockNgZoneRef = TestBed.inject(NgZone) as MockNgZone; + mockRouter = TestBed.inject(Router) as MockRouter; + }); + + afterEach(() => { + querySelectorSpy.mockRestore(); + consoleWarnSpy.mockRestore(); + + mockNgZoneRef.isStable = true; + TestBed.resetTestingModule(); + }); + + describe("default behavior", () => { + it("should focus main element after navigation", () => { + const mainElement = document.createElement("main"); + mainElement.focus = jest.fn(); + querySelectorSpy.mockReturnValue(mainElement); + + // Subscribe to start the service + service.start$.subscribe(); + + // Emit first navigation (should be skipped) + mockRouter.routerEventsSubject.next(new NavigationEnd(1, "/first", "/first")); + + // Emit second navigation (should trigger focus) + mockRouter.routerEventsSubject.next(new NavigationEnd(2, "/test", "/test")); + + expect(querySelectorSpy).toHaveBeenCalledWith("main"); + expect(mainElement.focus).toHaveBeenCalled(); + }); + }); + + describe("custom selector", () => { + it("should focus custom element when focusAfterNav selector is provided", () => { + const customElement = document.createElement("button"); + customElement.id = "custom-btn"; + customElement.focus = jest.fn(); + querySelectorSpy.mockReturnValue(customElement); + + // Subscribe to start the service + service.start$.subscribe(); + + // Emit first navigation (should be skipped) + mockRouter.routerEventsSubject.next(new NavigationEnd(1, "/first", "/first")); + + // Emit second navigation with custom selector + mockRouter.currentNavigationExtras.set({ state: { focusAfterNav: "#custom-btn" } }); + mockRouter.routerEventsSubject.next(new NavigationEnd(2, "/test", "/test")); + + expect(querySelectorSpy).toHaveBeenCalledWith("#custom-btn"); + expect(customElement.focus).toHaveBeenCalled(); + }); + }); + + describe("opt-out", () => { + it("should not focus when focusAfterNav is false", () => { + const mainElement = document.createElement("main"); + mainElement.focus = jest.fn(); + querySelectorSpy.mockReturnValue(mainElement); + + // Subscribe to start the service + service.start$.subscribe(); + + // Emit first navigation (should be skipped) + mockRouter.routerEventsSubject.next(new NavigationEnd(1, "/first", "/first")); + + // Emit second navigation with opt-out + mockRouter.currentNavigationExtras.set({ state: { focusAfterNav: false } }); + mockRouter.routerEventsSubject.next(new NavigationEnd(2, "/test", "/test")); + + expect(querySelectorSpy).not.toHaveBeenCalled(); + expect(mainElement.focus).not.toHaveBeenCalled(); + }); + }); + + describe("element not found", () => { + it("should log warning when custom selector does not match any element", () => { + querySelectorSpy.mockReturnValue(null); + + // Subscribe to start the service + service.start$.subscribe(); + + // Emit first navigation (should be skipped) + mockRouter.routerEventsSubject.next(new NavigationEnd(1, "/first", "/first")); + + // Emit second navigation with non-existent selector + mockRouter.currentNavigationExtras.set({ state: { focusAfterNav: "#non-existent" } }); + mockRouter.routerEventsSubject.next(new NavigationEnd(2, "/test", "/test")); + + expect(querySelectorSpy).toHaveBeenCalledWith("#non-existent"); + expect(consoleWarnSpy).toHaveBeenCalledWith( + 'RouterFocusManager: Could not find element with selector "#non-existent"', + ); + }); + }); + + // Remove describe block when FeatureFlag.RouterFocusManagement is removed + describe("feature flag", () => { + it("should not activate when RouterFocusManagement flag is disabled", () => { + const mainElement = document.createElement("main"); + mainElement.focus = jest.fn(); + querySelectorSpy.mockReturnValue(mainElement); + + // Disable feature flag + featureFlagSubject.next(false); + + // Subscribe to start the service + service.start$.subscribe(); + + // Emit first navigation (should be skipped) + mockRouter.routerEventsSubject.next(new NavigationEnd(1, "/first", "/first")); + + // Emit second navigation with flag disabled + mockRouter.routerEventsSubject.next(new NavigationEnd(2, "/test", "/test")); + + expect(querySelectorSpy).not.toHaveBeenCalled(); + expect(mainElement.focus).not.toHaveBeenCalled(); + }); + + it("should activate when RouterFocusManagement flag is enabled", () => { + const mainElement = document.createElement("main"); + mainElement.focus = jest.fn(); + querySelectorSpy.mockReturnValue(mainElement); + + // Ensure feature flag is enabled + featureFlagSubject.next(true); + + // Subscribe to start the service + service.start$.subscribe(); + + // Emit first navigation (should be skipped) + mockRouter.routerEventsSubject.next(new NavigationEnd(1, "/first", "/first")); + + // Emit second navigation with flag enabled + mockRouter.routerEventsSubject.next(new NavigationEnd(2, "/test", "/test")); + + expect(querySelectorSpy).toHaveBeenCalledWith("main"); + expect(mainElement.focus).toHaveBeenCalled(); + }); + }); + + describe("first navigation skip", () => { + it("should not trigger focus management on first navigation after page load", () => { + const mainElement = document.createElement("main"); + mainElement.focus = jest.fn(); + querySelectorSpy.mockReturnValue(mainElement); + + // Subscribe to start the service + service.start$.subscribe(); + + // Emit first navigation (should be skipped) + mockRouter.routerEventsSubject.next(new NavigationEnd(1, "/first", "/first")); + + expect(querySelectorSpy).not.toHaveBeenCalled(); + expect(mainElement.focus).not.toHaveBeenCalled(); + }); + + it("should trigger focus management on second and subsequent navigations", () => { + const mainElement = document.createElement("main"); + mainElement.focus = jest.fn(); + querySelectorSpy.mockReturnValue(mainElement); + + // Subscribe to start the service + service.start$.subscribe(); + + // Emit first navigation (should be skipped) + mockRouter.routerEventsSubject.next(new NavigationEnd(1, "/first", "/first")); + + // Emit second navigation (should trigger focus) + mockRouter.routerEventsSubject.next(new NavigationEnd(2, "/second", "/second")); + + expect(querySelectorSpy).toHaveBeenCalledWith("main"); + expect(mainElement.focus).toHaveBeenCalledTimes(1); + + // Emit third navigation (should also trigger focus) + mainElement.focus = jest.fn(); // Reset mock + mockRouter.routerEventsSubject.next(new NavigationEnd(3, "/third", "/third")); + + expect(mainElement.focus).toHaveBeenCalledTimes(1); + }); + }); + + describe("NgZone stability", () => { + it("should focus immediately when zone is stable", () => { + const mainElement = document.createElement("main"); + mainElement.focus = jest.fn(); + querySelectorSpy.mockReturnValue(mainElement); + + // Subscribe to start the service + service.start$.subscribe(); + + // Emit first navigation (should be skipped) + mockRouter.routerEventsSubject.next(new NavigationEnd(1, "/first", "/first")); + + // Emit second navigation + mockRouter.routerEventsSubject.next(new NavigationEnd(2, "/test", "/test")); + + expect(mainElement.focus).toHaveBeenCalled(); + }); + + it("should wait for zone stability before focusing when zone is not stable", () => { + const mainElement = document.createElement("main"); + mainElement.focus = jest.fn(); + querySelectorSpy.mockReturnValue(mainElement); + + // Set zone as not stable + mockNgZoneRef.isStable = false; + + // Subscribe to start the service + service.start$.subscribe(); + + // Emit first navigation (should be skipped) + mockRouter.routerEventsSubject.next(new NavigationEnd(1, "/first", "/first")); + + // Emit second navigation + mockRouter.routerEventsSubject.next(new NavigationEnd(2, "/test", "/test")); + + // Focus should not happen yet + expect(mainElement.focus).not.toHaveBeenCalled(); + + // Emit zone stability + mockNgZoneRef.onStable.emit(true); + + // Now focus should have happened + expect(mainElement.focus).toHaveBeenCalled(); + }); + }); +}); diff --git a/libs/components/src/navigation/nav-item.component.html b/libs/components/src/navigation/nav-item.component.html index 8a59d474d94..543e63ef076 100644 --- a/libs/components/src/navigation/nav-item.component.html +++ b/libs/components/src/navigation/nav-item.component.html @@ -58,6 +58,9 @@ [ariaCurrentWhenActive]="ariaCurrentWhenActive()" (isActiveChange)="setIsActive($event)" (click)="mainContentClicked.emit()" + [state]="{ + focusAfterNav: focusAfterNavTarget(), + }" > diff --git a/libs/components/src/navigation/nav-item.component.ts b/libs/components/src/navigation/nav-item.component.ts index b32ca0e3fde..aaba2ca421a 100644 --- a/libs/components/src/navigation/nav-item.component.ts +++ b/libs/components/src/navigation/nav-item.component.ts @@ -102,6 +102,18 @@ export class NavItemComponent extends NavBaseComponent { this.focusVisibleWithin$.next(false); } + /** + * By default, a navigation will put the user's focus on the `main` element. + * + * If the user's focus should be moved to another element upon navigation end, pass a selector + * here (i.e. `#elementId`). + * + * Pass `false` to opt out of moving the focus entirely. Focus will stay on the nav item. + * + * See router-focus-manager.service for implementation of focus management + */ + readonly focusAfterNavTarget = input(); + constructor( protected sideNavService: SideNavService, @Optional() private parentNavGroup: NavGroupAbstraction, 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 aa36eb37f99..932e2ce3b69 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,7 +5,7 @@ [routerLinkActiveOptions]="routerLinkMatchOptions" #rla="routerLinkActive" [active]="rla.isActive" - [state]="{ focusMainAfterNav: false }" + [state]="{ focusAfterNav: false }" [disabled]="disabled" [attr.aria-disabled]="disabled" ariaCurrentWhenActive="page" diff --git a/libs/vault/src/services/routed-vault-filter.service.ts b/libs/vault/src/services/routed-vault-filter.service.ts index 9005d507da7..e0d9f765361 100644 --- a/libs/vault/src/services/routed-vault-filter.service.ts +++ b/libs/vault/src/services/routed-vault-filter.service.ts @@ -82,7 +82,7 @@ export class RoutedVaultFilterService implements OnDestroy { }, queryParamsHandling: "merge", state: { - focusMainAfterNav: false, + focusAfterNav: false, }, }; return [commands, extras];