1
0
mirror of https://github.com/bitwarden/browser synced 2025-12-06 00:13:28 +00:00

[PM-14422] Vault Carousel (#12791)

* collect tailwind styles from the `libs/vault/*`

- Some unique styles were not showing in storybook

* initial add of carousel component

* initial add of carousel stories

* move carousel button to a standalone component for organization

* add key manager for carousel buttons

* add tab panel role to slide component

* make carousel slide focusable when it does not contain focusable elements

* add aria live to carousel slides

* add labels for carousel slide buttons

* emit slide change event

* move icons to carousel-icons folder

* add barrel file for carousel

* move protected properties

* remove underscore

* allow differing heights of carousel slides

* update interactive styles for the carousel icons

* allow for focus styled on carousel buttons

* fix tests

* fix imports

* add method to render each slide and get the height of the tallest slide

- This avoids consumers having to pass in a height.
- The height of the tallest slide is needed because it will stop the carousel from jumping around as the user scrolls.

* add comment to content property

* remove rem calculation
This commit is contained in:
Nick Krantz
2025-01-22 08:45:35 -06:00
committed by GitHub
parent 02e10b56f5
commit e26670c029
16 changed files with 621 additions and 0 deletions

View File

@@ -0,0 +1,13 @@
<button
#btn
type="button"
role="tab"
class="tw-h-6 tw-w-6 tw-p-0 tw-flex tw-items-center tw-justify-center tw-border-2 tw-border-solid tw-rounded-full tw-transition tw-bg-transparent tw-border-transparent focus-visible:tw-outline-none focus-visible:tw-border-primary-600"
[ngClass]="dynamicClasses"
[attr.aria-selected]="isActive"
[attr.tabindex]="isActive ? 0 : -1"
[attr.aria-label]="slide.label"
(click)="onClick.emit()"
>
<bit-icon [icon]="CarouselIcon"></bit-icon>
</button>

View File

@@ -0,0 +1,71 @@
import { ComponentFixture, TestBed } from "@angular/core/testing";
import { VaultCarouselSlideComponent } from "../carousel-slide/carousel-slide.component";
import { VaultCarouselButtonComponent } from "./carousel-button.component";
describe("VaultCarouselButtonComponent", () => {
let fixture: ComponentFixture<VaultCarouselButtonComponent>;
let component: VaultCarouselButtonComponent;
beforeEach(async () => {
await TestBed.configureTestingModule({
imports: [VaultCarouselButtonComponent],
}).compileComponents();
});
beforeEach(() => {
fixture = TestBed.createComponent(VaultCarouselButtonComponent);
component = fixture.componentInstance;
component.slide = { label: "Test Label" } as VaultCarouselSlideComponent;
fixture.detectChanges();
});
it("emits click event", () => {
jest.spyOn(component.onClick, "emit");
component.button.nativeElement.click();
expect(component.onClick.emit).toHaveBeenCalled();
});
it("focuses on button", () => {
component.focus();
expect(document.activeElement).toBe(component.button.nativeElement);
});
it('sets the "aria-label" attribute', () => {
expect(component.button.nativeElement.getAttribute("aria-label")).toBe("Test Label");
});
describe("is active", () => {
beforeEach(() => {
component.isActive = true;
fixture.detectChanges();
});
it("sets the aria-selected to true", () => {
expect(component.button.nativeElement.getAttribute("aria-selected")).toBe("true");
});
it("adds button to tab index", () => {
expect(component.button.nativeElement.getAttribute("tabindex")).toBe("0");
});
});
describe("is not active", () => {
beforeEach(() => {
component.isActive = false;
fixture.detectChanges();
});
it("sets the aria-selected to false", () => {
expect(component.button.nativeElement.getAttribute("aria-selected")).toBe("false");
});
it("removes button from tab index", () => {
expect(component.button.nativeElement.getAttribute("tabindex")).toBe("-1");
});
});
});

View File

@@ -0,0 +1,45 @@
import { FocusableOption } from "@angular/cdk/a11y";
import { CommonModule } from "@angular/common";
import { Component, ElementRef, EventEmitter, Input, Output, ViewChild } from "@angular/core";
import { IconModule } from "@bitwarden/components";
import { CarouselIcon } from "../carousel-icons/carousel-icon";
import { VaultCarouselSlideComponent } from "../carousel-slide/carousel-slide.component";
@Component({
selector: "vault-carousel-button",
templateUrl: "carousel-button.component.html",
standalone: true,
imports: [CommonModule, IconModule],
})
export class VaultCarouselButtonComponent implements FocusableOption {
/** Slide component that is associated with the individual button */
@Input({ required: true }) slide!: VaultCarouselSlideComponent;
@ViewChild("btn", { static: true }) button!: ElementRef<HTMLButtonElement>;
protected CarouselIcon = CarouselIcon;
/** When set to true the button is shown in an active state. */
@Input({ required: true }) isActive!: boolean;
/** Emits when the button is clicked. */
@Output() onClick = new EventEmitter<void>();
/** Focuses the underlying button element. */
focus(): void {
this.button.nativeElement.focus();
}
protected get dynamicClasses(): string[] {
const activeClasses = ["[&_rect]:tw-fill-primary-600", "tw-text-primary-600"];
const inactiveClasses = [
"tw-text-muted",
"[&_rect]:hover:tw-fill-text-muted",
"focus-visible:tw-text-info-700",
];
return this.isActive ? activeClasses : inactiveClasses;
}
}

View File

@@ -0,0 +1,3 @@
<div class="tw-m-auto tw-text-main" aria-live="polite" aria-atomic="false">
<ng-template [cdkPortalOutlet]="content"></ng-template>
</div>

View File

@@ -0,0 +1,58 @@
import { TemplatePortal } from "@angular/cdk/portal";
import { Component, OnInit, TemplateRef, ViewChild, ViewContainerRef } from "@angular/core";
import { ComponentFixture, TestBed } from "@angular/core/testing";
import { By } from "@angular/platform-browser";
import { VaultCarouselContentComponent } from "./carousel-content.component";
@Component({
selector: "app-test-template-ref",
standalone: true,
imports: [VaultCarouselContentComponent],
template: `
<ng-template #template>
<p>Test Template Content</p>
</ng-template>
<vault-carousel-content [content]="portal"></vault-carousel-content>
`,
})
class TestTemplateRefComponent implements OnInit {
// Test template content by creating a wrapping component and then pass a portal to the carousel content component.
@ViewChild("template", { static: true }) template!: TemplateRef<any>;
portal!: TemplatePortal;
constructor(private viewContainerRef: ViewContainerRef) {}
ngOnInit() {
this.portal = new TemplatePortal(this.template, this.viewContainerRef);
}
}
describe("VaultCarouselContentComponent", () => {
let fixture: ComponentFixture<TestTemplateRefComponent>;
beforeEach(async () => {
await TestBed.configureTestingModule({
imports: [VaultCarouselContentComponent],
}).compileComponents();
});
beforeEach(() => {
fixture = TestBed.createComponent(TestTemplateRefComponent);
fixture.detectChanges();
});
it("displays content", () => {
const carouselContent = fixture.debugElement.query(By.directive(VaultCarouselContentComponent));
expect(carouselContent.nativeElement.textContent).toBe("Test Template Content");
});
it("sets aria attributes for screen readers", () => {
const carouselContent = fixture.debugElement.query(By.directive(VaultCarouselContentComponent));
const wrappingDiv = carouselContent.nativeElement.querySelector("div");
expect(wrappingDiv.getAttribute("aria-live")).toBe("polite");
expect(wrappingDiv.getAttribute("aria-atomic")).toBe("false");
});
});

View File

@@ -0,0 +1,13 @@
import { TemplatePortal, CdkPortalOutlet } from "@angular/cdk/portal";
import { Component, Input } from "@angular/core";
@Component({
selector: "vault-carousel-content",
templateUrl: "carousel-content.component.html",
standalone: true,
imports: [CdkPortalOutlet],
})
export class VaultCarouselContentComponent {
/** Content to be displayed for the carousel. */
@Input({ required: true }) content!: TemplatePortal;
}

View File

@@ -0,0 +1,7 @@
import { svgIcon } from "@bitwarden/components";
export const CarouselIcon = svgIcon`
<svg class="tw-block" width="12" height="12" viewBox="0 0 12 12" fill="none" xmlns="http://www.w3.org/2000/svg" data-testid="inactive-carousel-icon">
<rect stroke="currentColor" x="0.5" y="0.5" width="11" height="11" rx="5.5" />
</svg>
`;

View File

@@ -0,0 +1,9 @@
<ng-template>
<div
role="tabpanel"
class="tw-px-4 tw-py-5"
[attr.tabIndex]="noFocusableChildren ? 0 : undefined"
>
<ng-content></ng-content>
</div>
</ng-template>

View File

@@ -0,0 +1,40 @@
import { TemplatePortal } from "@angular/cdk/portal";
import { Component } from "@angular/core";
import { ComponentFixture, TestBed } from "@angular/core/testing";
import { By } from "@angular/platform-browser";
import { VaultCarouselSlideComponent } from "./carousel-slide.component";
@Component({
selector: "app-test-carousel-slide",
standalone: true,
imports: [VaultCarouselSlideComponent],
template: ` <vault-carousel-slide><p>Carousel Slide Content!</p></vault-carousel-slide> `,
})
class TestCarouselSlideComponent {
// Test template content by creating a wrapping component.
}
describe("VaultCarouselSlideComponent", () => {
let fixture: ComponentFixture<TestCarouselSlideComponent>;
beforeEach(async () => {
await TestBed.configureTestingModule({
imports: [VaultCarouselSlideComponent],
}).compileComponents();
});
beforeEach(() => {
fixture = TestBed.createComponent(TestCarouselSlideComponent);
fixture.detectChanges();
});
it("sets content", () => {
const slideComponent = fixture.debugElement.query(
By.directive(VaultCarouselSlideComponent),
).componentInstance;
expect(slideComponent.content).not.toBeNull();
expect(slideComponent.content).toBeInstanceOf(TemplatePortal);
});
});

View File

@@ -0,0 +1,41 @@
import { coerceBooleanProperty } from "@angular/cdk/coercion";
import { TemplatePortal } from "@angular/cdk/portal";
import { Component, Input, OnInit, TemplateRef, ViewChild, ViewContainerRef } from "@angular/core";
@Component({
selector: "vault-carousel-slide",
templateUrl: "./carousel-slide.component.html",
standalone: true,
})
export class VaultCarouselSlideComponent implements OnInit {
/** `aria-label` that is assigned to the carousel toggle. */
@Input({ required: true }) label!: string;
/**
* Should be set to true when the slide has no focusable elements.
*
* When the slide does not contain any focusable elements or the first element with content is not focusable,
* this should be set to 0 to include it in the tab sequence of the page.
*
* @remarks See note 4 of https://www.w3.org/WAI/ARIA/apg/patterns/tabpanel/
*/
@Input({ transform: coerceBooleanProperty }) noFocusableChildren?: true;
@ViewChild(TemplateRef, { static: true }) implicitContent!: TemplateRef<unknown>;
private _contentPortal: TemplatePortal | null = null;
/**
* A Portal containing the content of the slide.
* Used by `VaultCarouselComponent` when the slide becomes active.
*/
get content(): TemplatePortal | null {
return this._contentPortal;
}
constructor(private viewContainerRef: ViewContainerRef) {}
ngOnInit(): void {
this._contentPortal = new TemplatePortal(this.implicitContent, this.viewContainerRef);
}
}

View File

@@ -0,0 +1,25 @@
<section
aria-roledescription="carousel"
[attr.aria-label]="label"
[ngStyle]="{ minHeight: minHeight ?? undefined }"
class="tw-flex tw-flex-col"
#container
>
<vault-carousel-content [content]="slides.get(selectedIndex)?.content"></vault-carousel-content>
<div
class="tw-w-full tw-flex tw-gap-2 tw-justify-center tw-mt-auto tw-pt-4"
role="tablist"
(keydown)="keyManager.onKeydown($event)"
#carouselButtonWrapper
>
<vault-carousel-button
*ngFor="let slide of slides; let i = index"
[slide]="slide"
[isActive]="i === selectedIndex"
(onClick)="selectSlide(i)"
></vault-carousel-button>
</div>
<div class="tw-absolute tw-invisible" #tempSlideContainer *ngIf="minHeight === null">
<ng-template [cdkPortalOutlet] #tempSlideOutlet></ng-template>
</div>
</section>

View File

@@ -0,0 +1,73 @@
import { Component } from "@angular/core";
import { ComponentFixture, TestBed } from "@angular/core/testing";
import { By } from "@angular/platform-browser";
import { VaultCarouselSlideComponent } from "./carousel-slide/carousel-slide.component";
import { VaultCarouselComponent } from "./carousel.component";
@Component({
selector: "app-test-carousel-slide",
standalone: true,
imports: [VaultCarouselComponent, VaultCarouselSlideComponent],
template: `
<vault-carousel label="Storybook Demo">
<vault-carousel-slide label="First Slide">
<h1>First Carousel Heading</h1>
</vault-carousel-slide>
<vault-carousel-slide label="Second Slide">
<h1>Second Carousel Heading</h1>
</vault-carousel-slide>
<vault-carousel-slide label="Third Slide">
<h1>Third Carousel Heading</h1>
</vault-carousel-slide>
</vault-carousel>
`,
})
class TestCarouselComponent {
// Test carousel by creating a wrapping component.
}
describe("VaultCarouselComponent", () => {
let fixture: ComponentFixture<TestCarouselComponent>;
let component: VaultCarouselComponent;
beforeEach(async () => {
await TestBed.configureTestingModule({
imports: [VaultCarouselComponent, VaultCarouselSlideComponent],
}).compileComponents();
});
beforeEach(() => {
fixture = TestBed.createComponent(TestCarouselComponent);
fixture.detectChanges();
component = fixture.debugElement.query(By.directive(VaultCarouselComponent)).componentInstance;
});
it("sets first slide as active by default", () => {
expect(component["selectedIndex"]).toBe(0);
});
it("shows the active slides content", () => {
// Set the second slide as active
fixture.debugElement.queryAll(By.css("button"))[1].nativeElement.click();
fixture.detectChanges();
const heading = fixture.debugElement.query(By.css("h1")).nativeElement;
expect(heading.textContent).toBe("Second Carousel Heading");
});
it("sets the initial focused button as the first button", () => {
expect(component["keyManager"]?.activeItemIndex).toBe(0);
});
it('emits "slideChange" event when slide changes', () => {
jest.spyOn(component.slideChange, "emit");
const thirdSlideButton = fixture.debugElement.queryAll(By.css("button"))[2];
thirdSlideButton.nativeElement.click();
expect(component.slideChange.emit).toHaveBeenCalledWith(2);
});
});

View File

@@ -0,0 +1,145 @@
import { FocusKeyManager } from "@angular/cdk/a11y";
import { CdkPortalOutlet } from "@angular/cdk/portal";
import { CommonModule } from "@angular/common";
import {
AfterViewInit,
ChangeDetectorRef,
Component,
ContentChildren,
ElementRef,
EventEmitter,
Input,
Output,
QueryList,
ViewChild,
ViewChildren,
inject,
} from "@angular/core";
import { ButtonModule } from "@bitwarden/components";
import { VaultCarouselButtonComponent } from "./carousel-button/carousel-button.component";
import { VaultCarouselContentComponent } from "./carousel-content/carousel-content.component";
import { VaultCarouselSlideComponent } from "./carousel-slide/carousel-slide.component";
@Component({
selector: "vault-carousel",
templateUrl: "./carousel.component.html",
standalone: true,
imports: [
CdkPortalOutlet,
CommonModule,
ButtonModule,
VaultCarouselContentComponent,
VaultCarouselButtonComponent,
],
})
export class VaultCarouselComponent implements AfterViewInit {
private changeDetectorRef = inject(ChangeDetectorRef);
/**
* Accessible Label for the carousel
*
* @remarks
* The label should not include the word "carousel", `aria-roledescription="carousel"` is already included.
*/
@Input({ required: true }) label = "";
/**
* Emits the index of of the newly selected slide.
*/
@Output() slideChange = new EventEmitter<number>();
/** All slides within the carousel. */
@ContentChildren(VaultCarouselSlideComponent) slides!: QueryList<VaultCarouselSlideComponent>;
/** All buttons that control the carousel */
@ViewChildren(VaultCarouselButtonComponent)
carouselButtons!: QueryList<VaultCarouselButtonComponent>;
/** Wrapping container for the carousel content and buttons */
@ViewChild("container") carouselContainer!: ElementRef<HTMLElement>;
/** Container for the carousel buttons */
@ViewChild("carouselButtonWrapper") carouselButtonWrapper!: ElementRef<HTMLDivElement>;
/** Temporary container containing `tempSlideOutlet` */
@ViewChild("tempSlideContainer") tempSlideContainer!: ElementRef<HTMLDivElement>;
/** Outlet to temporary render each slide within */
@ViewChild(CdkPortalOutlet) tempSlideOutlet!: CdkPortalOutlet;
/** The currently selected index of the carousel. */
protected selectedIndex = 0;
/**
* Slides that have differing heights can cause the carousel controls to jump.
* Set the min height based on the tallest slide.
*/
protected minHeight: `${number}px` | null = null;
/**
* Focus key manager for keeping tab controls accessible.
* https://developer.mozilla.org/en-US/docs/Web/Accessibility/ARIA/Roles/tablist_role#keyboard_interactions
*/
protected keyManager: FocusKeyManager<VaultCarouselButtonComponent> | null = null;
/** Set the selected index of the carousel. */
protected selectSlide(index: number) {
this.selectedIndex = index;
this.slideChange.emit(index);
}
ngAfterViewInit(): void {
this.keyManager = new FocusKeyManager(this.carouselButtons)
.withHorizontalOrientation("ltr")
.withWrap()
.withHomeAndEnd();
// Set the first carousel button as active, this avoids having to double tab the arrow keys on initial focus.
this.keyManager.setFirstItemActive();
this.setMinHeightOfCarousel();
}
/**
* Slides of differing height can cause the carousel to jump in height.
* Render each slide in a temporary portal outlet to get the height of each slide
* and store the tallest slide height.
*/
private setMinHeightOfCarousel() {
// Store the height of the carousel button element.
const heightOfButtonsPx = this.carouselButtonWrapper.nativeElement.offsetHeight;
// Get the width of the carousel so we know how much space each slide can render within.
const containerWidth = this.carouselContainer.nativeElement.offsetWidth;
const containerHeight = this.carouselContainer.nativeElement.offsetHeight;
// Set the width of the temp container to render each slide inside of.
this.tempSlideContainer.nativeElement.style.width = `${containerWidth}px`;
// The first slide is already rendered at this point, use the height of the container
// to determine the height of the first slide.
let tallestSlideHeightPx = containerHeight - heightOfButtonsPx;
this.slides.forEach((slide, index) => {
// Skip the first slide, the height is accounted for above.
if (index === this.selectedIndex) {
return;
}
this.tempSlideOutlet.attach(slide.content);
// Store the height of the current slide if is larger than the current stored height;
if (this.tempSlideContainer.nativeElement.offsetHeight > tallestSlideHeightPx) {
tallestSlideHeightPx = this.tempSlideContainer.nativeElement.offsetHeight;
}
// cleanup the outlet
this.tempSlideOutlet.detach();
});
// Set the min height of the entire carousel based on the largest slide.
this.minHeight = `${tallestSlideHeightPx + heightOfButtonsPx}px`;
this.changeDetectorRef.detectChanges();
}
}

View File

@@ -0,0 +1,76 @@
import { Meta, moduleMetadata, StoryObj } from "@storybook/angular";
import { ButtonComponent, TypographyModule } from "@bitwarden/components";
import { VaultCarouselSlideComponent } from "./carousel-slide/carousel-slide.component";
import { VaultCarouselComponent } from "./carousel.component";
export default {
title: "Vault/Carousel",
component: VaultCarouselComponent,
decorators: [
moduleMetadata({
imports: [VaultCarouselSlideComponent, TypographyModule, ButtonComponent],
}),
],
} as Meta;
type Story = StoryObj<VaultCarouselComponent>;
export const Default: Story = {
render: (args: any) => ({
props: args,
template: `
<vault-carousel label="Storybook Demo">
<vault-carousel-slide label="First Slide">
<div class="tw-flex tw-flex-col tw-items-center tw-gap-4">
<h2 bitTypography="h2">First Carousel Heading</h2>
<p bitTypography="body1">First Carousel Content</p>
</div>
</vault-carousel-slide>
<vault-carousel-slide label="Second Slide">
<div class="tw-flex tw-flex-col tw-items-center tw-gap-4">
<h2 bitTypography="h2">Second Carousel Heading</h2>
<p bitTypography="body1">Second Carousel Content</p>
</div>
</vault-carousel-slide>
<vault-carousel-slide label="Third Slide">
<div class="tw-flex tw-flex-col tw-items-center tw-gap-4">
<h2 bitTypography="h2">Third Carousel Heading</h2>
<p bitTypography="body1">Third Carousel Content</p>
<p bitTypography="body1">Third Carousel Content</p>
<p bitTypography="body1">Third Carousel Content</p>
</div>
</vault-carousel-slide>
<vault-carousel-slide label="Fourth Slide">
<div class="tw-flex tw-flex-col tw-items-center tw-gap-4">
<h2 bitTypography="h2">Fourth Carousel Heading</h2>
<p bitTypography="body1">Fourth Carousel Content</p>
</div>
</vault-carousel-slide>
</vault-carousel>
`,
}),
};
export const KeyboardNavigation: Story = {
render: (args: any) => ({
props: args,
template: `
<vault-carousel label="Storybook Demo">
<vault-carousel-slide label="Focusable Content">
<div class="tw-flex tw-flex-col tw-items-center tw-gap-4">
<h2 bitTypography="h2">First Carousel Heading</h2>
<button bitButton buttonType="primary">Button</button>
</div>
</vault-carousel-slide>
<vault-carousel-slide noFocusableChildren label="No Focusable Content">
<div class="tw-flex tw-flex-col tw-items-center tw-gap-4">
<h2 bitTypography="h2">Second Carousel Heading</h2>
<p bitTypography="body1">With no focusable elements, the entire slide should be focusable</p>
</div>
</vault-carousel-slide>
</vault-carousel>
`,
}),
};

View File

@@ -0,0 +1 @@
export { VaultCarouselComponent } from "./carousel.component";