1
0
mirror of https://github.com/bitwarden/browser synced 2026-02-28 02:23:25 +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,
});
}