1
0
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:
Vicki League
2026-01-15 13:52:18 -05:00
parent 417dfdd305
commit cd31bb747b
12 changed files with 514 additions and 46 deletions

View File

@@ -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,
});
}

View File

@@ -9,6 +9,7 @@
[route]="product.appRoute"
[attr.icon]="product.icon"
[forceActiveStyles]="product.isActive"
focusAfterNavTarget="body"
>
</bit-nav-item>
}

View File

@@ -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

View File

@@ -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>

View File

@@ -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,
});
}

View 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.

View File

@@ -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}"`);
}
}
}

View 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();
});
});
});

View File

@@ -58,6 +58,9 @@
[ariaCurrentWhenActive]="ariaCurrentWhenActive()"
(isActiveChange)="setIsActive($event)"
(click)="mainContentClicked.emit()"
[state]="{
focusAfterNav: focusAfterNavTarget(),
}"
>
<ng-container *ngTemplateOutlet="anchorAndButtonContent"></ng-container>
</a>

View File

@@ -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,

View File

@@ -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"

View File

@@ -82,7 +82,7 @@ export class RoutedVaultFilterService implements OnDestroy {
},
queryParamsHandling: "merge",
state: {
focusMainAfterNav: false,
focusAfterNav: false,
},
};
return [commands, extras];