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:
@@ -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>
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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>
|
||||
`;
|
||||
@@ -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>
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
25
libs/vault/src/components/carousel/carousel.component.html
Normal file
25
libs/vault/src/components/carousel/carousel.component.html
Normal 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>
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
145
libs/vault/src/components/carousel/carousel.component.ts
Normal file
145
libs/vault/src/components/carousel/carousel.component.ts
Normal 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();
|
||||
}
|
||||
}
|
||||
76
libs/vault/src/components/carousel/carousel.stories.ts
Normal file
76
libs/vault/src/components/carousel/carousel.stories.ts
Normal 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>
|
||||
`,
|
||||
}),
|
||||
};
|
||||
1
libs/vault/src/components/carousel/index.ts
Normal file
1
libs/vault/src/components/carousel/index.ts
Normal file
@@ -0,0 +1 @@
|
||||
export { VaultCarouselComponent } from "./carousel.component";
|
||||
Reference in New Issue
Block a user