1
0
mirror of https://github.com/bitwarden/browser synced 2026-02-06 19:53:59 +00:00

[CL-724] Vault list item keyboard nav dialog (#15591)

* add focus-visible-within util

* wip: add grid a11y dialog to vault

* add story and i18n
This commit is contained in:
Will Martin
2025-07-12 23:21:34 -04:00
committed by GitHub
parent 6c4d853b15
commit 50f8fbf56b
11 changed files with 189 additions and 4 deletions

View File

@@ -5419,5 +5419,17 @@
"wasmNotSupported": {
"message": "WebAssembly is not supported on your browser or is not enabled. WebAssembly is required to use the Bitwarden app.",
"description": "'WebAssembly' is a technical term and should not be translated."
},
"keyboardNavigationBehavior": {
"message": "Keyboard navigation behavior"
},
"keyboardNavigationBehaviorUpDown": {
"message": "Use up/down arrow keys (↑ ↓) to move between items"
},
"keyboardNavigationBehaviorRightLeft": {
"message": "Use right/left arrow keys (→ ←) to move between item actions"
},
"keyboardNavigationBehaviorTab": {
"message": "Use tab key (↹) to jump to the next focusable section on the page"
}
}

View File

@@ -0,0 +1,26 @@
<bit-simple-dialog>
<ng-container bitDialogIcon>
<i class="bwi bwi-info-circle tw-text-3xl tw-text-info" aria-hidden="true"></i>
</ng-container>
<ng-container bitDialogTitle>
{{ "keyboardNavigationBehavior" | i18n }}
</ng-container>
<ng-container bitDialogContent>
<ul class="!tw-mb-0 tw-text-start">
<li bitTypography="body1">
{{ "keyboardNavigationBehaviorUpDown" | i18n }}
</li>
<li bitTypography="body1">
{{ "keyboardNavigationBehaviorRightLeft" | i18n }}
</li>
<li bitTypography="body1">
{{ "keyboardNavigationBehaviorTab" | i18n }}
</li>
</ul>
</ng-container>
<ng-container bitDialogFooter>
<button bitButton type="button" buttonType="primary" bitDialogClose>
{{ "gotIt" | i18n }}
</button>
</ng-container>
</bit-simple-dialog>

View File

@@ -0,0 +1,10 @@
import { Component } from "@angular/core";
import { ButtonModule, DialogModule, TypographyModule } from "@bitwarden/components";
import { I18nPipe } from "@bitwarden/ui-common";
@Component({
imports: [DialogModule, ButtonModule, TypographyModule, I18nPipe],
templateUrl: "vault-item-group-navigation-dialog.component.html",
})
export class VaultItemGroupNavigationDialogComponent {}

View File

@@ -0,0 +1,37 @@
import { inject, Injectable } from "@angular/core";
import { firstValueFrom } from "rxjs";
import {
KeyDefinition,
StateProvider,
VAULT_ITEM_GROUP_NAVIGATION_DIALOG,
} from "@bitwarden/common/platform/state";
import { DialogService } from "@bitwarden/components";
import { VaultItemGroupNavigationDialogComponent } from "./vault-item-group-navigation-dialog.component";
const VAULT_ITEM_GROUP_NAVIGATION_DIALOG_SHOWN = new KeyDefinition<boolean>(
VAULT_ITEM_GROUP_NAVIGATION_DIALOG,
"dialogShown",
{
deserializer: (obj) => obj,
},
);
@Injectable({
providedIn: "root",
})
export class VaultItemGroupNavigationDialogService {
private dialogService = inject(DialogService);
private shownState = inject(StateProvider).getGlobal(VAULT_ITEM_GROUP_NAVIGATION_DIALOG_SHOWN);
/** Opens the dialog if it hasn't been opened before. */
async openOnce() {
if (await firstValueFrom(this.shownState.state$)) {
return;
}
const dialogRef = this.dialogService.open(VaultItemGroupNavigationDialogComponent);
await this.shownState.update(() => true);
return dialogRef;
}
}

View File

@@ -0,0 +1,38 @@
import { provideNoopAnimations } from "@angular/platform-browser/animations";
import { Meta, StoryObj, applicationConfig, moduleMetadata } from "@storybook/angular";
import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service";
import { I18nMockService } from "@bitwarden/components";
import { VaultItemGroupNavigationDialogComponent } from "./vault-item-group-navigation-dialog.component";
export default {
title: "Browser/Vault/Item Group Navigation Dialog",
component: VaultItemGroupNavigationDialogComponent,
decorators: [
moduleMetadata({
imports: [],
providers: [
{
provide: I18nService,
useFactory: () =>
new I18nMockService({
keyboardNavigationBehavior: "Keyboard navigation behavior",
keyboardNavigationBehaviorUpDown:
"Use up/down arrow keys (↑ ↓) to move between items",
keyboardNavigationBehaviorRightLeft:
"Use right/left arrow keys (→ ←) to move between item actions",
keyboardNavigationBehaviorTab:
"Use tab key (↹) to jump to the next focusable section on the page",
gotIt: "Got it",
}),
},
],
}),
applicationConfig({
providers: [provideNoopAnimations()],
}),
],
} as Meta<VaultItemGroupNavigationDialogComponent>;
export const Default: StoryObj<VaultItemGroupNavigationDialogComponent> = {};

