diff --git a/libs/components/tailwind.config.js b/libs/components/tailwind.config.js index 7a53c82ec5..2df648723f 100644 --- a/libs/components/tailwind.config.js +++ b/libs/components/tailwind.config.js @@ -4,6 +4,7 @@ const config = require("./tailwind.config.base"); config.content = [ "libs/components/src/**/*.{html,ts,mdx}", "libs/auth/src/**/*.{html,ts,mdx}", + "libs/vault/src/**/*.{html,ts,mdx}", "apps/web/src/**/*.{html,ts,mdx}", "apps/browser/src/**/*.{html,ts,mdx}", "bitwarden_license/bit-web/src/**/*.{html,ts,mdx}", diff --git a/libs/vault/src/components/carousel/carousel-button/carousel-button.component.html b/libs/vault/src/components/carousel/carousel-button/carousel-button.component.html new file mode 100644 index 0000000000..824b16bb3a --- /dev/null +++ b/libs/vault/src/components/carousel/carousel-button/carousel-button.component.html @@ -0,0 +1,13 @@ + diff --git a/libs/vault/src/components/carousel/carousel-button/carousel-button.component.spec.ts b/libs/vault/src/components/carousel/carousel-button/carousel-button.component.spec.ts new file mode 100644 index 0000000000..91239cd752 --- /dev/null +++ b/libs/vault/src/components/carousel/carousel-button/carousel-button.component.spec.ts @@ -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; + 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"); + }); + }); +}); diff --git a/libs/vault/src/components/carousel/carousel-button/carousel-button.component.ts b/libs/vault/src/components/carousel/carousel-button/carousel-button.component.ts new file mode 100644 index 0000000000..d0e353dcc7 --- /dev/null +++ b/libs/vault/src/components/carousel/carousel-button/carousel-button.component.ts @@ -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; + 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(); + + /** 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; + } +} diff --git a/libs/vault/src/components/carousel/carousel-content/carousel-content.component.html b/libs/vault/src/components/carousel/carousel-content/carousel-content.component.html new file mode 100644 index 0000000000..4d97cfd054 --- /dev/null +++ b/libs/vault/src/components/carousel/carousel-content/carousel-content.component.html @@ -0,0 +1,3 @@ +
+ +
diff --git a/libs/vault/src/components/carousel/carousel-content/carousel-content.component.spec.ts b/libs/vault/src/components/carousel/carousel-content/carousel-content.component.spec.ts new file mode 100644 index 0000000000..2900225b88 --- /dev/null +++ b/libs/vault/src/components/carousel/carousel-content/carousel-content.component.spec.ts @@ -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: ` + +

Test Template 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; + portal!: TemplatePortal; + + constructor(private viewContainerRef: ViewContainerRef) {} + + ngOnInit() { + this.portal = new TemplatePortal(this.template, this.viewContainerRef); + } +} + +describe("VaultCarouselContentComponent", () => { + let fixture: ComponentFixture; + + 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"); + }); +}); diff --git a/libs/vault/src/components/carousel/carousel-content/carousel-content.component.ts b/libs/vault/src/components/carousel/carousel-content/carousel-content.component.ts new file mode 100644 index 0000000000..7051a8ff8f --- /dev/null +++ b/libs/vault/src/components/carousel/carousel-content/carousel-content.component.ts @@ -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; +} diff --git a/libs/vault/src/components/carousel/carousel-icons/carousel-icon.ts b/libs/vault/src/components/carousel/carousel-icons/carousel-icon.ts new file mode 100644 index 0000000000..64052e6fa6 --- /dev/null +++ b/libs/vault/src/components/carousel/carousel-icons/carousel-icon.ts @@ -0,0 +1,7 @@ +import { svgIcon } from "@bitwarden/components"; + +export const CarouselIcon = svgIcon` + + + +`; diff --git a/libs/vault/src/components/carousel/carousel-slide/carousel-slide.component.html b/libs/vault/src/components/carousel/carousel-slide/carousel-slide.component.html new file mode 100644 index 0000000000..0c5c6f1cd7 --- /dev/null +++ b/libs/vault/src/components/carousel/carousel-slide/carousel-slide.component.html @@ -0,0 +1,9 @@ + +
+ +
+
diff --git a/libs/vault/src/components/carousel/carousel-slide/carousel-slide.component.spec.ts b/libs/vault/src/components/carousel/carousel-slide/carousel-slide.component.spec.ts new file mode 100644 index 0000000000..e69a2a2d75 --- /dev/null +++ b/libs/vault/src/components/carousel/carousel-slide/carousel-slide.component.spec.ts @@ -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: `

Carousel Slide Content!

`, +}) +class TestCarouselSlideComponent { + // Test template content by creating a wrapping component. +} + +describe("VaultCarouselSlideComponent", () => { + let fixture: ComponentFixture; + + 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); + }); +}); diff --git a/libs/vault/src/components/carousel/carousel-slide/carousel-slide.component.ts b/libs/vault/src/components/carousel/carousel-slide/carousel-slide.component.ts new file mode 100644 index 0000000000..23ce6a3d7d --- /dev/null +++ b/libs/vault/src/components/carousel/carousel-slide/carousel-slide.component.ts @@ -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; + + 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); + } +} diff --git a/libs/vault/src/components/carousel/carousel.component.html b/libs/vault/src/components/carousel/carousel.component.html new file mode 100644 index 0000000000..b3e124e02b --- /dev/null +++ b/libs/vault/src/components/carousel/carousel.component.html @@ -0,0 +1,25 @@ +
+ +
+ +
+
+ +
+
diff --git a/libs/vault/src/components/carousel/carousel.component.spec.ts b/libs/vault/src/components/carousel/carousel.component.spec.ts new file mode 100644 index 0000000000..500b8115ba --- /dev/null +++ b/libs/vault/src/components/carousel/carousel.component.spec.ts @@ -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: ` + + +

First Carousel Heading

+
+ +

Second Carousel Heading

+
+ +

Third Carousel Heading

+
+
+ `, +}) +class TestCarouselComponent { + // Test carousel by creating a wrapping component. +} + +describe("VaultCarouselComponent", () => { + let fixture: ComponentFixture; + 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); + }); +}); diff --git a/libs/vault/src/components/carousel/carousel.component.ts b/libs/vault/src/components/carousel/carousel.component.ts new file mode 100644 index 0000000000..ab6d0a38f3 --- /dev/null +++ b/libs/vault/src/components/carousel/carousel.component.ts @@ -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(); + + /** All slides within the carousel. */ + @ContentChildren(VaultCarouselSlideComponent) slides!: QueryList; + + /** All buttons that control the carousel */ + @ViewChildren(VaultCarouselButtonComponent) + carouselButtons!: QueryList; + + /** Wrapping container for the carousel content and buttons */ + @ViewChild("container") carouselContainer!: ElementRef; + + /** Container for the carousel buttons */ + @ViewChild("carouselButtonWrapper") carouselButtonWrapper!: ElementRef; + + /** Temporary container containing `tempSlideOutlet` */ + @ViewChild("tempSlideContainer") tempSlideContainer!: ElementRef; + + /** 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 | 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(); + } +} diff --git a/libs/vault/src/components/carousel/carousel.stories.ts b/libs/vault/src/components/carousel/carousel.stories.ts new file mode 100644 index 0000000000..521a561a19 --- /dev/null +++ b/libs/vault/src/components/carousel/carousel.stories.ts @@ -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; + +export const Default: Story = { + render: (args: any) => ({ + props: args, + template: ` + + +
+

First Carousel Heading

+

First Carousel Content

+
+
+ +
+

Second Carousel Heading

+

Second Carousel Content

+
+
+ +
+

Third Carousel Heading

+

Third Carousel Content

+

Third Carousel Content

+

Third Carousel Content

+
+
+ +
+

Fourth Carousel Heading

+

Fourth Carousel Content

+
+
+
+ `, + }), +}; + +export const KeyboardNavigation: Story = { + render: (args: any) => ({ + props: args, + template: ` + + +
+

First Carousel Heading

+ +
+
+ +
+

Second Carousel Heading

+

With no focusable elements, the entire slide should be focusable

+
+
+
+ `, + }), +}; diff --git a/libs/vault/src/components/carousel/index.ts b/libs/vault/src/components/carousel/index.ts new file mode 100644 index 0000000000..a785c26102 --- /dev/null +++ b/libs/vault/src/components/carousel/index.ts @@ -0,0 +1 @@ +export { VaultCarouselComponent } from "./carousel.component";