1
0
mirror of https://github.com/bitwarden/browser synced 2026-02-08 20:50:28 +00:00

Merge remote-tracking branch 'origin/main' into playwright

This commit is contained in:
Matt Gibson
2026-01-26 12:57:05 -08:00
1790 changed files with 150488 additions and 32025 deletions

View File

@@ -1,5 +1,4 @@
import { DOCUMENT } from "@angular/common";
import { Injectable, Inject, NgZone, OnDestroy } from "@angular/core";
import { Injectable, Inject, NgZone, OnDestroy, DOCUMENT } from "@angular/core";
@Injectable({ providedIn: "root" })
export class AriaDisabledClickCaptureService implements OnDestroy {

View File

@@ -1,7 +1,7 @@
import { inject, Injectable } from "@angular/core";
import { takeUntilDestroyed } from "@angular/core/rxjs-interop";
import { NavigationEnd, Router } from "@angular/router";
import { skip, filter, map, combineLatestWith, tap } from "rxjs";
import { skip, filter, combineLatestWith, tap } from "rxjs";
import { FeatureFlag } from "@bitwarden/common/enums/feature-flag.enum";
import { ConfigService } from "@bitwarden/common/platform/abstractions/config/config.service";
@@ -19,8 +19,10 @@ export class RouterFocusManagerService {
*
* By default, we focus the `main` after an internal route navigation.
*
* Consumers can opt out of the passing the following to the `info` input:
* `<a [routerLink]="route()" [info]="{ focusMainAfterNav: false }"></a>`
* Consumers can opt out of the passing the following to the `state` input. Using `state`
* allows us to access the value between browser back/forward arrows.
* In template: `<a [routerLink]="route()" [state]="{ focusMainAfterNav: false }"></a>`
* In typescript: `this.router.navigate([], { state: { focusMainAfterNav: false }})`
*
* Or, consumers can use the autofocus directive on an applicable interactive element.
* The autofocus directive will take precedence over this route focus pipeline.
@@ -44,15 +46,12 @@ export class RouterFocusManagerService {
skip(1),
combineLatestWith(this.configService.getFeatureFlag$(FeatureFlag.RouterFocusManagement)),
filter(([_navEvent, flagEnabled]) => flagEnabled),
map(() => {
const currentNavData = this.router.getCurrentNavigation()?.extras;
filter(() => {
const currentNavExtras = this.router.currentNavigation()?.extras;
const info = currentNavData?.info as { focusMainAfterNav?: boolean } | undefined;
const focusMainAfterNav: boolean | undefined = currentNavExtras?.state?.focusMainAfterNav;
return info;
}),
filter((currentNavInfo) => {
return currentNavInfo === undefined ? true : currentNavInfo?.focusMainAfterNav !== false;
return focusMainAfterNav !== false;
}),
tap(() => {
const mainEl = document.querySelector<HTMLElement>("main");

View File

@@ -7,6 +7,7 @@
[hideCardWrapper]="hideCardWrapper"
[hideBackgroundIllustration]="hideBackgroundIllustration"
>
<router-outlet slot="header-actions" name="header-actions"></router-outlet>
<router-outlet></router-outlet>
<router-outlet slot="secondary" name="secondary"></router-outlet>
<router-outlet slot="environment-selector" name="environment-selector"></router-outlet>

View File

@@ -7,10 +7,10 @@ import { Icon } from "@bitwarden/assets/svg";
import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service";
import { Translation } from "../dialog";
import { LandingContentMaxWidthType } from "../landing-layout";
import { AnonLayoutWrapperDataService } from "./anon-layout-wrapper-data.service";
import { AnonLayoutComponent, AnonLayoutMaxWidth } from "./anon-layout.component";
import { AnonLayoutComponent } from "./anon-layout.component";
export interface AnonLayoutWrapperData {
/**
* The optional title of the page.
@@ -35,7 +35,7 @@ export interface AnonLayoutWrapperData {
/**
* Optional flag to set the max-width of the page. Defaults to 'md' if not provided.
*/
maxWidth?: AnonLayoutMaxWidth;
maxWidth?: LandingContentMaxWidthType;
/**
* Hide the card that wraps the default content. Defaults to false.
*/
@@ -59,7 +59,7 @@ export class AnonLayoutWrapperComponent implements OnInit {
protected pageSubtitle?: string | null;
protected pageIcon: Icon | null = null;
protected showReadonlyHostname?: boolean | null;
protected maxWidth?: AnonLayoutMaxWidth | null;
protected maxWidth?: LandingContentMaxWidthType | null;
protected hideCardWrapper?: boolean | null;
protected hideBackgroundIllustration?: boolean | null;

View File

@@ -1,4 +1,4 @@
import { Meta, Story, Controls } from "@storybook/addon-docs";
import { Meta, Story, Controls } from "@storybook/addon-docs/blocks";
import * as stories from "./anon-layout-wrapper.stories";

View File

@@ -130,6 +130,15 @@ export class DefaultSecondaryOutletExampleComponent {}
})
export class DefaultEnvSelectorOutletExampleComponent {}
// FIXME(https://bitwarden.atlassian.net/browse/CL-764): Migrate to OnPush
// eslint-disable-next-line @angular-eslint/prefer-on-push-component-change-detection
@Component({
selector: "bit-header-actions-outlet-example-component",
template: "<p>Header Actions Outlet Example: <br> your header actions component goes here</p>",
standalone: false,
})
export class DefaultHeaderActionsOutletExampleComponent {}
export const DefaultContentExample: Story = {
render: (args) => ({
props: args,
@@ -171,6 +180,11 @@ export const DefaultContentExample: Story = {
component: DefaultEnvSelectorOutletExampleComponent,
outlet: "environment-selector",
},
{
path: "",
component: DefaultHeaderActionsOutletExampleComponent,
outlet: "header-actions",
},
],
},
],

View File

@@ -1,88 +1,39 @@
<main
class="tw-relative tw-flex tw-w-full tw-mx-auto tw-flex-col tw-bg-background-alt tw-p-5 tw-text-main"
[ngClass]="{
'tw-min-h-screen': clientType === 'web',
'tw-min-h-full': clientType === 'browser' || clientType === 'desktop',
}"
>
<a
*ngIf="!hideLogo()"
[routerLink]="['/']"
class="tw-w-[200px] tw-block tw-mb-12 [&>*]:tw-align-top"
>
<bit-icon [icon]="logo" [ariaLabel]="'appLogoLabel' | i18n"></bit-icon>
</a>
<bit-landing-layout [hideBackgroundIllustration]="hideBackgroundIllustration()">
<bit-landing-header [hideLogo]="hideLogo()">
<ng-content select="[slot=header-actions]"></ng-content>
</bit-landing-header>
<div class="tw-text-center tw-mb-4 sm:tw-mb-6 tw-mx-auto" [ngClass]="maxWidthClass">
@let iconInput = icon();
<!-- In some scenarios this icon's size is not limited by container width correctly -->
<!-- Targeting the SVG here to try and ensure it never grows too large in even the media queries are not working as expected -->
<div
*ngIf="iconInput !== null"
class="tw-size-20 sm:tw-size-24 [&_svg]:tw-w-full [&_svg]:tw-max-w-24 tw-mx-auto tw-content-center"
>
<bit-icon [icon]="iconInput"></bit-icon>
</div>
<ng-container *ngIf="title()">
<!-- Small screens -->
<h1 bitTypography="h2" class="tw-mt-2 sm:tw-hidden">
{{ title() }}
</h1>
<!-- Medium to Larger screens -->
<h1 bitTypography="h1" class="tw-mt-2 tw-hidden sm:tw-block">
{{ title() }}
</h1>
</ng-container>
<div *ngIf="subtitle()" class="tw-text-sm sm:tw-text-base">{{ subtitle() }}</div>
</div>
<div
class="tw-z-10 tw-grow tw-w-full tw-mx-auto tw-flex tw-flex-col tw-items-center sm:tw-min-w-[28rem]"
[ngClass]="maxWidthClass"
>
<bit-landing-content [maxWidth]="maxWidth()">
<bit-landing-hero [icon]="icon()" [title]="title()" [subtitle]="subtitle()"></bit-landing-hero>
@if (hideCardWrapper()) {
<div class="tw-mb-6 sm:tw-mb-10">
<ng-container *ngTemplateOutlet="defaultContent"></ng-container>
</div>
} @else {
<bit-base-card
class="!tw-rounded-2xl tw-mb-6 sm:tw-mb-10 tw-mx-auto tw-w-full tw-bg-transparent tw-border-none tw-shadow-none sm:tw-bg-background sm:tw-border sm:tw-border-solid sm:tw-border-secondary-100 sm:tw-shadow sm:tw-p-8"
>
<bit-landing-card>
<ng-container *ngTemplateOutlet="defaultContent"></ng-container>
</bit-base-card>
</bit-landing-card>
}
<ng-content select="[slot=secondary]"></ng-content>
</div>
<div class="tw-flex tw-flex-col tw-items-center">
<ng-content select="[slot=secondary]"></ng-content>
</div>
</bit-landing-content>
<footer *ngIf="!hideFooter()" class="tw-text-center tw-mt-4 sm:tw-mt-6">
<div *ngIf="showReadonlyHostname()" bitTypography="body2">
{{ "accessing" | i18n }} {{ hostname }}
</div>
<ng-container *ngIf="!showReadonlyHostname()">
<ng-content select="[slot=environment-selector]"></ng-content>
</ng-container>
<ng-container *ngIf="!hideYearAndVersion">
<div bitTypography="body2">&copy; {{ year }} Bitwarden Inc.</div>
<div bitTypography="body2">{{ version }}</div>
</ng-container>
</footer>
@if (!hideFooter()) {
<bit-landing-footer>
@if (showReadonlyHostname()) {
<div bitTypography="body2">{{ "accessing" | i18n }} {{ hostname }}</div>
} @else {
<ng-content select="[slot=environment-selector]"></ng-content>
}
@if (!hideBackgroundIllustration()) {
<div
class="tw-hidden md:tw-block [&_svg]:tw-absolute tw-z-[1] tw-opacity-[.11] [&_svg]:tw-bottom-0 [&_svg]:tw-start-0 [&_svg]:tw-w-[35%] [&_svg]:tw-max-w-[450px]"
>
<bit-icon [icon]="leftIllustration"></bit-icon>
</div>
<div
class="tw-hidden md:tw-block [&_svg]:tw-absolute tw-z-[1] tw-opacity-[.11] [&_svg]:tw-bottom-0 [&_svg]:tw-end-0 [&_svg]:tw-w-[35%] [&_svg]:tw-max-w-[450px]"
>
<bit-icon [icon]="rightIllustration"></bit-icon>
</div>
@if (!hideYearAndVersion) {
<div bitTypography="body2">&copy; {{ year }} Bitwarden Inc.</div>
<div bitTypography="body2">{{ version }}</div>
}
</bit-landing-footer>
}
</main>
</bit-landing-layout>
<ng-template #defaultContent>
<ng-content></ng-content>

View File

@@ -11,23 +11,17 @@ import {
import { RouterModule } from "@angular/router";
import { firstValueFrom } from "rxjs";
import {
BackgroundLeftIllustration,
BackgroundRightIllustration,
BitwardenLogo,
Icon,
} from "@bitwarden/assets/svg";
import { BitwardenLogo, Icon } from "@bitwarden/assets/svg";
import { ClientType } from "@bitwarden/common/enums";
import { EnvironmentService } from "@bitwarden/common/platform/abstractions/environment.service";
import { PlatformUtilsService } from "@bitwarden/common/platform/abstractions/platform-utils.service";
import { BaseCardComponent } from "../card";
import { IconModule } from "../icon";
import { LandingContentMaxWidthType } from "../landing-layout";
import { LandingLayoutModule } from "../landing-layout/landing-layout.module";
import { SharedModule } from "../shared";
import { TypographyModule } from "../typography";
export type AnonLayoutMaxWidth = "md" | "lg" | "xl" | "2xl" | "3xl" | "4xl";
// FIXME(https://bitwarden.atlassian.net/browse/CL-764): Migrate to OnPush
// eslint-disable-next-line @angular-eslint/prefer-on-push-component-change-detection
@Component({
@@ -39,7 +33,7 @@ export type AnonLayoutMaxWidth = "md" | "lg" | "xl" | "2xl" | "3xl" | "4xl";
TypographyModule,
SharedModule,
RouterModule,
BaseCardComponent,
LandingLayoutModule,
],
})
export class AnonLayoutComponent implements OnInit, OnChanges {
@@ -49,9 +43,6 @@ export class AnonLayoutComponent implements OnInit, OnChanges {
return ["tw-h-full"];
}
readonly leftIllustration = BackgroundLeftIllustration;
readonly rightIllustration = BackgroundRightIllustration;
readonly title = input<string>();
readonly subtitle = input<string>();
readonly icon = model.required<Icon | null>();
@@ -66,7 +57,7 @@ export class AnonLayoutComponent implements OnInit, OnChanges {
*
* @default 'md'
*/
readonly maxWidth = model<AnonLayoutMaxWidth>("md");
readonly maxWidth = model<LandingContentMaxWidthType>("md");
protected logo = BitwardenLogo;
protected year: string;
@@ -76,24 +67,6 @@ export class AnonLayoutComponent implements OnInit, OnChanges {
protected hideYearAndVersion = false;
get maxWidthClass(): string {
const maxWidth = this.maxWidth();
switch (maxWidth) {
case "md":
return "tw-max-w-md";
case "lg":
return "tw-max-w-lg";
case "xl":
return "tw-max-w-xl";
case "2xl":
return "tw-max-w-2xl";
case "3xl":
return "tw-max-w-3xl";
case "4xl":
return "tw-max-w-4xl";
}
}
constructor(
private environmentService: EnvironmentService,
private platformUtilsService: PlatformUtilsService,

View File

@@ -1,4 +1,4 @@
import { Meta, Story, Controls } from "@storybook/addon-docs";
import { Meta, Story, Controls } from "@storybook/addon-docs/blocks";
import * as stories from "./anon-layout.stories";

View File

@@ -8,6 +8,7 @@ import { EnvironmentService } from "@bitwarden/common/platform/abstractions/envi
import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service";
import { PlatformUtilsService } from "@bitwarden/common/platform/abstractions/platform-utils.service";
import { AvatarModule } from "../avatar";
import { ButtonModule } from "../button";
import { I18nMockService } from "../utils/i18n-mock.service";
@@ -23,6 +24,7 @@ type StoryArgs = AnonLayoutComponent & {
showSecondary: boolean;
useDefaultIcon: boolean;
icon: Icon;
includeHeaderActions: boolean;
};
export default {
@@ -30,7 +32,7 @@ export default {
component: AnonLayoutComponent,
decorators: [
moduleMetadata({
imports: [ButtonModule, RouterModule],
imports: [ButtonModule, RouterModule, AvatarModule],
providers: [
{
provide: PlatformUtilsService,
@@ -76,6 +78,14 @@ export default {
[hideFooter]="hideFooter"
[hideBackgroundIllustration]="hideBackgroundIllustration"
>
@if (includeHeaderActions) {
<div slot="header-actions" class="tw-flex tw-items-center tw-gap-2">
<bit-avatar
size="small"
text="Bob Loblaw"
></bit-avatar>
</div>
}
<ng-container [ngSwitch]="contentLength">
<div *ngSwitchCase="'thin'" class="tw-text-center"> <div class="tw-font-medium">Thin Content</div></div>
<div *ngSwitchCase="'long'">
@@ -116,7 +126,7 @@ export default {
hideLogo: { control: "boolean" },
hideFooter: { control: "boolean" },
hideBackgroundIllustration: { control: "boolean" },
includeHeaderActions: { control: "boolean" },
contentLength: {
control: "radio",
options: ["normal", "long", "thin"],
@@ -138,6 +148,7 @@ export default {
hideBackgroundIllustration: false,
contentLength: "normal",
showSecondary: false,
includeHeaderActions: false,
},
} satisfies Meta<StoryArgs>;
@@ -188,6 +199,12 @@ export const SecondaryContent: Story = {
},
};
export const WithHeaderActions: Story = {
args: {
includeHeaderActions: true,
},
};
export const NoTitle: Story = { args: { title: undefined } };
export const NoSubtitle: Story = { args: { subtitle: undefined } };

View File

@@ -1,4 +1,4 @@
import { Meta } from "@storybook/addon-docs";
import { Meta } from "@storybook/addon-docs/blocks";
<Meta title="Component Library/Async Actions/In Forms/Documentation" />

View File

@@ -1,8 +1,8 @@
import { Component } from "@angular/core";
import { FormsModule, ReactiveFormsModule, Validators, FormBuilder } from "@angular/forms";
import { action } from "@storybook/addon-actions";
import { Meta, moduleMetadata, StoryObj } from "@storybook/angular";
import { delay, of } from "rxjs";
import { action } from "storybook/actions";
import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service";
import { ValidationService } from "@bitwarden/common/platform/abstractions/validation.service";

View File

@@ -1,4 +1,4 @@
import { Meta } from "@storybook/addon-docs";
import { Meta } from "@storybook/addon-docs/blocks";
<Meta title="Component Library/Async Actions/Overview" />

View File

@@ -1,4 +1,4 @@
import { Meta, Story } from "@storybook/addon-docs";
import { Meta, Story } from "@storybook/addon-docs/blocks";
import * as stories from "./standalone.stories.ts";
<Meta of={stories} />

View File

@@ -1,7 +1,7 @@
import { Component } from "@angular/core";
import { action } from "@storybook/addon-actions";
import { Meta, StoryObj, moduleMetadata } from "@storybook/angular";
import { delay, of } from "rxjs";
import { action } from "storybook/actions";
import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service";
import { LogService } from "@bitwarden/common/platform/abstractions/log.service";

View File

@@ -1,5 +1,5 @@
import { NgClass } from "@angular/common";
import { Component, computed, input } from "@angular/core";
import { ChangeDetectionStrategy, Component, computed, input } from "@angular/core";
import { Utils } from "@bitwarden/common/platform/misc/utils";
@@ -14,13 +14,11 @@ const SizeClasses: Record<SizeTypes, string[]> = {
};
/**
* Avatars display a unique color that helps a user visually recognize their logged in account.
* A variance in color across the avatar component is important as it is used in Account Switching as a
* visual indicator to recognize which of a personal or work account a user is logged into.
*/
// FIXME(https://bitwarden.atlassian.net/browse/CL-764): Migrate to OnPush
// eslint-disable-next-line @angular-eslint/prefer-on-push-component-change-detection
* Avatars display a unique color that helps a user visually recognize their logged in account.
*
* A variance in color across the avatar component is important as it is used in Account Switching as a
* visual indicator to recognize which of a personal or work account a user is logged into.
*/
@Component({
selector: "bit-avatar",
template: `
@@ -49,13 +47,38 @@ const SizeClasses: Record<SizeTypes, string[]> = {
</span>
`,
imports: [NgClass],
changeDetection: ChangeDetectionStrategy.OnPush,
})
export class AvatarComponent {
/**
* Whether to display a border around the avatar.
*/
readonly border = input(false);
/**
* Custom background color for the avatar. If not provided, a color will be generated based on the id or text.
*/
readonly color = input<string>();
/**
* Unique identifier used to generate a consistent background color. Takes precedence over text for color generation.
*/
readonly id = input<string>();
/**
* Text to display in the avatar. The first letters of words (up to 2 characters) will be shown.
* Also used to generate background color if id is not provided.
*/
readonly text = input<string>();
/**
* Title attribute for the avatar. If not provided, falls back to the text value.
*/
readonly title = input<string>();
/**
* Size of the avatar.
*/
readonly size = input<SizeTypes>("default");
protected readonly svgCharCount = 2;

View File

@@ -1,4 +1,4 @@
import { Description, Meta, Canvas, Primary, Controls, Title } from "@storybook/addon-docs";
import { Meta, Description, Canvas, Primary, Controls, Title } from "@storybook/addon-docs/blocks";
import * as stories from "./avatar.stories";

View File

@@ -1,4 +1,4 @@
import { Meta, Canvas, Primary, Controls, Title, Description } from "@storybook/addon-docs";
import { Meta, Canvas, Primary, Controls, Title, Description } from "@storybook/addon-docs/blocks";
import * as stories from "./badge.stories";

View File

@@ -1,4 +1,4 @@
import { Canvas, Controls, Description, Meta, Primary, Title } from "@storybook/addon-docs";
import { Canvas, Controls, Description, Meta, Primary, Title } from "@storybook/addon-docs/blocks";
import * as stories from "./banner.stories";

View File

@@ -1,6 +1,6 @@
<ng-template>
@if (icon(); as icon) {
<i class="bwi {{ icon }} !tw-me-2" aria-hidden="true"></i>
<i class="bwi !tw-me-2" [class]="icon" aria-hidden="true"></i>
}
<ng-content></ng-content>
</ng-template>

View File

@@ -1,29 +1,55 @@
import { Component, EventEmitter, Output, TemplateRef, input, viewChild } from "@angular/core";
import {
ChangeDetectionStrategy,
Component,
TemplateRef,
input,
output,
viewChild,
} from "@angular/core";
import { QueryParamsHandling } from "@angular/router";
// FIXME(https://bitwarden.atlassian.net/browse/CL-764): Migrate to OnPush
// eslint-disable-next-line @angular-eslint/prefer-on-push-component-change-detection
/**
* Individual breadcrumb item used within the `bit-breadcrumbs` component.
* Represents a single navigation step in the breadcrumb trail.
*
* This component should be used as a child of `bit-breadcrumbs` and supports both
* router navigation and custom click handlers.
*/
@Component({
selector: "bit-breadcrumb",
templateUrl: "./breadcrumb.component.html",
changeDetection: ChangeDetectionStrategy.OnPush,
})
export class BreadcrumbComponent {
/**
* Optional icon to display before the breadcrumb text.
*/
readonly icon = input<string>();
/**
* Router link for the breadcrumb. Can be a string or an array of route segments.
*/
readonly route = input<string | any[]>();
/**
* Query parameters to include in the router link.
*/
readonly queryParams = input<Record<string, string>>({});
/**
* How to handle query parameters when navigating. Options include 'merge' or 'preserve'.
*/
readonly queryParamsHandling = input<QueryParamsHandling>();
// FIXME(https://bitwarden.atlassian.net/browse/CL-903): Migrate to Signals
// eslint-disable-next-line @angular-eslint/prefer-output-emitter-ref
@Output()
click = new EventEmitter();
/**
* Emitted when the breadcrumb is clicked.
*/
readonly click = output<unknown>();
/** Used by the BreadcrumbsComponent to access the breadcrumb content */
readonly content = viewChild(TemplateRef);
onClick(args: unknown) {
this.click.next(args);
this.click.emit(args);
}
}

View File

@@ -1,8 +1,7 @@
@for (breadcrumb of beforeOverflow; track breadcrumb; let last = $last) {
@for (breadcrumb of beforeOverflow(); track breadcrumb; let last = $last) {
@if (breadcrumb.route(); as route) {
<a
bitLink
linkType="primary"
class="tw-my-2 tw-inline-block"
[routerLink]="route"
[queryParams]="breadcrumb.queryParams()"
@@ -14,7 +13,6 @@
<button
type="button"
bitLink
linkType="primary"
class="tw-my-2 tw-inline-block"
(click)="breadcrumb.onClick($event)"
>
@@ -26,8 +24,8 @@
}
}
@if (hasOverflow) {
@if (beforeOverflow.length > 0) {
@if (hasOverflow()) {
@if (beforeOverflow().length > 0) {
<i class="bwi bwi-angle-right tw-mx-1.5 tw-text-main"></i>
}
<button
@@ -38,11 +36,10 @@
[label]="'moreBreadcrumbs' | i18n"
></button>
<bit-menu #overflowMenu>
@for (breadcrumb of overflow; track breadcrumb) {
@for (breadcrumb of overflow(); track breadcrumb) {
@if (breadcrumb.route(); as route) {
<a
bitMenuItem
linkType="primary"
[routerLink]="route"
[queryParams]="breadcrumb.queryParams()"
[queryParamsHandling]="breadcrumb.queryParamsHandling()"
@@ -50,18 +47,17 @@
<ng-container [ngTemplateOutlet]="breadcrumb.content()"></ng-container>
</a>
} @else {
<button type="button" bitMenuItem linkType="primary" (click)="breadcrumb.onClick($event)">
<button type="button" bitMenuItem (click)="breadcrumb.onClick($event)">
<ng-container [ngTemplateOutlet]="breadcrumb.content()"></ng-container>
</button>
}
}
</bit-menu>
<i class="bwi bwi-angle-right tw-mx-1.5 tw-text-main"></i>
@for (breadcrumb of afterOverflow; track breadcrumb; let last = $last) {
@for (breadcrumb of afterOverflow(); track breadcrumb; let last = $last) {
@if (breadcrumb.route(); as route) {
<a
bitLink
linkType="primary"
class="tw-my-2 tw-inline-block"
[routerLink]="route"
[queryParams]="breadcrumb.queryParams()"
@@ -73,7 +69,6 @@
<button
type="button"
bitLink
linkType="primary"
class="tw-my-2 tw-inline-block"
(click)="breadcrumb.onClick($event)"
>

View File

@@ -1,5 +1,11 @@
import { CommonModule } from "@angular/common";
import { Component, ContentChildren, QueryList, input } from "@angular/core";
import {
ChangeDetectionStrategy,
Component,
computed,
contentChildren,
input,
} from "@angular/core";
import { RouterModule } from "@angular/router";
import { I18nPipe } from "@bitwarden/ui-common";
@@ -15,42 +21,39 @@ import { BreadcrumbComponent } from "./breadcrumb.component";
* Bitwarden uses this component to indicate the user's current location in a set of data organized in
* containers (Collections, Folders, or Projects).
*/
// FIXME(https://bitwarden.atlassian.net/browse/CL-764): Migrate to OnPush
// eslint-disable-next-line @angular-eslint/prefer-on-push-component-change-detection
@Component({
selector: "bit-breadcrumbs",
templateUrl: "./breadcrumbs.component.html",
imports: [I18nPipe, CommonModule, LinkModule, RouterModule, IconButtonModule, MenuModule],
changeDetection: ChangeDetectionStrategy.OnPush,
})
export class BreadcrumbsComponent {
/**
* The maximum number of breadcrumbs to show before overflow.
*/
readonly show = input(3);
private breadcrumbs: BreadcrumbComponent[] = [];
protected readonly breadcrumbs = contentChildren(BreadcrumbComponent);
// FIXME(https://bitwarden.atlassian.net/browse/CL-903): Migrate to Signals
// eslint-disable-next-line @angular-eslint/prefer-signals
@ContentChildren(BreadcrumbComponent)
protected set breadcrumbList(value: QueryList<BreadcrumbComponent>) {
this.breadcrumbs = value.toArray();
}
/** Whether the breadcrumbs exceed the show limit and require an overflow menu */
protected readonly hasOverflow = computed(() => this.breadcrumbs().length > this.show());
protected get beforeOverflow() {
if (this.hasOverflow) {
return this.breadcrumbs.slice(0, this.show() - 1);
/** Breadcrumbs shown before the overflow menu */
protected readonly beforeOverflow = computed(() => {
const items = this.breadcrumbs();
const showCount = this.show();
if (items.length > showCount) {
return items.slice(0, showCount - 1);
}
return items;
});
return this.breadcrumbs;
}
/** Breadcrumbs hidden in the overflow menu */
protected readonly overflow = computed(() => {
return this.breadcrumbs().slice(this.show() - 1, -1);
});
protected get overflow() {
return this.breadcrumbs.slice(this.show() - 1, -1);
}
protected get afterOverflow() {
return this.breadcrumbs.slice(-1);
}
protected get hasOverflow() {
return this.breadcrumbs.length > this.show();
}
/** The last breadcrumb, shown after the overflow menu */
protected readonly afterOverflow = computed(() => this.breadcrumbs().slice(-1));
}

View File

@@ -1,4 +1,4 @@
import { Meta, Canvas, Primary, Controls, Title, Description } from "@storybook/addon-docs";
import { Meta, Canvas, Primary, Controls, Title, Description } from "@storybook/addon-docs/blocks";
import * as stories from "./breadcrumbs.stories";

View File

@@ -1,4 +1,4 @@
import { Component, importProvidersFrom } from "@angular/core";
import { ChangeDetectionStrategy, Component, importProvidersFrom } from "@angular/core";
import { RouterModule } from "@angular/router";
import { Meta, StoryObj, applicationConfig, moduleMetadata } from "@storybook/angular";
@@ -18,10 +18,9 @@ interface Breadcrumb {
route: string;
}
// FIXME(https://bitwarden.atlassian.net/browse/CL-764): Migrate to OnPush
// eslint-disable-next-line @angular-eslint/prefer-on-push-component-change-detection
@Component({
template: "",
changeDetection: ChangeDetectionStrategy.OnPush,
})
class EmptyComponent {}

View File

@@ -6,7 +6,7 @@ import {
Controls,
Title,
Description,
} from "@storybook/addon-docs";
} from "@storybook/addon-docs/blocks";
import * as stories from "./button.stories";

View File

@@ -81,10 +81,10 @@ export const Small: Story = {
props: args,
template: /*html*/ `
<div class="tw-flex tw-gap-4 tw-mb-6 tw-items-center">
<button type="button" bitButton [disabled]="disabled" [loading]="loading" [buttonType]="'primary'" [size]="size" [block]="block">Primary small</button>
<button type="button" bitButton [disabled]="disabled" [loading]="loading" [buttonType]="'secondary'" [size]="size" [block]="block">Secondary small</button>
<button type="button" bitButton [disabled]="disabled" [loading]="loading" [buttonType]="'danger'" [size]="size" [block]="block">Danger small</button>
<button type="button" bitButton [disabled]="disabled" [loading]="loading" [buttonType]="'dangerPrimary'" [size]="size" [block]="block">Danger Primary small</button>
<button type="button" bitButton [disabled]="disabled" [loading]="loading" buttonType="primary" [size]="size" [block]="block">Primary small</button>
<button type="button" bitButton [disabled]="disabled" [loading]="loading" buttonType="secondary" [size]="size" [block]="block">Secondary small</button>
<button type="button" bitButton [disabled]="disabled" [loading]="loading" buttonType="danger" [size]="size" [block]="block">Danger small</button>
<button type="button" bitButton [disabled]="disabled" [loading]="loading" buttonType="dangerPrimary" [size]="size" [block]="block">Danger Primary small</button>
</div>
`,
}),

View File

@@ -1,4 +1,4 @@
import { Meta, Canvas, Primary, Controls, Title, Description } from "@storybook/addon-docs";
import { Meta, Canvas, Primary, Controls, Title, Description } from "@storybook/addon-docs/blocks";
import * as stories from "./callout.stories";

View File

@@ -1,4 +1,4 @@
import { Meta, Primary, Controls, Canvas, Title, Description } from "@storybook/addon-docs";
import { Meta, Primary, Controls, Canvas, Title, Description } from "@storybook/addon-docs/blocks";
import * as stories from "./base-card.stories";

View File

@@ -1,4 +1,12 @@
import { Meta, Canvas, Source, Primary, Controls, Title, Description } from "@storybook/addon-docs";
import {
Meta,
Canvas,
Source,
Primary,
Controls,
Title,
Description,
} from "@storybook/addon-docs/blocks";
import * as stories from "./checkbox.stories";

View File

@@ -451,6 +451,24 @@ describe("ChipSelectComponent", () => {
expect(disabledMenuItem?.disabled).toBe(true);
});
it("should handle writeValue called before options are initialized", async () => {
const testApp = fixture.componentInstance;
component["rootTree"] = null;
component.writeValue("opt1");
expect(component["pendingValue"]).toBe("opt1");
expect(component["selectedOption"]).toBeUndefined();
testApp.options.set(testOptions);
fixture.detectChanges();
await fixture.whenStable();
expect(component["selectedOption"]?.value).toBe("opt1");
expect(component["pendingValue"]).toBeUndefined();
});
});
});

View File

@@ -100,10 +100,21 @@ export class ChipSelectComponent<T = unknown> implements ControlValueAccessor {
/** Tree constructed from `this.options` */
private rootTree?: ChipSelectOption<T> | null;
/** Store the pending value when writeValue is called before options are initialized */
private pendingValue?: T;
constructor() {
// Initialize the root tree whenever options change
effect(() => {
this.initializeRootTree(this.options());
// If there's a pending value, apply it now that options are available
if (this.pendingValue !== undefined) {
this.selectedOption = this.findOption(this.rootTree, this.pendingValue);
this.setOrResetRenderedOptions();
this.pendingValue = undefined;
this.cdr.markForCheck();
}
});
// Focus the first menu item when menuItems change (e.g., navigating submenus)
@@ -255,6 +266,12 @@ export class ChipSelectComponent<T = unknown> implements ControlValueAccessor {
/** Implemented as part of NG_VALUE_ACCESSOR */
writeValue(obj: T): void {
// If rootTree is not yet initialized, store the value to apply it later
if (!this.rootTree) {
this.pendingValue = obj;
return;
}
this.selectedOption = this.findOption(this.rootTree, obj);
this.setOrResetRenderedOptions();
// OnPush components require manual change detection when writeValue() is called

View File

@@ -1,4 +1,4 @@
import { Meta, Primary, Controls, Canvas, Title, Description } from "@storybook/addon-docs";
import { Meta, Primary, Controls, Canvas, Title, Description } from "@storybook/addon-docs/blocks";
import * as stories from "./chip-select.stories";

View File

@@ -1,6 +1,6 @@
import { FormsModule } from "@angular/forms";
import { Meta, StoryObj, moduleMetadata } from "@storybook/angular";
import { getAllByRole, userEvent } from "@storybook/test";
import { getAllByRole, userEvent } from "storybook/test";
import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service";

View File

@@ -1,5 +1,14 @@
import { Component, computed, HostBinding, input } from "@angular/core";
import {
Component,
computed,
ElementRef,
HostBinding,
HostListener,
inject,
input,
} from "@angular/core";
import { PlatformUtilsService } from "@bitwarden/common/platform/abstractions/platform-utils.service";
import { Utils } from "@bitwarden/common/platform/misc/utils";
type CharacterType = "letter" | "emoji" | "special" | "number";
@@ -14,7 +23,7 @@ type CharacterType = "letter" | "emoji" | "special" | "number";
@Component({
selector: "bit-color-password",
template: `@for (character of passwordCharArray(); track $index; let i = $index) {
<span [class]="getCharacterClass(character)">
<span [class]="getCharacterClass(character)" class="tw-font-mono" data-password-character>
<span>{{ character }}</span>
@if (showCount()) {
<span class="tw-whitespace-nowrap tw-text-xs tw-leading-5 tw-text-main">{{ i + 1 }}</span>
@@ -31,6 +40,9 @@ export class ColorPasswordComponent {
return Array.from(this.password() ?? "");
});
private platformUtilsService = inject(PlatformUtilsService);
private elementRef = inject(ElementRef);
characterStyles: Record<CharacterType, string[]> = {
emoji: [],
letter: ["tw-text-main"],
@@ -78,4 +90,28 @@ export class ColorPasswordComponent {
return "letter";
}
@HostListener("copy", ["$event"])
onCopy(event: ClipboardEvent) {
event.preventDefault();
const selection = window.getSelection();
if (!selection || selection.rangeCount === 0) {
return;
}
const spanElements = this.elementRef.nativeElement.querySelectorAll(
"span[data-password-character]",
);
let copiedText = "";
spanElements.forEach((span: HTMLElement, index: number) => {
if (selection.containsNode(span, true)) {
copiedText += this.passwordCharArray()[index];
}
});
if (copiedText) {
this.platformUtilsService.copyToClipboard(copiedText);
}
}
}

View File

@@ -1,4 +1,4 @@
import { Meta, Canvas, Primary, Controls, Title, Description } from "@storybook/addon-docs";
import { Meta, Canvas, Primary, Controls, Title, Description } from "@storybook/addon-docs/blocks";
import * as stories from "./color-password.stories";

View File

@@ -1,14 +1,29 @@
import { Meta, StoryObj } from "@storybook/angular";
import { applicationConfig, Meta, StoryObj } from "@storybook/angular";
import { PlatformUtilsService } from "@bitwarden/common/platform/abstractions/platform-utils.service";
import { formatArgsForCodeSnippet } from "../../../../.storybook/format-args-for-code-snippet";
import { ColorPasswordComponent } from "./color-password.component";
const examplePassword = "Wq$Jk😀7jlI DX#rS5Sdi!z ";
const examplePassword = "Wq$Jk😀7jlI DX#rS5Sdi!z0O ";
export default {
title: "Component Library/Color Password",
component: ColorPasswordComponent,
decorators: [
applicationConfig({
providers: [
{
provide: PlatformUtilsService,
useValue: {
// eslint-disable-next-line
copyToClipboard: (text: string) => console.log(`${text} copied to clipboard`),
},
},
],
}),
],
args: {
password: examplePassword,
showCount: false,

View File

@@ -1,4 +1,4 @@
import { Meta, Primary, Title, Description } from "@storybook/addon-docs";
import { Meta, Primary, Title, Description } from "@storybook/addon-docs/blocks";
import * as stories from "./container.stories";

View File

@@ -64,7 +64,7 @@ export const Default: Story = {
type="button"
bitSuffix
bitIconButton="bwi-clone"
[label]="'Copy'"
label="Copy"
[appCopyClick]="value"
></button>
</bit-form-field>
@@ -86,7 +86,7 @@ export const WithDefaultToast: Story = {
type="button"
bitSuffix
bitIconButton="bwi-clone"
[label]="'Copy'"
label="Copy"
[appCopyClick]="value"
showToast
></button>
@@ -109,7 +109,7 @@ export const WithCustomToastVariant: Story = {
type="button"
bitSuffix
bitIconButton="bwi-clone"
[label]="'Copy'"
label="Copy"
[appCopyClick]="value"
showToast="info"
></button>
@@ -132,7 +132,7 @@ export const WithCustomValueLabel: Story = {
type="button"
bitSuffix
bitIconButton="bwi-clone"
[label]="'Copy'"
label="Copy"
[appCopyClick]="value"
showToast
valueLabel="API Key"

View File

@@ -1,18 +1,19 @@
import { DIALOG_DATA, DialogRef } from "@angular/cdk/dialog";
import { Component, Inject } from "@angular/core";
import { Component, inject } from "@angular/core";
import { NoopAnimationsModule, provideAnimations } from "@angular/platform-browser/animations";
import { RouterTestingModule } from "@angular/router/testing";
import { Meta, StoryObj, applicationConfig, moduleMetadata } from "@storybook/angular";
import { getAllByRole, userEvent } from "@storybook/test";
import { getAllByRole, userEvent } from "storybook/test";
import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service";
import { GlobalStateProvider } from "@bitwarden/state";
import { ButtonModule } from "../button";
import { IconButtonModule } from "../icon-button";
import { LayoutComponent } from "../layout";
import { SharedModule } from "../shared";
import { positionFixedWrapperDecorator } from "../stories/storybook-decorators";
import { I18nMockService } from "../utils/i18n-mock.service";
import { I18nMockService, StorybookGlobalStateProvider } from "../utils";
import { DialogModule } from "./dialog.module";
import { DialogService } from "./dialog.service";
@@ -30,13 +31,17 @@ interface Animal {
<button class="tw-mr-2" bitButton type="button" (click)="openDialogNonDismissable()">
Open Non-Dismissable Dialog
</button>
<button bitButton type="button" (click)="openDrawer()">Open Drawer</button>
<button class="tw-mr-2" bitButton type="button" (click)="openDrawer()">Open Drawer</button>
<button class="tw-mr-2" bitButton size="small" type="button" (click)="openSmallDrawer()">
Open Small Drawer
</button>
<button bitButton type="button" (click)="openLargeDrawer()">Open Large Drawer</button>
</bit-layout>
`,
imports: [ButtonModule, LayoutComponent],
})
class StoryDialogComponent {
constructor(public dialogService: DialogService) {}
dialogService = inject(DialogService);
openDialog() {
this.dialogService.open(StoryDialogContentComponent, {
@@ -62,13 +67,29 @@ class StoryDialogComponent {
},
});
}
openSmallDrawer() {
this.dialogService.openDrawer(SmallDrawerContentComponent, {
data: {
animal: "panda",
},
});
}
openLargeDrawer() {
this.dialogService.openDrawer(LargeDrawerContentComponent, {
data: {
animal: "panda",
},
});
}
}
// FIXME(https://bitwarden.atlassian.net/browse/CL-764): Migrate to OnPush
// eslint-disable-next-line @angular-eslint/prefer-on-push-component-change-detection
@Component({
template: `
<bit-dialog title="Dialog Title" dialogSize="large">
<bit-dialog title="Dialog Title">
<span bitDialogContent>
Dialog body text goes here.
<br />
@@ -85,10 +106,8 @@ class StoryDialogComponent {
imports: [DialogModule, ButtonModule],
})
class StoryDialogContentComponent {
constructor(
public dialogRef: DialogRef,
@Inject(DIALOG_DATA) private data: Animal,
) {}
dialogRef = inject(DialogRef);
private data = inject<Animal>(DIALOG_DATA);
get animal() {
return this.data?.animal;
@@ -101,7 +120,6 @@ class StoryDialogContentComponent {
template: `
<bit-dialog
title="Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore"
dialogSize="large"
>
<span bitDialogContent>
Dialog body text goes here.
@@ -118,10 +136,66 @@ class StoryDialogContentComponent {
imports: [DialogModule, ButtonModule],
})
class NonDismissableContentComponent {
constructor(
public dialogRef: DialogRef,
@Inject(DIALOG_DATA) private data: Animal,
) {}
dialogRef = inject(DialogRef);
private data = inject<Animal>(DIALOG_DATA);
get animal() {
return this.data?.animal;
}
}
// FIXME(https://bitwarden.atlassian.net/browse/CL-764): Migrate to OnPush
// eslint-disable-next-line @angular-eslint/prefer-on-push-component-change-detection
@Component({
template: `
<bit-dialog title="Small Drawer" dialogSize="small">
<span bitDialogContent>
Dialog body text goes here.
<br />
Animal: {{ animal }}
</span>
<ng-container bitDialogFooter>
<button type="button" bitButton buttonType="primary" (click)="dialogRef.close()">
Save
</button>
<button type="button" bitButton buttonType="secondary" bitDialogClose>Cancel</button>
</ng-container>
</bit-dialog>
`,
imports: [DialogModule, ButtonModule],
})
class SmallDrawerContentComponent {
dialogRef = inject(DialogRef);
private data = inject<Animal>(DIALOG_DATA);
get animal() {
return this.data?.animal;
}
}
// FIXME(https://bitwarden.atlassian.net/browse/CL-764): Migrate to OnPush
// eslint-disable-next-line @angular-eslint/prefer-on-push-component-change-detection
@Component({
template: `
<bit-dialog title="Large Drawer" dialogSize="large">
<span bitDialogContent>
Dialog body text goes here.
<br />
Animal: {{ animal }}
</span>
<ng-container bitDialogFooter>
<button type="button" bitButton buttonType="primary" (click)="dialogRef.close()">
Save
</button>
<button type="button" bitButton buttonType="secondary" bitDialogClose>Cancel</button>
</ng-container>
</bit-dialog>
`,
imports: [DialogModule, ButtonModule],
})
class LargeDrawerContentComponent {
dialogRef = inject(DialogRef);
private data = inject<Animal>(DIALOG_DATA);
get animal() {
return this.data?.animal;
@@ -165,6 +239,10 @@ export default {
});
},
},
{
provide: GlobalStateProvider,
useClass: StorybookGlobalStateProvider,
},
],
}),
],
@@ -205,3 +283,21 @@ export const Drawer: Story = {
await userEvent.click(button);
},
};
export const DrawerSmall: Story = {
play: async (context) => {
const canvas = context.canvasElement;
const button = getAllByRole(canvas, "button")[3];
await userEvent.click(button);
},
};
export const DrawerLarge: Story = {
play: async (context) => {
const canvas = context.canvasElement;
const button = getAllByRole(canvas, "button")[4];
await userEvent.click(button);
},
};

View File

@@ -10,7 +10,7 @@ import { ComponentPortal, Portal } from "@angular/cdk/portal";
import { Injectable, Injector, TemplateRef, inject } from "@angular/core";
import { takeUntilDestroyed } from "@angular/core/rxjs-interop";
import { NavigationEnd, Router } from "@angular/router";
import { filter, firstValueFrom, map, Observable, Subject, switchMap } from "rxjs";
import { filter, firstValueFrom, map, Observable, Subject, switchMap, take } from "rxjs";
import { AuthService } from "@bitwarden/common/auth/abstractions/auth.service";
import { AuthenticationStatus } from "@bitwarden/common/auth/enums/authentication-status";
@@ -43,9 +43,10 @@ class CustomBlockScrollStrategy implements ScrollStrategy {
detach() {}
}
export abstract class DialogRef<R = unknown, C = unknown>
implements Pick<CdkDialogRef<R, C>, "close" | "closed" | "disableClose" | "componentInstance">
{
export abstract class DialogRef<R = unknown, C = unknown> implements Pick<
CdkDialogRef<R, C>,
"close" | "closed" | "disableClose" | "componentInstance"
> {
abstract readonly isDrawer?: boolean;
// --- From CdkDialogRef ---
@@ -61,7 +62,7 @@ export abstract class DialogRef<R = unknown, C = unknown>
export type DialogConfig<D = unknown, R = unknown> = Pick<
CdkDialogConfig<D, R>,
"data" | "disableClose" | "ariaModal" | "positionStrategy" | "height" | "width"
"data" | "disableClose" | "ariaModal" | "positionStrategy" | "height" | "width" | "restoreFocus"
>;
/**
@@ -241,6 +242,11 @@ export class DialogService {
};
ref.cdkDialogRefBase = this.dialog.open<R, D, C>(componentOrTemplateRef, _config);
if (config?.restoreFocus === undefined) {
this.setRestoreFocusEl<R, C>(ref);
}
return ref;
}
@@ -299,6 +305,53 @@ export class DialogService {
return this.dialog.closeAll();
}
/** Close the open drawer */
closeDrawer(): void {
return this.activeDrawer?.close();
}
/**
* Configure the dialog to return focus to the previous active element upon closing.
* @param ref CdkDialogRef
*
* The cdk dialog already has the optional directive `cdkTrapFocusAutoCapture` to capture the
* current active element and return focus to it upon close. However, it does not have a way to
* delay the capture of the element. We need this delay in some situations, where the active
* element may be changing as the dialog is opening, and we want to wait for that to settle.
*
* For example -- the menu component often contains menu items that open dialogs. When the dialog
* opens, the menu is closing and is setting focus back to the menu trigger since the menu item no
* longer exists. We want to capture the menu trigger as the active element, not the about-to-be-
* nonexistent menu item. If we wait a tick, we can let the menu finish that focus move.
*/
private setRestoreFocusEl<R = unknown, C = unknown>(ref: CdkDialogRef<R, C>) {
/**
* First, capture the current active el with no delay so that we can support normal use cases
* where we are not doing manual focus management
*/
const activeEl = document.activeElement;
const restoreFocusTimeout = setTimeout(() => {
let restoreFocusEl = activeEl;
/**
* If the original active element is no longer connected, it's because we purposely removed it
* from the DOM and have moved focus. Select the new active element instead.
*/
if (!restoreFocusEl?.isConnected) {
restoreFocusEl = document.activeElement;
}
if (restoreFocusEl instanceof HTMLElement) {
ref.cdkDialogRefBase.config.restoreFocus = restoreFocusEl;
}
}, 0);
ref.closed.pipe(take(1)).subscribe(() => {
clearTimeout(restoreFocusTimeout);
});
}
/** The injector that is passed to the opened dialog */
private createInjector(opts: { data: unknown; dialogRef: DialogRef }): Injector {
return Injector.create({

View File

@@ -2,11 +2,10 @@
<section
class="tw-flex tw-w-full tw-flex-col tw-self-center tw-overflow-hidden tw-border tw-border-solid tw-border-secondary-100 tw-bg-background tw-text-main"
[ngClass]="[
width,
isDrawer ? 'tw-h-screen tw-border-t-0' : 'tw-rounded-t-xl md:tw-rounded-xl tw-shadow-lg',
width(),
isDrawer ? 'tw-h-full tw-border-t-0' : 'tw-rounded-t-xl md:tw-rounded-xl tw-shadow-lg',
]"
cdkTrapFocus
cdkTrapFocusAutoCapture
>
@let showHeaderBorder = bodyHasScrolledFrom().top;
<header
@@ -23,8 +22,8 @@
bitTypography="h3"
noMargin
class="tw-text-main tw-mb-0 tw-line-clamp-2 tw-text-ellipsis tw-break-words focus-visible:tw-outline-none"
cdkFocusInitial
tabindex="-1"
#dialogHeader
>
{{ title() }}
@if (subtitle(); as subtitleText) {
@@ -34,16 +33,21 @@
}
<ng-content select="[bitDialogTitle]"></ng-content>
</h2>
@if (!this.dialogRef?.disableClose) {
<button
type="button"
bitIconButton="bwi-close"
buttonType="main"
size="default"
bitDialogClose
[label]="'close' | i18n"
></button>
}
<div class="tw-flex tw-items-center tw-justify-center">
<ng-content select="[bitDialogHeaderEnd]"></ng-content>
@if (!this.dialogRef?.disableClose) {
<button
type="button"
bitIconButton="bwi-close"
buttonType="main"
size="default"
bitDialogClose
[label]="'close' | i18n"
></button>
}
</div>
</header>
<div

View File

@@ -11,6 +11,7 @@ import {
DestroyRef,
computed,
signal,
AfterViewInit,
} from "@angular/core";
import { toObservable } from "@angular/core/rxjs-interop";
import { combineLatest, switchMap } from "rxjs";
@@ -26,6 +27,20 @@ import { DialogRef } from "../dialog.service";
import { DialogCloseDirective } from "../directives/dialog-close.directive";
import { DialogTitleContainerDirective } from "../directives/dialog-title-container.directive";
type DialogSize = "small" | "default" | "large";
const dialogSizeToWidth = {
small: "md:tw-max-w-sm",
default: "md:tw-max-w-xl",
large: "md:tw-max-w-3xl",
} as const;
const drawerSizeToWidth = {
small: "md:tw-max-w-sm",
default: "md:tw-max-w-lg",
large: "md:tw-max-w-2xl",
} as const;
// FIXME(https://bitwarden.atlassian.net/browse/CL-764): Migrate to OnPush
// eslint-disable-next-line @angular-eslint/prefer-on-push-component-change-detection
@Component({
@@ -48,8 +63,10 @@ import { DialogTitleContainerDirective } from "../directives/dialog-title-contai
SpinnerComponent,
],
})
export class DialogComponent {
export class DialogComponent implements AfterViewInit {
private readonly destroyRef = inject(DestroyRef);
private readonly dialogHeader =
viewChild.required<ElementRef<HTMLHeadingElement>>("dialogHeader");
private readonly scrollableBody = viewChild.required(CdkScrollable);
private readonly scrollBottom = viewChild.required<ElementRef<HTMLDivElement>>("scrollBottom");
@@ -71,7 +88,7 @@ export class DialogComponent {
/**
* Dialog size, more complex dialogs should use large, otherwise default is fine.
*/
readonly dialogSize = input<"small" | "default" | "large">("default");
readonly dialogSize = input<DialogSize>("default");
/**
* Title to show in the dialog's header
@@ -100,23 +117,49 @@ export class DialogComponent {
private readonly animationCompleted = signal(false);
protected readonly width = computed(() => {
const size = this.dialogSize() ?? "default";
const isDrawer = this.dialogRef?.isDrawer;
if (isDrawer) {
return drawerSizeToWidth[size];
}
return dialogSizeToWidth[size];
});
protected readonly classes = computed(() => {
// `tw-max-h-[90vh]` is needed to prevent dialogs from overlapping the desktop header
const baseClasses = ["tw-flex", "tw-flex-col", "tw-w-screen"];
const sizeClasses = this.dialogRef?.isDrawer
? ["tw-min-h-screen", "md:tw-w-[23rem]"]
: ["md:tw-p-4", "tw-w-screen", "tw-max-h-[90vh]"];
const sizeClasses = this.dialogRef?.isDrawer ? ["tw-h-full"] : ["md:tw-p-4", "tw-max-h-[90vh]"];
const size = this.dialogSize() ?? "default";
const animationClasses =
this.disableAnimations() || this.animationCompleted() || this.dialogRef?.isDrawer
? []
: this.dialogSize() === "small"
: size === "small"
? ["tw-animate-slide-down"]
: ["tw-animate-slide-up", "md:tw-animate-slide-down"];
return [...baseClasses, this.width, ...sizeClasses, ...animationClasses];
return [...baseClasses, this.width(), ...sizeClasses, ...animationClasses];
});
ngAfterViewInit() {
/**
* Wait a tick for any focus management to occur on the trigger element before moving focus to
* the dialog header. We choose the dialog header because it is always present, unlike possible
* interactive elements.
*
* We are doing this manually instead of using `cdkTrapFocusAutoCapture` and `cdkFocusInitial`
* because we need this delay behavior.
*/
const headerFocusTimeout = setTimeout(() => {
this.dialogHeader().nativeElement.focus();
}, 0);
this.destroyRef.onDestroy(() => clearTimeout(headerFocusTimeout));
}
handleEsc(event: Event) {
if (!this.dialogRef?.disableClose) {
this.dialogRef?.close();
@@ -124,20 +167,6 @@ export class DialogComponent {
}
}
get width() {
switch (this.dialogSize()) {
case "small": {
return "md:tw-max-w-sm";
}
case "large": {
return "md:tw-max-w-3xl";
}
default: {
return "md:tw-max-w-xl";
}
}
}
onAnimationEnd() {
this.animationCompleted.set(true);
}

View File

@@ -1,4 +1,4 @@
import { Meta, Canvas, Primary, Controls } from "@storybook/addon-docs";
import { Meta, Canvas, Primary, Controls } from "@storybook/addon-docs/blocks";
import * as stories from "./dialog.stories";

View File

@@ -94,6 +94,7 @@ export const Default: Story = {
<ng-container bitDialogTitle>
<span bitBadge variant="success">Foobar</span>
</ng-container>
<ng-container bitDialogContent>Dialog body text goes here.</ng-container>
<ng-container bitDialogFooter>
<button type="button" bitButton buttonType="primary" [disabled]="loading">Save</button>
@@ -292,3 +293,42 @@ export const WithCards: Story = {
disableAnimations: true,
},
};
export const HeaderEnd: Story = {
render: (args) => ({
props: args,
template: /*html*/ `
<bit-dialog
[dialogSize]="dialogSize"
[title]="title"
[subtitle]="subtitle"
[loading]="loading"
[disablePadding]="disablePadding"
[disableAnimations]="disableAnimations">
<ng-container bitDialogHeaderEnd>
<span bitBadge>Archived</span>
</ng-container>
<ng-container bitDialogContent>Dialog body text goes here.</ng-container>
<ng-container bitDialogFooter>
<button type="button" bitButton buttonType="primary" [disabled]="loading">Save</button>
<button type="button" bitButton buttonType="secondary" [disabled]="loading">Cancel</button>
<button
type="button"
[disabled]="loading"
class="tw-ms-auto"
bitIconButton="bwi-trash"
buttonType="danger"
size="default"
label="Delete"></button>
</ng-container>
</bit-dialog>
`,
}),
args: {
dialogSize: "small",
title: "Very Long Title That Should Be Truncated After Two Lines To Test Header End Slot",
subtitle: "Subtitle",
},
};

View File

@@ -1,4 +1,4 @@
import { Meta, Canvas, Source } from "@storybook/addon-docs";
import { Meta, Canvas, Source } from "@storybook/addon-docs/blocks";
import * as stories from "./dialog.service.stories";

View File

@@ -1,4 +1,4 @@
import { Meta, Canvas, Primary, Controls } from "@storybook/addon-docs";
import { Meta, Canvas, Primary, Controls } from "@storybook/addon-docs/blocks";
import * as stories from "./simple-dialog.stories";

View File

@@ -1,8 +1,8 @@
import { DialogRef, DIALOG_DATA } from "@angular/cdk/dialog";
import { Component, Inject } from "@angular/core";
import { Component, inject } from "@angular/core";
import { provideAnimations } from "@angular/platform-browser/animations";
import { Meta, StoryObj, moduleMetadata } from "@storybook/angular";
import { getAllByRole, userEvent } from "@storybook/test";
import { getAllByRole, userEvent } from "storybook/test";
import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service";
@@ -30,7 +30,7 @@ interface Animal {
imports: [ButtonModule],
})
class StoryDialogComponent {
constructor(public dialogService: DialogService) {}
dialogService = inject(DialogService);
openSimpleDialog() {
this.dialogService.open(SimpleDialogContentComponent, {
@@ -84,10 +84,8 @@ class StoryDialogComponent {
imports: [ButtonModule, DialogModule],
})
class SimpleDialogContentComponent {
constructor(
public dialogRef: DialogRef,
@Inject(DIALOG_DATA) private data: Animal,
) {}
dialogRef = inject(DialogRef);
private data = inject<Animal>(DIALOG_DATA);
get animal() {
return this.data?.animal;
@@ -115,10 +113,8 @@ class SimpleDialogContentComponent {
imports: [ButtonModule, DialogModule],
})
class NonDismissableWithPrimaryButtonContentComponent {
constructor(
public dialogRef: DialogRef,
@Inject(DIALOG_DATA) private data: Animal,
) {}
dialogRef = inject(DialogRef);
private data = inject<Animal>(DIALOG_DATA);
get animal() {
return this.data?.animal;
@@ -141,10 +137,8 @@ class NonDismissableWithPrimaryButtonContentComponent {
imports: [ButtonModule, DialogModule],
})
class NonDismissableWithNoButtonsContentComponent {
constructor(
public dialogRef: DialogRef,
@Inject(DIALOG_DATA) private data: Animal,
) {}
dialogRef = inject(DialogRef);
private data = inject<Animal>(DIALOG_DATA);
get animal() {
return this.data?.animal;

View File

@@ -1,4 +1,4 @@
import { Meta, Canvas, Primary, Controls, Title, Description } from "@storybook/addon-docs";
import { Meta, Canvas, Primary, Controls, Title, Description } from "@storybook/addon-docs/blocks";
import * as stories from "./disclosure.stories";

View File

@@ -12,7 +12,7 @@ import { hasScrolledFrom } from "../utils/has-scrolled-from";
imports: [],
host: {
class:
"tw-p-4 tw-pt-0 tw-block tw-overflow-auto tw-border-solid tw-border tw-border-transparent tw-transition-colors tw-duration-200",
"tw-p-4 tw-pt-0 tw-flex-1 tw-overflow-auto tw-border-solid tw-border tw-border-transparent tw-transition-colors tw-duration-200",
"[class.tw-border-t-secondary-300]": "this.hasScrolledFrom().top",
},
hostDirectives: [

View File

@@ -1,7 +1,7 @@
<ng-container *cdkPortal>
<section
[attr.role]="role()"
class="tw-w-[23rem] tw-sticky tw-top-0 tw-h-screen tw-flex tw-flex-col tw-overflow-auto tw-border-0 tw-border-l tw-border-solid tw-border-secondary-300 tw-bg-background"
class="tw-w-[23rem] tw-sticky tw-top-0 tw-h-full tw-flex tw-flex-col tw-overflow-auto tw-border-0 tw-border-l tw-border-solid tw-border-secondary-300 tw-bg-background"
>
<ng-content></ng-content>
</section>

View File

@@ -1,4 +1,4 @@
import { Meta, Canvas, Primary, Controls } from "@storybook/addon-docs";
import { Meta, Canvas, Primary, Controls } from "@storybook/addon-docs/blocks";
import * as stories from "./drawer.stories";

View File

@@ -1,7 +1,8 @@
import { RouterTestingModule } from "@angular/router/testing";
import { Meta, StoryObj, moduleMetadata } from "@storybook/angular";
import { Meta, StoryObj, applicationConfig, moduleMetadata } from "@storybook/angular";
import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service";
import { GlobalStateProvider } from "@bitwarden/state";
import { ButtonModule } from "../button";
import { CalloutModule } from "../callout";
@@ -9,7 +10,7 @@ import { LayoutComponent } from "../layout";
import { mockLayoutI18n } from "../layout/mocks";
import { positionFixedWrapperDecorator } from "../stories/storybook-decorators";
import { TypographyModule } from "../typography";
import { I18nMockService } from "../utils";
import { I18nMockService, StorybookGlobalStateProvider } from "../utils";
import { DrawerBodyComponent } from "./drawer-body.component";
import { DrawerHeaderComponent } from "./drawer-header.component";
@@ -47,6 +48,14 @@ export default {
},
],
}),
applicationConfig({
providers: [
{
provide: GlobalStateProvider,
useClass: StorybookGlobalStateProvider,
},
],
}),
],
} as Meta<DrawerComponent>;

View File

@@ -83,7 +83,7 @@
<ng-container *ngTemplateOutlet="labelContent"></ng-container>
</label>
<div
class="tw-gap-1 tw-flex tw-min-h-[1.85rem] tw-border-0 tw-border-solid"
class="tw-gap-1 tw-flex tw-min-h-[1.85rem] tw-border-0 tw-border-solid tw-items-center"
[ngClass]="{
'tw-border-secondary-300/50 tw-border-b tw-pb-[2px]': !disableReadOnlyBorder,
'tw-border-transparent tw-pb-[3px]': disableReadOnlyBorder,

View File

@@ -1,4 +1,4 @@
import { Meta, Canvas, Source, Primary, Controls } from "@storybook/addon-docs";
import { Meta, Canvas, Source, Primary, Controls } from "@storybook/addon-docs/blocks";
import * as stories from "./form-field.stories";

View File

@@ -241,7 +241,7 @@ export const Readonly: Story = {
<bit-label>Input</bit-label>
<input bitInput type="password" value="Foobar" [readonly]="true" />
<button type="button" label="Toggle password" bitIconButton bitSuffix bitPasswordInputToggle></button>
<button type="button" bitSuffix bitIconButton="bwi-clone" [label]="'Clone Input'"></button>
<button type="button" bitSuffix bitIconButton="bwi-clone" label="Clone Input"></button>
</bit-form-field>
<bit-form-field>
@@ -262,7 +262,7 @@ export const Readonly: Story = {
<bit-label>Input</bit-label>
<input bitInput type="password" value="Foobar" readonly />
<button type="button" bitIconButton bitSuffix bitPasswordInputToggle></button>
<button type="button" bitSuffix bitIconButton="bwi-clone" [label]="'Clone Input'"></button>
<button type="button" bitSuffix bitIconButton="bwi-clone" label="Clone Input"></button>
</bit-form-field>
<bit-form-field>
@@ -310,11 +310,11 @@ export const ButtonInputGroup: Story = {
<i class="bwi bwi-question-circle" aria-hidden="true"></i>
</a>
</bit-label>
<button type="button" bitPrefix bitIconButton="bwi-star" [label]="'Favorite Label'"></button>
<button type="button" bitPrefix bitIconButton="bwi-star" label="Favorite Label"></button>
<input bitInput placeholder="Placeholder" />
<button type="button" bitSuffix bitIconButton="bwi-eye" [label]="'Hide Label'"></button>
<button type="button" bitSuffix bitIconButton="bwi-clone" [label]="'Clone Label'"></button>
<button type="button" bitSuffix bitIconButton="bwi-ellipsis-v" [label]="'Menu Label'"></button>
<button type="button" bitSuffix bitIconButton="bwi-eye" label="Hide Label"></button>
<button type="button" bitSuffix bitIconButton="bwi-clone" label="Clone Label"></button>
<button type="button" bitSuffix bitIconButton="bwi-ellipsis-v" label="Menu Label"></button>
</bit-form-field>
`,
}),
@@ -327,12 +327,11 @@ export const DisabledButtonInputGroup: Story = {
template: /*html*/ `
<bit-form-field>
<bit-label>Label</bit-label>
<button type="button" bitPrefix bitIconButton="bwi-star" disabled [label]="'Favorite Label'"></button>
<button type="button" bitPrefix bitIconButton="bwi-star" disabled label="Favorite Label"></button>
<input bitInput placeholder="Placeholder" disabled />
<button type="button" bitSuffix bitIconButton="bwi-eye" disabled [label]="'Hide Label'"></button>
<button type="button" bitSuffix bitIconButton="bwi-clone" disabled [label]="'Clone Label'"></button>
<button type="button" bitSuffix bitIconButton="bwi-ellipsis-v" disabled [label]="'Menu Label'"></button>
<button type="button" bitSuffix bitIconButton="bwi-eye" disabled label="Hide Label"></button>
<button type="button" bitSuffix bitIconButton="bwi-clone" disabled label="Clone Label"></button>
<button type="button" bitSuffix bitIconButton="bwi-ellipsis-v" disabled label="Menu Label"></button>
</bit-form-field>
`,
}),
@@ -346,9 +345,9 @@ export const PartiallyDisabledButtonInputGroup: Story = {
<bit-form-field>
<bit-label>Label</bit-label>
<input bitInput placeholder="Placeholder" disabled />
<button type="button" bitSuffix bitIconButton="bwi-eye" [label]="'Hide Label'"></button>
<button type="button" bitSuffix bitIconButton="bwi-clone" [label]="'Clone Label'"></button>
<button type="button" bitSuffix bitIconButton="bwi-ellipsis-v" disabled [label]="'Menu Label'"></button>
<button type="button" bitSuffix bitIconButton="bwi-eye" label="Hide Label"></button>
<button type="button" bitSuffix bitIconButton="bwi-clone" label="Clone Label"></button>
<button type="button" bitSuffix bitIconButton="bwi-ellipsis-v" disabled label="Menu Label"></button>
</bit-form-field>
`,
}),

View File

@@ -6,8 +6,8 @@ import {
FormGroup,
} from "@angular/forms";
import { NgSelectModule } from "@ng-select/ng-select";
import { action } from "@storybook/addon-actions";
import { Meta, StoryObj, moduleMetadata } from "@storybook/angular";
import { action } from "storybook/actions";
import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service";

View File

@@ -1,6 +1,6 @@
import { FormBuilder, FormsModule, ReactiveFormsModule, Validators } from "@angular/forms";
import { Meta, StoryObj, moduleMetadata } from "@storybook/angular";
import { userEvent, getByText } from "@storybook/test";
import { userEvent, getByText } from "storybook/test";
import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service";

View File

@@ -1,4 +1,4 @@
import { Meta, Canvas, Source } from "@storybook/addon-docs";
import { Meta, Canvas, Source } from "@storybook/addon-docs/blocks";
import * as formStories from "./form.stories";
import * as fieldStories from "../form-field/form-field.stories";

View File

@@ -1,8 +1,11 @@
import { ChangeDetectionStrategy, Component, input } from "@angular/core";
import { TypographyDirective } from "../typography/typography.directive";
@Component({
selector: "bit-header",
templateUrl: "./header.component.html",
imports: [TypographyDirective],
changeDetection: ChangeDetectionStrategy.OnPush,
standalone: true,
})

View File

@@ -1,6 +1,6 @@
<span class="tw-relative tw-inline-block tw-leading-[0px]">
<span class="tw-inline-block tw-leading-[0px]" [ngClass]="{ 'tw-invisible': showLoadingStyle() }">
<i class="bwi" [ngClass]="iconClass" aria-hidden="true"></i>
<i class="bwi" [ngClass]="iconClass()" aria-hidden="true"></i>
</span>
<span
class="tw-absolute tw-inset-0 tw-flex tw-items-center tw-justify-center"

View File

@@ -20,7 +20,16 @@ import { SpinnerComponent } from "../spinner";
import { TooltipDirective } from "../tooltip";
import { ariaDisableElement } from "../utils";
export type IconButtonType = "primary" | "danger" | "contrast" | "main" | "muted" | "nav-contrast";
export const IconButtonTypes = [
"primary",
"danger",
"contrast",
"main",
"muted",
"nav-contrast",
] as const;
export type IconButtonType = (typeof IconButtonTypes)[number];
const focusRing = [
// Workaround for box-shadow with transparent offset issue:
@@ -62,9 +71,9 @@ const styles: Record<IconButtonType, string[]> = {
primary: ["!tw-text-primary-600", "focus-visible:before:tw-ring-primary-600", ...focusRing],
danger: ["!tw-text-danger-600", "focus-visible:before:tw-ring-primary-600", ...focusRing],
"nav-contrast": [
"!tw-text-alt2",
"!tw-text-fg-sidenav-text",
"hover:!tw-bg-hover-contrast",
"focus-visible:before:tw-ring-text-alt2",
"focus-visible:before:tw-ring-border-focus",
...focusRing,
],
};
@@ -120,7 +129,7 @@ export class BitIconButtonComponent implements ButtonLikeAbstraction, FocusableE
* label input will be used to set the `aria-label` attributes on the button.
* This is for accessibility purposes, as it provides a text alternative for the icon button.
*
* NOTE: It will also be used to set the `title` attribute on the button if no `title` is provided.
* NOTE: It will also be used to set the content of the tooltip on the button if no `title` is provided.
*/
readonly label = input<string>();
@@ -148,9 +157,7 @@ export class BitIconButtonComponent implements ButtonLikeAbstraction, FocusableE
);
}
get iconClass() {
return [this.icon(), "!tw-m-0"];
}
readonly iconClass = computed(() => [this.icon(), "!tw-m-0"]);
protected readonly disabledAttr = computed(() => {
const disabled = this.disabled() != null && this.disabled() !== false;

View File

@@ -1,4 +1,4 @@
import { Meta, Canvas, Primary, Controls, Title, Description } from "@storybook/addon-docs";
import { Meta, Canvas, Primary, Controls, Title, Description } from "@storybook/addon-docs/blocks";
import * as stories from "./icon-button.stories";
@@ -81,10 +81,5 @@ with less padding around the icon, such as in the navigation component.
Follow guidelines outlined in the [Button docs](?path=/docs/component-library-button--doc)
Always use the `appA11yTitle` directive set to a string that describes the action of the
icon-button. This will auto assign the same string to the `title` and `aria-label` attributes.
`aria-label` allows assistive technology to announce the action the button takes to the users.
`title` attribute provides a user with the browser tool tip if they do not understand what the icon
is indicating.
label input will be used to set the `aria-label` attributes on the button. This is for accessibility
purposes, as it provides a text alternative for the icon button.

View File

@@ -5,7 +5,7 @@ import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.servic
import { formatArgsForCodeSnippet } from "../../../../.storybook/format-args-for-code-snippet";
import { I18nMockService } from "../utils";
import { BitIconButtonComponent } from "./icon-button.component";
import { BitIconButtonComponent, IconButtonTypes } from "./icon-button.component";
export default {
title: "Component Library/Icon Button",
@@ -30,7 +30,7 @@ export default {
},
argTypes: {
buttonType: {
options: ["primary", "secondary", "danger", "unstyled", "contrast", "main", "muted", "light"],
options: IconButtonTypes,
},
},
parameters: {

View File

@@ -1,2 +1,2 @@
export * from "./icon-button.module";
export { BitIconButtonComponent } from "./icon-button.component";
export * from "./icon-button.component";

View File

@@ -1,4 +1,4 @@
import { Meta, Story, Controls } from "@storybook/addon-docs";
import { Meta, Story, Controls } from "@storybook/addon-docs/blocks";
import * as stories from "./icon.stories";

View File

@@ -17,7 +17,7 @@ export default {
const {
// Filtering out the few non-icons in the libs/assets/svg import
// eslint-disable-next-line @typescript-eslint/no-unused-vars
DynamicContentNotAllowedError: _DynamicContentNotAllowedError,
// eslint-disable-next-line @typescript-eslint/no-unused-vars
isIcon,

View File

@@ -25,6 +25,7 @@ export * from "./icon";
export * from "./icon-tile";
export * from "./input";
export * from "./item";
export * from "./landing-layout";
export * from "./layout";
export * from "./link";
export * from "./menu";
@@ -39,6 +40,9 @@ export * from "./section";
export * from "./select";
export * from "./shared/compact-mode.service";
export * from "./skeleton";
export * from "./spinner";
export * from "./stepper";
export * from "./switch";
export * from "./table";
export * from "./tabs";
export * from "./toast";
@@ -46,5 +50,3 @@ export * from "./toggle-group";
export * from "./tooltip";
export * from "./typography";
export * from "./utils";
export * from "./stepper";
export * from "./switch";

View File

@@ -1,4 +1,4 @@
import { Meta, Canvas, Primary, Controls, Title, Description } from "@storybook/addon-docs";
import { Meta, Canvas, Primary, Controls, Title, Description } from "@storybook/addon-docs/blocks";
import * as stories from "./autofocus.stories";

View File

@@ -1,4 +1,4 @@
import { Meta } from "@storybook/addon-docs";
import { Meta } from "@storybook/addon-docs/blocks";
<Meta title="Component Library/Form/Input Directive" />

View File

@@ -1,4 +1,4 @@
import { Meta, Canvas, Primary, Controls } from "@storybook/addon-docs";
import { Meta, Canvas, Primary, Controls } from "@storybook/addon-docs/blocks";
import * as stories from "./item.stories";

View File

@@ -1,16 +1,23 @@
import { ScrollingModule } from "@angular/cdk/scrolling";
import { CommonModule } from "@angular/common";
import { RouterTestingModule } from "@angular/router/testing";
import { Meta, StoryObj, componentWrapperDecorator, moduleMetadata } from "@storybook/angular";
import {
Meta,
StoryObj,
applicationConfig,
componentWrapperDecorator,
moduleMetadata,
} from "@storybook/angular";
import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service";
import { GlobalStateProvider } from "@bitwarden/state";
import { AvatarModule } from "../avatar";
import { BadgeModule } from "../badge";
import { IconButtonModule } from "../icon-button";
import { LayoutComponent } from "../layout";
import { TypographyModule } from "../typography";
import { I18nMockService } from "../utils/i18n-mock.service";
import { I18nMockService, StorybookGlobalStateProvider } from "../utils";
import { ItemActionComponent } from "./item-action.component";
import { ItemContentComponent } from "./item-content.component";
@@ -50,6 +57,14 @@ export default {
},
],
}),
applicationConfig({
providers: [
{
provide: GlobalStateProvider,
useClass: StorybookGlobalStateProvider,
},
],
}),
componentWrapperDecorator((story) => `<div class="tw-bg-background-alt tw-p-2">${story}</div>`),
],
parameters: {
@@ -97,7 +112,7 @@ export const ContentSlots: Story = {
<button bit-item-content type="button">
<bit-avatar
slot="start"
[text]="'Foo'"
text="Foo"
></bit-avatar>
foo&#64;bitwarden.com
<ng-container slot="secondary">

View File

@@ -0,0 +1,7 @@
export * from "./landing-layout.component";
export * from "./landing-layout.module";
export * from "./landing-card.component";
export * from "./landing-content.component";
export * from "./landing-footer.component";
export * from "./landing-header.component";
export * from "./landing-hero.component";

View File

@@ -0,0 +1,5 @@
<bit-base-card
class="tw-z-[2] tw-relative !tw-rounded-2xl tw-mb-6 sm:tw-mb-10 tw-mx-auto tw-w-full tw-bg-transparent tw-border-none tw-shadow-none sm:tw-bg-background sm:tw-border sm:tw-border-solid sm:tw-border-secondary-100 sm:tw-shadow sm:tw-p-8"
>
<ng-content></ng-content>
</bit-base-card>

View File

@@ -0,0 +1,33 @@
import { ChangeDetectionStrategy, Component } from "@angular/core";
import { BaseCardComponent } from "../card";
/**
* Card component for landing pages that wraps content in a styled container.
*
* @remarks
* This component provides:
* - Card-based layout with consistent styling
* - Content projection for forms, text, or other content
* - Proper elevation and border styling
*
* Use this component inside `bit-landing-content` to wrap forms, content sections,
* or any content that should appear in a contained, elevated card.
*
* @example
* ```html
* <bit-landing-card>
* <form>
* <!-- Your form fields here -->
* </form>
* </bit-landing-card>
* ```
*/
@Component({
selector: "bit-landing-card",
changeDetection: ChangeDetectionStrategy.OnPush,
standalone: true,
imports: [BaseCardComponent],
templateUrl: "./landing-card.component.html",
})
export class LandingCardComponent {}

View File

@@ -0,0 +1,8 @@
<div
class="tw-flex tw-flex-col tw-flex-1 tw-items-center tw-bg-background-alt tw-p-5 tw-pt-12 tw-text-main"
>
<div [class]="maxWidthClasses()">
<ng-content select="bit-landing-hero"></ng-content>
<ng-content></ng-content>
</div>
</div>

View File

@@ -0,0 +1,63 @@
import { ChangeDetectionStrategy, Component, computed, input } from "@angular/core";
export const LandingContentMaxWidth = ["md", "lg", "xl", "2xl", "3xl", "4xl"] as const;
export type LandingContentMaxWidthType = (typeof LandingContentMaxWidth)[number];
/**
* Main content container for landing pages with configurable max-width constraints.
*
* @remarks
* This component provides:
* - Centered content area with alternative background color
* - Configurable maximum width to control content readability
* - Content projection slots for hero section and main content
* - Responsive padding and layout
*
* Use this component inside `bit-landing-layout` to wrap your main page content.
* Optionally include a `bit-landing-hero` as the first child for consistent hero section styling.
*
* @example
* ```html
* <bit-landing-content [maxWidth]="'xl'">
* <bit-landing-hero
* [icon]="lockIcon"
* [title]="'Welcome'"
* [subtitle]="'Get started with your account'"
* ></bit-landing-hero>
* <bit-landing-card>
* <!-- Your form or content here -->
* </bit-landing-card>
* </bit-landing-content>
* ```
*/
@Component({
selector: "bit-landing-content",
changeDetection: ChangeDetectionStrategy.OnPush,
templateUrl: "./landing-content.component.html",
host: {
class: "tw-grow tw-flex tw-flex-col",
},
})
export class LandingContentComponent {
/**
* Max width of the landing layout container.
*
* @default "md"
*/
readonly maxWidth = input<LandingContentMaxWidthType>("md");
private readonly maxWidthClassMap: Record<LandingContentMaxWidthType, string> = {
md: "tw-max-w-md",
lg: "tw-max-w-lg",
xl: "tw-max-w-xl",
"2xl": "tw-max-w-2xl",
"3xl": "tw-max-w-3xl",
"4xl": "tw-max-w-4xl",
};
readonly maxWidthClasses = computed(() => {
const maxWidthClass = this.maxWidthClassMap[this.maxWidth()];
return `tw-flex tw-flex-col tw-w-full ${maxWidthClass}`;
});
}

View File

@@ -0,0 +1,3 @@
<footer class="tw-bg-background-alt tw-text-center tw-p-5 tw-pt-4 sm:tw-pt-6">
<ng-content></ng-content>
</footer>

View File

@@ -0,0 +1,29 @@
import { ChangeDetectionStrategy, Component } from "@angular/core";
/**
* Footer component for landing pages.
*
* @remarks
* This component provides:
* - Content projection for custom footer content (e.g., links, copyright, legal)
* - Consistent footer positioning at the bottom of the page
* - Proper z-index to appear above background illustrations
*
* Use this component inside `bit-landing-layout` as the last child to position it at the bottom.
*
* @example
* ```html
* <bit-landing-footer>
* <div class="tw-text-center tw-text-sm">
* <a routerLink="/privacy">Privacy</a>
* <span>© 2024 Bitwarden</span>
* </div>
* </bit-landing-footer>
* ```
*/
@Component({
selector: "bit-landing-footer",
changeDetection: ChangeDetectionStrategy.OnPush,
templateUrl: "./landing-footer.component.html",
})
export class LandingFooterComponent {}

View File

@@ -0,0 +1,13 @@
<header class="tw-flex tw-w-full tw-bg-background-alt tw-px-5">
@if (!hideLogo()) {
<a
[routerLink]="['/']"
class="tw-w-32 tw-py-5 sm:tw-w-[200px] tw-self-center sm:tw-self-start tw-block [&>*]:tw-align-top"
>
<bit-icon [icon]="logo" [ariaLabel]="'appLogoLabel' | i18n"></bit-icon>
</a>
}
<div class="[&:has(*)]:tw-ms-auto [&:has(*)]:tw-py-5">
<ng-content></ng-content>
</div>
</header>

View File

@@ -0,0 +1,42 @@
import { ChangeDetectionStrategy, Component, input } from "@angular/core";
import { RouterModule } from "@angular/router";
import { BitwardenLogo } from "@bitwarden/assets/svg";
import { IconModule } from "../icon";
import { SharedModule } from "../shared";
/**
* Header component for landing pages with optional Bitwarden logo and header actions slot.
*
* @remarks
* This component provides:
* - Optional Bitwarden logo with link to home page (left-aligned)
* - Default content projection slot for header actions (right-aligned, auto-margin left)
* - Consistent header styling across landing pages
* - Responsive layout that adapts logo size
*
* Use this component inside `bit-landing-layout` as the first child to position it at the top.
* Content projected into this component will automatically align to the right side of the header.
*
* @example
* ```html
* <bit-landing-header [hideLogo]="false">
* <!-- Content here appears in the right-aligned actions slot -->
* <nav>
* <a routerLink="/login">Log in</a>
* <button type="button">Sign up</button>
* </nav>
* </bit-landing-header>
* ```
*/
@Component({
selector: "bit-landing-header",
changeDetection: ChangeDetectionStrategy.OnPush,
templateUrl: "./landing-header.component.html",
imports: [RouterModule, IconModule, SharedModule],
})
export class LandingHeaderComponent {
readonly hideLogo = input<boolean>(false);
protected readonly logo = BitwardenLogo;
}

View File

@@ -0,0 +1,28 @@
@if (icon() || title() || subtitle()) {
<div class="tw-text-center tw-mb-4 sm:tw-mb-6 tw-mx-auto">
@if (icon()) {
<!-- In some scenarios this icon's size is not limited by container width correctly -->
<!-- Targeting the SVG here to try and ensure it never grows too large in even the media queries are not working as expected -->
<div
class="tw-size-20 sm:tw-size-24 [&_svg]:tw-w-full [&_svg]:tw-max-w-24 tw-mx-auto tw-content-center"
>
<bit-icon [icon]="icon()"></bit-icon>
</div>
}
@if (title()) {
<!-- Small screens -->
<h1 bitTypography="h2" class="tw-mt-2 sm:tw-hidden">
{{ title() }}
</h1>
<!-- Medium to Larger screens -->
<h1 bitTypography="h1" class="tw-mt-2 tw-hidden sm:tw-block">
{{ title() }}
</h1>
}
@if (subtitle()) {
<div class="tw-text-sm sm:tw-text-base">{{ subtitle() }}</div>
}
</div>
}

View File

@@ -0,0 +1,40 @@
import { ChangeDetectionStrategy, Component, input } from "@angular/core";
import { Icon } from "@bitwarden/assets/svg";
import { IconModule } from "../icon";
import { TypographyModule } from "../typography";
/**
* Hero section component for landing pages featuring an optional icon, title, and subtitle.
*
* @remarks
* This component provides:
* - Optional icon display (e.g., feature icons, status icons)
* - Large title text with consistent typography
* - Subtitle text for additional context
* - Centered layout with proper spacing
*
* Use this component as the first child inside `bit-landing-content` to create a prominent
* hero section that introduces the page's purpose.
*
* @example
* ```html
* <bit-landing-hero
* [icon]="lockIcon"
* [title]="'Secure Your Passwords'"
* [subtitle]="'Create your account to get started'"
* ></bit-landing-hero>
* ```
*/
@Component({
selector: "bit-landing-hero",
changeDetection: ChangeDetectionStrategy.OnPush,
templateUrl: "./landing-hero.component.html",
imports: [IconModule, TypographyModule],
})
export class LandingHeroComponent {
readonly icon = input<Icon | null>(null);
readonly title = input<string | undefined>();
readonly subtitle = input<string | undefined>();
}

View File

@@ -0,0 +1,25 @@
<div
class="tw-relative tw-flex tw-size-full tw-mx-auto tw-flex-col"
[class]="{
'tw-min-h-screen': clientType === 'web',
'tw-min-h-full': clientType === 'browser' || clientType === 'desktop',
}"
>
<ng-content select="bit-landing-header"></ng-content>
<main class="tw-relative tw-flex tw-flex-1 tw-size-full tw-mx-auto tw-flex-col">
<ng-content></ng-content>
</main>
@if (!hideBackgroundIllustration()) {
<div
class="tw-hidden md:tw-block [&_svg]:tw-absolute tw-z-[1] tw-opacity-[.11] [&_svg]:tw-bottom-0 [&_svg]:tw-start-0 [&_svg]:tw-w-[35%] [&_svg]:tw-max-w-[450px]"
>
<bit-icon [icon]="leftIllustration"></bit-icon>
</div>
<div
class="tw-hidden md:tw-block [&_svg]:tw-absolute tw-z-[1] tw-opacity-[.11] [&_svg]:tw-bottom-0 [&_svg]:tw-end-0 [&_svg]:tw-w-[35%] [&_svg]:tw-max-w-[450px]"
>
<bit-icon [icon]="rightIllustration"></bit-icon>
</div>
}
<ng-content select="bit-landing-footer"></ng-content>
</div>

View File

@@ -0,0 +1,40 @@
import { Component, ChangeDetectionStrategy, inject, input } from "@angular/core";
import { BackgroundLeftIllustration, BackgroundRightIllustration } from "@bitwarden/assets/svg";
import { PlatformUtilsService } from "@bitwarden/common/platform/abstractions/platform-utils.service";
import { IconModule } from "../icon";
/**
* Root layout component for landing pages providing a full-screen container with optional decorative background illustrations.
*
* @remarks
* This component serves as the outermost wrapper for landing pages and provides:
* - Full-screen layout that adapts to different client types (web, browser, desktop)
* - Optional decorative background illustrations in the bottom corners
* - Content projection slots for header, main content, and footer
*
* @example
* ```html
* <bit-landing-layout [hideBackgroundIllustration]="false">
* <bit-landing-header>...</bit-landing-header>
* <bit-landing-content>...</bit-landing-content>
* <bit-landing-footer>...</bit-landing-footer>
* </bit-landing-layout>
* ```
*/
@Component({
selector: "bit-landing-layout",
changeDetection: ChangeDetectionStrategy.OnPush,
templateUrl: "./landing-layout.component.html",
imports: [IconModule],
})
export class LandingLayoutComponent {
readonly hideBackgroundIllustration = input<boolean>(false);
protected readonly leftIllustration = BackgroundLeftIllustration;
protected readonly rightIllustration = BackgroundRightIllustration;
private readonly platformUtilsService: PlatformUtilsService = inject(PlatformUtilsService);
protected readonly clientType = this.platformUtilsService.getClientType();
}

View File

@@ -0,0 +1,28 @@
import { NgModule } from "@angular/core";
import { LandingCardComponent } from "./landing-card.component";
import { LandingContentComponent } from "./landing-content.component";
import { LandingFooterComponent } from "./landing-footer.component";
import { LandingHeaderComponent } from "./landing-header.component";
import { LandingHeroComponent } from "./landing-hero.component";
import { LandingLayoutComponent } from "./landing-layout.component";
@NgModule({
imports: [
LandingLayoutComponent,
LandingHeaderComponent,
LandingHeroComponent,
LandingFooterComponent,
LandingContentComponent,
LandingCardComponent,
],
exports: [
LandingLayoutComponent,
LandingHeaderComponent,
LandingHeroComponent,
LandingFooterComponent,
LandingContentComponent,
LandingCardComponent,
],
})
export class LandingLayoutModule {}

View File

@@ -0,0 +1,162 @@
import { Meta, StoryObj, moduleMetadata } from "@storybook/angular";
import { ClientType } from "@bitwarden/common/enums";
import { PlatformUtilsService } from "@bitwarden/common/platform/abstractions/platform-utils.service";
import { ButtonModule } from "../button";
import { LandingLayoutComponent } from "./landing-layout.component";
class MockPlatformUtilsService implements Partial<PlatformUtilsService> {
getClientType = () => ClientType.Web;
}
type StoryArgs = LandingLayoutComponent & {
contentLength: "normal" | "long" | "thin";
includeHeader: boolean;
includeFooter: boolean;
};
export default {
title: "Component Library/Landing Layout",
component: LandingLayoutComponent,
decorators: [
moduleMetadata({
imports: [ButtonModule],
providers: [
{
provide: PlatformUtilsService,
useClass: MockPlatformUtilsService,
},
],
}),
],
render: (args) => {
return {
props: args,
template: /*html*/ `
<bit-landing-layout
[hideBackgroundIllustration]="hideBackgroundIllustration"
>
@if (includeHeader) {
<bit-landing-header>
<div class="tw-p-4">
<div class="tw-flex tw-items-center tw-gap-4">
<div class="tw-text-xl tw-font-semibold">Header Content</div>
</div>
</div>
</bit-landing-header>
}
<div>
@switch (contentLength) {
@case ('thin') {
<div class="tw-text-center tw-p-8">
<div class="tw-font-medium">Thin Content</div>
</div>
}
@case ('long') {
<div class="tw-p-8">
<div class="tw-font-medium tw-mb-4">Long Content</div>
<div class="tw-mb-4">Sed ut perspiciatis unde omnis iste natus error sit voluptatem accusantium doloremque laudantium, totam rem aperiam, eaque ipsa quae ab illo inventore veritatis et quasi architecto beatae vitae dicta sunt explicabo. Nemo enim ipsam voluptatem quia voluptas sit aspernatur aut odit aut fugit, sed quia consequuntur magni dolores eos qui ratione voluptatem sequi nesciunt. Neque porro quisquam est, qui dolorem ipsum quia dolor sit amet, consectetur, adipisci velit, sed quia non numquam eius modi tempora incidunt ut labore et dolore magnam aliquam quaerat voluptatem. Ut enim ad minima veniam, quis nostrum exercitationem ullam corporis suscipit laboriosam, nisi ut aliquid ex ea commodi consequatur? Quis autem vel eum iure reprehenderit qui in ea voluptate velit esse quam nihil molestiae consequatur, vel illum qui dolorem eum fugiat quo voluptas nulla pariatur?</div>
<div class="tw-mb-4">Sed ut perspiciatis unde omnis iste natus error sit voluptatem accusantium doloremque laudantium, totam rem aperiam, eaque ipsa quae ab illo inventore veritatis et quasi architecto beatae vitae dicta sunt explicabo. Nemo enim ipsam voluptatem quia voluptas sit aspernatur aut odit aut fugit, sed quia consequuntur magni dolores eos qui ratione voluptatem sequi nesciunt. Neque porro quisquam est, qui dolorem ipsum quia dolor sit amet, consectetur, adipisci velit, sed quia non numquam eius modi tempora incidunt ut labore et dolore magnam aliquam quaerat voluptatem. Ut enim ad minima veniam, quis nostrum exercitationem ullam corporis suscipit laboriosam, nisi ut aliquid ex ea commodi consequatur? Quis autem vel eum iure reprehenderit qui in ea voluptate velit esse quam nihil molestiae consequatur, vel illum qui dolorem eum fugiat quo voluptas nulla pariatur?</div>
</div>
}
@default {
<div class="tw-p-8">
<div class="tw-font-medium tw-mb-4">Normal Content</div>
<div>Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua. Ut enim ad minim veniam, quis nostrud exercitation ullamco laboris nisi ut aliquip ex ea commodo consequat.</div>
</div>
}
}
</div>
@if (includeFooter) {
<bit-landing-footer>
<div class="tw-text-center tw-text-sm tw-text-muted">
<div>Footer Content</div>
</div>
</bit-landing-footer>
}
</bit-landing-layout>
`,
};
},
argTypes: {
hideBackgroundIllustration: { control: "boolean" },
contentLength: {
control: "radio",
options: ["normal", "long", "thin"],
},
includeHeader: { control: "boolean" },
includeFooter: { control: "boolean" },
},
args: {
hideBackgroundIllustration: false,
contentLength: "normal",
includeHeader: false,
includeFooter: false,
},
} satisfies Meta<StoryArgs>;
type Story = StoryObj<StoryArgs>;
export const Default: Story = {
args: {
contentLength: "normal",
},
};
export const WithHeader: Story = {
args: {
includeHeader: true,
},
};
export const WithFooter: Story = {
args: {
includeFooter: true,
},
};
export const WithHeaderAndFooter: Story = {
args: {
includeHeader: true,
includeFooter: true,
},
};
export const LongContent: Story = {
args: {
contentLength: "long",
includeHeader: true,
includeFooter: true,
},
};
export const ThinContent: Story = {
args: {
contentLength: "thin",
includeHeader: true,
includeFooter: true,
},
};
export const NoBackgroundIllustration: Story = {
args: {
hideBackgroundIllustration: true,
includeHeader: true,
includeFooter: true,
},
};
export const MinimalState: Story = {
args: {
contentLength: "thin",
hideBackgroundIllustration: true,
includeHeader: false,
includeFooter: false,
},
};

View File

@@ -1,6 +1,6 @@
@let mainContentId = "main-content";
<div class="tw-flex tw-w-full">
<div class="tw-flex tw-w-full" cdkTrapFocus>
<div class="tw-flex tw-size-full" [class.tw-bg-background-alt3]="rounded()">
<div class="tw-flex tw-size-full" cdkTrapFocus>
<div
class="tw-fixed tw-z-50 tw-w-full tw-flex tw-justify-center tw-opacity-0 focus-within:tw-opacity-100 tw-pointer-events-none focus-within:tw-pointer-events-auto"
>
@@ -23,29 +23,23 @@
[id]="mainContentId"
tabindex="-1"
bitScrollLayoutHost
class="tw-overflow-auto tw-max-h-screen tw-min-w-0 tw-flex-1 tw-bg-background tw-p-8 tw-pt-6 tw-@container"
class="tw-overflow-auto tw-max-h-full tw-min-w-0 tw-flex-1 tw-bg-background tw-p-8 tw-pt-6 tw-@container"
[class.tw-rounded-tl-2xl]="rounded()"
>
<!-- ^ If updating this padding, also update the padding correction in bit-banner! ^ -->
<ng-content></ng-content>
</main>
<!-- overlay backdrop for side-nav -->
@if (
{
open: sideNavService.open$ | async,
};
as data
) {
<div
class="tw-pointer-events-none tw-fixed tw-inset-0 tw-z-10 tw-bg-black tw-bg-opacity-0 motion-safe:tw-transition-colors md:tw-hidden"
[ngClass]="[data.open ? 'tw-bg-opacity-30 md:tw-bg-opacity-0' : 'tw-bg-opacity-0']"
>
@if (data.open) {
<div (click)="sideNavService.toggle()" class="tw-pointer-events-auto tw-size-full"></div>
}
</div>
}
<div
class="tw-pointer-events-none tw-fixed tw-inset-0 tw-z-10 tw-bg-black tw-bg-opacity-0 motion-safe:tw-transition-colors md:tw-hidden"
[class]="sideNavService.open() ? 'tw-bg-opacity-30 md:tw-bg-opacity-0' : 'tw-bg-opacity-0'"
>
@if (sideNavService.open()) {
<div (click)="sideNavService.toggle()" class="tw-pointer-events-auto tw-size-full"></div>
}
</div>
</div>
<div class="tw-absolute tw-z-50 tw-left-0 md:tw-sticky tw-top-0 tw-h-screen md:tw-w-auto">
<div class="tw-absolute tw-z-50 tw-left-0 md:tw-sticky tw-top-0 tw-h-full md:tw-w-auto">
<ng-template [cdkPortalOutlet]="drawerPortal()"></ng-template>
</div>
</div>

View File

@@ -1,7 +1,7 @@
import { A11yModule, CdkTrapFocus } from "@angular/cdk/a11y";
import { PortalModule } from "@angular/cdk/portal";
import { CommonModule } from "@angular/common";
import { Component, ElementRef, inject, viewChild } from "@angular/core";
import { booleanAttribute, Component, ElementRef, inject, input, viewChild } from "@angular/core";
import { RouterModule } from "@angular/router";
import { DrawerHostDirective } from "../drawer/drawer-host.directive";
@@ -29,6 +29,7 @@ import { ScrollLayoutHostDirective } from "./scroll-layout.directive";
],
host: {
"(document:keydown.tab)": "handleKeydown($event)",
class: "tw-block tw-h-screen",
},
hostDirectives: [DrawerHostDirective],
})
@@ -37,6 +38,12 @@ export class LayoutComponent {
protected drawerPortal = inject(DrawerService).portal;
private readonly mainContent = viewChild.required<ElementRef<HTMLElement>>("main");
/**
* Rounded top left corner for the main content area
*/
readonly rounded = input(false, { transform: booleanAttribute });
protected focusMainContent() {
this.mainContent().nativeElement.focus();
}

View File

@@ -1,17 +1,21 @@
import { RouterTestingModule } from "@angular/router/testing";
import { Meta, StoryObj, moduleMetadata } from "@storybook/angular";
import { userEvent } from "@storybook/test";
import { Meta, StoryObj, applicationConfig, moduleMetadata } from "@storybook/angular";
import { userEvent } from "storybook/test";
import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service";
import { GlobalStateProvider } from "@bitwarden/state";
import { CalloutModule } from "../callout";
import { NavigationModule } from "../navigation";
import { positionFixedWrapperDecorator } from "../stories/storybook-decorators";
import { I18nMockService } from "../utils/i18n-mock.service";
import { StorybookGlobalStateProvider } from "../utils/state-mock";
import { LayoutComponent } from "./layout.component";
import { mockLayoutI18n } from "./mocks";
import { formatArgsForCodeSnippet } from ".storybook/format-args-for-code-snippet";
export default {
title: "Component Library/Layout",
component: LayoutComponent,
@@ -28,6 +32,14 @@ export default {
},
],
}),
applicationConfig({
providers: [
{
provide: GlobalStateProvider,
useClass: StorybookGlobalStateProvider,
},
],
}),
],
parameters: {
chromatic: { viewports: [640, 1280] },
@@ -53,7 +65,7 @@ export const WithContent: Story = {
render: (args) => ({
props: args,
template: /* HTML */ `
<bit-layout>
<bit-layout ${formatArgsForCodeSnippet<LayoutComponent>(args)}>
<bit-side-nav>
<bit-nav-group text="Hello World (Anchor)" [route]="['a']" icon="bwi-filter">
<bit-nav-item text="Child A" route="a" icon="bwi-filter"></bit-nav-item>
@@ -101,3 +113,10 @@ export const Secondary: Story = {
`,
}),
};
export const Rounded: Story = {
...WithContent,
args: {
rounded: true,
},
};

View File

@@ -5,4 +5,5 @@ export const mockLayoutI18n = {
submenu: "submenu",
toggleCollapse: "toggle collapse",
loading: "Loading",
resizeSideNavigation: "Resize side navigation",
};

View File

@@ -3,21 +3,34 @@ import { input, HostBinding, Directive, inject, ElementRef, booleanAttribute } f
import { AriaDisableDirective } from "../a11y";
import { ariaDisableElement } from "../utils";
export type LinkType = "primary" | "secondary" | "contrast" | "light";
export const LinkTypes = [
"primary",
"secondary",
"contrast",
"light",
"default",
"subtle",
"success",
"warning",
"danger",
] as const;
export type LinkType = (typeof LinkTypes)[number];
const linkStyles: Record<LinkType, string[]> = {
primary: [
"!tw-text-primary-600",
"hover:!tw-text-primary-700",
"focus-visible:before:tw-ring-primary-600",
],
secondary: ["!tw-text-main", "hover:!tw-text-main", "focus-visible:before:tw-ring-primary-600"],
primary: ["tw-text-fg-brand", "hover:tw-text-fg-brand-strong"],
default: ["tw-text-fg-brand", "hover:tw-text-fg-brand-strong"],
secondary: ["tw-text-fg-heading", "hover:tw-text-fg-heading"],
light: ["tw-text-fg-white", "hover:tw-text-fg-white", "focus-visible:before:tw-ring-fg-contrast"],
subtle: ["!tw-text-fg-heading", "hover:tw-text-fg-heading"],
success: ["tw-text-fg-success", "hover:tw-text-fg-success-strong"],
warning: ["tw-text-fg-warning", "hover:tw-text-fg-warning-strong"],
danger: ["tw-text-fg-danger", "hover:tw-text-fg-danger-strong"],
contrast: [
"!tw-text-contrast",
"hover:!tw-text-contrast",
"focus-visible:before:tw-ring-text-contrast",
"tw-text-fg-contrast",
"hover:tw-text-fg-contrast",
"focus-visible:before:tw-ring-fg-contrast",
],
light: ["!tw-text-alt2", "hover:!tw-text-alt2", "focus-visible:before:tw-ring-text-alt2"],
};
const commonStyles = [
@@ -32,16 +45,18 @@ const commonStyles = [
"tw-rounded",
"tw-transition",
"tw-no-underline",
"tw-cursor-pointer",
"hover:tw-underline",
"hover:tw-decoration-1",
"disabled:tw-no-underline",
"disabled:tw-cursor-not-allowed",
"disabled:!tw-text-secondary-300",
"disabled:hover:!tw-text-secondary-300",
"disabled:!tw-text-fg-disabled",
"disabled:hover:!tw-text-fg-disabled",
"disabled:hover:tw-no-underline",
"focus-visible:tw-outline-none",
"focus-visible:tw-underline",
"focus-visible:tw-decoration-1",
"focus-visible:before:tw-ring-border-focus",
// Workaround for html button tag not being able to be set to `display: inline`
// and at the same time not being able to use `tw-ring-offset` because of box-shadow issue.
@@ -63,14 +78,14 @@ const commonStyles = [
"focus-visible:tw-z-10",
"aria-disabled:tw-no-underline",
"aria-disabled:tw-pointer-events-none",
"aria-disabled:!tw-text-secondary-300",
"aria-disabled:hover:!tw-text-secondary-300",
"aria-disabled:!tw-text-fg-disabled",
"aria-disabled:hover:!tw-text-fg-disabled",
"aria-disabled:hover:tw-no-underline",
];
@Directive()
abstract class LinkDirective {
readonly linkType = input<LinkType>("primary");
readonly linkType = input<LinkType>("default");
}
/**

View File

@@ -1,4 +1,4 @@
import { Meta, Story, Primary, Controls, Title, Description } from "@storybook/addon-docs";
import { Meta, Story, Primary, Controls, Title, Description } from "@storybook/addon-docs/blocks";
import * as stories from "./link.stories";
@@ -18,10 +18,15 @@ import { LinkModule } from "@bitwarden/components";
You can use one of the following variants by providing it as the `linkType` input:
- `primary` - most common, uses brand color
- `secondary` - matches the main text color
- @deprecated `primary` => use `default` instead
- @deprecated `secondary` => use `subtle` instead
- `default` - most common, uses brand color
- `subtle` - matches the main text color
- `contrast` - for high contrast against a dark background (or a light background in dark mode)
- `light` - always a light color, even in dark mode
- `warning` - used in association with warning callouts/banners
- `success` - used in association with success callouts/banners
- `danger` - used in association with danger callouts/banners
## Sizes

View File

@@ -2,7 +2,7 @@ import { Meta, StoryObj, moduleMetadata } from "@storybook/angular";
import { formatArgsForCodeSnippet } from "../../../../.storybook/format-args-for-code-snippet";
import { AnchorLinkDirective, ButtonLinkDirective } from "./link.directive";
import { AnchorLinkDirective, ButtonLinkDirective, LinkTypes } from "./link.directive";
import { LinkModule } from "./link.module";
export default {
@@ -14,7 +14,7 @@ export default {
],
argTypes: {
linkType: {
options: ["primary", "secondary", "contrast"],
options: LinkTypes.map((type) => type),
control: { type: "radio" },
},
},
@@ -30,48 +30,153 @@ type Story = StoryObj<ButtonLinkDirective>;
export const Default: Story = {
render: (args) => ({
props: {
linkType: args.linkType,
backgroundClass:
args.linkType === "contrast"
? "tw-bg-bg-contrast"
: args.linkType === "light"
? "tw-bg-bg-brand"
: "tw-bg-transparent",
},
template: /*html*/ `
<a bitLink ${formatArgsForCodeSnippet<ButtonLinkDirective>(args)}>Your text here</a>
<div class="tw-p-2" [class]="backgroundClass">
<a bitLink href="" ${formatArgsForCodeSnippet<ButtonLinkDirective>(args)}>Your text here</a>
</div>
`,
}),
args: {
linkType: "primary",
},
parameters: {
chromatic: { disableSnapshot: true },
},
};
export const AllVariations: Story = {
render: () => ({
template: /*html*/ `
<div class="tw-flex tw-flex-col tw-gap-6">
<div class="tw-flex tw-gap-4 tw-p-2">
<a bitLink linkType="primary" href="#">Primary</a>
</div>
<div class="tw-flex tw-gap-4 tw-p-2">
<a bitLink linkType="secondary" href="#">Secondary</a>
</div>
<div class="tw-flex tw-gap-4 tw-p-2 tw-bg-bg-contrast">
<a bitLink linkType="contrast" href="#">Contrast</a>
</div>
<div class="tw-flex tw-gap-4 tw-p-2 tw-bg-bg-brand">
<a bitLink linkType="light" href="#">Light</a>
</div>
<div class="tw-flex tw-gap-4 tw-p-2">
<a bitLink linkType="default" href="#">Default</a>
</div>
<div class="tw-flex tw-gap-4 tw-p-2">
<a bitLink linkType="subtle" href="#">Subtle</a>
</div>
<div class="tw-flex tw-gap-4 tw-p-2">
<a bitLink linkType="success" href="#">Success</a>
</div>
<div class="tw-flex tw-gap-4 tw-p-2">
<a bitLink linkType="warning" href="#">Warning</a>
</div>
<div class="tw-flex tw-gap-4 tw-p-2">
<a bitLink linkType="danger" href="#">Danger</a>
</div>
</div>
`,
}),
parameters: {
controls: {
exclude: ["linkType"],
hideNoControlsWarning: true,
},
},
};
export const InteractionStates: Story = {
render: () => ({
template: /*html*/ `
<div class="tw-flex tw-gap-4 tw-p-2 tw-mb-6">
<div class="tw-flex tw-flex-col tw-gap-6">
<div class="tw-flex tw-gap-4 tw-p-2">
<a bitLink linkType="primary" href="#">Primary</a>
<a bitLink linkType="primary" href="#" class="tw-test-hover">Primary</a>
<a bitLink linkType="primary" href="#" class="tw-test-focus-visible">Primary</a>
<a bitLink linkType="primary" href="#" class="tw-test-hover tw-test-focus-visible">Primary</a>
</div>
<div class="tw-flex tw-gap-4 tw-p-2 tw-mb-6">
<div class="tw-flex tw-gap-4 tw-p-2">
<a bitLink linkType="secondary" href="#">Secondary</a>
<a bitLink linkType="secondary" href="#" class="tw-test-hover">Secondary</a>
<a bitLink linkType="secondary" href="#" class="tw-test-focus-visible">Secondary</a>
<a bitLink linkType="secondary" href="#" class="tw-test-hover tw-test-focus-visible">Secondary</a>
</div>
<div class="tw-flex tw-gap-4 tw-p-2 tw-mb-6 tw-bg-primary-600">
<div class="tw-flex tw-gap-4 tw-p-2 tw-bg-bg-contrast">
<a bitLink linkType="contrast" href="#">Contrast</a>
<a bitLink linkType="contrast" href="#" class="tw-test-hover">Contrast</a>
<a bitLink linkType="contrast" href="#" class="tw-test-focus-visible">Contrast</a>
<a bitLink linkType="contrast" href="#" class="tw-test-hover tw-test-focus-visible">Contrast</a>
</div>
<div class="tw-flex tw-gap-4 tw-p-2 tw-mb-6 tw-bg-primary-600">
<div class="tw-flex tw-gap-4 tw-p-2 tw-bg-bg-brand">
<a bitLink linkType="light" href="#">Light</a>
<a bitLink linkType="light" href="#" class="tw-test-hover">Light</a>
<a bitLink linkType="light" href="#" class="tw-test-focus-visible">Light</a>
<a bitLink linkType="light" href="#" class="tw-test-hover tw-test-focus-visible">Light</a>
</div>
<div class="tw-flex tw-gap-4 tw-p-2">
<a bitLink linkType="default" href="#">Default</a>
<a bitLink linkType="default" href="#" class="tw-test-hover">Default</a>
<a bitLink linkType="default" href="#" class="tw-test-focus-visible">Default</a>
<a bitLink linkType="default" href="#" class="tw-test-hover tw-test-focus-visible">Default</a>
</div>
<div class="tw-flex tw-gap-4 tw-p-2">
<a bitLink linkType="subtle" href="#">Subtle</a>
<a bitLink linkType="subtle" href="#" class="tw-test-hover">Subtle</a>
<a bitLink linkType="subtle" href="#" class="tw-test-focus-visible">Subtle</a>
<a bitLink linkType="subtle" href="#" class="tw-test-hover tw-test-focus-visible">Subtle</a>
</div>
<div class="tw-flex tw-gap-4 tw-p-2">
<a bitLink linkType="success" href="#">Success</a>
<a bitLink linkType="success" href="#" class="tw-test-hover">Success</a>
<a bitLink linkType="success" href="#" class="tw-test-focus-visible">Success</a>
<a bitLink linkType="success" href="#" class="tw-test-hover tw-test-focus-visible">Success</a>
</div>
<div class="tw-flex tw-gap-4 tw-p-2">
<a bitLink linkType="warning" href="#">Warning</a>
<a bitLink linkType="warning" href="#" class="tw-test-hover">Warning</a>
<a bitLink linkType="warning" href="#" class="tw-test-focus-visible">Warning</a>
<a bitLink linkType="warning" href="#" class="tw-test-hover tw-test-focus-visible">Warning</a>
</div>
<div class="tw-flex tw-gap-4 tw-p-2">
<a bitLink linkType="danger" href="#">Danger</a>
<a bitLink linkType="danger" href="#" class="tw-test-hover">Danger</a>
<a bitLink linkType="danger" href="#" class="tw-test-focus-visible">Danger</a>
<a bitLink linkType="danger" href="#" class="tw-test-hover tw-test-focus-visible">Danger</a>
</div>
</div>
`,
}),
parameters: {
controls: {
exclude: ["linkType"],
hideNoControlsWarning: true,
},
},
};
export const Buttons: Story = {
render: (args) => ({
props: args,
props: {
linkType: args.linkType,
backgroundClass:
args.linkType === "contrast"
? "tw-bg-bg-contrast"
: args.linkType === "light"
? "tw-bg-bg-brand"
: "tw-bg-transparent",
},
template: /*html*/ `
<div class="tw-p-2" [ngClass]="{ 'tw-bg-transparent': linkType != 'contrast', 'tw-bg-primary-600': linkType === 'contrast' }">
<div class="tw-p-2" [class]="backgroundClass">
<div class="tw-block tw-p-2">
<button type="button" bitLink [linkType]="linkType">Button</button>
</div>
@@ -100,9 +205,17 @@ export const Buttons: Story = {
export const Anchors: StoryObj<AnchorLinkDirective> = {
render: (args) => ({
props: args,
props: {
linkType: args.linkType,
backgroundClass:
args.linkType === "contrast"
? "tw-bg-bg-contrast"
: args.linkType === "light"
? "tw-bg-bg-brand"
: "tw-bg-transparent",
},
template: /*html*/ `
<div class="tw-p-2" [ngClass]="{ 'tw-bg-transparent': linkType != 'contrast', 'tw-bg-primary-600': linkType === 'contrast' }">
<div class="tw-p-2" [class]="backgroundClass">
<div class="tw-block tw-p-2">
<a bitLink [linkType]="linkType" href="#">Anchor</a>
</div>
@@ -138,18 +251,15 @@ export const Inline: Story = {
</span>
`,
}),
args: {
linkType: "primary",
},
};
export const Disabled: Story = {
export const Inactive: Story = {
render: (args) => ({
props: args,
template: /*html*/ `
<button type="button" bitLink disabled linkType="primary" class="tw-me-2">Primary</button>
<button type="button" bitLink disabled linkType="secondary" class="tw-me-2">Secondary</button>
<div class="tw-bg-primary-600 tw-p-2 tw-inline-block">
<div class="tw-bg-bg-contrast tw-p-2 tw-inline-block">
<button type="button" bitLink disabled linkType="contrast">Contrast</button>
</div>
`,

View File

@@ -192,7 +192,7 @@ export class MenuTriggerForDirective implements OnDestroy {
return;
}
const escKey = this.overlayRef.keydownEvents().pipe(
const keyEvents = this.overlayRef.keydownEvents().pipe(
filter((event: KeyboardEvent) => {
const keys = this.menu().ariaRole() === "menu" ? ["Escape", "Tab"] : ["Escape"];
return keys.includes(event.key);
@@ -202,8 +202,8 @@ export class MenuTriggerForDirective implements OnDestroy {
const detachments = this.overlayRef.detachments();
const closeEvents = isContextMenu
? merge(detachments, escKey, menuClosed)
: merge(detachments, escKey, this.overlayRef.backdropClick(), menuClosed);
? merge(detachments, keyEvents, menuClosed)
: merge(detachments, keyEvents, this.overlayRef.backdropClick(), menuClosed);
this.closedEventsSub = closeEvents
.pipe(takeUntil(this.overlayRef.detachments()))
@@ -215,9 +215,9 @@ export class MenuTriggerForDirective implements OnDestroy {
event.preventDefault();
}
if (event instanceof KeyboardEvent && (event.key === "Tab" || event.key === "Escape")) {
this.elementRef.nativeElement.focus();
}
// Move focus to the menu trigger, since any active menu items are about to be destroyed
this.elementRef.nativeElement.focus();
this.destroyMenu();
});
}

View File

@@ -1,4 +1,4 @@
import { Meta, Canvas, Primary, Controls } from "@storybook/addon-docs";
import { Meta, Canvas, Primary, Controls } from "@storybook/addon-docs/blocks";
import * as stories from "./menu.stories";

View File

@@ -108,11 +108,11 @@ export class MultiSelectComponent implements OnInit, BitFormFieldControl, Contro
/** Needs to be arrow function to retain `this` scope. */
keyDown = (event: KeyboardEvent) => {
const select = this.select();
if (!select.isOpen && event.key === "Enter" && !hasModifierKey(event)) {
if (!select.isOpen() && event.key === "Enter" && !hasModifierKey(event)) {
return false;
}
if (select.isOpen && event.key === "Escape" && !hasModifierKey(event)) {
if (select.isOpen() && event.key === "Escape" && !hasModifierKey(event)) {
this.selectedItems = [];
select.close();
event.stopPropagation();
@@ -198,7 +198,9 @@ export class MultiSelectComponent implements OnInit, BitFormFieldControl, Contro
}
set ariaDescribedBy(value: string | undefined) {
this._ariaDescribedBy = value;
this.select()?.searchInput.nativeElement.setAttribute("aria-describedby", value ?? "");
this.select()
?.searchInput()
.nativeElement.setAttribute("aria-describedby", value ?? "");
}
private _ariaDescribedBy?: string;

View File

@@ -1,8 +1,11 @@
import { Directive, EventEmitter, Output, input, model } from "@angular/core";
import { Directive, output, input, model } from "@angular/core";
import { RouterLink, RouterLinkActive } from "@angular/router";
/**
* `NavGroupComponent` builds upon `NavItemComponent`. This class represents the properties that are passed down to `NavItemComponent`.
* Base class for navigation components in the side navigation.
*
* `NavGroupComponent` builds upon `NavItemComponent`. This class represents the properties
* that are passed down to `NavItemComponent`.
*/
@Directive()
export abstract class NavBaseComponent {
@@ -38,23 +41,26 @@ export abstract class NavBaseComponent {
*
* ---
*
* @remarks
* We can't name this "routerLink" because Angular will mount the `RouterLink` directive.
*
* See: {@link https://github.com/angular/angular/issues/24482}
* @see {@link RouterLink.routerLink}
* @see {@link https://github.com/angular/angular/issues/24482}
*/
readonly route = input<RouterLink["routerLink"]>();
/**
* Passed to internal `routerLink`
*
* See {@link RouterLink.relativeTo}
* @see {@link RouterLink.relativeTo}
*/
readonly relativeTo = input<RouterLink["relativeTo"]>();
/**
* Passed to internal `routerLink`
*
* See {@link RouterLinkActive.routerLinkActiveOptions}
* @default { paths: "subset", queryParams: "ignored", fragment: "ignored", matrixParams: "ignored" }
* @see {@link RouterLinkActive.routerLinkActiveOptions}
*/
readonly routerLinkActiveOptions = input<RouterLinkActive["routerLinkActiveOptions"]>({
paths: "subset",
@@ -71,7 +77,5 @@ export abstract class NavBaseComponent {
/**
* Fires when main content is clicked
*/
// FIXME(https://bitwarden.atlassian.net/browse/CL-903): Migrate to Signals
// eslint-disable-next-line @angular-eslint/prefer-output-emitter-ref
@Output() mainContentClicked: EventEmitter<MouseEvent> = new EventEmitter();
readonly mainContentClicked = output<void>();
}

View File

@@ -1,3 +1,3 @@
@if (sideNavService.open$ | async) {
@if (sideNavService.open()) {
<div class="tw-h-px tw-w-full tw-my-2 tw-bg-secondary-300"></div>
}

View File

@@ -1,15 +1,15 @@
import { CommonModule } from "@angular/common";
import { Component } from "@angular/core";
import { ChangeDetectionStrategy, Component, inject } from "@angular/core";
import { SideNavService } from "./side-nav.service";
// FIXME(https://bitwarden.atlassian.net/browse/CL-764): Migrate to OnPush
// eslint-disable-next-line @angular-eslint/prefer-on-push-component-change-detection
/**
* A visual divider for separating navigation items in the side navigation.
*/
@Component({
selector: "bit-nav-divider",
templateUrl: "./nav-divider.component.html",
imports: [CommonModule],
changeDetection: ChangeDetectionStrategy.OnPush,
})
export class NavDividerComponent {
constructor(protected sideNavService: SideNavService) {}
protected readonly sideNavService = inject(SideNavService);
}

Some files were not shown because too many files have changed in this diff Show More