1
0
mirror of https://github.com/bitwarden/browser synced 2026-01-31 00:33:33 +00:00

[PM-30638] Cipher Add/Edit dialog focus (#18536)

* allow exporting of the DialogComponent

* focus on dialog header when switching modes

* update to view child fixmes
This commit is contained in:
Nick Krantz
2026-01-30 10:10:26 -06:00
committed by GitHub
parent 122203f589
commit 93ce914f79
4 changed files with 106 additions and 23 deletions

View File

@@ -104,7 +104,7 @@ describe("VaultItemDialogComponent", () => {
getFeatureFlag$: () => of(false),
},
},
{ provide: Router, useValue: {} },
{ provide: Router, useValue: { navigate: jest.fn() } },
{ provide: ActivatedRoute, useValue: {} },
{
provide: BillingAccountProfileStateService,
@@ -356,4 +356,76 @@ describe("VaultItemDialogComponent", () => {
});
});
});
describe("changeMode", () => {
beforeEach(() => {
component.setTestCipher({ type: CipherType.Login, id: "cipher-id" });
});
it("refocuses the dialog header", async () => {
const focusOnHeaderSpy = jest.spyOn(component["dialogComponent"](), "focusOnHeader");
await component["changeMode"]("view");
expect(focusOnHeaderSpy).toHaveBeenCalled();
});
describe("to view", () => {
beforeEach(() => {
component.setTestParams({ mode: "form" });
fixture.detectChanges();
});
it("sets mode to view", async () => {
await component["changeMode"]("view");
expect(component["params"].mode).toBe("view");
});
it("updates the url", async () => {
const router = TestBed.inject(Router);
await component["changeMode"]("view");
expect(router.navigate).toHaveBeenCalledWith([], {
queryParams: { action: "view", itemId: "cipher-id" },
queryParamsHandling: "merge",
replaceUrl: true,
});
});
});
describe("to form", () => {
const waitForFormReady = async () => {
const changeModePromise = component["changeMode"]("form");
expect(component["loadForm"]).toBe(true);
component["onFormReady"]();
await changeModePromise;
};
beforeEach(() => {
component.setTestParams({ mode: "view" });
fixture.detectChanges();
});
it("waits for form to be ready when switching to form mode", async () => {
await waitForFormReady();
expect(component["params"].mode).toBe("form");
});
it("updates the url", async () => {
const router = TestBed.inject(Router);
await waitForFormReady();
expect(router.navigate).toHaveBeenCalledWith([], {
queryParams: { action: "edit", itemId: "cipher-id" },
queryParamsHandling: "merge",
replaceUrl: true,
});
});
});
});
});

View File

@@ -8,7 +8,7 @@ import {
Inject,
OnDestroy,
OnInit,
ViewChild,
viewChild,
} from "@angular/core";
import { takeUntilDestroyed } from "@angular/core/rxjs-interop";
import { Router } from "@angular/router";
@@ -50,6 +50,7 @@ import {
ItemModule,
ToastService,
CenterPositionStrategy,
DialogComponent,
} from "@bitwarden/components";
import {
AttachmentDialogCloseResult,
@@ -163,14 +164,11 @@ export class VaultItemDialogComponent implements OnInit, OnDestroy {
* Reference to the dialog content element. Used to scroll to the top of the dialog when switching modes.
* @protected
*/
// FIXME(https://bitwarden.atlassian.net/browse/CL-903): Migrate to Signals
// eslint-disable-next-line @angular-eslint/prefer-signals
@ViewChild("dialogContent")
protected dialogContent: ElementRef<HTMLElement>;
protected readonly dialogContent = viewChild.required<ElementRef<HTMLElement>>("dialogContent");
// FIXME(https://bitwarden.atlassian.net/browse/CL-903): Migrate to Signals
// eslint-disable-next-line @angular-eslint/prefer-signals
@ViewChild(CipherFormComponent) cipherFormComponent!: CipherFormComponent;
private readonly cipherFormComponent = viewChild.required(CipherFormComponent);
private readonly dialogComponent = viewChild(DialogComponent);
/**
* Tracks if the cipher was ever modified while the dialog was open. Used to ensure the dialog emits the correct result
@@ -536,7 +534,7 @@ export class VaultItemDialogComponent implements OnInit, OnDestroy {
updatedCipherView = await this.cipherService.decrypt(updatedCipher, activeUserId);
}
this.cipherFormComponent.patchCipher((currentCipher) => {
this.cipherFormComponent().patchCipher((currentCipher) => {
currentCipher.attachments = updatedCipherView.attachments;
currentCipher.revisionDate = updatedCipherView.revisionDate;
@@ -574,7 +572,7 @@ export class VaultItemDialogComponent implements OnInit, OnDestroy {
return;
}
this.cipherFormComponent.patchCipher((current) => {
this.cipherFormComponent().patchCipher((current) => {
current.revisionDate = revisionDate;
current.archivedDate = archivedDate;
return current;
@@ -691,7 +689,10 @@ export class VaultItemDialogComponent implements OnInit, OnDestroy {
this.params.mode = mode;
this.updateTitle();
// Scroll to the top of the dialog content when switching modes.
this.dialogContent.nativeElement.parentElement.scrollTop = 0;
this.dialogContent().nativeElement.parentElement.scrollTop = 0;
// Refocus on title element, the built-in focus management of the dialog only works for the initial open.
this.dialogComponent().focusOnHeader();
// Update the URL query params to reflect the new mode.
await this.router.navigate([], {

View File

@@ -145,6 +145,26 @@ export class DialogComponent implements AfterViewInit {
});
ngAfterViewInit() {
this.focusOnHeader();
}
handleEsc(event: Event) {
if (!this.dialogRef?.disableClose) {
this.dialogRef?.close();
event.stopPropagation();
}
}
onAnimationEnd() {
this.animationCompleted.set(true);
}
/**
* Moves focus to the dialog header element.
* This is done automatically when the dialog is opened but can be called manually
* when the contents of the dialog change and focus should be reset.
*/
focusOnHeader(): void {
/**
* Wait a tick for any focus management to occur on the trigger element before moving focus to
* the dialog header. We choose the dialog header because it is always present, unlike possible
@@ -159,15 +179,4 @@ export class DialogComponent implements AfterViewInit {
this.destroyRef.onDestroy(() => clearTimeout(headerFocusTimeout));
}
handleEsc(event: Event) {
if (!this.dialogRef?.disableClose) {
this.dialogRef?.close();
event.stopPropagation();
}
}
onAnimationEnd() {
this.animationCompleted.set(true);
}
}

View File

@@ -2,3 +2,4 @@ export * from "./dialog.module";
export * from "./simple-dialog/types";
export * from "./dialog.service";
export { DIALOG_DATA } from "@angular/cdk/dialog";
export { DialogComponent } from "./dialog/dialog.component";