mirror of
https://github.com/bitwarden/browser
synced 2026-01-30 08:13:44 +00:00
[PM-24178] Handle focus when routed dialog closes in vault table
This commit is contained in:
@@ -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<NavigationExtras>, specifically the state.focusAfterNav property, or undefined
|
||||
*/
|
||||
private configureRouterFocusToCipher(cipherId?: string): Partial<NavigationExtras> | 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,
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -9,6 +9,7 @@
|
||||
[route]="product.appRoute"
|
||||
[attr.icon]="product.icon"
|
||||
[forceActiveStyles]="product.isActive"
|
||||
focusAfterNavTarget="body"
|
||||
>
|
||||
</bit-nav-item>
|
||||
}
|
||||
|
||||
@@ -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',
|
||||
}"
|
||||
>
|
||||
<i class="bwi {{ product.icon }} tw-text-4xl !tw-m-0 !tw-mb-1"></i>
|
||||
<span
|
||||
|
||||
@@ -15,6 +15,7 @@
|
||||
</td>
|
||||
<td bitCell [ngClass]="RowHeightClass" class="tw-truncate">
|
||||
<div class="tw-inline-flex tw-w-full">
|
||||
<!-- Opt out of router focus manager via [state] input, since the dialog will handle focus -->
|
||||
<button
|
||||
bitLink
|
||||
class="tw-overflow-hidden tw-text-ellipsis tw-text-start tw-leading-snug"
|
||||
@@ -27,6 +28,10 @@
|
||||
type="button"
|
||||
appStopProp
|
||||
aria-haspopup="true"
|
||||
id="cipher-btn-{{ cipher.id }}"
|
||||
[state]="{
|
||||
focusAfterNav: false,
|
||||
}"
|
||||
>
|
||||
{{ cipher.name }}
|
||||
</button>
|
||||
|
||||
@@ -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<C extends CipherViewLike> implements OnInit, OnDestr
|
||||
queryParamsHandling: "merge",
|
||||
replaceUrl: true,
|
||||
state: {
|
||||
focusMainAfterNav: false,
|
||||
focusAfterNav: false,
|
||||
},
|
||||
}),
|
||||
);
|
||||
@@ -971,7 +971,10 @@ export class VaultComponent<C extends CipherViewLike> 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<C extends CipherViewLike> 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<C extends CipherViewLike> 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<C extends CipherViewLike> 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<NavigationExtras>, specifically the state.focusAfterNav property, or undefined
|
||||
*/
|
||||
private configureRouterFocusToCipher(cipherId?: string): Partial<NavigationExtras> | 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<C extends CipherViewLike> implements OnInit, OnDestr
|
||||
queryParams: queryParams,
|
||||
queryParamsHandling: "merge",
|
||||
replaceUrl: true,
|
||||
...navigateOptions,
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
69
libs/components/src/a11y/router-focus-manager.mdx
Normal file
69
libs/components/src/a11y/router-focus-manager.mdx
Normal file
@@ -0,0 +1,69 @@
|
||||
import { Meta } from "@storybook/addon-docs/blocks";
|
||||
|
||||
<Meta title="Documentation/Router Focus Management" />
|
||||
|
||||
# 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: `<a [routerLink]="route()" [state]="{ focusAfterNav: '#selector' }"></a>`
|
||||
|
||||
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: `<a [routerLink]="route()" [state]="{ focusAfterNav: false }"></a>`
|
||||
|
||||
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.
|
||||
@@ -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: `<a [routerLink]="route()" [state]="{ focusMainAfterNav: false }"></a>`
|
||||
* 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<HTMLElement>("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<HTMLElement>(elSelector);
|
||||
if (targetEl) {
|
||||
targetEl.focus();
|
||||
} else {
|
||||
// eslint-disable-next-line no-console
|
||||
console.warn(`RouterFocusManager: Could not find element with selector "${elSelector}"`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
320
libs/components/src/a11y/router-focus-manager.spec.ts
Normal file
320
libs/components/src/a11y/router-focus-manager.spec.ts
Normal file
@@ -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<any> = 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<Navigation> = computed(() => ({
|
||||
...mock<Navigation>(),
|
||||
extras: this.currentNavigationExtras(),
|
||||
}));
|
||||
|
||||
// eslint-disable-next-line rxjs/no-exposed-subjects
|
||||
readonly routerEventsSubject = new Subject<Event>();
|
||||
|
||||
override get events() {
|
||||
return this.routerEventsSubject.asObservable();
|
||||
}
|
||||
}
|
||||
|
||||
let service: RouterFocusManagerService;
|
||||
let featureFlagSubject: BehaviorSubject<boolean>;
|
||||
let mockRouter: MockRouter;
|
||||
let mockConfigService: Partial<ConfigService>;
|
||||
let mockNgZoneRef: MockNgZone;
|
||||
|
||||
let querySelectorSpy: jest.SpyInstance;
|
||||
let consoleWarnSpy: jest.SpyInstance;
|
||||
|
||||
beforeEach(() => {
|
||||
// Mock ConfigService
|
||||
featureFlagSubject = new BehaviorSubject<boolean>(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();
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -58,6 +58,9 @@
|
||||
[ariaCurrentWhenActive]="ariaCurrentWhenActive()"
|
||||
(isActiveChange)="setIsActive($event)"
|
||||
(click)="mainContentClicked.emit()"
|
||||
[state]="{
|
||||
focusAfterNav: focusAfterNavTarget(),
|
||||
}"
|
||||
>
|
||||
<ng-container *ngTemplateOutlet="anchorAndButtonContent"></ng-container>
|
||||
</a>
|
||||
|
||||
@@ -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<string | boolean>();
|
||||
|
||||
constructor(
|
||||
protected sideNavService: SideNavService,
|
||||
@Optional() private parentNavGroup: NavGroupAbstraction,
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -82,7 +82,7 @@ export class RoutedVaultFilterService implements OnDestroy {
|
||||
},
|
||||
queryParamsHandling: "merge",
|
||||
state: {
|
||||
focusMainAfterNav: false,
|
||||
focusAfterNav: false,
|
||||
},
|
||||
};
|
||||
return [commands, extras];
|
||||
|
||||
Reference in New Issue
Block a user