1
0
mirror of https://github.com/bitwarden/browser.git synced 2025-01-07 19:07:45 +01:00

[PM-12743] a11y changes to make new drop down list for send and vault accessible (#11717)

* updating new menus to allow tab + enter to submit the link/button

* Updating New actions to use button instead of a for accessibiity purposes

* refactor

* refactor

* test fix

* fixes

* fixing tests

* fixing test

* fixing tests

---------

Co-authored-by: --global <>
This commit is contained in:
cd-bitwarden 2024-12-02 11:30:38 -05:00 committed by GitHub
parent 456c516a6e
commit 0ff48aa345
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
5 changed files with 163 additions and 121 deletions

View File

@ -3,19 +3,27 @@
{{ "new" | i18n }} {{ "new" | i18n }}
</button> </button>
<bit-menu #itemOptions> <bit-menu #itemOptions>
<a bitMenuItem (click)="newItemNavigate(cipherType.Login)"> <a bitMenuItem [routerLink]="['/add-cipher']" [queryParams]="buildQueryParams(cipherType.Login)">
<i class="bwi bwi-globe" slot="start" aria-hidden="true"></i> <i class="bwi bwi-globe" slot="start" aria-hidden="true"></i>
{{ "typeLogin" | i18n }} {{ "typeLogin" | i18n }}
</a> </a>
<a bitMenuItem (click)="newItemNavigate(cipherType.Card)"> <a bitMenuItem [routerLink]="['/add-cipher']" [queryParams]="buildQueryParams(cipherType.Card)">
<i class="bwi bwi-credit-card" slot="start" aria-hidden="true"></i> <i class="bwi bwi-credit-card" slot="start" aria-hidden="true"></i>
{{ "typeCard" | i18n }} {{ "typeCard" | i18n }}
</a> </a>
<a bitMenuItem (click)="newItemNavigate(cipherType.Identity)"> <a
bitMenuItem
[routerLink]="['/add-cipher']"
[queryParams]="buildQueryParams(cipherType.Identity)"
>
<i class="bwi bwi-id-card" slot="start" aria-hidden="true"></i> <i class="bwi bwi-id-card" slot="start" aria-hidden="true"></i>
{{ "typeIdentity" | i18n }} {{ "typeIdentity" | i18n }}
</a> </a>
<a bitMenuItem (click)="newItemNavigate(cipherType.SecureNote)"> <a
bitMenuItem
[routerLink]="['/add-cipher']"
[queryParams]="buildQueryParams(cipherType.SecureNote)"
>
<i class="bwi bwi-sticky-note" slot="start" aria-hidden="true"></i> <i class="bwi bwi-sticky-note" slot="start" aria-hidden="true"></i>
{{ "note" | i18n }} {{ "note" | i18n }}
</a> </a>

View File

@ -1,141 +1,163 @@
import { CommonModule } from "@angular/common"; import { CommonModule } from "@angular/common";
import { ComponentFixture, TestBed } from "@angular/core/testing"; import { ComponentFixture, TestBed } from "@angular/core/testing";
import { Router } from "@angular/router"; import { ActivatedRoute, RouterLink } from "@angular/router";
import { mock } from "jest-mock-extended";
import { JslibModule } from "@bitwarden/angular/jslib.module"; import { AccountService } from "@bitwarden/common/auth/abstractions/account.service";
import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service"; import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service";
import { Utils } from "@bitwarden/common/platform/misc/utils";
import { FolderApiServiceAbstraction } from "@bitwarden/common/vault/abstractions/folder/folder-api.service.abstraction";
import { FolderService } from "@bitwarden/common/vault/abstractions/folder/folder.service.abstraction";
import { CipherType } from "@bitwarden/common/vault/enums"; import { CipherType } from "@bitwarden/common/vault/enums";
import { ButtonModule, DialogService, MenuModule } from "@bitwarden/components"; import { ButtonModule, DialogService, MenuModule, NoItemsModule } from "@bitwarden/components";
import { BrowserApi } from "../../../../../platform/browser/browser-api"; import { BrowserApi } from "../../../../../platform/browser/browser-api";
import BrowserPopupUtils from "../../../../../platform/popup/browser-popup-utils"; import BrowserPopupUtils from "../../../../../platform/popup/browser-popup-utils";
import { AddEditQueryParams } from "../add-edit/add-edit-v2.component";
import { AddEditFolderDialogComponent } from "../add-edit-folder-dialog/add-edit-folder-dialog.component";
import { NewItemDropdownV2Component, NewItemInitialValues } from "./new-item-dropdown-v2.component"; import { NewItemDropdownV2Component, NewItemInitialValues } from "./new-item-dropdown-v2.component";
describe("NewItemDropdownV2Component", () => { describe("NewItemDropdownV2Component", () => {
let component: NewItemDropdownV2Component; let component: NewItemDropdownV2Component;
let fixture: ComponentFixture<NewItemDropdownV2Component>; let fixture: ComponentFixture<NewItemDropdownV2Component>;
const open = jest.fn(); let dialogServiceMock: jest.Mocked<DialogService>;
const navigate = jest.fn(); let browserApiMock: jest.Mocked<typeof BrowserApi>;
jest const mockTab = { url: "https://example.com" };
.spyOn(BrowserApi, "getTabFromCurrentWindow")
.mockResolvedValue({ url: "https://example.com" } as chrome.tabs.Tab); beforeAll(() => {
jest.spyOn(BrowserApi, "getTabFromCurrentWindow").mockResolvedValue(mockTab as chrome.tabs.Tab);
jest.spyOn(BrowserPopupUtils, "inPopout").mockReturnValue(false);
jest.spyOn(Utils, "getHostname").mockReturnValue("example.com");
});
beforeEach(async () => { beforeEach(async () => {
open.mockClear(); dialogServiceMock = mock<DialogService>();
navigate.mockClear(); dialogServiceMock.open.mockClear();
const activatedRouteMock = {
snapshot: { paramMap: { get: jest.fn() } },
};
const i18nServiceMock = mock<I18nService>();
const folderServiceMock = mock<FolderService>();
const folderApiServiceAbstractionMock = mock<FolderApiServiceAbstraction>();
const accountServiceMock = mock<AccountService>();
await TestBed.configureTestingModule({ await TestBed.configureTestingModule({
imports: [NewItemDropdownV2Component, MenuModule, ButtonModule, JslibModule, CommonModule], imports: [
providers: [ CommonModule,
{ provide: I18nService, useValue: { t: (key: string) => key } }, RouterLink,
{ provide: Router, useValue: { navigate } }, ButtonModule,
MenuModule,
NoItemsModule,
NewItemDropdownV2Component,
], ],
}) providers: [
.overrideProvider(DialogService, { useValue: { open } }) { provide: DialogService, useValue: dialogServiceMock },
.compileComponents(); { provide: I18nService, useValue: i18nServiceMock },
{ provide: ActivatedRoute, useValue: activatedRouteMock },
{ provide: BrowserApi, useValue: browserApiMock },
{ provide: FolderService, useValue: folderServiceMock },
{ provide: FolderApiServiceAbstraction, useValue: folderApiServiceAbstractionMock },
{ provide: AccountService, useValue: accountServiceMock },
],
}).compileComponents();
});
beforeEach(() => {
fixture = TestBed.createComponent(NewItemDropdownV2Component); fixture = TestBed.createComponent(NewItemDropdownV2Component);
component = fixture.componentInstance; component = fixture.componentInstance;
fixture.detectChanges(); fixture.detectChanges();
}); });
it("opens new folder dialog", () => { describe("buildQueryParams", () => {
component.openFolderDialog(); it("should build query params for a Login cipher when not popped out", async () => {
await component.ngOnInit();
expect(open).toHaveBeenCalledWith(AddEditFolderDialogComponent);
});
describe("new item", () => {
const emptyParams: AddEditQueryParams = {
collectionId: undefined,
organizationId: undefined,
folderId: undefined,
};
beforeEach(() => {
jest.spyOn(component, "newItemNavigate");
});
it("navigates to new login", async () => {
await component.newItemNavigate(CipherType.Login);
expect(navigate).toHaveBeenCalledWith(["/add-cipher"], {
queryParams: {
type: CipherType.Login.toString(),
name: "example.com",
uri: "https://example.com",
...emptyParams,
},
});
});
it("navigates to new card", async () => {
await component.newItemNavigate(CipherType.Card);
expect(navigate).toHaveBeenCalledWith(["/add-cipher"], {
queryParams: { type: CipherType.Card.toString(), ...emptyParams },
});
});
it("navigates to new identity", async () => {
await component.newItemNavigate(CipherType.Identity);
expect(navigate).toHaveBeenCalledWith(["/add-cipher"], {
queryParams: { type: CipherType.Identity.toString(), ...emptyParams },
});
});
it("navigates to new note", async () => {
await component.newItemNavigate(CipherType.SecureNote);
expect(navigate).toHaveBeenCalledWith(["/add-cipher"], {
queryParams: { type: CipherType.SecureNote.toString(), ...emptyParams },
});
});
it("includes initial values", async () => {
component.initialValues = { component.initialValues = {
folderId: "222-333-444", folderId: "222-333-444",
organizationId: "444-555-666", organizationId: "444-555-666",
collectionId: "777-888-999", collectionId: "777-888-999",
} as NewItemInitialValues; } as NewItemInitialValues;
await component.newItemNavigate(CipherType.Login); jest.spyOn(BrowserPopupUtils, "inPopout").mockReturnValue(false);
jest.spyOn(Utils, "getHostname").mockReturnValue("example.com");
expect(navigate).toHaveBeenCalledWith(["/add-cipher"], { const params = component.buildQueryParams(CipherType.Login);
queryParams: {
type: CipherType.Login.toString(), expect(params).toEqual({
folderId: "222-333-444", type: CipherType.Login.toString(),
organizationId: "444-555-666", collectionId: "777-888-999",
collectionId: "777-888-999", organizationId: "444-555-666",
uri: "https://example.com", folderId: "222-333-444",
name: "example.com", uri: "https://example.com",
}, name: "example.com",
}); });
}); });
it("does not include name or uri when the extension is popped out", async () => { it("should build query params for a Login cipher when popped out", () => {
component.initialValues = {
collectionId: "777-888-999",
} as NewItemInitialValues;
jest.spyOn(BrowserPopupUtils, "inPopout").mockReturnValue(true); jest.spyOn(BrowserPopupUtils, "inPopout").mockReturnValue(true);
const params = component.buildQueryParams(CipherType.Login);
expect(params).toEqual({
type: CipherType.Login.toString(),
collectionId: "777-888-999",
});
});
it("should build query params for a secure note", () => {
component.initialValues = { component.initialValues = {
folderId: "222-333-444",
organizationId: "444-555-666",
collectionId: "777-888-999", collectionId: "777-888-999",
} as NewItemInitialValues; } as NewItemInitialValues;
await component.newItemNavigate(CipherType.Login); const params = component.buildQueryParams(CipherType.SecureNote);
expect(navigate).toHaveBeenCalledWith(["/add-cipher"], { expect(params).toEqual({
queryParams: { type: CipherType.SecureNote.toString(),
type: CipherType.Login.toString(), collectionId: "777-888-999",
folderId: "222-333-444", });
organizationId: "444-555-666", });
collectionId: "777-888-999",
}, it("should build query params for an Identity", () => {
component.initialValues = {
collectionId: "777-888-999",
} as NewItemInitialValues;
const params = component.buildQueryParams(CipherType.Identity);
expect(params).toEqual({
type: CipherType.Identity.toString(),
collectionId: "777-888-999",
});
});
it("should build query params for a Card", () => {
component.initialValues = {
collectionId: "777-888-999",
} as NewItemInitialValues;
const params = component.buildQueryParams(CipherType.Card);
expect(params).toEqual({
type: CipherType.Card.toString(),
collectionId: "777-888-999",
});
});
it("should build query params for a SshKey", () => {
component.initialValues = {
collectionId: "777-888-999",
} as NewItemInitialValues;
const params = component.buildQueryParams(CipherType.SshKey);
expect(params).toEqual({
type: CipherType.SshKey.toString(),
collectionId: "777-888-999",
}); });
}); });
}); });

View File

@ -1,6 +1,6 @@
import { CommonModule } from "@angular/common"; import { CommonModule } from "@angular/common";
import { Component, Input } from "@angular/core"; import { Component, Input, OnInit } from "@angular/core";
import { Router, RouterLink } from "@angular/router"; import { RouterLink } from "@angular/router";
import { JslibModule } from "@bitwarden/angular/jslib.module"; import { JslibModule } from "@bitwarden/angular/jslib.module";
import { Utils } from "@bitwarden/common/platform/misc/utils"; import { Utils } from "@bitwarden/common/platform/misc/utils";
@ -25,31 +25,31 @@ export interface NewItemInitialValues {
standalone: true, standalone: true,
imports: [NoItemsModule, JslibModule, CommonModule, ButtonModule, RouterLink, MenuModule], imports: [NoItemsModule, JslibModule, CommonModule, ButtonModule, RouterLink, MenuModule],
}) })
export class NewItemDropdownV2Component { export class NewItemDropdownV2Component implements OnInit {
cipherType = CipherType; cipherType = CipherType;
private tab?: chrome.tabs.Tab;
/** /**
* Optional initial values to pass to the add cipher form * Optional initial values to pass to the add cipher form
*/ */
@Input() @Input()
initialValues: NewItemInitialValues; initialValues: NewItemInitialValues;
constructor( constructor(private dialogService: DialogService) {}
private router: Router,
private dialogService: DialogService,
) {}
private async buildQueryParams(type: CipherType): Promise<AddEditQueryParams> { async ngOnInit() {
const tab = await BrowserApi.getTabFromCurrentWindow(); this.tab = await BrowserApi.getTabFromCurrentWindow();
}
buildQueryParams(type: CipherType): AddEditQueryParams {
const poppedOut = BrowserPopupUtils.inPopout(window); const poppedOut = BrowserPopupUtils.inPopout(window);
const loginDetails: { uri?: string; name?: string } = {}; const loginDetails: { uri?: string; name?: string } = {};
// When a Login Cipher is created and the extension is not popped out, // When a Login Cipher is created and the extension is not popped out,
// pass along the uri and name // pass along the uri and name
if (!poppedOut && type === CipherType.Login && tab) { if (!poppedOut && type === CipherType.Login && this.tab) {
loginDetails.uri = tab.url; loginDetails.uri = this.tab.url;
loginDetails.name = Utils.getHostname(tab.url); loginDetails.name = Utils.getHostname(this.tab.url);
} }
return { return {
@ -61,10 +61,6 @@ export class NewItemDropdownV2Component {
}; };
} }
async newItemNavigate(type: CipherType) {
await this.router.navigate(["/add-cipher"], { queryParams: await this.buildQueryParams(type) });
}
openFolderDialog() { openFolderDialog() {
this.dialogService.open(AddEditFolderDialogComponent); this.dialogService.open(AddEditFolderDialogComponent);
} }

View File

@ -3,11 +3,19 @@
{{ (hideIcon ? "createSend" : "new") | i18n }} {{ (hideIcon ? "createSend" : "new") | i18n }}
</button> </button>
<bit-menu #itemOptions> <bit-menu #itemOptions>
<a type="button" bitMenuItem (click)="newItemNavigate(sendType.Text)"> <a
bitMenuItem
[routerLink]="buildRouterLink(sendType.File)"
[queryParams]="buildQueryParams(sendType.Text)"
>
<i class="bwi bwi-file-text" slot="start" aria-hidden="true"></i> <i class="bwi bwi-file-text" slot="start" aria-hidden="true"></i>
{{ "sendTypeText" | i18n }} {{ "sendTypeText" | i18n }}
</a> </a>
<a type="button" bitMenuItem (click)="newItemNavigate(sendType.File)"> <a
bitMenuItem
[routerLink]="buildRouterLink(sendType.File)"
[queryParams]="buildQueryParams(sendType.File)"
>
<i class="bwi bwi-file" slot="start" aria-hidden="true"></i> <i class="bwi bwi-file" slot="start" aria-hidden="true"></i>
{{ "sendTypeFile" | i18n }} {{ "sendTypeFile" | i18n }}
<button type="button" slot="end" *ngIf="hasNoPremium" bitBadge variant="success"> <button type="button" slot="end" *ngIf="hasNoPremium" bitBadge variant="success">

View File

@ -32,10 +32,18 @@ export class NewSendDropdownComponent implements OnInit {
)); ));
} }
newItemNavigate(type: SendType) { buildRouterLink(type: SendType) {
if (this.hasNoPremium && type === SendType.File) { if (this.hasNoPremium && type === SendType.File) {
return this.router.navigate(["/premium"]); return "/premium";
} else {
return "/add-send";
} }
void this.router.navigate(["/add-send"], { queryParams: { type: type, isNew: true } }); }
buildQueryParams(type: SendType) {
if (this.hasNoPremium && type === SendType.File) {
return null;
}
return { type: type, isNew: true };
} }
} }