View File

@@ -33,9 +33,7 @@
<ng-template #sectionHeader>
<bit-section-header class="tw-p-0.5 -tw-mx-0.5">
<h2 bitTypography="h6">
{{ title }}
</h2>
<h2 bitTypography="h6">foobar</h2>
<button
*ngIf="showRefresh"
bitIconButton="bwi-refresh"
@@ -89,7 +87,7 @@
</ng-container>
<cdk-virtual-scroll-viewport [itemSize]="itemHeight$ | async" bitScrollLayout>
<bit-item-group>
<bit-item-group (bitFocusVisibleWithin)="handleItemGroupFocusVisibleWithin()">
<bit-item *cdkVirtualFor="let cipher of group.ciphers">
<button
bit-item-content

View File

@@ -44,6 +44,7 @@ import {
TypographyModule,
MenuModule,
ScrollLayoutDirective,
FocusVisibleWithinDirective,
} from "@bitwarden/components";
import {
CopyCipherFieldDirective,
@@ -60,6 +61,8 @@ import { VaultPopupSectionService } from "../../../services/vault-popup-section.
import { PopupCipherView } from "../../../views/popup-cipher.view";
import { ItemMoreOptionsComponent } from "../item-more-options/item-more-options.component";
import { VaultItemGroupNavigationDialogService } from "./navigation-dialog/vault-item-group-navigation-dialog.service";
@Component({
imports: [
CommonModule,
@@ -80,6 +83,7 @@ import { ItemMoreOptionsComponent } from "../item-more-options/item-more-options
MenuModule,
CopyCipherFieldDirective,
ScrollLayoutDirective,
FocusVisibleWithinDirective,
],
selector: "app-vault-list-items-container",
templateUrl: "vault-list-items-container.component.html",
@@ -88,6 +92,7 @@ import { ItemMoreOptionsComponent } from "../item-more-options/item-more-options
export class VaultListItemsContainerComponent implements OnInit, AfterViewInit {
private compactModeService = inject(CompactModeService);
private vaultPopupSectionService = inject(VaultPopupSectionService);
private vaultItemGroupNavigationDialogService = inject(VaultItemGroupNavigationDialogService);
protected copyButtonsService = inject(VaultPopupCopyButtonsService);
protected CipherType = CipherType;
@@ -308,6 +313,10 @@ export class VaultListItemsContainerComponent implements OnInit, AfterViewInit {
}
}
async handleItemGroupFocusVisibleWithin() {
await this.vaultItemGroupNavigationDialogService.openOnce();
}
async primaryActionOnSelect(cipher: CipherView) {
const isBlocked = await firstValueFrom(this.currentURIIsBlocked$);

View File

@@ -200,3 +200,7 @@ export const VAULT_BROWSER_INTRO_CAROUSEL = new StateDefinition(
"vaultBrowserIntroCarousel",
"disk",
);
export const VAULT_ITEM_GROUP_NAVIGATION_DIALOG = new StateDefinition(
"vaultItemGroupNavigationDialog",
"disk",
);

View File

@@ -1 +1,4 @@
export * from "./a11y-cell.directive";
export * from "./a11y-grid.directive";
export * from "./a11y-row.directive";
export * from "./a11y-title.directive";

View File

@@ -0,0 +1,47 @@
import { Directive, ElementRef, inject, input, output } from "@angular/core";
import { takeUntilDestroyed, toObservable } from "@angular/core/rxjs-interop";
import { filter, fromEvent, map, switchMap } from "rxjs";
@Directive({
selector: "[bitFocusVisibleWithin]",
})
export class FocusVisibleWithinDirective {
private elementRef = inject(ElementRef) as ElementRef<HTMLElement>;
/**
* Emits when the host element has a child with `:focus-visible`.
*
* The target selector can be narrowed with the `selector` input.
**/
bitFocusVisibleWithin = output<void>();
/**
* The child selector to watch.
*
* Defaults to `:focus-visble`, but sometimes it may be useful to be more specific, e.g. `foo-bar:focus-visible`.
**/
selector = input<`${string}:focus-visible`>(":focus-visible");
constructor() {
toObservable(this.selector)
.pipe(
switchMap((selector) =>
fromEvent(this.elementRef.nativeElement, "focusin").pipe(
map(() => {
const activeEl = document.activeElement;
return (
!!activeEl &&
this.elementRef.nativeElement.contains(activeEl) &&
activeEl.matches(selector)
);
}),
),
),
filter((hasFocusVisibleWithin) => hasFocusVisibleWithin),
takeUntilDestroyed(),
)
.subscribe(() => {
this.bitFocusVisibleWithin.emit();
});
}
}

View File

@@ -1,2 +1,3 @@
export * from "./focus-visible-within";
export * from "./function-to-observable";
export * from "./i18n-mock.service";