This commit is contained in:
Will Martin 2024-04-25 22:52:00 +00:00 committed by GitHub
commit 7a265066de
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
14 changed files with 563 additions and 14 deletions

View File

@ -0,0 +1,23 @@
import { AfterContentChecked, ContentChild, Directive, HostBinding } from "@angular/core";
import { FocusableElement } from "../shared/focusable-element";
@Directive({
selector: "bitA11yCell",
standalone: true,
})
export class A11yCellDirective implements AfterContentChecked {
@HostBinding("attr.role")
role = "gridcell";
@ContentChild(FocusableElement)
focusableChild: FocusableElement;
ngAfterContentChecked(): void {
if (!this.focusableChild) {
// eslint-disable-next-line no-console
console.error("A11yCellDirective must contain content that provides FocusableElement");
return;
}
}
}

View File

@ -0,0 +1,134 @@
import {
AfterViewInit,
ContentChildren,
Directive,
HostBinding,
HostListener,
Input,
QueryList,
} from "@angular/core";
import type { A11yCellDirective } from "./a11y-cell.directive";
import { A11yRowDirective } from "./a11y-row.directive";
@Directive({
selector: "bitA11yGrid",
standalone: true,
})
export class A11yGridDirective implements AfterViewInit {
@HostBinding("attr.role")
role = "grid";
@ContentChildren(A11yRowDirective)
rows: QueryList<A11yRowDirective>;
/** The number of pages to navigate on `PageUp` and `PageDown` */
@Input() pageSize = 5;
private grid: A11yCellDirective[][];
/** The row that currently has focus */
private activeRow = 0;
/** The cell that currently has focus */
private activeCol = 0;
@HostListener("keydown", ["$event"])
onKeyDown(event: KeyboardEvent) {
switch (event.code) {
case "ArrowUp":
this.updateCellFocusByDelta(-1, 0);
break;
case "ArrowRight":
this.updateCellFocusByDelta(0, 1);
break;
case "ArrowDown":
this.updateCellFocusByDelta(1, 0);
break;
case "ArrowLeft":
this.updateCellFocusByDelta(0, -1);
break;
case "Home":
this.updateCellFocusByDelta(-this.activeRow, -this.activeCol);
break;
case "End":
this.updateCellFocusByDelta(this.grid.length, this.grid[this.grid.length - 1].length);
break;
case "PageUp":
this.updateCellFocusByDelta(-this.pageSize, 0);
break;
case "PageDown":
this.updateCellFocusByDelta(this.pageSize, 0);
break;
default:
return;
}
/** Prevent default scrolling behavior */
event.preventDefault();
}
ngAfterViewInit(): void {
this.initializeGrid();
}
private initializeGrid(): void {
this.grid = this.rows.map((listItem) => [...listItem.cells]);
this.grid.flat().map((cell) => (cell.focusableChild.getFocusTarget().tabIndex = -1));
this.getActiveCellContent().tabIndex = 0;
}
/** Get the focusable content of the active cell */
private getActiveCellContent(): HTMLElement {
return this.grid[this.activeRow][this.activeCol].focusableChild.getFocusTarget();
}
/** Move focus via a delta against the currently active gridcell */
private updateCellFocusByDelta(rowDelta: number, colDelta: number) {
const prevActive = this.getActiveCellContent();
this.activeCol += colDelta;
this.activeRow += rowDelta;
// Row upper bound
if (this.activeRow >= this.grid.length) {
this.activeRow = this.grid.length - 1;
}
// Row lower bound
if (this.activeRow < 0) {
this.activeRow = 0;
}
// Column upper bound
if (this.activeCol >= this.grid[this.activeRow].length) {
if (this.activeRow < this.grid.length - 1) {
// Wrap to next row on right arrow
this.activeCol = 0;
this.activeRow += 1;
} else {
this.activeCol = this.grid[this.activeRow].length - 1;
}
}
// Column lower bound
if (this.activeCol < 0) {
if (this.activeRow > 0) {
// Wrap to prev row on left arrow
this.activeRow -= 1;
this.activeCol = this.grid[this.activeRow].length - 1;
} else {
this.activeCol = 0;
}
}
const nextActive = this.getActiveCellContent();
nextActive.tabIndex = 0;
nextActive.focus();
if (nextActive !== prevActive) {
prevActive.tabIndex = -1;
}
}
}

