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:
@@ -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,
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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([], {
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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";
|
||||
|
||||
Reference in New Issue
Block a user