View File

@ -0,0 +1,31 @@
import {
AfterViewInit,
ContentChildren,
Directive,
HostBinding,
QueryList,
ViewChildren,
} from "@angular/core";
import { A11yCellDirective } from "./a11y-cell.directive";
@Directive({
selector: "bitA11yRow",
standalone: true,
})
export class A11yRowDirective implements AfterViewInit {
@HostBinding("attr.role")
role = "row";
cells: A11yCellDirective[];
@ViewChildren(A11yCellDirective)
private viewCells: QueryList<A11yCellDirective>;
@ContentChildren(A11yCellDirective)
private contentCells: QueryList<A11yCellDirective>;
ngAfterViewInit(): void {
this.cells = [...this.viewCells, ...this.contentCells];
}
}

View File

@ -1,5 +1,7 @@
import { Directive, ElementRef, HostBinding, Input } from "@angular/core";
import { FocusableElement } from "../shared/focusable-element";
export type BadgeVariant = "primary" | "secondary" | "success" | "danger" | "warning" | "info";
const styles: Record<BadgeVariant, string[]> = {
@ -22,8 +24,9 @@ const hoverStyles: Record<BadgeVariant, string[]> = {
@Directive({
selector: "span[bitBadge], a[bitBadge], button[bitBadge]",
providers: [{ provide: FocusableElement, useExisting: BadgeDirective }],
})
export class BadgeDirective {
export class BadgeDirective implements FocusableElement {
@HostBinding("class") get classList() {
return [
"tw-inline-block",
@ -62,6 +65,10 @@ export class BadgeDirective {
*/
@Input() truncate = true;
getFocusTarget() {
return this.el.nativeElement;
}
private hasHoverEffects = false;
constructor(private el: ElementRef<HTMLElement>) {

View File

@ -1,6 +1,7 @@
import { Component, HostBinding, Input } from "@angular/core";
import { Component, ElementRef, HostBinding, Input } from "@angular/core";
import { ButtonLikeAbstraction, ButtonType } from "../shared/button-like.abstraction";
import { FocusableElement } from "../shared/focusable-element";
export type IconButtonType = ButtonType | "contrast" | "main" | "muted" | "light";
@ -123,9 +124,12 @@ const sizes: Record<IconButtonSize, string[]> = {
@Component({
selector: "button[bitIconButton]:not(button[bitButton])",
templateUrl: "icon-button.component.html",
providers: [{ provide: ButtonLikeAbstraction, useExisting: BitIconButtonComponent }],
providers: [
{ provide: ButtonLikeAbstraction, useExisting: BitIconButtonComponent },
{ provide: FocusableElement, useExisting: BitIconButtonComponent },
],
})
export class BitIconButtonComponent implements ButtonLikeAbstraction {
export class BitIconButtonComponent implements ButtonLikeAbstraction, FocusableElement {
@Input("bitIconButton") icon: string;
@Input() buttonType: IconButtonType;
@ -162,4 +166,10 @@ export class BitIconButtonComponent implements ButtonLikeAbstraction {
setButtonType(value: "primary" | "secondary" | "danger" | "unstyled") {
this.buttonType = value;
}
getFocusTarget() {
return this.elementRef.nativeElement;
}
constructor(private elementRef: ElementRef) {}
}

View File

@ -3,12 +3,7 @@ import { take } from "rxjs/operators";
import { Utils } from "@bitwarden/common/platform/misc/utils";
/**
* Interface for implementing focusable components. Used by the AutofocusDirective.
*/
export abstract class FocusableElement {
focus: () => void;
}
import { FocusableElement } from "../shared/focusable-element";
/**
* Directive to focus an element.
@ -46,7 +41,7 @@ export class AutofocusDirective {
private focus() {
if (this.focusableElement) {
this.focusableElement.focus();
this.focusableElement.getFocusTarget().focus();
} else {
this.el.nativeElement.focus();
}

View File

@ -0,0 +1,13 @@
import { CommonModule } from "@angular/common";
import { Component } from "@angular/core";
import { A11yCellDirective } from "../a11y/a11y-cell.directive";
@Component({
selector: "bit-item-action",
standalone: true,
imports: [CommonModule],
template: `<ng-content></ng-content>`,
providers: [{ provide: A11yCellDirective, useExisting: ItemActionComponent }],
})
export class ItemActionComponent extends A11yCellDirective {}

View File

@ -0,0 +1,32 @@
<!-- TODO: Colors will be finalized in the extension refresh feature branch -->
<div
class="tw-box-border tw-overflow-auto tw-flex tw-bg-background [&:hover:not(:has(.end-slot:hover))]:tw-bg-primary-300/20 tw-text-main tw-border-solid tw-border-b tw-border-0 tw-rounded-lg tw-mb-1.5"
[ngClass]="
focusVisibleWithin()
? 'tw-z-10 tw-rounded tw-outline-none tw-ring tw-ring-primary-600 tw-border-transparent'
: 'tw-border-b-secondary-300 [&:hover:not(:has(.end-slot:hover))]:tw-border-b-transparent'
"
>
<!-- TODO render as anchor -->
<bit-item-action class="tw-block tw-w-full">
<button
bitFocusableElement
type="button"
class="fvw-target tw-outline-none tw-text-main tw-text-base tw-p-4 tw-bg-transparent tw-w-full tw-border-none tw-flex tw-gap-4 tw-items-center tw-justify-between"
>
<div class="tw-flex tw-gap-2 tw-items-center">
<i *ngIf="iconStart" class="bwi tw-text-[1.75rem] tw-text-muted" [ngClass]="iconStart"></i>
<ng-content></ng-content>
</div>
<i *ngIf="iconEnd" class="bwi tw-text-2xl tw-text-main" [ngClass]="iconEnd"></i>
</button>
</bit-item-action>
<div
#endSlot
class="end-slot tw-p-2 tw-flex tw-gap-2 tw-items-center"
[hidden]="endSlot.childElementCount === 0"
>
<ng-content select="bit-item-action"></ng-content>
</div>
</div>

View File

@ -0,0 +1,48 @@
import { CommonModule } from "@angular/common";
import {
ChangeDetectionStrategy,
Component,
EventEmitter,
HostListener,
Input,
Output,
signal,
} from "@angular/core";
import { A11yRowDirective } from "../a11y/a11y-row.directive";
import { FocusableElementDirective } from "../shared/focusable-element";
import { TypographyModule } from "../typography";
import { ItemActionComponent } from "./item-action.component";
@Component({
selector: "bit-item",
standalone: true,
imports: [CommonModule, TypographyModule, ItemActionComponent, FocusableElementDirective],
changeDetection: ChangeDetectionStrategy.OnPush,
templateUrl: "item.component.html",
providers: [{ provide: A11yRowDirective, useExisting: ItemComponent }],
})
export class ItemComponent extends A11yRowDirective {
@Input()
iconStart: string | null = null;
@Input()
iconEnd: string | null = null;
@Output()
mainContentClicked: EventEmitter<MouseEvent> = new EventEmitter();
/**
* We have `:focus-within` and `:focus-visible` but no `:focus-visible-within`
*/
protected focusVisibleWithin = signal(false);
@HostListener("focusin", ["$event.target"])
onFocusIn(target: HTMLElement) {
this.focusVisibleWithin.set(target.matches(".fvw-target:focus-visible"));
}
@HostListener("focusout")
onFocusOut() {
this.focusVisibleWithin.set(false);
}
}

View File

@ -0,0 +1,12 @@
import { CommonModule } from "@angular/common";
import { Component } from "@angular/core";
import { A11yGridDirective } from "../a11y/a11y-grid.directive";
@Component({
selector: "bit-list",
standalone: true,
imports: [CommonModule],
template: `<ng-content></ng-content>`,
})
export class ListComponent extends A11yGridDirective {}

View File

@ -0,0 +1,17 @@
import { Meta, Story, Primary, Controls } from "@storybook/addon-docs";
import * as stories from "./list.stories";
<Meta of={stories} />
# List
The `BitListComponent` is a container that displays one or more instances of `BitItemComponent`.
## Icons
## Primary Action
## Secondary Actions
## A11y

View File

@ -0,0 +1,206 @@
import { Meta, StoryObj, componentWrapperDecorator, moduleMetadata } from "@storybook/angular";
import { AvatarModule } from "../avatar";
import { BadgeModule } from "../badge";
import { IconButtonModule } from "../icon-button";
import { TypographyModule } from "../typography";
import { ItemActionComponent } from "./item-action.component";
import { ItemComponent } from "./item.component";
import { ListComponent } from "./list.component";
export default {
title: "Component Library/List",
component: ListComponent,
decorators: [
moduleMetadata({
imports: [
ItemComponent,
AvatarModule,
IconButtonModule,
BadgeModule,
TypographyModule,
ItemActionComponent,
],
}),
componentWrapperDecorator((story) => `<div class="tw-bg-background-alt tw-p-2">${story}</div>`),
],
} as Meta;
type Story = StoryObj<ListComponent>;
export const StandaloneItem: Story = {
render: (args) => ({
props: args,
template: /*html*/ `
<bit-item iconEnd="bwi-angle-right">
Foo
</bit-item>
`,
}),
};
export const CustomContent: Story = {
render: (args) => ({
props: args,
template: /*html*/ `
<bit-item iconEnd="bwi-lock">
<bit-avatar slot="start" size="small" text="Baz"></bit-avatar>
<div class="tw-flex tw-flex-col tw-items-start">
<span>baz@bitwarden.com</span>
<span bitTypography="helper" class="tw-text-muted">bitwarden.com</span>
<span bitTypography="helper" class="tw-text-muted"><em>locked</em></span>
</div>
</bit-item>
`,
}),
};
export const SingleActionList: Story = {
render: (args) => ({
props: args,
template: /*html*/ `
<bit-list>
<bit-item iconEnd="bwi-angle-right">
Foo
</bit-item>
<bit-item iconEnd="bwi-angle-right">
Foo
</bit-item>
<bit-item iconEnd="bwi-angle-right">
Foo
</bit-item>
<bit-item iconEnd="bwi-angle-right">
Foo
</bit-item>
<bit-item iconEnd="bwi-angle-right">
Foo
</bit-item>
<bit-item iconEnd="bwi-angle-right">
Foo
</bit-item>
<bit-item iconEnd="bwi-angle-right">
Foo
</bit-item>
<bit-item iconEnd="bwi-angle-right">
Foo
</bit-item>
<bit-item iconEnd="bwi-angle-right">
Foo
</bit-item>
<bit-item iconEnd="bwi-angle-right">
Foo
</bit-item>
<bit-item iconEnd="bwi-angle-right">
Foo
</bit-item>
</bit-list>
`,
}),
};
export const MultiActionList: Story = {
render: (args) => ({
props: args,
template: /*html*/ `
<bit-list>
<bit-item iconStart="bwi-globe">
Bar
<bit-item-action>
<button type="button" bitBadge variant="primary">Auto-fill</button>
</bit-item-action>
<bit-item-action>
<button type="button" bitIconButton="bwi-clone"></button>
</bit-item-action>
<bit-item-action>
<button type="button" bitIconButton="bwi-ellipsis-v"></button>
</bit-item-action>
</bit-item>
<bit-item iconStart="bwi-globe">
Bar
<bit-item-action>
<button type="button" bitBadge variant="primary">Auto-fill</button>
</bit-item-action>
<bit-item-action>
<button type="button" bitIconButton="bwi-clone"></button>
</bit-item-action>
<bit-item-action>
<button type="button" bitIconButton="bwi-ellipsis-v"></button>
</bit-item-action>
</bit-item>
<bit-item iconStart="bwi-globe">
Bar
<bit-item-action>
<button type="button" bitBadge variant="primary">Auto-fill</button>
</bit-item-action>
<bit-item-action>
<button type="button" bitIconButton="bwi-clone"></button>
</bit-item-action>
<bit-item-action>
<button type="button" bitIconButton="bwi-ellipsis-v"></button>
</bit-item-action>
</bit-item>
<bit-item iconStart="bwi-globe">
Bar
<bit-item-action>
<button type="button" bitBadge variant="primary">Auto-fill</button>
</bit-item-action>
<bit-item-action>
<button type="button" bitIconButton="bwi-clone"></button>
</bit-item-action>
<bit-item-action>
<button type="button" bitIconButton="bwi-ellipsis-v"></button>
</bit-item-action>
</bit-item>
<bit-item iconStart="bwi-globe">
Bar
<bit-item-action>
<button type="button" bitBadge variant="primary">Auto-fill</button>
</bit-item-action>
<bit-item-action>
<button type="button" bitIconButton="bwi-clone"></button>
</bit-item-action>
<bit-item-action>
<button type="button" bitIconButton="bwi-ellipsis-v"></button>
</bit-item-action>
</bit-item>
<bit-item iconStart="bwi-globe">
Bar
<bit-item-action>
<button type="button" bitBadge variant="primary">Auto-fill</button>
</bit-item-action>
<bit-item-action>
<button type="button" bitIconButton="bwi-clone"></button>
</bit-item-action>
<bit-item-action>
<button type="button" bitIconButton="bwi-ellipsis-v"></button>
</bit-item-action>
</bit-item>
<bit-item iconStart="bwi-globe">
Bar
<bit-item-action>
<button type="button" bitBadge variant="primary">Auto-fill</button>
</bit-item-action>
<bit-item-action>
<button type="button" bitIconButton="bwi-clone"></button>
</bit-item-action>
<bit-item-action>
<button type="button" bitIconButton="bwi-ellipsis-v"></button>
</bit-item-action>
</bit-item>
<bit-item iconStart="bwi-globe">
Bar
<bit-item-action>
<button type="button" bitBadge variant="primary">Auto-fill</button>
</bit-item-action>
<bit-item-action>
<button type="button" bitIconButton="bwi-clone"></button>
</bit-item-action>
<bit-item-action>
<button type="button" bitIconButton="bwi-ellipsis-v"></button>
</bit-item-action>
</bit-item>
</bit-list>
`,
}),
};

View File

@ -1,7 +1,7 @@
import { Component, ElementRef, Input, ViewChild } from "@angular/core";
import { ControlValueAccessor, NG_VALUE_ACCESSOR } from "@angular/forms";
import { FocusableElement } from "../input/autofocus.directive";
import { FocusableElement } from "../shared/focusable-element";
let nextId = 0;
@ -32,8 +32,8 @@ export class SearchComponent implements ControlValueAccessor, FocusableElement {
@Input() disabled: boolean;
@Input() placeholder: string;
focus() {
this.input.nativeElement.focus();
getFocusTarget() {
return this.input.nativeElement;
}
onChange(searchText: string) {

View File

@ -0,0 +1,21 @@
import { Directive, ElementRef } from "@angular/core";
/**
* Interface for implementing focusable components. Used by the AutofocusDirective.
*/
export abstract class FocusableElement {
getFocusTarget: () => HTMLElement;
}
@Directive({
selector: "[bitFocusableElement]",
standalone: true,
providers: [{ provide: FocusableElement, useExisting: FocusableElementDirective }],
})
export class FocusableElementDirective implements FocusableElement {
constructor(private elementRef: ElementRef) {}
getFocusTarget() {
return this.elementRef.nativeElement;
}
}