mirror of
https://github.com/bitwarden/browser
synced 2026-02-24 08:33:29 +00:00
fix conflicts
This commit is contained in:
@@ -6,6 +6,8 @@ import { Directive, ElementRef, Input, OnInit, Renderer2 } from "@angular/core";
|
||||
selector: "[appA11yTitle]",
|
||||
})
|
||||
export class A11yTitleDirective implements OnInit {
|
||||
// TODO: Skipped for signal migration because:
|
||||
// Accessor inputs cannot be migrated as they are too complex.
|
||||
@Input() set appA11yTitle(title: string) {
|
||||
this.title = title;
|
||||
this.setAttributes();
|
||||
|
||||
@@ -4,8 +4,8 @@
|
||||
[icon]="pageIcon"
|
||||
[showReadonlyHostname]="showReadonlyHostname"
|
||||
[maxWidth]="maxWidth"
|
||||
[titleAreaMaxWidth]="titleAreaMaxWidth"
|
||||
[hideCardWrapper]="hideCardWrapper"
|
||||
[hideIcon]="hideIcon"
|
||||
>
|
||||
<router-outlet></router-outlet>
|
||||
<router-outlet slot="secondary" name="secondary"></router-outlet>
|
||||
|
||||
@@ -10,7 +10,7 @@ import { Translation } from "../dialog";
|
||||
import { Icon } from "../icon";
|
||||
|
||||
import { AnonLayoutWrapperDataService } from "./anon-layout-wrapper-data.service";
|
||||
import { AnonLayoutComponent } from "./anon-layout.component";
|
||||
import { AnonLayoutComponent, AnonLayoutMaxWidth } from "./anon-layout.component";
|
||||
|
||||
export interface AnonLayoutWrapperData {
|
||||
/**
|
||||
@@ -29,6 +29,10 @@ export interface AnonLayoutWrapperData {
|
||||
* The optional icon to display on the page.
|
||||
*/
|
||||
pageIcon?: Icon | null;
|
||||
/**
|
||||
* Hides the default Bitwarden shield icon.
|
||||
*/
|
||||
hideIcon?: boolean;
|
||||
/**
|
||||
* Optional flag to either show the optional environment selector (false) or just a readonly hostname (true).
|
||||
*/
|
||||
@@ -36,11 +40,7 @@ export interface AnonLayoutWrapperData {
|
||||
/**
|
||||
* Optional flag to set the max-width of the page. Defaults to 'md' if not provided.
|
||||
*/
|
||||
maxWidth?: "md" | "3xl";
|
||||
/**
|
||||
* Optional flag to set the max-width of the title area. Defaults to null if not provided.
|
||||
*/
|
||||
titleAreaMaxWidth?: "md";
|
||||
maxWidth?: AnonLayoutMaxWidth;
|
||||
/**
|
||||
* Hide the card that wraps the default content. Defaults to false.
|
||||
*/
|
||||
@@ -58,9 +58,9 @@ export class AnonLayoutWrapperComponent implements OnInit, OnDestroy {
|
||||
protected pageSubtitle: string;
|
||||
protected pageIcon: Icon;
|
||||
protected showReadonlyHostname: boolean;
|
||||
protected maxWidth: "md" | "3xl";
|
||||
protected titleAreaMaxWidth: "md";
|
||||
protected maxWidth: AnonLayoutMaxWidth;
|
||||
protected hideCardWrapper: boolean;
|
||||
protected hideIcon: boolean = false;
|
||||
|
||||
constructor(
|
||||
private router: Router,
|
||||
@@ -109,9 +109,12 @@ export class AnonLayoutWrapperComponent implements OnInit, OnDestroy {
|
||||
this.pageIcon = firstChildRouteData["pageIcon"];
|
||||
}
|
||||
|
||||
if (firstChildRouteData["hideIcon"] !== undefined) {
|
||||
this.hideIcon = firstChildRouteData["hideIcon"];
|
||||
}
|
||||
|
||||
this.showReadonlyHostname = Boolean(firstChildRouteData["showReadonlyHostname"]);
|
||||
this.maxWidth = firstChildRouteData["maxWidth"];
|
||||
this.titleAreaMaxWidth = firstChildRouteData["titleAreaMaxWidth"];
|
||||
this.hideCardWrapper = Boolean(firstChildRouteData["hideCardWrapper"]);
|
||||
}
|
||||
|
||||
@@ -174,8 +177,8 @@ export class AnonLayoutWrapperComponent implements OnInit, OnDestroy {
|
||||
this.pageIcon = null;
|
||||
this.showReadonlyHostname = null;
|
||||
this.maxWidth = null;
|
||||
this.titleAreaMaxWidth = null;
|
||||
this.hideCardWrapper = null;
|
||||
this.hideIcon = null;
|
||||
}
|
||||
|
||||
ngOnDestroy() {
|
||||
|
||||
@@ -6,40 +6,37 @@
|
||||
}"
|
||||
>
|
||||
<a
|
||||
*ngIf="!hideLogo"
|
||||
*ngIf="!hideLogo()"
|
||||
[routerLink]="['/']"
|
||||
class="tw-w-[128px] tw-block tw-mb-12 [&>*]:tw-align-top"
|
||||
>
|
||||
<bit-icon [icon]="logo" [ariaLabel]="'appLogoLabel' | i18n"></bit-icon>
|
||||
</a>
|
||||
|
||||
<div
|
||||
class="tw-text-center tw-mb-4 sm:tw-mb-6"
|
||||
[ngClass]="{ 'tw-max-w-md tw-mx-auto': titleAreaMaxWidth === 'md' }"
|
||||
>
|
||||
<div *ngIf="!hideIcon" class="tw-mx-auto tw-max-w-24 sm:tw-max-w-28 md:tw-max-w-32">
|
||||
<bit-icon [icon]="icon"></bit-icon>
|
||||
<div class="tw-text-center tw-mb-4 sm:tw-mb-6 tw-mx-auto" [ngClass]="maxWidthClass">
|
||||
<div *ngIf="!hideIcon()" class="tw-w-24 sm:tw-w-28 md:tw-w-32 tw-mx-auto">
|
||||
<bit-icon [icon]="icon()"></bit-icon>
|
||||
</div>
|
||||
|
||||
<ng-container *ngIf="title">
|
||||
<ng-container *ngIf="title()">
|
||||
<!-- Small screens -->
|
||||
<h1 bitTypography="h3" class="tw-mt-2 sm:tw-hidden">
|
||||
{{ title }}
|
||||
{{ title() }}
|
||||
</h1>
|
||||
<!-- Medium to Larger screens -->
|
||||
<h1 bitTypography="h2" class="tw-mt-2 tw-hidden sm:tw-block">
|
||||
{{ title }}
|
||||
{{ title() }}
|
||||
</h1>
|
||||
</ng-container>
|
||||
|
||||
<div *ngIf="subtitle" class="tw-text-sm sm:tw-text-base">{{ subtitle }}</div>
|
||||
<div *ngIf="subtitle()" class="tw-text-sm sm:tw-text-base">{{ subtitle() }}</div>
|
||||
</div>
|
||||
|
||||
<div
|
||||
class="tw-grow tw-w-full tw-max-w-md tw-mx-auto tw-flex tw-flex-col tw-items-center sm:tw-min-w-[28rem]"
|
||||
[ngClass]="{ 'tw-max-w-md': maxWidth === 'md', 'tw-max-w-3xl': maxWidth === '3xl' }"
|
||||
class="tw-grow tw-w-full tw-mx-auto tw-flex tw-flex-col tw-items-center sm:tw-min-w-[28rem]"
|
||||
[ngClass]="maxWidthClass"
|
||||
>
|
||||
@if (hideCardWrapper) {
|
||||
@if (hideCardWrapper()) {
|
||||
<div class="tw-mb-6 sm:tw-mb-10">
|
||||
<ng-container *ngTemplateOutlet="defaultContent"></ng-container>
|
||||
</div>
|
||||
@@ -53,11 +50,11 @@
|
||||
<ng-content select="[slot=secondary]"></ng-content>
|
||||
</div>
|
||||
|
||||
<footer *ngIf="!hideFooter" class="tw-text-center tw-mt-4 sm:tw-mt-6">
|
||||
<div *ngIf="showReadonlyHostname" bitTypography="body2">
|
||||
<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-container *ngIf="!showReadonlyHostname()">
|
||||
<ng-content select="[slot=environment-selector]"></ng-content>
|
||||
</ng-container>
|
||||
<ng-container *ngIf="!hideYearAndVersion">
|
||||
|
||||
@@ -1,7 +1,15 @@
|
||||
// FIXME: Update this file to be type safe and remove this and next line
|
||||
// @ts-strict-ignore
|
||||
import { CommonModule } from "@angular/common";
|
||||
import { Component, HostBinding, Input, OnChanges, OnInit, SimpleChanges } from "@angular/core";
|
||||
import {
|
||||
Component,
|
||||
HostBinding,
|
||||
OnChanges,
|
||||
OnInit,
|
||||
SimpleChanges,
|
||||
input,
|
||||
model,
|
||||
} from "@angular/core";
|
||||
import { RouterModule } from "@angular/router";
|
||||
import { firstValueFrom } from "rxjs";
|
||||
|
||||
@@ -10,10 +18,13 @@ import { EnvironmentService } from "@bitwarden/common/platform/abstractions/envi
|
||||
import { PlatformUtilsService } from "@bitwarden/common/platform/abstractions/platform-utils.service";
|
||||
|
||||
import { IconModule, Icon } from "../icon";
|
||||
import { BitwardenLogo, BitwardenShield } from "../icon/icons";
|
||||
import { BitwardenLogo } from "../icon/icons";
|
||||
import { AnonLayoutBitwardenShield } from "../icon/logos";
|
||||
import { SharedModule } from "../shared";
|
||||
import { TypographyModule } from "../typography";
|
||||
|
||||
export type AnonLayoutMaxWidth = "md" | "lg" | "xl" | "2xl" | "3xl" | "4xl";
|
||||
|
||||
@Component({
|
||||
selector: "auth-anon-layout",
|
||||
templateUrl: "./anon-layout.component.html",
|
||||
@@ -26,37 +37,48 @@ export class AnonLayoutComponent implements OnInit, OnChanges {
|
||||
return ["tw-h-full"];
|
||||
}
|
||||
|
||||
@Input() title: string;
|
||||
@Input() subtitle: string;
|
||||
@Input() icon: Icon;
|
||||
@Input() showReadonlyHostname: boolean;
|
||||
@Input() hideLogo: boolean = false;
|
||||
@Input() hideFooter: boolean = false;
|
||||
@Input() hideIcon: boolean = false;
|
||||
@Input() hideCardWrapper: boolean = false;
|
||||
readonly title = input<string>();
|
||||
readonly subtitle = input<string>();
|
||||
readonly icon = model<Icon>();
|
||||
readonly showReadonlyHostname = input<boolean>(false);
|
||||
readonly hideLogo = input<boolean>(false);
|
||||
readonly hideFooter = input<boolean>(false);
|
||||
readonly hideIcon = input<boolean>(false);
|
||||
readonly hideCardWrapper = input<boolean>(false);
|
||||
|
||||
/**
|
||||
* Max width of the title area content
|
||||
*
|
||||
* @default null
|
||||
*/
|
||||
@Input() titleAreaMaxWidth?: "md";
|
||||
|
||||
/**
|
||||
* Max width of the layout content
|
||||
* Max width of the anon layout title, subtitle, and content areas.
|
||||
*
|
||||
* @default 'md'
|
||||
*/
|
||||
@Input() maxWidth: "md" | "3xl" = "md";
|
||||
readonly maxWidth = model<AnonLayoutMaxWidth>("md");
|
||||
|
||||
protected logo = BitwardenLogo;
|
||||
protected year = "2024";
|
||||
protected year: string;
|
||||
protected clientType: ClientType;
|
||||
protected hostname: string;
|
||||
protected version: string;
|
||||
|
||||
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,
|
||||
@@ -67,20 +89,19 @@ export class AnonLayoutComponent implements OnInit, OnChanges {
|
||||
}
|
||||
|
||||
async ngOnInit() {
|
||||
this.maxWidth = this.maxWidth ?? "md";
|
||||
this.titleAreaMaxWidth = this.titleAreaMaxWidth ?? null;
|
||||
this.maxWidth.set(this.maxWidth() ?? "md");
|
||||
this.hostname = (await firstValueFrom(this.environmentService.environment$)).getHostname();
|
||||
this.version = await this.platformUtilsService.getApplicationVersion();
|
||||
|
||||
// If there is no icon input, then use the default icon
|
||||
if (this.icon == null) {
|
||||
this.icon = BitwardenShield;
|
||||
if (this.icon() == null) {
|
||||
this.icon.set(AnonLayoutBitwardenShield);
|
||||
}
|
||||
}
|
||||
|
||||
async ngOnChanges(changes: SimpleChanges) {
|
||||
if (changes.maxWidth) {
|
||||
this.maxWidth = changes.maxWidth.currentValue ?? "md";
|
||||
this.maxWidth.set(changes.maxWidth.currentValue ?? "md");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -165,4 +165,4 @@ import { EnvironmentSelectorComponent } from "./components/environment-selector/
|
||||
|
||||
---
|
||||
|
||||
<Story of={stories.WithSecondaryContent} />
|
||||
<Story of={stories.SecondaryContent} />
|
||||
|
||||
@@ -8,6 +8,7 @@ import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.servic
|
||||
import { PlatformUtilsService } from "@bitwarden/common/platform/abstractions/platform-utils.service";
|
||||
|
||||
import { ButtonModule } from "../button";
|
||||
import { Icon } from "../icon";
|
||||
import { LockIcon } from "../icon/icons";
|
||||
import { I18nMockService } from "../utils/i18n-mock.service";
|
||||
|
||||
@@ -18,6 +19,13 @@ class MockPlatformUtilsService implements Partial<PlatformUtilsService> {
|
||||
getClientType = () => ClientType.Web;
|
||||
}
|
||||
|
||||
type StoryArgs = AnonLayoutComponent & {
|
||||
contentLength: "normal" | "long" | "thin";
|
||||
showSecondary: boolean;
|
||||
useDefaultIcon: boolean;
|
||||
icon: Icon;
|
||||
};
|
||||
|
||||
export default {
|
||||
title: "Component Library/Anon Layout",
|
||||
component: AnonLayoutComponent,
|
||||
@@ -31,12 +39,11 @@ export default {
|
||||
},
|
||||
{
|
||||
provide: I18nService,
|
||||
useFactory: () => {
|
||||
return new I18nMockService({
|
||||
useFactory: () =>
|
||||
new I18nMockService({
|
||||
accessing: "Accessing",
|
||||
appLogoLabel: "app logo label",
|
||||
});
|
||||
},
|
||||
}),
|
||||
},
|
||||
{
|
||||
provide: EnvironmentService,
|
||||
@@ -55,196 +62,178 @@ export default {
|
||||
],
|
||||
}),
|
||||
],
|
||||
render: (args) => {
|
||||
const { useDefaultIcon, icon, ...rest } = args;
|
||||
return {
|
||||
props: {
|
||||
...rest,
|
||||
icon: useDefaultIcon ? null : icon,
|
||||
},
|
||||
template: /*html*/ `
|
||||
<auth-anon-layout
|
||||
[title]="title"
|
||||
[subtitle]="subtitle"
|
||||
[icon]="icon"
|
||||
[showReadonlyHostname]="showReadonlyHostname"
|
||||
[maxWidth]="maxWidth"
|
||||
[hideCardWrapper]="hideCardWrapper"
|
||||
[hideIcon]="hideIcon"
|
||||
[hideLogo]="hideLogo"
|
||||
[hideFooter]="hideFooter"
|
||||
>
|
||||
<ng-container [ngSwitch]="contentLength">
|
||||
<div *ngSwitchCase="'thin'" class="tw-text-center"> <div class="tw-font-bold">Thin Content</div></div>
|
||||
<div *ngSwitchCase="'long'">
|
||||
<div class="tw-font-bold">Long Content</div>
|
||||
<div>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>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>
|
||||
<div *ngSwitchDefault>
|
||||
<div class="tw-font-bold">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>
|
||||
</ng-container>
|
||||
|
||||
<div *ngIf="showSecondary" slot="secondary" class="tw-text-center">
|
||||
<div class="tw-font-bold tw-mb-2">
|
||||
Secondary Projected Content (optional)
|
||||
</div>
|
||||
<button bitButton>Perform Action</button>
|
||||
</div>
|
||||
</auth-anon-layout>
|
||||
`,
|
||||
};
|
||||
},
|
||||
|
||||
argTypes: {
|
||||
title: { control: "text" },
|
||||
subtitle: { control: "text" },
|
||||
|
||||
icon: { control: false, table: { disable: true } },
|
||||
useDefaultIcon: {
|
||||
control: false,
|
||||
table: { disable: true },
|
||||
description: "If true, passes null so component falls back to its built-in icon",
|
||||
},
|
||||
|
||||
showReadonlyHostname: { control: "boolean" },
|
||||
maxWidth: {
|
||||
control: "select",
|
||||
options: ["md", "lg", "xl", "2xl", "3xl"],
|
||||
},
|
||||
|
||||
hideCardWrapper: { control: "boolean" },
|
||||
hideIcon: { control: "boolean" },
|
||||
hideLogo: { control: "boolean" },
|
||||
hideFooter: { control: "boolean" },
|
||||
|
||||
contentLength: {
|
||||
control: "radio",
|
||||
options: ["normal", "long", "thin"],
|
||||
},
|
||||
|
||||
showSecondary: { control: "boolean" },
|
||||
},
|
||||
|
||||
args: {
|
||||
title: "The Page Title",
|
||||
subtitle: "The subtitle (optional)",
|
||||
showReadonlyHostname: true,
|
||||
icon: LockIcon,
|
||||
hideLogo: false,
|
||||
useDefaultIcon: false,
|
||||
showReadonlyHostname: false,
|
||||
maxWidth: "md",
|
||||
hideCardWrapper: false,
|
||||
hideIcon: false,
|
||||
hideLogo: false,
|
||||
hideFooter: false,
|
||||
contentLength: "normal",
|
||||
showSecondary: false,
|
||||
},
|
||||
} as Meta;
|
||||
} satisfies Meta<StoryArgs>;
|
||||
|
||||
type Story = StoryObj<AnonLayoutComponent>;
|
||||
type Story = StoryObj<StoryArgs>;
|
||||
|
||||
export const WithPrimaryContent: Story = {
|
||||
render: (args) => ({
|
||||
props: args,
|
||||
template:
|
||||
// Projected content (the <div>) and styling is just a sample and can be replaced with any content/styling.
|
||||
`
|
||||
<auth-anon-layout [title]="title" [subtitle]="subtitle" [showReadonlyHostname]="showReadonlyHostname" [hideLogo]="hideLogo" >
|
||||
<div>
|
||||
<div class="tw-font-bold">Primary Projected Content Area (customizable)</div>
|
||||
<div>Lorem ipsum dolor sit amet consectetur adipisicing elit. Necessitatibus illum vero, placeat recusandae esse ratione eius minima veniam nemo, quas beatae! Impedit molestiae alias sapiente explicabo. Sapiente corporis ipsa numquam?</div>
|
||||
</div>
|
||||
</auth-anon-layout>
|
||||
`,
|
||||
}),
|
||||
export const NormalPrimaryContent: Story = {
|
||||
args: {
|
||||
contentLength: "normal",
|
||||
},
|
||||
};
|
||||
|
||||
export const WithSecondaryContent: Story = {
|
||||
render: (args) => ({
|
||||
props: args,
|
||||
template:
|
||||
// Projected content (the <div>'s) and styling is just a sample and can be replaced with any content/styling.
|
||||
// Notice that slot="secondary" is requred to project any secondary content.
|
||||
`
|
||||
<auth-anon-layout [title]="title" [subtitle]="subtitle" [showReadonlyHostname]="showReadonlyHostname" [hideLogo]="hideLogo" >
|
||||
<div>
|
||||
<div class="tw-font-bold">Primary Projected Content Area (customizable)</div>
|
||||
<div>Lorem ipsum dolor sit amet consectetur adipisicing elit. Necessitatibus illum vero, placeat recusandae esse ratione eius minima veniam nemo, quas beatae! Impedit molestiae alias sapiente explicabo. Sapiente corporis ipsa numquam?</div>
|
||||
</div>
|
||||
|
||||
<div slot="secondary" class="tw-text-center">
|
||||
<div class="tw-font-bold tw-mb-2">Secondary Projected Content (optional)</div>
|
||||
<button bitButton>Perform Action</button>
|
||||
</div>
|
||||
</auth-anon-layout>
|
||||
`,
|
||||
}),
|
||||
export const LongPrimaryContent: Story = {
|
||||
args: {
|
||||
contentLength: "long",
|
||||
},
|
||||
};
|
||||
|
||||
export const WithLongContent: Story = {
|
||||
render: (args) => ({
|
||||
props: args,
|
||||
template:
|
||||
// Projected content (the <div>'s) and styling is just a sample and can be replaced with any content/styling.
|
||||
`
|
||||
<auth-anon-layout title="Page Title lorem ipsum dolor consectetur sit amet expedita quod est" subtitle="Subtitle here Lorem ipsum dolor sit amet consectetur adipisicing elit. Expedita, quod est?" [showReadonlyHostname]="showReadonlyHostname" [hideLogo]="hideLogo" >
|
||||
<div>
|
||||
<div class="tw-font-bold">Primary Projected Content Area (customizable)</div>
|
||||
<div>Lorem ipsum dolor sit amet consectetur adipisicing elit. Necessitatibus illum vero, placeat recusandae esse ratione eius minima veniam nemo, quas beatae! Impedit molestiae alias sapiente explicabo. Sapiente corporis ipsa numquam? Lorem ipsum dolor sit amet consectetur adipisicing elit. Lorem ipsum dolor sit amet consectetur adipisicing elit. Lorem ipsum dolor sit amet consectetur adipisicing elit. Lorem ipsum dolor sit amet consectetur adipisicing elit. Lorem ipsum dolor sit amet consectetur adipisicing elit. Lorem ipsum dolor sit amet consectetur adipisicing elit. Lorem ipsum dolor sit amet consectetur adipisicing elit. Lorem ipsum dolor sit amet consectetur adipisicing elit. Lorem ipsum dolor sit amet consectetur adipisicing elit.</div>
|
||||
</div>
|
||||
|
||||
<div slot="secondary" class="tw-text-center">
|
||||
<div class="tw-font-bold tw-mb-2">Secondary Projected Content (optional)</div>
|
||||
<p>Lorem ipsum dolor sit amet consectetur adipisicing elit. Molestias laborum nostrum natus. Lorem ipsum dolor sit amet consectetur adipisicing elit. Molestias laborum nostrum natus. Expedita, quod est? </p>
|
||||
<button bitButton>Perform Action</button>
|
||||
</div>
|
||||
</auth-anon-layout>
|
||||
`,
|
||||
}),
|
||||
export const ThinPrimaryContent: Story = {
|
||||
args: {
|
||||
contentLength: "thin",
|
||||
},
|
||||
};
|
||||
|
||||
export const WithThinPrimaryContent: Story = {
|
||||
render: (args) => ({
|
||||
props: args,
|
||||
template:
|
||||
// Projected content (the <div>'s) and styling is just a sample and can be replaced with any content/styling.
|
||||
`
|
||||
<auth-anon-layout [title]="title" [subtitle]="subtitle" [showReadonlyHostname]="showReadonlyHostname" [hideLogo]="hideLogo" >
|
||||
<div class="tw-text-center">Lorem ipsum</div>
|
||||
|
||||
<div slot="secondary" class="tw-text-center">
|
||||
<div class="tw-font-bold tw-mb-2">Secondary Projected Content (optional)</div>
|
||||
<button bitButton>Perform Action</button>
|
||||
</div>
|
||||
</auth-anon-layout>
|
||||
`,
|
||||
}),
|
||||
export const LongContentAndTitlesAndDefaultWidth: Story = {
|
||||
args: {
|
||||
title:
|
||||
"This is a very long title that might not fit in the default width. It's really long and descriptive, so it might take up more space than usual.",
|
||||
subtitle:
|
||||
"This is a very long subtitle that might not fit in the default width. It's really long and descriptive, so it might take up more space than usual.",
|
||||
contentLength: "long",
|
||||
},
|
||||
};
|
||||
|
||||
export const WithCustomIcon: Story = {
|
||||
render: (args) => ({
|
||||
props: args,
|
||||
template:
|
||||
// Projected content (the <div>) and styling is just a sample and can be replaced with any content/styling.
|
||||
`
|
||||
<auth-anon-layout [title]="title" [subtitle]="subtitle" [icon]="icon" [showReadonlyHostname]="showReadonlyHostname">
|
||||
<div>
|
||||
<div class="tw-font-bold">Primary Projected Content Area (customizable)</div>
|
||||
<div>Lorem ipsum dolor sit amet consectetur adipisicing elit. Necessitatibus illum vero, placeat recusandae esse ratione eius minima veniam nemo, quas beatae! Impedit molestiae alias sapiente explicabo. Sapiente corporis ipsa numquam?</div>
|
||||
</div>
|
||||
</auth-anon-layout>
|
||||
`,
|
||||
}),
|
||||
export const LongContentAndTitlesAndLargestWidth: Story = {
|
||||
args: {
|
||||
title:
|
||||
"This is a very long title that might not fit in the default width. It's really long and descriptive, so it might take up more space than usual.",
|
||||
subtitle:
|
||||
"This is a very long subtitle that might not fit in the default width. It's really long and descriptive, so it might take up more space than usual.",
|
||||
contentLength: "long",
|
||||
maxWidth: "3xl",
|
||||
},
|
||||
};
|
||||
|
||||
export const HideCardWrapper: Story = {
|
||||
render: (args) => ({
|
||||
props: {
|
||||
...args,
|
||||
hideCardWrapper: true,
|
||||
},
|
||||
template: `
|
||||
<auth-anon-layout [title]="title" [subtitle]="subtitle" [showReadonlyHostname]="showReadonlyHostname" [hideLogo]="hideLogo" [hideCardWrapper]="hideCardWrapper">
|
||||
<div>
|
||||
<div class="tw-font-bold">Primary Projected Content Area (customizable)</div>
|
||||
<div>Lorem ipsum dolor sit amet consectetur adipisicing elit. Necessitatibus illum vero, placeat recusandae esse ratione eius minima veniam nemo, quas beatae! Impedit molestiae alias sapiente explicabo. Sapiente corporis ipsa numquam?</div>
|
||||
</div>
|
||||
<div slot="secondary" class="tw-text-center">
|
||||
<div class="tw-font-bold tw-mb-2">Secondary Projected Content (optional)</div>
|
||||
<button bitButton>Perform Action</button>
|
||||
</div>
|
||||
</auth-anon-layout>
|
||||
`,
|
||||
}),
|
||||
export const SecondaryContent: Story = {
|
||||
args: {
|
||||
showSecondary: true,
|
||||
},
|
||||
};
|
||||
|
||||
export const HideIcon: Story = {
|
||||
render: (args) => ({
|
||||
props: args,
|
||||
template:
|
||||
// Projected content (the <div>) and styling is just a sample and can be replaced with any content/styling.
|
||||
`
|
||||
<auth-anon-layout [title]="title" [subtitle]="subtitle" [showReadonlyHostname]="showReadonlyHostname" [hideIcon]="true" >
|
||||
<div>
|
||||
<div class="tw-font-bold">Primary Projected Content Area (customizable)</div>
|
||||
<div>Lorem ipsum dolor sit amet consectetur adipisicing elit. Necessitatibus illum vero, placeat recusandae esse ratione eius minima veniam nemo, quas beatae! Impedit molestiae alias sapiente explicabo. Sapiente corporis ipsa numquam?</div>
|
||||
</div>
|
||||
</auth-anon-layout>
|
||||
`,
|
||||
}),
|
||||
export const NoTitle: Story = { args: { title: undefined } };
|
||||
|
||||
export const NoSubtitle: Story = { args: { subtitle: undefined } };
|
||||
|
||||
export const NoWrapper: Story = {
|
||||
args: { hideCardWrapper: true },
|
||||
};
|
||||
|
||||
export const HideLogo: Story = {
|
||||
render: (args) => ({
|
||||
props: args,
|
||||
template:
|
||||
// Projected content (the <div>) and styling is just a sample and can be replaced with any content/styling.
|
||||
`
|
||||
<auth-anon-layout [title]="title" [subtitle]="subtitle" [showReadonlyHostname]="showReadonlyHostname" [hideLogo]="true" >
|
||||
<div>
|
||||
<div class="tw-font-bold">Primary Projected Content Area (customizable)</div>
|
||||
<div>Lorem ipsum dolor sit amet consectetur adipisicing elit. Necessitatibus illum vero, placeat recusandae esse ratione eius minima veniam nemo, quas beatae! Impedit molestiae alias sapiente explicabo. Sapiente corporis ipsa numquam?</div>
|
||||
</div>
|
||||
</auth-anon-layout>
|
||||
`,
|
||||
}),
|
||||
export const DefaultIcon: Story = {
|
||||
args: { useDefaultIcon: true },
|
||||
};
|
||||
|
||||
export const HideFooter: Story = {
|
||||
render: (args) => ({
|
||||
props: args,
|
||||
template:
|
||||
// Projected content (the <div>) and styling is just a sample and can be replaced with any content/styling.
|
||||
`
|
||||
<auth-anon-layout [title]="title" [subtitle]="subtitle" [showReadonlyHostname]="showReadonlyHostname" [hideFooter]="true" [hideLogo]="hideLogo" >
|
||||
<div>
|
||||
<div class="tw-font-bold">Primary Projected Content Area (customizable)</div>
|
||||
<div>Lorem ipsum dolor sit amet consectetur adipisicing elit. Necessitatibus illum vero, placeat recusandae esse ratione eius minima veniam nemo, quas beatae! Impedit molestiae alias sapiente explicabo. Sapiente corporis ipsa numquam?</div>
|
||||
</div>
|
||||
</auth-anon-layout>
|
||||
`,
|
||||
}),
|
||||
export const NoIcon: Story = {
|
||||
args: { hideIcon: true },
|
||||
};
|
||||
|
||||
export const WithTitleAreaMaxWidth: Story = {
|
||||
render: (args) => ({
|
||||
props: {
|
||||
...args,
|
||||
title: "This is a very long long title to demonstrate titleAreaMaxWidth set to 'md'",
|
||||
subtitle:
|
||||
"This is a very long subtitle that demonstrates how the max width container handles longer text content with the titleAreaMaxWidth input set to 'md'. Lorem ipsum dolor sit amet consectetur adipisicing elit. Expedita, quod est?",
|
||||
},
|
||||
template: `
|
||||
<auth-anon-layout [title]="title" [subtitle]="subtitle" [showReadonlyHostname]="showReadonlyHostname" [hideLogo]="hideLogo" [titleAreaMaxWidth]="'md'">
|
||||
<div>
|
||||
<div class="tw-font-bold">Primary Projected Content Area (customizable)</div>
|
||||
<div>Lorem ipsum dolor sit amet consectetur adipisicing elit. Necessitatibus illum vero, placeat recusandae esse ratione eius minima veniam nemo, quas beatae! Impedit molestiae alias sapiente explicabo. Sapiente corporis ipsa numquam?</div>
|
||||
</div>
|
||||
</auth-anon-layout>
|
||||
`,
|
||||
}),
|
||||
export const NoLogo: Story = {
|
||||
args: { hideLogo: true },
|
||||
};
|
||||
|
||||
export const NoFooter: Story = {
|
||||
args: { hideFooter: true },
|
||||
};
|
||||
|
||||
export const ReadonlyHostname: Story = {
|
||||
args: { showReadonlyHostname: true },
|
||||
};
|
||||
|
||||
export const MinimalState: Story = {
|
||||
args: {
|
||||
title: undefined,
|
||||
subtitle: undefined,
|
||||
contentLength: "normal",
|
||||
hideCardWrapper: true,
|
||||
hideIcon: true,
|
||||
hideLogo: true,
|
||||
hideFooter: true,
|
||||
},
|
||||
};
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
// FIXME: Update this file to be type safe and remove this and next line
|
||||
// @ts-strict-ignore
|
||||
import { Directive, HostListener, Input, OnDestroy, Optional } from "@angular/core";
|
||||
import { Directive, HostListener, model, OnDestroy, Optional } from "@angular/core";
|
||||
import { BehaviorSubject, finalize, Subject, takeUntil, tap } from "rxjs";
|
||||
|
||||
import { LogService } from "@bitwarden/common/platform/abstractions/log.service";
|
||||
@@ -38,7 +38,7 @@ export class BitActionDirective implements OnDestroy {
|
||||
|
||||
disabled = false;
|
||||
|
||||
@Input("bitAction") handler: FunctionReturningAwaitable;
|
||||
readonly handler = model<FunctionReturningAwaitable>(undefined, { alias: "bitAction" });
|
||||
|
||||
constructor(
|
||||
private buttonComponent: ButtonLikeAbstraction,
|
||||
@@ -48,12 +48,12 @@ export class BitActionDirective implements OnDestroy {
|
||||
|
||||
@HostListener("click")
|
||||
protected async onClick() {
|
||||
if (!this.handler || this.loading || this.disabled || this.buttonComponent.disabled()) {
|
||||
if (!this.handler() || this.loading || this.disabled || this.buttonComponent.disabled()) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.loading = true;
|
||||
functionToObservable(this.handler)
|
||||
functionToObservable(this.handler())
|
||||
.pipe(
|
||||
tap({
|
||||
error: (err: unknown) => {
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
// FIXME: Update this file to be type safe and remove this and next line
|
||||
// @ts-strict-ignore
|
||||
import { Directive, Input, OnDestroy, OnInit, Optional } from "@angular/core";
|
||||
import { Directive, OnDestroy, OnInit, Optional, input } from "@angular/core";
|
||||
import { FormGroupDirective } from "@angular/forms";
|
||||
import { BehaviorSubject, catchError, filter, of, Subject, switchMap, takeUntil } from "rxjs";
|
||||
|
||||
@@ -20,9 +20,9 @@ export class BitSubmitDirective implements OnInit, OnDestroy {
|
||||
private _loading$ = new BehaviorSubject<boolean>(false);
|
||||
private _disabled$ = new BehaviorSubject<boolean>(false);
|
||||
|
||||
@Input("bitSubmit") handler: FunctionReturningAwaitable;
|
||||
readonly handler = input<FunctionReturningAwaitable>(undefined, { alias: "bitSubmit" });
|
||||
|
||||
@Input() allowDisabledFormSubmit?: boolean = false;
|
||||
readonly allowDisabledFormSubmit = input<boolean>(false);
|
||||
|
||||
readonly loading$ = this._loading$.asObservable();
|
||||
readonly disabled$ = this._disabled$.asObservable();
|
||||
@@ -38,7 +38,7 @@ export class BitSubmitDirective implements OnInit, OnDestroy {
|
||||
switchMap(() => {
|
||||
// Calling functionToObservable executes the sync part of the handler
|
||||
// allowing the function to check form validity before it gets disabled.
|
||||
const awaitable = functionToObservable(this.handler);
|
||||
const awaitable = functionToObservable(this.handler());
|
||||
|
||||
// Disable form
|
||||
this.loading = true;
|
||||
@@ -61,7 +61,7 @@ export class BitSubmitDirective implements OnInit, OnDestroy {
|
||||
|
||||
ngOnInit(): void {
|
||||
this.formGroupDirective.statusChanges.pipe(takeUntil(this.destroy$)).subscribe((c) => {
|
||||
if (this.allowDisabledFormSubmit) {
|
||||
if (this.allowDisabledFormSubmit()) {
|
||||
this._disabled$.next(false);
|
||||
} else {
|
||||
this._disabled$.next(c === "DISABLED");
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
// FIXME: Update this file to be type safe and remove this and next line
|
||||
// @ts-strict-ignore
|
||||
import { Directive, Input, OnDestroy, Optional } from "@angular/core";
|
||||
import { Directive, OnDestroy, Optional, input } from "@angular/core";
|
||||
import { Subject, takeUntil } from "rxjs";
|
||||
|
||||
import { ButtonLikeAbstraction } from "../shared/button-like.abstraction";
|
||||
@@ -29,8 +29,8 @@ import { BitSubmitDirective } from "./bit-submit.directive";
|
||||
export class BitFormButtonDirective implements OnDestroy {
|
||||
private destroy$ = new Subject<void>();
|
||||
|
||||
@Input() type: string;
|
||||
@Input() disabled?: boolean;
|
||||
readonly type = input<string>();
|
||||
readonly disabled = input<boolean>();
|
||||
|
||||
constructor(
|
||||
buttonComponent: ButtonLikeAbstraction,
|
||||
@@ -39,16 +39,17 @@ export class BitFormButtonDirective implements OnDestroy {
|
||||
) {
|
||||
if (submitDirective && buttonComponent) {
|
||||
submitDirective.loading$.pipe(takeUntil(this.destroy$)).subscribe((loading) => {
|
||||
if (this.type === "submit") {
|
||||
if (this.type() === "submit") {
|
||||
buttonComponent.loading.set(loading);
|
||||
} else {
|
||||
buttonComponent.disabled.set(this.disabled || loading);
|
||||
buttonComponent.disabled.set(this.disabled() || loading);
|
||||
}
|
||||
});
|
||||
|
||||
submitDirective.disabled$.pipe(takeUntil(this.destroy$)).subscribe((disabled) => {
|
||||
if (this.disabled !== false) {
|
||||
buttonComponent.disabled.set(this.disabled || disabled);
|
||||
const disabledValue = this.disabled();
|
||||
if (disabledValue !== false) {
|
||||
buttonComponent.disabled.set(disabledValue || disabled);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
@@ -13,6 +13,7 @@ import { IconButtonModule } from "../icon-button";
|
||||
import { InputModule } from "../input/input.module";
|
||||
import { I18nMockService } from "../utils/i18n-mock.service";
|
||||
|
||||
import { AsyncActionsModule } from "./async-actions.module";
|
||||
import { BitActionDirective } from "./bit-action.directive";
|
||||
import { BitSubmitDirective } from "./bit-submit.directive";
|
||||
import { BitFormButtonDirective } from "./form-button.directive";
|
||||
@@ -40,6 +41,13 @@ const template = `
|
||||
@Component({
|
||||
selector: "app-promise-example",
|
||||
template,
|
||||
imports: [
|
||||
AsyncActionsModule,
|
||||
ButtonModule,
|
||||
FormFieldModule,
|
||||
IconButtonModule,
|
||||
ReactiveFormsModule,
|
||||
],
|
||||
})
|
||||
class PromiseExampleComponent {
|
||||
formObj = this.formBuilder.group({
|
||||
@@ -77,6 +85,13 @@ class PromiseExampleComponent {
|
||||
@Component({
|
||||
selector: "app-observable-example",
|
||||
template,
|
||||
imports: [
|
||||
AsyncActionsModule,
|
||||
ButtonModule,
|
||||
FormFieldModule,
|
||||
IconButtonModule,
|
||||
ReactiveFormsModule,
|
||||
],
|
||||
})
|
||||
class ObservableExampleComponent {
|
||||
formObj = this.formBuilder.group({
|
||||
@@ -109,7 +124,6 @@ export default {
|
||||
title: "Component Library/Async Actions/In Forms",
|
||||
decorators: [
|
||||
moduleMetadata({
|
||||
declarations: [PromiseExampleComponent, ObservableExampleComponent],
|
||||
imports: [
|
||||
BitSubmitDirective,
|
||||
BitFormButtonDirective,
|
||||
@@ -120,6 +134,8 @@ export default {
|
||||
ButtonModule,
|
||||
IconButtonModule,
|
||||
BitActionDirective,
|
||||
PromiseExampleComponent,
|
||||
ObservableExampleComponent,
|
||||
],
|
||||
providers: [
|
||||
{
|
||||
|
||||
@@ -9,6 +9,7 @@ import { ValidationService } from "@bitwarden/common/platform/abstractions/valid
|
||||
import { ButtonModule } from "../button";
|
||||
import { IconButtonModule } from "../icon-button";
|
||||
|
||||
import { AsyncActionsModule } from "./async-actions.module";
|
||||
import { BitActionDirective } from "./bit-action.directive";
|
||||
|
||||
const template = /*html*/ `
|
||||
@@ -20,6 +21,7 @@ const template = /*html*/ `
|
||||
@Component({
|
||||
template,
|
||||
selector: "app-promise-example",
|
||||
imports: [AsyncActionsModule, ButtonModule, IconButtonModule],
|
||||
})
|
||||
class PromiseExampleComponent {
|
||||
statusEmoji = "🟡";
|
||||
@@ -36,6 +38,7 @@ class PromiseExampleComponent {
|
||||
@Component({
|
||||
template,
|
||||
selector: "app-action-resolves-quickly",
|
||||
imports: [AsyncActionsModule, ButtonModule, IconButtonModule],
|
||||
})
|
||||
class ActionResolvesQuicklyComponent {
|
||||
statusEmoji = "🟡";
|
||||
@@ -53,6 +56,7 @@ class ActionResolvesQuicklyComponent {
|
||||
@Component({
|
||||
template,
|
||||
selector: "app-observable-example",
|
||||
imports: [AsyncActionsModule, ButtonModule, IconButtonModule],
|
||||
})
|
||||
class ObservableExampleComponent {
|
||||
action = () => {
|
||||
@@ -63,6 +67,7 @@ class ObservableExampleComponent {
|
||||
@Component({
|
||||
template,
|
||||
selector: "app-rejected-promise-example",
|
||||
imports: [AsyncActionsModule, ButtonModule, IconButtonModule],
|
||||
})
|
||||
class RejectedPromiseExampleComponent {
|
||||
action = async () => {
|
||||
@@ -76,13 +81,15 @@ export default {
|
||||
title: "Component Library/Async Actions/Standalone",
|
||||
decorators: [
|
||||
moduleMetadata({
|
||||
declarations: [
|
||||
imports: [
|
||||
ButtonModule,
|
||||
IconButtonModule,
|
||||
BitActionDirective,
|
||||
PromiseExampleComponent,
|
||||
ObservableExampleComponent,
|
||||
RejectedPromiseExampleComponent,
|
||||
ActionResolvesQuicklyComponent,
|
||||
],
|
||||
imports: [ButtonModule, IconButtonModule, BitActionDirective],
|
||||
providers: [
|
||||
{
|
||||
provide: ValidationService,
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
// FIXME: Update this file to be type safe and remove this and next line
|
||||
// @ts-strict-ignore
|
||||
import { NgClass } from "@angular/common";
|
||||
import { Component, Input, OnChanges } from "@angular/core";
|
||||
import { Component, OnChanges, input } from "@angular/core";
|
||||
import { DomSanitizer, SafeResourceUrl } from "@angular/platform-browser";
|
||||
|
||||
import { Utils } from "@bitwarden/common/platform/misc/utils";
|
||||
@@ -25,17 +25,17 @@ const SizeClasses: Record<SizeTypes, string[]> = {
|
||||
@Component({
|
||||
selector: "bit-avatar",
|
||||
template: `@if (src) {
|
||||
<img [src]="src" title="{{ title || text }}" [ngClass]="classList" />
|
||||
<img [src]="src" title="{{ title() || text() }}" [ngClass]="classList" />
|
||||
}`,
|
||||
imports: [NgClass],
|
||||
})
|
||||
export class AvatarComponent implements OnChanges {
|
||||
@Input() border = false;
|
||||
@Input() color?: string;
|
||||
@Input() id?: string;
|
||||
@Input() text?: string;
|
||||
@Input() title: string;
|
||||
@Input() size: SizeTypes = "default";
|
||||
readonly border = input(false);
|
||||
readonly color = input<string>();
|
||||
readonly id = input<string>();
|
||||
readonly text = input<string>();
|
||||
readonly title = input<string>();
|
||||
readonly size = input<SizeTypes>("default");
|
||||
|
||||
private svgCharCount = 2;
|
||||
private svgFontSize = 20;
|
||||
@@ -51,13 +51,13 @@ export class AvatarComponent implements OnChanges {
|
||||
|
||||
get classList() {
|
||||
return ["tw-rounded-full"]
|
||||
.concat(SizeClasses[this.size] ?? [])
|
||||
.concat(this.border ? ["tw-border", "tw-border-solid", "tw-border-secondary-600"] : []);
|
||||
.concat(SizeClasses[this.size()] ?? [])
|
||||
.concat(this.border() ? ["tw-border", "tw-border-solid", "tw-border-secondary-600"] : []);
|
||||
}
|
||||
|
||||
private generate() {
|
||||
let chars: string = null;
|
||||
const upperCaseText = this.text?.toUpperCase() ?? "";
|
||||
const upperCaseText = this.text()?.toUpperCase() ?? "";
|
||||
|
||||
chars = this.getFirstLetters(upperCaseText, this.svgCharCount);
|
||||
|
||||
@@ -71,12 +71,13 @@ export class AvatarComponent implements OnChanges {
|
||||
}
|
||||
|
||||
let svg: HTMLElement;
|
||||
let hexColor = this.color;
|
||||
let hexColor = this.color();
|
||||
|
||||
if (!Utils.isNullOrWhitespace(this.color)) {
|
||||
const id = this.id();
|
||||
if (!Utils.isNullOrWhitespace(this.color())) {
|
||||
svg = this.createSvgElement(this.svgSize, hexColor);
|
||||
} else if (!Utils.isNullOrWhitespace(this.id)) {
|
||||
hexColor = Utils.stringToColor(this.id.toString());
|
||||
} else if (!Utils.isNullOrWhitespace(id)) {
|
||||
hexColor = Utils.stringToColor(id.toString());
|
||||
svg = this.createSvgElement(this.svgSize, hexColor);
|
||||
} else {
|
||||
hexColor = Utils.stringToColor(upperCaseText);
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
<div class="tw-inline-flex tw-flex-wrap tw-gap-2">
|
||||
@for (item of filteredItems; track item; let last = $last) {
|
||||
<span bitBadge [variant]="variant" [truncate]="truncate">
|
||||
<span bitBadge [variant]="variant()" [truncate]="truncate()">
|
||||
{{ item }}
|
||||
</span>
|
||||
@if (!last || isFiltered) {
|
||||
@@ -8,8 +8,8 @@
|
||||
}
|
||||
}
|
||||
@if (isFiltered) {
|
||||
<span bitBadge [variant]="variant">
|
||||
{{ "plusNMore" | i18n: (items.length - filteredItems.length).toString() }}
|
||||
<span bitBadge [variant]="variant()">
|
||||
{{ "plusNMore" | i18n: (items().length - filteredItems.length).toString() }}
|
||||
</span>
|
||||
}
|
||||
</div>
|
||||
|
||||
@@ -1,42 +1,36 @@
|
||||
// FIXME: Update this file to be type safe and remove this and next line
|
||||
// @ts-strict-ignore
|
||||
|
||||
import { Component, Input, OnChanges } from "@angular/core";
|
||||
import { Component, OnChanges, input } from "@angular/core";
|
||||
|
||||
import { I18nPipe } from "@bitwarden/ui-common";
|
||||
|
||||
import { BadgeModule, BadgeVariant } from "../badge";
|
||||
|
||||
function transformMaxItems(value: number | undefined) {
|
||||
return value == undefined ? undefined : Math.max(1, value);
|
||||
}
|
||||
|
||||
@Component({
|
||||
selector: "bit-badge-list",
|
||||
templateUrl: "badge-list.component.html",
|
||||
imports: [BadgeModule, I18nPipe],
|
||||
})
|
||||
export class BadgeListComponent implements OnChanges {
|
||||
private _maxItems: number;
|
||||
|
||||
protected filteredItems: string[] = [];
|
||||
protected isFiltered = false;
|
||||
|
||||
@Input() variant: BadgeVariant = "primary";
|
||||
@Input() items: string[] = [];
|
||||
@Input() truncate = true;
|
||||
readonly variant = input<BadgeVariant>("primary");
|
||||
readonly items = input<string[]>([]);
|
||||
readonly truncate = input(true);
|
||||
|
||||
@Input()
|
||||
get maxItems(): number | undefined {
|
||||
return this._maxItems;
|
||||
}
|
||||
|
||||
set maxItems(value: number | undefined) {
|
||||
this._maxItems = value == undefined ? undefined : Math.max(1, value);
|
||||
}
|
||||
readonly maxItems = input(undefined, { transform: transformMaxItems });
|
||||
|
||||
ngOnChanges() {
|
||||
if (this.maxItems == undefined || this.items.length <= this.maxItems) {
|
||||
this.filteredItems = this.items;
|
||||
const maxItems = this.maxItems();
|
||||
|
||||
if (maxItems == undefined || this.items().length <= maxItems) {
|
||||
this.filteredItems = this.items();
|
||||
} else {
|
||||
this.filteredItems = this.items.slice(0, this.maxItems - 1);
|
||||
this.filteredItems = this.items().slice(0, maxItems - 1);
|
||||
}
|
||||
this.isFiltered = this.items.length > this.filteredItems.length;
|
||||
this.isFiltered = this.items().length > this.filteredItems.length;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,3 +1,3 @@
|
||||
<span [ngClass]="{ 'tw-truncate tw-block': truncate }">
|
||||
<span [ngClass]="{ 'tw-truncate tw-block': truncate() }">
|
||||
<ng-content></ng-content>
|
||||
</span>
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { CommonModule } from "@angular/common";
|
||||
import { Component, ElementRef, HostBinding, Input } from "@angular/core";
|
||||
import { Component, ElementRef, HostBinding, input } from "@angular/core";
|
||||
|
||||
import { FocusableElement } from "../shared/focusable-element";
|
||||
|
||||
@@ -89,33 +89,34 @@ export class BadgeComponent implements FocusableElement {
|
||||
"disabled:hover:!tw-text-muted",
|
||||
"disabled:tw-cursor-not-allowed",
|
||||
]
|
||||
.concat(styles[this.variant])
|
||||
.concat(this.hasHoverEffects ? [...hoverStyles[this.variant], "tw-min-w-10"] : [])
|
||||
.concat(this.truncate ? this.maxWidthClass : []);
|
||||
.concat(styles[this.variant()])
|
||||
.concat(this.hasHoverEffects ? [...hoverStyles[this.variant()], "tw-min-w-10"] : [])
|
||||
.concat(this.truncate() ? this.maxWidthClass() : []);
|
||||
}
|
||||
@HostBinding("attr.title") get titleAttr() {
|
||||
if (this.title !== undefined) {
|
||||
return this.title;
|
||||
const title = this.title();
|
||||
if (title !== undefined) {
|
||||
return title;
|
||||
}
|
||||
return this.truncate ? this?.el?.nativeElement?.textContent?.trim() : null;
|
||||
return this.truncate() ? this?.el?.nativeElement?.textContent?.trim() : null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Optional override for the automatic badge title when truncating.
|
||||
*/
|
||||
@Input() title?: string;
|
||||
readonly title = input<string>();
|
||||
|
||||
/**
|
||||
* Variant, sets the background color of the badge.
|
||||
*/
|
||||
@Input() variant: BadgeVariant = "primary";
|
||||
readonly variant = input<BadgeVariant>("primary");
|
||||
|
||||
/**
|
||||
* Truncate long text
|
||||
*/
|
||||
@Input() truncate = true;
|
||||
readonly truncate = input(true);
|
||||
|
||||
@Input() maxWidthClass: `tw-max-w-${string}` = "tw-max-w-40";
|
||||
readonly maxWidthClass = input<`tw-max-w-${string}`>("tw-max-w-40");
|
||||
|
||||
getFocusTarget() {
|
||||
return this.el.nativeElement;
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
<div
|
||||
class="tw-flex tw-items-center tw-gap-2 tw-p-2 tw-ps-4 tw-text-main tw-border-transparent tw-bg-clip-padding tw-border-solid tw-border-b tw-border-0"
|
||||
[ngClass]="bannerClass"
|
||||
[attr.role]="useAlertRole ? 'status' : null"
|
||||
[attr.aria-live]="useAlertRole ? 'polite' : null"
|
||||
[attr.role]="useAlertRole() ? 'status' : null"
|
||||
[attr.aria-live]="useAlertRole() ? 'polite' : null"
|
||||
>
|
||||
@if (icon) {
|
||||
@if (icon(); as icon) {
|
||||
<i class="bwi tw-align-middle tw-text-base" [ngClass]="icon" aria-hidden="true"></i>
|
||||
}
|
||||
<!-- Overriding focus-visible color for link buttons for a11y against colored background -->
|
||||
@@ -12,7 +12,7 @@
|
||||
<ng-content></ng-content>
|
||||
</span>
|
||||
<!-- Overriding hover and focus-visible colors for a11y against colored background -->
|
||||
@if (showClose) {
|
||||
@if (showClose()) {
|
||||
<button
|
||||
class="hover:tw-border-text-main focus-visible:before:tw-ring-text-main"
|
||||
type="button"
|
||||
|
||||
@@ -30,17 +30,17 @@ describe("BannerComponent", () => {
|
||||
});
|
||||
|
||||
it("should create with alert", () => {
|
||||
expect(component.useAlertRole).toBe(true);
|
||||
expect(component.useAlertRole()).toBe(true);
|
||||
const el = fixture.nativeElement.children[0];
|
||||
expect(el.getAttribute("role")).toEqual("status");
|
||||
expect(el.getAttribute("aria-live")).toEqual("polite");
|
||||
});
|
||||
|
||||
it("useAlertRole=false", () => {
|
||||
component.useAlertRole = false;
|
||||
fixture.componentRef.setInput("useAlertRole", false);
|
||||
fixture.autoDetectChanges();
|
||||
|
||||
expect(component.useAlertRole).toBe(false);
|
||||
expect(component.useAlertRole()).toBe(false);
|
||||
const el = fixture.nativeElement.children[0];
|
||||
expect(el.getAttribute("role")).toBeNull();
|
||||
expect(el.getAttribute("aria-live")).toBeNull();
|
||||
|
||||
@@ -1,7 +1,5 @@
|
||||
// FIXME: Update this file to be type safe and remove this and next line
|
||||
// @ts-strict-ignore
|
||||
import { CommonModule } from "@angular/common";
|
||||
import { Component, Input, OnInit, Output, EventEmitter } from "@angular/core";
|
||||
import { Component, OnInit, Output, EventEmitter, input, model } from "@angular/core";
|
||||
|
||||
import { I18nPipe } from "@bitwarden/ui-common";
|
||||
|
||||
@@ -31,19 +29,22 @@ const defaultIcon: Record<BannerType, string> = {
|
||||
imports: [CommonModule, IconButtonModule, I18nPipe],
|
||||
})
|
||||
export class BannerComponent implements OnInit {
|
||||
@Input("bannerType") bannerType: BannerType = "info";
|
||||
@Input() icon: string;
|
||||
@Input() useAlertRole = true;
|
||||
@Input() showClose = true;
|
||||
readonly bannerType = input<BannerType>("info");
|
||||
|
||||
readonly icon = model<string>();
|
||||
readonly useAlertRole = input(true);
|
||||
readonly showClose = input(true);
|
||||
|
||||
@Output() onClose = new EventEmitter<void>();
|
||||
|
||||
ngOnInit(): void {
|
||||
this.icon ??= defaultIcon[this.bannerType];
|
||||
if (!this.icon()) {
|
||||
this.icon.set(defaultIcon[this.bannerType()]);
|
||||
}
|
||||
}
|
||||
|
||||
get bannerClass() {
|
||||
switch (this.bannerType) {
|
||||
switch (this.bannerType()) {
|
||||
case "danger":
|
||||
return "tw-bg-danger-100 tw-border-b-danger-700";
|
||||
case "info":
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
<ng-template>
|
||||
@if (icon) {
|
||||
@if (icon(); as icon) {
|
||||
<i class="bwi {{ icon }} !tw-me-2" aria-hidden="true"></i>
|
||||
}
|
||||
<ng-content></ng-content>
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
// FIXME: Update this file to be type safe and remove this and next line
|
||||
// @ts-strict-ignore
|
||||
|
||||
import { Component, EventEmitter, Input, Output, TemplateRef, ViewChild } from "@angular/core";
|
||||
import { Component, EventEmitter, Output, TemplateRef, ViewChild, input } from "@angular/core";
|
||||
import { QueryParamsHandling } from "@angular/router";
|
||||
|
||||
@Component({
|
||||
@@ -9,17 +9,13 @@ import { QueryParamsHandling } from "@angular/router";
|
||||
templateUrl: "./breadcrumb.component.html",
|
||||
})
|
||||
export class BreadcrumbComponent {
|
||||
@Input()
|
||||
icon?: string;
|
||||
readonly icon = input<string>();
|
||||
|
||||
@Input()
|
||||
route?: string | any[] = undefined;
|
||||
readonly route = input<string | any[]>();
|
||||
|
||||
@Input()
|
||||
queryParams?: Record<string, string> = {};
|
||||
readonly queryParams = input<Record<string, string>>({});
|
||||
|
||||
@Input()
|
||||
queryParamsHandling?: QueryParamsHandling;
|
||||
readonly queryParamsHandling = input<QueryParamsHandling>();
|
||||
|
||||
@Output()
|
||||
click = new EventEmitter();
|
||||
|
||||
@@ -1,17 +1,16 @@
|
||||
@for (breadcrumb of beforeOverflow; track breadcrumb; let last = $last) {
|
||||
@if (breadcrumb.route) {
|
||||
@if (breadcrumb.route(); as route) {
|
||||
<a
|
||||
bitLink
|
||||
linkType="primary"
|
||||
class="tw-my-2 tw-inline-block"
|
||||
[routerLink]="breadcrumb.route"
|
||||
[queryParams]="breadcrumb.queryParams"
|
||||
[queryParamsHandling]="breadcrumb.queryParamsHandling"
|
||||
[routerLink]="route"
|
||||
[queryParams]="breadcrumb.queryParams()"
|
||||
[queryParamsHandling]="breadcrumb.queryParamsHandling()"
|
||||
>
|
||||
<ng-container [ngTemplateOutlet]="breadcrumb.content"></ng-container>
|
||||
</a>
|
||||
}
|
||||
@if (!breadcrumb.route) {
|
||||
} @else {
|
||||
<button
|
||||
type="button"
|
||||
bitLink
|
||||
@@ -39,18 +38,17 @@
|
||||
></button>
|
||||
<bit-menu #overflowMenu>
|
||||
@for (breadcrumb of overflow; track breadcrumb) {
|
||||
@if (breadcrumb.route) {
|
||||
@if (breadcrumb.route(); as route) {
|
||||
<a
|
||||
bitMenuItem
|
||||
linkType="primary"
|
||||
[routerLink]="breadcrumb.route"
|
||||
[queryParams]="breadcrumb.queryParams"
|
||||
[queryParamsHandling]="breadcrumb.queryParamsHandling"
|
||||
[routerLink]="route"
|
||||
[queryParams]="breadcrumb.queryParams()"
|
||||
[queryParamsHandling]="breadcrumb.queryParamsHandling()"
|
||||
>
|
||||
<ng-container [ngTemplateOutlet]="breadcrumb.content"></ng-container>
|
||||
</a>
|
||||
}
|
||||
@if (!breadcrumb.route) {
|
||||
} @else {
|
||||
<button type="button" bitMenuItem linkType="primary" (click)="breadcrumb.onClick($event)">
|
||||
<ng-container [ngTemplateOutlet]="breadcrumb.content"></ng-container>
|
||||
</button>
|
||||
@@ -59,19 +57,18 @@
|
||||
</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) {
|
||||
@if (breadcrumb.route) {
|
||||
@if (breadcrumb.route(); as route) {
|
||||
<a
|
||||
bitLink
|
||||
linkType="primary"
|
||||
class="tw-my-2 tw-inline-block"
|
||||
[routerLink]="breadcrumb.route"
|
||||
[queryParams]="breadcrumb.queryParams"
|
||||
[queryParamsHandling]="breadcrumb.queryParamsHandling"
|
||||
[routerLink]="route"
|
||||
[queryParams]="breadcrumb.queryParams()"
|
||||
[queryParamsHandling]="breadcrumb.queryParamsHandling()"
|
||||
>
|
||||
<ng-container [ngTemplateOutlet]="breadcrumb.content"></ng-container>
|
||||
</a>
|
||||
}
|
||||
@if (!breadcrumb.route) {
|
||||
} @else {
|
||||
<button
|
||||
type="button"
|
||||
bitLink
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { CommonModule } from "@angular/common";
|
||||
import { Component, ContentChildren, Input, QueryList } from "@angular/core";
|
||||
import { Component, ContentChildren, QueryList, input } from "@angular/core";
|
||||
import { RouterModule } from "@angular/router";
|
||||
|
||||
import { IconButtonModule } from "../icon-button";
|
||||
@@ -19,8 +19,7 @@ import { BreadcrumbComponent } from "./breadcrumb.component";
|
||||
imports: [CommonModule, LinkModule, RouterModule, IconButtonModule, MenuModule],
|
||||
})
|
||||
export class BreadcrumbsComponent {
|
||||
@Input()
|
||||
show = 3;
|
||||
readonly show = input(3);
|
||||
|
||||
private breadcrumbs: BreadcrumbComponent[] = [];
|
||||
|
||||
@@ -31,14 +30,14 @@ export class BreadcrumbsComponent {
|
||||
|
||||
protected get beforeOverflow() {
|
||||
if (this.hasOverflow) {
|
||||
return this.breadcrumbs.slice(0, this.show - 1);
|
||||
return this.breadcrumbs.slice(0, this.show() - 1);
|
||||
}
|
||||
|
||||
return this.breadcrumbs;
|
||||
}
|
||||
|
||||
protected get overflow() {
|
||||
return this.breadcrumbs.slice(this.show - 1, -1);
|
||||
return this.breadcrumbs.slice(this.show() - 1, -1);
|
||||
}
|
||||
|
||||
protected get afterOverflow() {
|
||||
@@ -46,6 +45,6 @@ export class BreadcrumbsComponent {
|
||||
}
|
||||
|
||||
protected get hasOverflow() {
|
||||
return this.breadcrumbs.length > this.show;
|
||||
return this.breadcrumbs.length > this.show();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
import { coerceBooleanProperty } from "@angular/cdk/coercion";
|
||||
import { NgClass } from "@angular/common";
|
||||
import { Input, HostBinding, Component, model, computed, input } from "@angular/core";
|
||||
import { input, HostBinding, Component, model, computed, booleanAttribute } from "@angular/core";
|
||||
import { toObservable, toSignal } from "@angular/core/rxjs-interop";
|
||||
import { debounce, interval } from "rxjs";
|
||||
|
||||
@@ -31,9 +30,7 @@ const buttonStyles: Record<ButtonType, string[]> = {
|
||||
"tw-bg-transparent",
|
||||
"tw-border-primary-600",
|
||||
"!tw-text-primary-600",
|
||||
"hover:tw-bg-primary-600",
|
||||
"hover:tw-border-primary-600",
|
||||
"hover:!tw-text-contrast",
|
||||
"hover:tw-bg-hover-default",
|
||||
...focusRing,
|
||||
],
|
||||
danger: [
|
||||
@@ -70,8 +67,8 @@ export class ButtonComponent implements ButtonLikeAbstraction {
|
||||
"hover:tw-no-underline",
|
||||
"focus:tw-outline-none",
|
||||
]
|
||||
.concat(this.block ? ["tw-w-full", "tw-block"] : ["tw-inline-block"])
|
||||
.concat(buttonStyles[this.buttonType ?? "secondary"])
|
||||
.concat(this.block() ? ["tw-w-full", "tw-block"] : ["tw-inline-block"])
|
||||
.concat(buttonStyles[this.buttonType() ?? "secondary"])
|
||||
.concat(
|
||||
this.showDisabledStyles() || this.disabled()
|
||||
? [
|
||||
@@ -106,22 +103,13 @@ export class ButtonComponent implements ButtonLikeAbstraction {
|
||||
return this.showLoadingStyle() || (this.disabledAttr() && this.loading() === false);
|
||||
});
|
||||
|
||||
@Input() buttonType: ButtonType = "secondary";
|
||||
readonly buttonType = input<ButtonType>("secondary");
|
||||
|
||||
size = input<ButtonSize>("default");
|
||||
readonly size = input<ButtonSize>("default");
|
||||
|
||||
private _block = false;
|
||||
readonly block = input(false, { transform: booleanAttribute });
|
||||
|
||||
@Input()
|
||||
get block(): boolean {
|
||||
return this._block;
|
||||
}
|
||||
|
||||
set block(value: boolean | "") {
|
||||
this._block = coerceBooleanProperty(value);
|
||||
}
|
||||
|
||||
loading = model<boolean>(false);
|
||||
readonly loading = model<boolean>(false);
|
||||
|
||||
/**
|
||||
* Determine whether it is appropriate to display a loading spinner. We only want to show
|
||||
|
||||
@@ -3,12 +3,12 @@
|
||||
[ngClass]="calloutClass"
|
||||
[attr.aria-labelledby]="titleId"
|
||||
>
|
||||
@if (title) {
|
||||
@if (titleComputed(); as title) {
|
||||
<header
|
||||
id="{{ titleId }}"
|
||||
class="tw-mb-1 tw-mt-0 tw-text-base tw-font-semibold tw-flex tw-gap-2 tw-items-start"
|
||||
>
|
||||
@if (icon) {
|
||||
@if (iconComputed(); as icon) {
|
||||
<i
|
||||
class="bwi !tw-text-main tw-relative tw-top-[3px]"
|
||||
[ngClass]="[icon]"
|
||||
|
||||
@@ -30,31 +30,31 @@ describe("Callout", () => {
|
||||
|
||||
describe("default state", () => {
|
||||
it("success", () => {
|
||||
component.type = "success";
|
||||
fixture.componentRef.setInput("type", "success");
|
||||
fixture.detectChanges();
|
||||
expect(component.title).toBeUndefined();
|
||||
expect(component.icon).toBe("bwi-check-circle");
|
||||
expect(component.titleComputed()).toBeUndefined();
|
||||
expect(component.iconComputed()).toBe("bwi-check-circle");
|
||||
});
|
||||
|
||||
it("info", () => {
|
||||
component.type = "info";
|
||||
fixture.componentRef.setInput("type", "info");
|
||||
fixture.detectChanges();
|
||||
expect(component.title).toBeUndefined();
|
||||
expect(component.icon).toBe("bwi-info-circle");
|
||||
expect(component.titleComputed()).toBeUndefined();
|
||||
expect(component.iconComputed()).toBe("bwi-info-circle");
|
||||
});
|
||||
|
||||
it("warning", () => {
|
||||
component.type = "warning";
|
||||
fixture.componentRef.setInput("type", "warning");
|
||||
fixture.detectChanges();
|
||||
expect(component.title).toBe("Warning");
|
||||
expect(component.icon).toBe("bwi-exclamation-triangle");
|
||||
expect(component.titleComputed()).toBe("Warning");
|
||||
expect(component.iconComputed()).toBe("bwi-exclamation-triangle");
|
||||
});
|
||||
|
||||
it("danger", () => {
|
||||
component.type = "danger";
|
||||
fixture.componentRef.setInput("type", "danger");
|
||||
fixture.detectChanges();
|
||||
expect(component.title).toBe("Error");
|
||||
expect(component.icon).toBe("bwi-error");
|
||||
expect(component.titleComputed()).toBe("Error");
|
||||
expect(component.iconComputed()).toBe("bwi-error");
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
// FIXME: Update this file to be type safe and remove this and next line
|
||||
// @ts-strict-ignore
|
||||
import { Component, Input, OnInit } from "@angular/core";
|
||||
import { Component, computed, input } from "@angular/core";
|
||||
|
||||
import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service";
|
||||
|
||||
@@ -34,24 +34,28 @@ let nextId = 0;
|
||||
templateUrl: "callout.component.html",
|
||||
imports: [SharedModule, TypographyModule],
|
||||
})
|
||||
export class CalloutComponent implements OnInit {
|
||||
@Input() type: CalloutTypes = "info";
|
||||
@Input() icon: string;
|
||||
@Input() title: string;
|
||||
@Input() useAlertRole = false;
|
||||
export class CalloutComponent {
|
||||
readonly type = input<CalloutTypes>("info");
|
||||
readonly icon = input<string>();
|
||||
readonly title = input<string>();
|
||||
readonly useAlertRole = input(false);
|
||||
readonly iconComputed = computed(() => this.icon() ?? defaultIcon[this.type()]);
|
||||
readonly titleComputed = computed(() => {
|
||||
const title = this.title();
|
||||
const type = this.type();
|
||||
if (title == null && defaultI18n[type] != null) {
|
||||
return this.i18nService.t(defaultI18n[type]);
|
||||
}
|
||||
|
||||
return title;
|
||||
});
|
||||
|
||||
protected titleId = `bit-callout-title-${nextId++}`;
|
||||
|
||||
constructor(private i18nService: I18nService) {}
|
||||
|
||||
ngOnInit() {
|
||||
this.icon ??= defaultIcon[this.type];
|
||||
if (this.title == null && defaultI18n[this.type] != null) {
|
||||
this.title = this.i18nService.t(defaultI18n[this.type]);
|
||||
}
|
||||
}
|
||||
|
||||
get calloutClass() {
|
||||
switch (this.type) {
|
||||
switch (this.type()) {
|
||||
case "danger":
|
||||
return "tw-bg-danger-100";
|
||||
case "info":
|
||||
|
||||
@@ -68,3 +68,18 @@ export const Danger: Story = {
|
||||
type: "danger",
|
||||
},
|
||||
};
|
||||
|
||||
export const CustomIcon: Story = {
|
||||
...Info,
|
||||
args: {
|
||||
...Info.args,
|
||||
icon: "bwi-star",
|
||||
},
|
||||
};
|
||||
|
||||
export const NoTitle: Story = {
|
||||
...Info,
|
||||
args: {
|
||||
icon: "bwi-star",
|
||||
},
|
||||
};
|
||||
|
||||
@@ -15,69 +15,83 @@ export class CheckboxComponent extends BitFormControlAbstraction {
|
||||
protected inputClasses = [
|
||||
"tw-appearance-none",
|
||||
"tw-outline-none",
|
||||
"tw-box-border",
|
||||
"tw-relative",
|
||||
"tw-transition",
|
||||
"tw-cursor-pointer",
|
||||
"tw-inline-block",
|
||||
"tw-align-sub",
|
||||
"tw-rounded",
|
||||
"tw-border",
|
||||
"tw-border-solid",
|
||||
"tw-border-secondary-500",
|
||||
"tw-h-[1.12rem]",
|
||||
"tw-w-[1.12rem]",
|
||||
"tw-me-1.5",
|
||||
"tw-flex-none", // Flexbox fix for bit-form-control
|
||||
"!tw-p-1",
|
||||
"after:tw-inset-1",
|
||||
// negative margin to negate the positioning added by the padding
|
||||
"!-tw-mt-1",
|
||||
"!-tw-mb-1",
|
||||
"!-tw-ms-1",
|
||||
|
||||
"before:tw-content-['']",
|
||||
"before:tw-block",
|
||||
"before:tw-absolute",
|
||||
"before:tw-inset-0",
|
||||
"before:tw-h-[1.12rem]",
|
||||
"before:tw-w-[1.12rem]",
|
||||
"before:tw-rounded",
|
||||
"before:tw-border",
|
||||
"before:tw-border-solid",
|
||||
"before:tw-border-secondary-500",
|
||||
"before:tw-box-border",
|
||||
|
||||
"hover:tw-border-2",
|
||||
"[&>label]:tw-border-2",
|
||||
"after:tw-content-['']",
|
||||
"after:tw-block",
|
||||
"after:tw-absolute",
|
||||
"after:tw-inset-0",
|
||||
"after:tw-h-[1.12rem]",
|
||||
"after:tw-w-[1.12rem]",
|
||||
"after:tw-box-border",
|
||||
|
||||
"hover:before:tw-border-2",
|
||||
"[&>label]:before:tw-border-2",
|
||||
|
||||
// if it exists, the parent form control handles focus
|
||||
"[&:not(bit-form-control_*)]:focus-visible:tw-ring-2",
|
||||
"[&:not(bit-form-control_*)]:focus-visible:tw-ring-offset-2",
|
||||
"[&:not(bit-form-control_*)]:focus-visible:tw-ring-primary-600",
|
||||
"[&:not(bit-form-control_*)]:focus-visible:before:tw-ring-2",
|
||||
"[&:not(bit-form-control_*)]:focus-visible:before:tw-ring-offset-2",
|
||||
"[&:not(bit-form-control_*)]:focus-visible:before:tw-ring-primary-600",
|
||||
|
||||
"disabled:tw-cursor-auto",
|
||||
"disabled:tw-border",
|
||||
"disabled:hover:tw-border",
|
||||
"disabled:tw-bg-secondary-100",
|
||||
"disabled:hover:tw-bg-secondary-100",
|
||||
"disabled:before:tw-cursor-auto",
|
||||
"disabled:before:tw-border",
|
||||
"disabled:before:hover:tw-border",
|
||||
"disabled:before:tw-bg-secondary-100",
|
||||
"disabled:hover:before:tw-bg-secondary-100",
|
||||
|
||||
"checked:tw-bg-primary-600",
|
||||
"checked:tw-border-primary-600",
|
||||
"checked:hover:tw-bg-primary-700",
|
||||
"checked:hover:tw-border-primary-700",
|
||||
"[&>label:hover]:checked:tw-bg-primary-700",
|
||||
"[&>label:hover]:checked:tw-border-primary-700",
|
||||
"checked:before:tw-bg-text-contrast",
|
||||
"checked:before:tw-mask-position-[center]",
|
||||
"checked:before:tw-mask-repeat-[no-repeat]",
|
||||
"checked:disabled:tw-border-secondary-100",
|
||||
"checked:disabled:hover:tw-border-secondary-100",
|
||||
"checked:disabled:tw-bg-secondary-100",
|
||||
"checked:disabled:before:tw-bg-text-muted",
|
||||
"checked:before:tw-bg-primary-600",
|
||||
"checked:before:tw-border-primary-600",
|
||||
"checked:before:hover:tw-bg-primary-700",
|
||||
"checked:before:hover:tw-border-primary-700",
|
||||
"[&>label:hover]:checked:before:tw-bg-primary-700",
|
||||
"[&>label:hover]:checked:before:tw-border-primary-700",
|
||||
"checked:after:tw-bg-text-contrast",
|
||||
"checked:after:tw-mask-position-[center]",
|
||||
"checked:after:tw-mask-repeat-[no-repeat]",
|
||||
"checked:disabled:before:tw-border-secondary-100",
|
||||
"checked:disabled:hover:before:tw-border-secondary-100",
|
||||
"checked:disabled:before:tw-bg-secondary-100",
|
||||
"checked:disabled:after:tw-bg-text-muted",
|
||||
|
||||
"[&:not(:indeterminate)]:checked:before:tw-mask-image-[var(--mask-image)]",
|
||||
"indeterminate:before:tw-mask-image-[var(--indeterminate-mask-image)]",
|
||||
"[&:not(:indeterminate)]:checked:after:tw-mask-image-[var(--mask-image)]",
|
||||
"indeterminate:after:tw-mask-image-[var(--indeterminate-mask-image)]",
|
||||
|
||||
"indeterminate:tw-bg-primary-600",
|
||||
"indeterminate:tw-border-primary-600",
|
||||
"indeterminate:hover:tw-bg-primary-700",
|
||||
"indeterminate:hover:tw-border-primary-700",
|
||||
"[&>label:hover]:indeterminate:tw-bg-primary-700",
|
||||
"[&>label:hover]:indeterminate:tw-border-primary-700",
|
||||
"indeterminate:before:tw-bg-text-contrast",
|
||||
"indeterminate:before:tw-mask-position-[center]",
|
||||
"indeterminate:before:tw-mask-repeat-[no-repeat]",
|
||||
"indeterminate:before:tw-mask-image-[var(--indeterminate-mask-image)]",
|
||||
"indeterminate:before:tw-bg-primary-600",
|
||||
"indeterminate:before:tw-border-primary-600",
|
||||
"indeterminate:hover:before:tw-bg-primary-700",
|
||||
"indeterminate:hover:before:tw-border-primary-700",
|
||||
"[&>label:hover]:indeterminate:before:tw-bg-primary-700",
|
||||
"[&>label:hover]:indeterminate:before:tw-border-primary-700",
|
||||
"indeterminate:after:tw-bg-text-contrast",
|
||||
"indeterminate:after:tw-mask-position-[center]",
|
||||
"indeterminate:after:tw-mask-repeat-[no-repeat]",
|
||||
"indeterminate:after:tw-mask-image-[var(--indeterminate-mask-image)]",
|
||||
"indeterminate:disabled:tw-border-secondary-100",
|
||||
"indeterminate:disabled:tw-bg-secondary-100",
|
||||
"indeterminate:disabled:before:tw-bg-text-muted",
|
||||
"indeterminate:disabled:after:tw-bg-text-muted",
|
||||
];
|
||||
|
||||
constructor(
|
||||
@@ -95,6 +109,8 @@ export class CheckboxComponent extends BitFormControlAbstraction {
|
||||
protected indeterminateImage =
|
||||
`url('data:image/svg+xml,%3Csvg xmlns="http://www.w3.org/2000/svg" width="14" height="14" fill="none" viewBox="0 0 13 13"%3E%3Cpath stroke="%23fff" stroke-width="2" d="M2.5 6.5h8"/%3E%3C/svg%3E%0A')`;
|
||||
|
||||
// TODO: Skipped for signal migration because:
|
||||
// Accessor inputs cannot be migrated as they are too complex.
|
||||
@HostBinding()
|
||||
@Input()
|
||||
get disabled() {
|
||||
@@ -105,6 +121,8 @@ export class CheckboxComponent extends BitFormControlAbstraction {
|
||||
}
|
||||
// private _disabled: boolean;
|
||||
|
||||
// TODO: Skipped for signal migration because:
|
||||
// Accessor inputs cannot be migrated as they are too complex.
|
||||
@Input()
|
||||
get required() {
|
||||
return (
|
||||
|
||||
@@ -31,7 +31,7 @@ const template = /*html*/ `
|
||||
@Component({
|
||||
selector: "app-example",
|
||||
template,
|
||||
imports: [CheckboxModule, FormFieldModule, ReactiveFormsModule],
|
||||
imports: [FormControlModule, CheckboxModule, FormsModule, FormFieldModule, ReactiveFormsModule],
|
||||
})
|
||||
class ExampleComponent {
|
||||
protected formObj = this.formBuilder.group({
|
||||
@@ -197,17 +197,17 @@ export const Custom: Story = {
|
||||
props: args,
|
||||
template: /*html*/ `
|
||||
<div class="tw-flex tw-flex-col tw-w-32">
|
||||
<label class="tw-text-main tw-flex tw-bg-secondary-300 tw-p-2">
|
||||
<label class="tw-text-main tw-gap-2 tw-flex tw-items-center tw-justify-between tw-bg-secondary-300 tw-p-2">
|
||||
A-Z
|
||||
<input class="tw-ms-auto focus-visible:tw-ring-offset-secondary-300" type="checkbox" bitCheckbox />
|
||||
<input class="tw-me-0 focus-visible:tw-ring-offset-secondary-300" type="checkbox" bitCheckbox />
|
||||
</label>
|
||||
<label class="tw-text-main tw-flex tw-bg-secondary-300 tw-p-2">
|
||||
<label class="tw-text-main tw-flex tw-items-center tw-justify-between tw-bg-secondary-300 tw-p-2">
|
||||
a-z
|
||||
<input class="tw-ms-auto focus-visible:tw-ring-offset-secondary-300" type="checkbox" bitCheckbox />
|
||||
<input class="tw-me-0 focus-visible:tw-ring-offset-secondary-300" type="checkbox" bitCheckbox />
|
||||
</label>
|
||||
<label class="tw-text-main tw-flex tw-bg-secondary-300 tw-p-2">
|
||||
<label class="tw-text-main tw-flex tw-items-center tw-justify-between tw-bg-secondary-300 tw-p-2">
|
||||
0-9
|
||||
<input class="tw-ms-auto focus-visible:tw-ring-offset-secondary-300" type="checkbox" bitCheckbox />
|
||||
<input class="tw-me-0 focus-visible:tw-ring-offset-secondary-300" type="checkbox" bitCheckbox />
|
||||
</label>
|
||||
</div>
|
||||
`,
|
||||
|
||||
@@ -69,10 +69,10 @@
|
||||
bitMenuItem
|
||||
(click)="viewOption(parent, $event)"
|
||||
class="tw-text-[length:inherit]"
|
||||
[title]="'backTo' | i18n: parent.label ?? placeholderText"
|
||||
[title]="'backTo' | i18n: parent.label ?? placeholderText()"
|
||||
>
|
||||
<i slot="start" class="bwi bwi-angle-left" aria-hidden="true"></i>
|
||||
{{ "backTo" | i18n: parent.label ?? placeholderText }}
|
||||
{{ "backTo" | i18n: parent.label ?? placeholderText() }}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
|
||||
@@ -14,6 +14,7 @@ import {
|
||||
booleanAttribute,
|
||||
inject,
|
||||
signal,
|
||||
input,
|
||||
} from "@angular/core";
|
||||
import { takeUntilDestroyed } from "@angular/core/rxjs-interop";
|
||||
import { ControlValueAccessor, NG_VALUE_ACCESSOR } from "@angular/forms";
|
||||
@@ -54,12 +55,15 @@ export class ChipSelectComponent<T = unknown> implements ControlValueAccessor, A
|
||||
@ViewChild("chipSelectButton") chipSelectButton: ElementRef<HTMLButtonElement>;
|
||||
|
||||
/** Text to show when there is no selected option */
|
||||
@Input({ required: true }) placeholderText: string;
|
||||
readonly placeholderText = input.required<string>();
|
||||
|
||||
/** Icon to show when there is no selected option or the selected option does not have an icon */
|
||||
@Input() placeholderIcon: string;
|
||||
readonly placeholderIcon = input<string>();
|
||||
|
||||
private _options: ChipSelectOption<T>[];
|
||||
|
||||
// TODO: Skipped for signal migration because:
|
||||
// Accessor inputs cannot be migrated as they are too complex.
|
||||
/** The select options to render */
|
||||
@Input({ required: true })
|
||||
get options(): ChipSelectOption<T>[] {
|
||||
@@ -71,10 +75,12 @@ export class ChipSelectComponent<T = unknown> implements ControlValueAccessor, A
|
||||
}
|
||||
|
||||
/** Disables the entire chip */
|
||||
// TODO: Skipped for signal migration because:
|
||||
// Your application code writes to the input. This prevents migration.
|
||||
@Input({ transform: booleanAttribute }) disabled = false;
|
||||
|
||||
/** Chip will stretch to full width of its container */
|
||||
@Input({ transform: booleanAttribute }) fullWidth?: boolean;
|
||||
readonly fullWidth = input<boolean, unknown>(undefined, { transform: booleanAttribute });
|
||||
|
||||
/**
|
||||
* We have `:focus-within` and `:focus-visible` but no `:focus-visible-within`
|
||||
@@ -91,7 +97,7 @@ export class ChipSelectComponent<T = unknown> implements ControlValueAccessor, A
|
||||
|
||||
@HostBinding("class")
|
||||
get classList() {
|
||||
return ["tw-inline-block", this.fullWidth ? "tw-w-full" : "tw-max-w-52"];
|
||||
return ["tw-inline-block", this.fullWidth() ? "tw-w-full" : "tw-max-w-52"];
|
||||
}
|
||||
|
||||
private destroyRef = inject(DestroyRef);
|
||||
@@ -113,12 +119,12 @@ export class ChipSelectComponent<T = unknown> implements ControlValueAccessor, A
|
||||
|
||||
/** The label to show in the chip button */
|
||||
protected get label(): string {
|
||||
return this.selectedOption?.label || this.placeholderText;
|
||||
return this.selectedOption?.label || this.placeholderText();
|
||||
}
|
||||
|
||||
/** The icon to show in the chip button */
|
||||
protected get icon(): string {
|
||||
return this.selectedOption?.icon || this.placeholderIcon;
|
||||
return this.selectedOption?.icon || this.placeholderIcon();
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { Component } from "@angular/core";
|
||||
|
||||
/**
|
||||
* Generic container that constrains page content width.
|
||||
* bit-container is a minimally styled component that limits the max width of its content to the tailwind theme variable '4xl'. '4xl' is equal to the value of 56rem
|
||||
*/
|
||||
@Component({
|
||||
selector: "bit-container",
|
||||
|
||||
13
libs/components/src/container/container.mdx
Normal file
13
libs/components/src/container/container.mdx
Normal file
@@ -0,0 +1,13 @@
|
||||
import { Meta, Primary, Title, Description } from "@storybook/addon-docs";
|
||||
|
||||
import * as stories from "./container.stories";
|
||||
|
||||
<Meta of={stories} />
|
||||
|
||||
```ts
|
||||
import { ContainerComponent } from "@bitwarden/components";
|
||||
```
|
||||
|
||||
<Title />
|
||||
<Description />
|
||||
<Primary />
|
||||
34
libs/components/src/container/container.stories.ts
Normal file
34
libs/components/src/container/container.stories.ts
Normal file
@@ -0,0 +1,34 @@
|
||||
import { Meta, moduleMetadata, StoryObj } from "@storybook/angular";
|
||||
|
||||
import { ContainerComponent } from "./container.component";
|
||||
|
||||
export default {
|
||||
title: "Component Library/Container",
|
||||
component: ContainerComponent,
|
||||
decorators: [
|
||||
moduleMetadata({
|
||||
imports: [ContainerComponent],
|
||||
}),
|
||||
],
|
||||
parameters: {
|
||||
design: {
|
||||
type: "figma",
|
||||
url: "https://www.figma.com/design/Zt3YSeb6E6lebAffrNLa0h/Tailwind-Component-Library?node-id=21662-47329&t=k6OTDDPZOTtypRqo-11",
|
||||
},
|
||||
},
|
||||
} as Meta<ContainerComponent>;
|
||||
|
||||
type Story = StoryObj<ContainerComponent>;
|
||||
|
||||
export const Container: Story = {
|
||||
render: (args) => ({
|
||||
props: args,
|
||||
template: /*html*/ `
|
||||
<bit-container>
|
||||
<p>Lorem ipsum dolor sit amet, consectetur adipiscing elit. Sed malesuada felis nulla, dignissim suscipit metus posuere vel. Duis eget porttitor arcu. Praesent tempor sodales nisi ut rhoncus. Curabitur vel enim eget est elementum finibus nec vitae erat. Duis dapibus, purus varius porttitor facilisis, justo nibh scelerisque tortor, consequat eleifend augue mi et nisi. Pellentesque convallis eget sem vitae malesuada. In hac habitasse platea dictumst. Suspendisse vulputate, neque in feugiat ultricies, mi diam malesuada tellus, at ultrices nisi enim nec nunc. Integer sapien mi, facilisis sed ultrices eget, dapibus sed velit. Aenean convallis nulla id lacus mattis gravida.<p>
|
||||
|
||||
<p>Etiam quis ipsum in risus euismod sagittis ac vel lorem. Donec eget mollis augue. Maecenas vitae libero ornare felis sagittis consequat et nec urna. Integer velit sapien, mollis non magna consectetur, laoreet placerat risus. Pellentesque bibendum ante in diam commodo imperdiet. Donec ante ligula, interdum eu facilisis non, commodo eu dolor. Cras rutrum imperdiet tortor eget finibus. Donec fringilla vitae libero sed tincidunt. Quisque nulla quam, consectetur et dictum sit amet, ultrices quis tortor. Cras lacinia, lacus sed venenatis luctus, risus odio ultricies lacus, eu lacinia sapien nisl vel augue. Nunc fermentum ac nisl at dictum. Nulla gravida, odio ut pellentesque commodo, sapien urna ultrices enim, ut euismod odio nisi ac justo. Pellentesque auctor erat sit amet semper convallis. In finibus enim in lorem commodo, id pretium ligula finibus. Cras vehicula nisl eget gravida dapibus.</p>
|
||||
</bit-container>
|
||||
`,
|
||||
}),
|
||||
};
|
||||
@@ -1,4 +1,12 @@
|
||||
import { Directive, HostListener, Input, InjectionToken, Inject, Optional } from "@angular/core";
|
||||
import {
|
||||
Directive,
|
||||
HostListener,
|
||||
Input,
|
||||
InjectionToken,
|
||||
Inject,
|
||||
Optional,
|
||||
input,
|
||||
} from "@angular/core";
|
||||
|
||||
import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service";
|
||||
import { PlatformUtilsService } from "@bitwarden/common/platform/abstractions/platform-utils.service";
|
||||
@@ -28,13 +36,13 @@ export class CopyClickDirective {
|
||||
@Optional() @Inject(COPY_CLICK_LISTENER) private copyListener?: CopyClickListener,
|
||||
) {}
|
||||
|
||||
@Input("appCopyClick") valueToCopy = "";
|
||||
readonly valueToCopy = input("", { alias: "appCopyClick" });
|
||||
|
||||
/**
|
||||
* When set, the toast displayed will show `<valueLabel> copied`
|
||||
* instead of the default messaging.
|
||||
*/
|
||||
@Input() valueLabel?: string;
|
||||
readonly valueLabel = input<string>();
|
||||
|
||||
/**
|
||||
* When set without a value, a success toast will be shown when the value is copied
|
||||
@@ -49,6 +57,8 @@ export class CopyClickDirective {
|
||||
* <app-component [appCopyClick]="value to copy" showToast="info"/></app-component>
|
||||
* ```
|
||||
*/
|
||||
// TODO: Skipped for signal migration because:
|
||||
// Accessor inputs cannot be migrated as they are too complex.
|
||||
@Input() set showToast(value: ToastVariant | "") {
|
||||
// When the `showToast` is set without a value, an empty string will be passed
|
||||
if (value === "") {
|
||||
@@ -60,15 +70,17 @@ export class CopyClickDirective {
|
||||
}
|
||||
|
||||
@HostListener("click") onClick() {
|
||||
this.platformUtilsService.copyToClipboard(this.valueToCopy);
|
||||
const valueToCopy = this.valueToCopy();
|
||||
this.platformUtilsService.copyToClipboard(valueToCopy);
|
||||
|
||||
if (this.copyListener) {
|
||||
this.copyListener.onCopy(this.valueToCopy);
|
||||
this.copyListener.onCopy(valueToCopy);
|
||||
}
|
||||
|
||||
if (this._showToast) {
|
||||
const message = this.valueLabel
|
||||
? this.i18nService.t("valueCopied", this.valueLabel)
|
||||
const valueLabel = this.valueLabel();
|
||||
const message = valueLabel
|
||||
? this.i18nService.t("valueCopied", valueLabel)
|
||||
: this.i18nService.t("copySuccessful");
|
||||
|
||||
this.toastService.showToast({
|
||||
|
||||
@@ -25,10 +25,13 @@ interface Animal {
|
||||
template: `
|
||||
<bit-layout>
|
||||
<button class="tw-mr-2" bitButton type="button" (click)="openDialog()">Open Dialog</button>
|
||||
<button class="tw-mr-2" bitButton type="button" (click)="openDialogNonDismissable()">
|
||||
Open Non-Dismissable Dialog
|
||||
</button>
|
||||
<button bitButton type="button" (click)="openDrawer()">Open Drawer</button>
|
||||
</bit-layout>
|
||||
`,
|
||||
imports: [ButtonModule],
|
||||
imports: [ButtonModule, LayoutComponent],
|
||||
})
|
||||
class StoryDialogComponent {
|
||||
constructor(public dialogService: DialogService) {}
|
||||
@@ -41,6 +44,15 @@ class StoryDialogComponent {
|
||||
});
|
||||
}
|
||||
|
||||
openDialogNonDismissable() {
|
||||
this.dialogService.open(NonDismissableContent, {
|
||||
data: {
|
||||
animal: "panda",
|
||||
},
|
||||
disableClose: true,
|
||||
});
|
||||
}
|
||||
|
||||
openDrawer() {
|
||||
this.dialogService.openDrawer(StoryDialogContentComponent, {
|
||||
data: {
|
||||
@@ -79,13 +91,40 @@ class StoryDialogContentComponent {
|
||||
}
|
||||
}
|
||||
|
||||
@Component({
|
||||
template: `
|
||||
<bit-dialog title="Dialog Title" 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>
|
||||
</ng-container>
|
||||
</bit-dialog>
|
||||
`,
|
||||
imports: [DialogModule, ButtonModule],
|
||||
})
|
||||
class NonDismissableContent {
|
||||
constructor(
|
||||
public dialogRef: DialogRef,
|
||||
@Inject(DIALOG_DATA) private data: Animal,
|
||||
) {}
|
||||
|
||||
get animal() {
|
||||
return this.data?.animal;
|
||||
}
|
||||
}
|
||||
|
||||
export default {
|
||||
title: "Component Library/Dialogs/Service",
|
||||
component: StoryDialogComponent,
|
||||
decorators: [
|
||||
positionFixedWrapperDecorator(),
|
||||
moduleMetadata({
|
||||
declarations: [StoryDialogContentComponent],
|
||||
imports: [
|
||||
SharedModule,
|
||||
ButtonModule,
|
||||
@@ -138,8 +177,7 @@ export const Default: Story = {
|
||||
},
|
||||
};
|
||||
|
||||
/** Drawers must be a descendant of `bit-layout`. */
|
||||
export const Drawer: Story = {
|
||||
export const NonDismissable: Story = {
|
||||
play: async (context) => {
|
||||
const canvas = context.canvasElement;
|
||||
|
||||
@@ -147,3 +185,13 @@ export const Drawer: Story = {
|
||||
await userEvent.click(button);
|
||||
},
|
||||
};
|
||||
|
||||
/** Drawers must be a descendant of `bit-layout`. */
|
||||
export const Drawer: Story = {
|
||||
play: async (context) => {
|
||||
const canvas = context.canvasElement;
|
||||
|
||||
const button = getAllByRole(canvas, "button")[2];
|
||||
await userEvent.click(button);
|
||||
},
|
||||
};
|
||||
|
||||
@@ -6,7 +6,7 @@
|
||||
cdkTrapFocus
|
||||
cdkTrapFocusAutoCapture
|
||||
>
|
||||
@let showHeaderBorder = !isDrawer || background === "alt" || bodyHasScrolledFrom().top;
|
||||
@let showHeaderBorder = !isDrawer || background() === "alt" || bodyHasScrolledFrom().top;
|
||||
<header
|
||||
class="tw-flex tw-justify-between tw-items-center tw-gap-4 tw-border-0 tw-border-b tw-border-solid"
|
||||
[ngClass]="{
|
||||
@@ -22,34 +22,36 @@
|
||||
noMargin
|
||||
class="tw-text-main tw-mb-0 tw-truncate"
|
||||
>
|
||||
{{ title }}
|
||||
@if (subtitle) {
|
||||
{{ title() }}
|
||||
@if (subtitle(); as subtitleText) {
|
||||
<span class="tw-text-muted tw-font-normal tw-text-sm">
|
||||
{{ subtitle }}
|
||||
{{ subtitleText }}
|
||||
</span>
|
||||
}
|
||||
<ng-content select="[bitDialogTitle]"></ng-content>
|
||||
</h2>
|
||||
<button
|
||||
type="button"
|
||||
bitIconButton="bwi-close"
|
||||
buttonType="main"
|
||||
size="default"
|
||||
bitDialogClose
|
||||
[attr.title]="'close' | i18n"
|
||||
[attr.aria-label]="'close' | i18n"
|
||||
></button>
|
||||
@if (!this.dialogRef?.disableClose) {
|
||||
<button
|
||||
type="button"
|
||||
bitIconButton="bwi-close"
|
||||
buttonType="main"
|
||||
size="default"
|
||||
bitDialogClose
|
||||
[attr.title]="'close' | i18n"
|
||||
[attr.aria-label]="'close' | i18n"
|
||||
></button>
|
||||
}
|
||||
</header>
|
||||
|
||||
<div
|
||||
class="tw-relative tw-flex-1 tw-flex tw-flex-col tw-overflow-hidden"
|
||||
[ngClass]="{
|
||||
'tw-min-h-60': loading,
|
||||
'tw-bg-background': background === 'default',
|
||||
'tw-bg-background-alt': background === 'alt',
|
||||
'tw-min-h-60': loading(),
|
||||
'tw-bg-background': background() === 'default',
|
||||
'tw-bg-background-alt': background() === 'alt',
|
||||
}"
|
||||
>
|
||||
@if (loading) {
|
||||
@if (loading()) {
|
||||
<div class="tw-absolute tw-flex tw-size-full tw-items-center tw-justify-center">
|
||||
<i class="bwi bwi-spinner bwi-spin bwi-lg" [attr.aria-label]="'loading' | i18n"></i>
|
||||
</div>
|
||||
@@ -57,17 +59,17 @@
|
||||
<div
|
||||
cdkScrollable
|
||||
[ngClass]="{
|
||||
'tw-p-4': !disablePadding && !isDrawer,
|
||||
'tw-px-6 tw-py-4': !disablePadding && isDrawer,
|
||||
'tw-overflow-y-auto': !loading,
|
||||
'tw-invisible tw-overflow-y-hidden': loading,
|
||||
'tw-p-4': !disablePadding() && !isDrawer,
|
||||
'tw-px-6 tw-py-4': !disablePadding() && isDrawer,
|
||||
'tw-overflow-y-auto': !loading(),
|
||||
'tw-invisible tw-overflow-y-hidden': loading(),
|
||||
}"
|
||||
>
|
||||
<ng-content select="[bitDialogContent]"></ng-content>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@let showFooterBorder = !isDrawer || background === "alt" || bodyHasScrolledFrom().bottom;
|
||||
@let showFooterBorder = !isDrawer || background() === "alt" || bodyHasScrolledFrom().bottom;
|
||||
<footer
|
||||
class="tw-flex tw-flex-row tw-items-center tw-gap-2 tw-border-0 tw-border-t tw-border-solid tw-border-secondary-300 tw-bg-background"
|
||||
[ngClass]="[isDrawer ? 'tw-px-6 tw-py-4' : 'tw-p-4']"
|
||||
|
||||
@@ -1,10 +1,7 @@
|
||||
// FIXME: Update this file to be type safe and remove this and next line
|
||||
// @ts-strict-ignore
|
||||
import { CdkTrapFocus } from "@angular/cdk/a11y";
|
||||
import { coerceBooleanProperty } from "@angular/cdk/coercion";
|
||||
import { CdkScrollable } from "@angular/cdk/scrolling";
|
||||
import { CommonModule } from "@angular/common";
|
||||
import { Component, HostBinding, Input, inject, viewChild } from "@angular/core";
|
||||
import { Component, HostBinding, inject, viewChild, input, booleanAttribute } from "@angular/core";
|
||||
|
||||
import { I18nPipe } from "@bitwarden/ui-common";
|
||||
|
||||
@@ -40,39 +37,32 @@ export class DialogComponent {
|
||||
protected bodyHasScrolledFrom = hasScrolledFrom(this.scrollableBody);
|
||||
|
||||
/** Background color */
|
||||
@Input()
|
||||
background: "default" | "alt" = "default";
|
||||
readonly background = input<"default" | "alt">("default");
|
||||
|
||||
/**
|
||||
* Dialog size, more complex dialogs should use large, otherwise default is fine.
|
||||
*/
|
||||
@Input() dialogSize: "small" | "default" | "large" = "default";
|
||||
readonly dialogSize = input<"small" | "default" | "large">("default");
|
||||
|
||||
/**
|
||||
* Title to show in the dialog's header
|
||||
*/
|
||||
@Input() title: string;
|
||||
readonly title = input<string>();
|
||||
|
||||
/**
|
||||
* Subtitle to show in the dialog's header
|
||||
*/
|
||||
@Input() subtitle: string;
|
||||
readonly subtitle = input<string>();
|
||||
|
||||
private _disablePadding = false;
|
||||
/**
|
||||
* Disable the built-in padding on the dialog, for use with tabbed dialogs.
|
||||
*/
|
||||
@Input() set disablePadding(value: boolean | "") {
|
||||
this._disablePadding = coerceBooleanProperty(value);
|
||||
}
|
||||
get disablePadding() {
|
||||
return this._disablePadding;
|
||||
}
|
||||
readonly disablePadding = input(false, { transform: booleanAttribute });
|
||||
|
||||
/**
|
||||
* Mark the dialog as loading which replaces the content with a spinner.
|
||||
*/
|
||||
@Input() loading = false;
|
||||
readonly loading = input(false);
|
||||
|
||||
@HostBinding("class") get classes() {
|
||||
// `tw-max-h-[90vh]` is needed to prevent dialogs from overlapping the desktop header
|
||||
@@ -87,12 +77,14 @@ export class DialogComponent {
|
||||
}
|
||||
|
||||
handleEsc(event: Event) {
|
||||
this.dialogRef?.close();
|
||||
event.stopPropagation();
|
||||
if (!this.dialogRef?.disableClose) {
|
||||
this.dialogRef?.close();
|
||||
event.stopPropagation();
|
||||
}
|
||||
}
|
||||
|
||||
get width() {
|
||||
switch (this.dialogSize) {
|
||||
switch (this.dialogSize()) {
|
||||
case "small": {
|
||||
return "md:tw-max-w-sm";
|
||||
}
|
||||
|
||||
@@ -1,11 +1,11 @@
|
||||
import { DialogRef } from "@angular/cdk/dialog";
|
||||
import { Directive, HostBinding, HostListener, Input, Optional } from "@angular/core";
|
||||
import { Directive, HostBinding, HostListener, Optional, input } from "@angular/core";
|
||||
|
||||
@Directive({
|
||||
selector: "[bitDialogClose]",
|
||||
})
|
||||
export class DialogCloseDirective {
|
||||
@Input("bitDialogClose") dialogResult: any;
|
||||
readonly dialogResult = input<any>(undefined, { alias: "bitDialogClose" });
|
||||
|
||||
constructor(@Optional() public dialogRef: DialogRef) {}
|
||||
|
||||
@@ -20,6 +20,6 @@ export class DialogCloseDirective {
|
||||
return;
|
||||
}
|
||||
|
||||
this.dialogRef.close(this.dialogResult);
|
||||
this.dialogRef.close(this.dialogResult());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { CdkDialogContainer, DialogRef } from "@angular/cdk/dialog";
|
||||
import { Directive, HostBinding, Input, OnInit, Optional } from "@angular/core";
|
||||
import { Directive, HostBinding, OnInit, Optional, input } from "@angular/core";
|
||||
|
||||
// Increments for each instance of this component
|
||||
let nextId = 0;
|
||||
@@ -10,7 +10,7 @@ let nextId = 0;
|
||||
export class DialogTitleContainerDirective implements OnInit {
|
||||
@HostBinding("id") id = `bit-dialog-title-${nextId++}`;
|
||||
|
||||
@Input() simple = false;
|
||||
readonly simple = input(false);
|
||||
|
||||
constructor(@Optional() private dialogRef: DialogRef<any>) {}
|
||||
|
||||
|
||||
@@ -2,6 +2,7 @@ import { DialogRef, DIALOG_DATA } from "@angular/cdk/dialog";
|
||||
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 { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service";
|
||||
|
||||
@@ -15,19 +16,45 @@ interface Animal {
|
||||
}
|
||||
|
||||
@Component({
|
||||
template: `<button type="button" bitButton (click)="openDialog()">Open Simple Dialog</button>`,
|
||||
template: `
|
||||
<button type="button" bitButton (click)="openSimpleDialog()">Open Simple Dialog</button>
|
||||
<button type="button" bitButton (click)="openNonDismissableWithPrimaryButtonDialog()">
|
||||
Open Non-Dismissable Simple Dialog with Primary Button
|
||||
</button>
|
||||
<button type="button" bitButton (click)="openNonDismissableWithNoButtonsDialog()">
|
||||
Open Non-Dismissable Simple Dialog with No Buttons
|
||||
</button>
|
||||
`,
|
||||
imports: [ButtonModule],
|
||||
})
|
||||
class StoryDialogComponent {
|
||||
constructor(public dialogService: DialogService) {}
|
||||
|
||||
openDialog() {
|
||||
this.dialogService.open(StoryDialogContentComponent, {
|
||||
openSimpleDialog() {
|
||||
this.dialogService.open(SimpleDialogContent, {
|
||||
data: {
|
||||
animal: "panda",
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
openNonDismissableWithPrimaryButtonDialog() {
|
||||
this.dialogService.open(NonDismissableWithPrimaryButtonContent, {
|
||||
data: {
|
||||
animal: "panda",
|
||||
},
|
||||
disableClose: true,
|
||||
});
|
||||
}
|
||||
|
||||
openNonDismissableWithNoButtonsDialog() {
|
||||
this.dialogService.open(NonDismissableWithNoButtonsContent, {
|
||||
data: {
|
||||
animal: "panda",
|
||||
},
|
||||
disableClose: true,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@Component({
|
||||
@@ -49,7 +76,60 @@ class StoryDialogComponent {
|
||||
`,
|
||||
imports: [ButtonModule, DialogModule],
|
||||
})
|
||||
class StoryDialogContentComponent {
|
||||
class SimpleDialogContent {
|
||||
constructor(
|
||||
public dialogRef: DialogRef,
|
||||
@Inject(DIALOG_DATA) private data: Animal,
|
||||
) {}
|
||||
|
||||
get animal() {
|
||||
return this.data?.animal;
|
||||
}
|
||||
}
|
||||
|
||||
@Component({
|
||||
template: `
|
||||
<bit-simple-dialog>
|
||||
<span bitDialogTitle>Dialog Title</span>
|
||||
<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>
|
||||
</ng-container>
|
||||
</bit-simple-dialog>
|
||||
`,
|
||||
imports: [ButtonModule, DialogModule],
|
||||
})
|
||||
class NonDismissableWithPrimaryButtonContent {
|
||||
constructor(
|
||||
public dialogRef: DialogRef,
|
||||
@Inject(DIALOG_DATA) private data: Animal,
|
||||
) {}
|
||||
|
||||
get animal() {
|
||||
return this.data?.animal;
|
||||
}
|
||||
}
|
||||
|
||||
@Component({
|
||||
template: `
|
||||
<bit-simple-dialog>
|
||||
<span bitDialogTitle>Dialog Title</span>
|
||||
<span bitDialogContent>
|
||||
Dialog body text goes here.
|
||||
<br />
|
||||
Animal: {{ animal }}
|
||||
</span>
|
||||
</bit-simple-dialog>
|
||||
`,
|
||||
imports: [ButtonModule, DialogModule],
|
||||
})
|
||||
class NonDismissableWithNoButtonsContent {
|
||||
constructor(
|
||||
public dialogRef: DialogRef,
|
||||
@Inject(DIALOG_DATA) private data: Animal,
|
||||
@@ -89,4 +169,29 @@ export default {
|
||||
|
||||
type Story = StoryObj<StoryDialogComponent>;
|
||||
|
||||
export const Default: Story = {};
|
||||
export const Default: Story = {
|
||||
play: async (context) => {
|
||||
const canvas = context.canvasElement;
|
||||
|
||||
const button = getAllByRole(canvas, "button")[0];
|
||||
await userEvent.click(button);
|
||||
},
|
||||
};
|
||||
|
||||
export const NonDismissableWithPrimaryButton: Story = {
|
||||
play: async (context) => {
|
||||
const canvas = context.canvasElement;
|
||||
|
||||
const button = getAllByRole(canvas, "button")[1];
|
||||
await userEvent.click(button);
|
||||
},
|
||||
};
|
||||
|
||||
export const NonDismissableWithNoButtons: Story = {
|
||||
play: async (context) => {
|
||||
const canvas = context.canvasElement;
|
||||
|
||||
const button = getAllByRole(canvas, "button")[2];
|
||||
await userEvent.click(button);
|
||||
},
|
||||
};
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
// FIXME: Update this file to be type safe and remove this and next line
|
||||
// @ts-strict-ignore
|
||||
import { Directive, HostBinding, HostListener, Input } from "@angular/core";
|
||||
import { Directive, HostBinding, HostListener, input } from "@angular/core";
|
||||
|
||||
import { DisclosureComponent } from "./disclosure.component";
|
||||
|
||||
@@ -12,17 +12,17 @@ export class DisclosureTriggerForDirective {
|
||||
/**
|
||||
* Accepts template reference for a bit-disclosure component instance
|
||||
*/
|
||||
@Input("bitDisclosureTriggerFor") disclosure: DisclosureComponent;
|
||||
readonly disclosure = input<DisclosureComponent>(undefined, { alias: "bitDisclosureTriggerFor" });
|
||||
|
||||
@HostBinding("attr.aria-expanded") get ariaExpanded() {
|
||||
return this.disclosure.open;
|
||||
return this.disclosure().open;
|
||||
}
|
||||
|
||||
@HostBinding("attr.aria-controls") get ariaControls() {
|
||||
return this.disclosure.id;
|
||||
return this.disclosure().id;
|
||||
}
|
||||
|
||||
@HostListener("click") click() {
|
||||
this.disclosure.open = !this.disclosure.open;
|
||||
this.disclosure().open = !this.disclosure().open;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -48,6 +48,8 @@ export class DisclosureComponent {
|
||||
/**
|
||||
* Optionally init the disclosure in its opened state
|
||||
*/
|
||||
// TODO: Skipped for signal migration because:
|
||||
// Accessor inputs cannot be migrated as they are too complex.
|
||||
@Input({ transform: booleanAttribute }) set open(isOpen: boolean) {
|
||||
this._open = isOpen;
|
||||
this.openChange.emit(isOpen);
|
||||
|
||||
@@ -1,8 +1,7 @@
|
||||
// FIXME: Update this file to be type safe and remove this and next line
|
||||
// @ts-strict-ignore
|
||||
import { coerceBooleanProperty } from "@angular/cdk/coercion";
|
||||
import { NgClass } from "@angular/common";
|
||||
import { Component, HostBinding, Input, AfterContentInit, contentChild } from "@angular/core";
|
||||
import { booleanAttribute, Component, contentChild, HostBinding, input, AfterContentInit } from "@angular/core";
|
||||
|
||||
import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service";
|
||||
import { I18nPipe } from "@bitwarden/ui-common";
|
||||
@@ -19,30 +18,18 @@ let nextId = 0;
|
||||
imports: [NgClass, TypographyDirective, I18nPipe],
|
||||
})
|
||||
export class FormControlComponent implements AfterContentInit {
|
||||
@Input() label: string;
|
||||
readonly label = input<string>();
|
||||
|
||||
private _inline = false;
|
||||
@Input() get inline() {
|
||||
return this._inline;
|
||||
}
|
||||
set inline(value: boolean | "") {
|
||||
this._inline = coerceBooleanProperty(value);
|
||||
}
|
||||
readonly inline = input(false, { transform: booleanAttribute });
|
||||
|
||||
private _disableMargin = false;
|
||||
@Input() set disableMargin(value: boolean | "") {
|
||||
this._disableMargin = coerceBooleanProperty(value);
|
||||
}
|
||||
get disableMargin() {
|
||||
return this._disableMargin;
|
||||
}
|
||||
readonly disableMargin = input(false, { transform: booleanAttribute });
|
||||
|
||||
formControl = contentChild(BitFormControlAbstraction);
|
||||
inputId = "";
|
||||
@HostBinding("class") get classes() {
|
||||
return []
|
||||
.concat(this.inline ? ["tw-inline-block", "tw-me-4"] : ["tw-block"])
|
||||
.concat(this.disableMargin ? [] : ["tw-mb-4"]);
|
||||
.concat(this.inline() ? ["tw-inline-block", "tw-me-4"] : ["tw-block"])
|
||||
.concat(this.disableMargin() ? [] : ["tw-mb-4"]);
|
||||
}
|
||||
|
||||
constructor(private i18nService: I18nService) {}
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
// FIXME: Update this file to be type safe and remove this and next line
|
||||
// @ts-strict-ignore
|
||||
import { CommonModule } from "@angular/common";
|
||||
import { Component, ElementRef, HostBinding, Input, Optional } from "@angular/core";
|
||||
import { Component, ElementRef, HostBinding, input, Optional } from "@angular/core";
|
||||
|
||||
import { FormControlComponent } from "./form-control.component";
|
||||
|
||||
@@ -12,6 +12,10 @@ let nextId = 0;
|
||||
selector: "bit-label",
|
||||
templateUrl: "label.component.html",
|
||||
imports: [CommonModule],
|
||||
host: {
|
||||
"[class]": "classList",
|
||||
"[id]": "id()",
|
||||
},
|
||||
})
|
||||
export class BitLabel {
|
||||
constructor(
|
||||
@@ -19,15 +23,19 @@ export class BitLabel {
|
||||
@Optional() private parentFormControl: FormControlComponent,
|
||||
) {}
|
||||
|
||||
@HostBinding("class") @Input() get classList() {
|
||||
return ["tw-inline-flex", "tw-gap-1", "tw-items-baseline", "tw-flex-row", "tw-min-w-0"];
|
||||
}
|
||||
readonly classList = [
|
||||
"tw-inline-flex",
|
||||
"tw-gap-1",
|
||||
"tw-items-baseline",
|
||||
"tw-flex-row",
|
||||
"tw-min-w-0",
|
||||
];
|
||||
|
||||
@HostBinding("title") get title() {
|
||||
return this.elementRef.nativeElement.textContent.trim();
|
||||
}
|
||||
|
||||
@HostBinding() @Input() id = `bit-label-${nextId++}`;
|
||||
readonly id = input(`bit-label-${nextId++}`);
|
||||
|
||||
get isInsideFormControl() {
|
||||
return !!this.parentFormControl;
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
// FIXME: Update this file to be type safe and remove this and next line
|
||||
// @ts-strict-ignore
|
||||
|
||||
import { Component, Input } from "@angular/core";
|
||||
import { Component, input } from "@angular/core";
|
||||
import { AbstractControl, UntypedFormGroup } from "@angular/forms";
|
||||
|
||||
import { I18nPipe } from "@bitwarden/ui-common";
|
||||
@@ -18,11 +18,10 @@ import { I18nPipe } from "@bitwarden/ui-common";
|
||||
imports: [I18nPipe],
|
||||
})
|
||||
export class BitErrorSummary {
|
||||
@Input()
|
||||
formGroup: UntypedFormGroup;
|
||||
readonly formGroup = input<UntypedFormGroup>();
|
||||
|
||||
get errorCount(): number {
|
||||
return this.getErrorCount(this.formGroup);
|
||||
return this.getErrorCount(this.formGroup());
|
||||
}
|
||||
|
||||
get errorString() {
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
// FIXME: Update this file to be type safe and remove this and next line
|
||||
// @ts-strict-ignore
|
||||
import { Component, HostBinding, Input } from "@angular/core";
|
||||
import { Component, HostBinding, input } from "@angular/core";
|
||||
|
||||
import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service";
|
||||
|
||||
@@ -18,37 +18,38 @@ let nextId = 0;
|
||||
export class BitErrorComponent {
|
||||
@HostBinding() id = `bit-error-${nextId++}`;
|
||||
|
||||
@Input() error: [string, any];
|
||||
readonly error = input<[string, any]>();
|
||||
|
||||
constructor(private i18nService: I18nService) {}
|
||||
|
||||
get displayError() {
|
||||
switch (this.error[0]) {
|
||||
const error = this.error();
|
||||
switch (error[0]) {
|
||||
case "required":
|
||||
return this.i18nService.t("inputRequired");
|
||||
case "email":
|
||||
return this.i18nService.t("inputEmail");
|
||||
case "minlength":
|
||||
return this.i18nService.t("inputMinLength", this.error[1]?.requiredLength);
|
||||
return this.i18nService.t("inputMinLength", error[1]?.requiredLength);
|
||||
case "maxlength":
|
||||
return this.i18nService.t("inputMaxLength", this.error[1]?.requiredLength);
|
||||
return this.i18nService.t("inputMaxLength", error[1]?.requiredLength);
|
||||
case "min":
|
||||
return this.i18nService.t("inputMinValue", this.error[1]?.min);
|
||||
return this.i18nService.t("inputMinValue", error[1]?.min);
|
||||
case "max":
|
||||
return this.i18nService.t("inputMaxValue", this.error[1]?.max);
|
||||
return this.i18nService.t("inputMaxValue", error[1]?.max);
|
||||
case "forbiddenCharacters":
|
||||
return this.i18nService.t("inputForbiddenCharacters", this.error[1]?.characters.join(", "));
|
||||
return this.i18nService.t("inputForbiddenCharacters", error[1]?.characters.join(", "));
|
||||
case "multipleEmails":
|
||||
return this.i18nService.t("multipleInputEmails");
|
||||
case "trim":
|
||||
return this.i18nService.t("inputTrimValidator");
|
||||
default:
|
||||
// Attempt to show a custom error message.
|
||||
if (this.error[1]?.message) {
|
||||
return this.error[1]?.message;
|
||||
if (error[1]?.message) {
|
||||
return error[1]?.message;
|
||||
}
|
||||
|
||||
return this.error;
|
||||
return error;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,4 +1,7 @@
|
||||
// FIXME: Update this file to be type safe and remove this and next line
|
||||
|
||||
import { ModelSignal, Signal } from "@angular/core";
|
||||
|
||||
// @ts-strict-ignore
|
||||
export type InputTypes =
|
||||
| "text"
|
||||
@@ -14,13 +17,13 @@ export type InputTypes =
|
||||
|
||||
export abstract class BitFormFieldControl {
|
||||
ariaDescribedBy: string;
|
||||
id: string;
|
||||
id: Signal<string>;
|
||||
labelForId: string;
|
||||
required: boolean;
|
||||
hasError: boolean;
|
||||
error: [string, any];
|
||||
type?: InputTypes;
|
||||
spellcheck?: boolean;
|
||||
type?: ModelSignal<InputTypes>;
|
||||
spellcheck?: ModelSignal<boolean | undefined>;
|
||||
readOnly?: boolean;
|
||||
focus?: () => void;
|
||||
}
|
||||
|
||||
@@ -9,9 +9,10 @@ import {
|
||||
ElementRef,
|
||||
HostBinding,
|
||||
HostListener,
|
||||
Input,
|
||||
ViewChild,
|
||||
signal,
|
||||
input,
|
||||
Input,
|
||||
} from "@angular/core";
|
||||
|
||||
import { I18nPipe } from "@bitwarden/ui-common";
|
||||
@@ -38,10 +39,11 @@ export class BitFormFieldComponent implements AfterContentChecked {
|
||||
|
||||
@ViewChild(BitErrorComponent) error: BitErrorComponent;
|
||||
|
||||
@Input({ transform: booleanAttribute })
|
||||
disableMargin = false;
|
||||
readonly disableMargin = input(false, { transform: booleanAttribute });
|
||||
|
||||
/** If `true`, remove the bottom border for `readonly` inputs */
|
||||
// TODO: Skipped for signal migration because:
|
||||
// Your application code writes to the input. This prevents migration.
|
||||
@Input({ transform: booleanAttribute })
|
||||
disableReadOnlyBorder = false;
|
||||
|
||||
@@ -76,7 +78,7 @@ export class BitFormFieldComponent implements AfterContentChecked {
|
||||
@HostBinding("class")
|
||||
get classList() {
|
||||
return ["tw-block"]
|
||||
.concat(this.disableMargin ? [] : ["tw-mb-4", "bit-compact:tw-mb-3"])
|
||||
.concat(this.disableMargin() ? [] : ["tw-mb-4", "bit-compact:tw-mb-3"])
|
||||
.concat(this.readOnly ? [] : "tw-pt-2");
|
||||
}
|
||||
|
||||
|
||||
@@ -72,6 +72,7 @@ export default {
|
||||
decorators: [
|
||||
moduleMetadata({
|
||||
imports: [
|
||||
A11yTitleDirective,
|
||||
FormsModule,
|
||||
ReactiveFormsModule,
|
||||
FormFieldModule,
|
||||
@@ -87,8 +88,8 @@ export default {
|
||||
SectionComponent,
|
||||
TextFieldModule,
|
||||
BadgeModule,
|
||||
A11yTitleDirective,
|
||||
],
|
||||
declarations: [A11yTitleDirective],
|
||||
providers: [
|
||||
{
|
||||
provide: I18nService,
|
||||
@@ -413,13 +414,18 @@ export const Select: Story = {
|
||||
|
||||
export const AdvancedSelect: Story = {
|
||||
render: (args) => ({
|
||||
props: args,
|
||||
props: {
|
||||
formObj: fb.group({
|
||||
select: "value1",
|
||||
}),
|
||||
...args,
|
||||
},
|
||||
template: /*html*/ `
|
||||
<bit-form-field>
|
||||
<bit-form-field [formGroup]="formObj">
|
||||
<bit-label>Label</bit-label>
|
||||
<bit-select>
|
||||
<bit-option label="Select"></bit-option>
|
||||
<bit-option label="Other"></bit-option>
|
||||
<bit-select formControlName="select">
|
||||
<bit-option label="Select" value="value1"></bit-option>
|
||||
<bit-option label="Other" value="value2"></bit-option>
|
||||
</bit-select>
|
||||
</bit-form-field>
|
||||
`,
|
||||
|
||||
@@ -5,7 +5,7 @@ import {
|
||||
Host,
|
||||
HostBinding,
|
||||
HostListener,
|
||||
Input,
|
||||
model,
|
||||
OnChanges,
|
||||
Output,
|
||||
} from "@angular/core";
|
||||
@@ -18,12 +18,15 @@ import { BitFormFieldComponent } from "./form-field.component";
|
||||
|
||||
@Directive({
|
||||
selector: "[bitPasswordInputToggle]",
|
||||
host: {
|
||||
"[attr.aria-pressed]": "toggled()",
|
||||
},
|
||||
})
|
||||
export class BitPasswordInputToggleDirective implements AfterContentInit, OnChanges {
|
||||
/**
|
||||
* Whether the input is toggled to show the password.
|
||||
*/
|
||||
@HostBinding("attr.aria-pressed") @Input() toggled = false;
|
||||
readonly toggled = model(false);
|
||||
@Output() toggledChange = new EventEmitter<boolean>();
|
||||
|
||||
@HostBinding("attr.title") title = this.i18nService.t("toggleVisibility");
|
||||
@@ -33,8 +36,8 @@ export class BitPasswordInputToggleDirective implements AfterContentInit, OnChan
|
||||
* Click handler to toggle the state of the input type.
|
||||
*/
|
||||
@HostListener("click") onClick() {
|
||||
this.toggled = !this.toggled;
|
||||
this.toggledChange.emit(this.toggled);
|
||||
this.toggled.update((toggled) => !toggled);
|
||||
this.toggledChange.emit(this.toggled());
|
||||
|
||||
this.update();
|
||||
}
|
||||
@@ -46,7 +49,7 @@ export class BitPasswordInputToggleDirective implements AfterContentInit, OnChan
|
||||
) {}
|
||||
|
||||
get icon() {
|
||||
return this.toggled ? "bwi-eye-slash" : "bwi-eye";
|
||||
return this.toggled() ? "bwi-eye-slash" : "bwi-eye";
|
||||
}
|
||||
|
||||
ngOnChanges(): void {
|
||||
@@ -55,16 +58,16 @@ export class BitPasswordInputToggleDirective implements AfterContentInit, OnChan
|
||||
|
||||
ngAfterContentInit(): void {
|
||||
if (this.formField.input?.type) {
|
||||
this.toggled = this.formField.input.type !== "password";
|
||||
this.toggled.set(this.formField.input.type() !== "password");
|
||||
}
|
||||
this.button.icon = this.icon;
|
||||
this.button.icon.set(this.icon);
|
||||
}
|
||||
|
||||
private update() {
|
||||
this.button.icon = this.icon;
|
||||
this.button.icon.set(this.icon);
|
||||
if (this.formField.input?.type != null) {
|
||||
this.formField.input.type = this.toggled ? "text" : "password";
|
||||
this.formField.input.spellcheck = this.toggled ? false : undefined;
|
||||
this.formField.input.type.set(this.toggled() ? "text" : "password");
|
||||
this.formField?.input?.spellcheck?.set(this.toggled() ? false : undefined);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -60,15 +60,15 @@ describe("PasswordInputToggle", () => {
|
||||
|
||||
describe("initial state", () => {
|
||||
it("has correct icon", () => {
|
||||
expect(button.icon).toBe("bwi-eye");
|
||||
expect(button.icon()).toBe("bwi-eye");
|
||||
});
|
||||
|
||||
it("input is type password", () => {
|
||||
expect(input.type).toBe("password");
|
||||
expect(input.type!()).toBe("password");
|
||||
});
|
||||
|
||||
it("spellcheck is disabled", () => {
|
||||
expect(input.spellcheck).toBe(undefined);
|
||||
expect(input.spellcheck!()).toBe(undefined);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -78,15 +78,15 @@ describe("PasswordInputToggle", () => {
|
||||
});
|
||||
|
||||
it("has correct icon", () => {
|
||||
expect(button.icon).toBe("bwi-eye-slash");
|
||||
expect(button.icon()).toBe("bwi-eye-slash");
|
||||
});
|
||||
|
||||
it("input is type text", () => {
|
||||
expect(input.type).toBe("text");
|
||||
expect(input.type!()).toBe("text");
|
||||
});
|
||||
|
||||
it("spellcheck is disabled", () => {
|
||||
expect(input.spellcheck).toBe(false);
|
||||
expect(input.spellcheck!()).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -97,15 +97,15 @@ describe("PasswordInputToggle", () => {
|
||||
});
|
||||
|
||||
it("has correct icon", () => {
|
||||
expect(button.icon).toBe("bwi-eye");
|
||||
expect(button.icon()).toBe("bwi-eye");
|
||||
});
|
||||
|
||||
it("input is type password", () => {
|
||||
expect(input.type).toBe("password");
|
||||
expect(input.type!()).toBe("password");
|
||||
});
|
||||
|
||||
it("spellcheck is disabled", () => {
|
||||
expect(input.spellcheck).toBe(undefined);
|
||||
expect(input.spellcheck!()).toBe(undefined);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,20 +1,21 @@
|
||||
import { Directive, HostBinding, Input, OnInit, Optional } from "@angular/core";
|
||||
import { Directive, OnInit, Optional } from "@angular/core";
|
||||
|
||||
import { BitIconButtonComponent } from "../icon-button/icon-button.component";
|
||||
|
||||
@Directive({
|
||||
selector: "[bitPrefix]",
|
||||
host: {
|
||||
"[class]": "classList",
|
||||
},
|
||||
})
|
||||
export class BitPrefixDirective implements OnInit {
|
||||
@HostBinding("class") @Input() get classList() {
|
||||
return ["tw-text-muted"];
|
||||
}
|
||||
readonly classList = ["tw-text-muted"];
|
||||
|
||||
constructor(@Optional() private iconButtonComponent: BitIconButtonComponent) {}
|
||||
|
||||
ngOnInit() {
|
||||
if (this.iconButtonComponent) {
|
||||
this.iconButtonComponent.size = "small";
|
||||
this.iconButtonComponent.size.set("small");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,20 +1,21 @@
|
||||
import { Directive, HostBinding, Input, OnInit, Optional } from "@angular/core";
|
||||
import { Directive, OnInit, Optional } from "@angular/core";
|
||||
|
||||
import { BitIconButtonComponent } from "../icon-button/icon-button.component";
|
||||
|
||||
@Directive({
|
||||
selector: "[bitSuffix]",
|
||||
host: {
|
||||
"[class]": "classList",
|
||||
},
|
||||
})
|
||||
export class BitSuffixDirective implements OnInit {
|
||||
@HostBinding("class") @Input() get classList() {
|
||||
return ["tw-text-muted"];
|
||||
}
|
||||
readonly classList = ["tw-text-muted"];
|
||||
|
||||
constructor(@Optional() private iconButtonComponent: BitIconButtonComponent) {}
|
||||
|
||||
ngOnInit() {
|
||||
if (this.iconButtonComponent) {
|
||||
this.iconButtonComponent.size = "small";
|
||||
this.iconButtonComponent.size.set("small");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,13 +1,6 @@
|
||||
import {
|
||||
AbstractControl,
|
||||
FormBuilder,
|
||||
FormsModule,
|
||||
ReactiveFormsModule,
|
||||
ValidationErrors,
|
||||
ValidatorFn,
|
||||
Validators,
|
||||
} from "@angular/forms";
|
||||
import { FormBuilder, FormsModule, ReactiveFormsModule, Validators } from "@angular/forms";
|
||||
import { Meta, StoryObj, moduleMetadata } from "@storybook/angular";
|
||||
import { userEvent, getByText } from "@storybook/test";
|
||||
|
||||
import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service";
|
||||
|
||||
@@ -15,6 +8,7 @@ import { ButtonModule } from "../button";
|
||||
import { CheckboxModule } from "../checkbox";
|
||||
import { FormControlModule } from "../form-control";
|
||||
import { FormFieldModule } from "../form-field";
|
||||
import { trimValidator, forbiddenCharacters } from "../form-field/bit-validators";
|
||||
import { InputModule } from "../input/input.module";
|
||||
import { MultiSelectModule } from "../multi-select";
|
||||
import { RadioButtonModule } from "../radio-button";
|
||||
@@ -48,13 +42,19 @@ export default {
|
||||
required: "required",
|
||||
checkboxRequired: "Option is required",
|
||||
inputRequired: "Input is required.",
|
||||
inputEmail: "Input is not an email-address.",
|
||||
inputEmail: "Input is not an email address.",
|
||||
inputForbiddenCharacters: (char) =>
|
||||
`The following characters are not allowed: "${char}"`,
|
||||
inputMinValue: (min) => `Input value must be at least ${min}.`,
|
||||
inputMaxValue: (max) => `Input value must not exceed ${max}.`,
|
||||
inputMinLength: (min) => `Input value must be at least ${min} characters long.`,
|
||||
inputMaxLength: (max) => `Input value must not exceed ${max} characters in length.`,
|
||||
inputTrimValidator: `Input must not contain only whitespace.`,
|
||||
multiSelectPlaceholder: "-- Type to Filter --",
|
||||
multiSelectLoading: "Retrieving options...",
|
||||
multiSelectNotFound: "No items found",
|
||||
multiSelectClearAll: "Clear all",
|
||||
fieldsNeedAttention: "__$1__ field(s) above need your attention.",
|
||||
});
|
||||
},
|
||||
},
|
||||
@@ -72,7 +72,7 @@ export default {
|
||||
const fb = new FormBuilder();
|
||||
const exampleFormObj = fb.group({
|
||||
name: ["", [Validators.required]],
|
||||
email: ["", [Validators.required, Validators.email, forbiddenNameValidator(/bit/i)]],
|
||||
email: ["", [Validators.required, Validators.email, forbiddenCharacters(["#"])]],
|
||||
country: [undefined as string | undefined, [Validators.required]],
|
||||
groups: [],
|
||||
terms: [false, [Validators.requiredTrue]],
|
||||
@@ -80,14 +80,6 @@ const exampleFormObj = fb.group({
|
||||
age: [null, [Validators.min(0), Validators.max(150)]],
|
||||
});
|
||||
|
||||
// Custom error message, `message` is shown as the error message
|
||||
function forbiddenNameValidator(nameRe: RegExp): ValidatorFn {
|
||||
return (control: AbstractControl): ValidationErrors | null => {
|
||||
const forbidden = nameRe.test(control.value);
|
||||
return forbidden ? { forbiddenName: { message: "forbiddenName" } } : null;
|
||||
};
|
||||
}
|
||||
|
||||
type Story = StoryObj;
|
||||
|
||||
export const FullExample: Story = {
|
||||
@@ -177,3 +169,95 @@ export const FullExample: Story = {
|
||||
],
|
||||
},
|
||||
};
|
||||
|
||||
const showValidationsFormObj = fb.group({
|
||||
required: ["", [Validators.required]],
|
||||
whitespace: [" ", trimValidator],
|
||||
email: ["example?bad-email", [Validators.email]],
|
||||
minLength: ["Hello", [Validators.minLength(8)]],
|
||||
maxLength: ["Hello there", [Validators.maxLength(8)]],
|
||||
minValue: [9, [Validators.min(10)]],
|
||||
maxValue: [15, [Validators.max(10)]],
|
||||
forbiddenChars: ["Th!$ value cont#in$ forbidden char$", forbiddenCharacters(["#", "!", "$"])],
|
||||
});
|
||||
|
||||
export const Validations: Story = {
|
||||
render: (args) => ({
|
||||
props: {
|
||||
formObj: showValidationsFormObj,
|
||||
submit: () => showValidationsFormObj.markAllAsTouched(),
|
||||
...args,
|
||||
},
|
||||
template: /*html*/ `
|
||||
<form [formGroup]="formObj" (ngSubmit)="submit()">
|
||||
<bit-form-field>
|
||||
<bit-label>Required validation</bit-label>
|
||||
<input bitInput formControlName="required" />
|
||||
<bit-hint>This field is required. Submit form or blur input to see error</bit-hint>
|
||||
</bit-form-field>
|
||||
|
||||
<bit-form-field>
|
||||
<bit-label>Email validation</bit-label>
|
||||
<input bitInput type="email" formControlName="email" />
|
||||
<bit-hint>This field contains a malformed email address. Submit form or blur input to see error</bit-hint>
|
||||
</bit-form-field>
|
||||
|
||||
<bit-form-field>
|
||||
<bit-label>Min length validation</bit-label>
|
||||
<input bitInput formControlName="minLength" />
|
||||
<bit-hint>Value must be at least 8 characters. Submit form or blur input to see error</bit-hint>
|
||||
</bit-form-field>
|
||||
|
||||
<bit-form-field>
|
||||
<bit-label>Max length validation</bit-label>
|
||||
<input bitInput formControlName="maxLength" />
|
||||
<bit-hint>Value must be less then 8 characters. Submit form or blur input to see error</bit-hint>
|
||||
</bit-form-field>
|
||||
|
||||
<bit-form-field>
|
||||
<bit-label>Min number value validation</bit-label>
|
||||
<input
|
||||
bitInput
|
||||
type="number"
|
||||
formControlName="minValue"
|
||||
/>
|
||||
<bit-hint>Value must be greater than 10. Submit form or blur input to see error</bit-hint>
|
||||
</bit-form-field>
|
||||
|
||||
<bit-form-field>
|
||||
<bit-label>Max number value validation</bit-label>
|
||||
<input
|
||||
bitInput
|
||||
type="number"
|
||||
formControlName="maxValue"
|
||||
/>
|
||||
<bit-hint>Value must be less than than 10. Submit form or blur input to see error</bit-hint>
|
||||
</bit-form-field>
|
||||
|
||||
<bit-form-field>
|
||||
<bit-label>Forbidden characters validation</bit-label>
|
||||
<input
|
||||
bitInput
|
||||
formControlName="forbiddenChars"
|
||||
/>
|
||||
<bit-hint>Value must not contain '#', '!' or '$'. Submit form or blur input to see error</bit-hint>
|
||||
</bit-form-field>
|
||||
|
||||
<bit-form-field>
|
||||
<bit-label>White space validation</bit-label>
|
||||
<input bitInput formControlName="whitespace" />
|
||||
<bit-hint>This input contains only white space. Submit form or blur input to see error</bit-hint>
|
||||
</bit-form-field>
|
||||
|
||||
<button type="submit" bitButton buttonType="primary">Submit</button>
|
||||
<bit-error-summary [formGroup]="formObj"></bit-error-summary>
|
||||
</form>
|
||||
`,
|
||||
}),
|
||||
play: async (context) => {
|
||||
const canvas = context.canvasElement;
|
||||
const submitButton = getByText(canvas, "Submit");
|
||||
|
||||
await userEvent.click(submitButton);
|
||||
},
|
||||
};
|
||||
|
||||
@@ -142,8 +142,20 @@ If a checkbox group has more than 4 options a
|
||||
|
||||
<Canvas of={checkboxStories.Default} />
|
||||
|
||||
## Validation messages
|
||||
|
||||
These are examples of our default validation error messages:
|
||||
|
||||
<Canvas of={formStories.Validations} />
|
||||
|
||||
## Accessibility
|
||||
|
||||
### Icon Buttons in Form Fields
|
||||
|
||||
When adding prefix or suffix icon buttons to a form field, be sure to use the `appA11yTitle`
|
||||
directive to provide a label for screenreaders. Typically, the label should follow this pattern:
|
||||
`{Action} {field label}`, i.e. "Copy username".
|
||||
|
||||
### Required Fields
|
||||
|
||||
- Use "(required)" in the label of each required form field styled the same as the field's helper
|
||||
@@ -152,12 +164,6 @@ If a checkbox group has more than 4 options a
|
||||
helper text.
|
||||
- **Example:** "Billing Email is required if owned by a business".
|
||||
|
||||
### Icon Buttons in Form Fields
|
||||
|
||||
When adding prefix or suffix icon buttons to a form field, be sure to use the `appA11yTitle`
|
||||
directive to provide a label for screenreaders. Typically, the label should follow this pattern:
|
||||
`{Action} {field label}`, i.e. "Copy username".
|
||||
|
||||
### Form Field Errors
|
||||
|
||||
- When a resting field is filled out, validation is triggered when the user de-focuses the field
|
||||
|
||||
@@ -9,7 +9,7 @@
|
||||
<i
|
||||
class="bwi bwi-spinner bwi-spin"
|
||||
aria-hidden="true"
|
||||
[ngClass]="{ 'bwi-lg': size === 'default' }"
|
||||
[ngClass]="{ 'bwi-lg': size() === 'default' }"
|
||||
></i>
|
||||
</span>
|
||||
</span>
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
// FIXME: Update this file to be type safe and remove this and next line
|
||||
// @ts-strict-ignore
|
||||
import { NgClass } from "@angular/common";
|
||||
import { Component, computed, ElementRef, HostBinding, Input, model } from "@angular/core";
|
||||
import { Component, computed, ElementRef, HostBinding, input, model } from "@angular/core";
|
||||
import { toObservable, toSignal } from "@angular/core/rxjs-interop";
|
||||
import { debounce, interval } from "rxjs";
|
||||
|
||||
@@ -167,11 +167,11 @@ const sizes: Record<IconButtonSize, string[]> = {
|
||||
},
|
||||
})
|
||||
export class BitIconButtonComponent implements ButtonLikeAbstraction, FocusableElement {
|
||||
@Input("bitIconButton") icon: string;
|
||||
readonly icon = model<string>(undefined, { alias: "bitIconButton" });
|
||||
|
||||
@Input() buttonType: IconButtonType = "main";
|
||||
readonly buttonType = input<IconButtonType>("main");
|
||||
|
||||
@Input() size: IconButtonSize = "default";
|
||||
readonly size = model<IconButtonSize>("default");
|
||||
|
||||
@HostBinding("class") get classList() {
|
||||
return [
|
||||
@@ -183,13 +183,15 @@ export class BitIconButtonComponent implements ButtonLikeAbstraction, FocusableE
|
||||
"hover:tw-no-underline",
|
||||
"focus:tw-outline-none",
|
||||
]
|
||||
.concat(styles[this.buttonType])
|
||||
.concat(sizes[this.size])
|
||||
.concat(this.showDisabledStyles() || this.disabled() ? disabledStyles[this.buttonType] : []);
|
||||
.concat(styles[this.buttonType()])
|
||||
.concat(sizes[this.size()])
|
||||
.concat(
|
||||
this.showDisabledStyles() || this.disabled() ? disabledStyles[this.buttonType()] : [],
|
||||
);
|
||||
}
|
||||
|
||||
get iconClass() {
|
||||
return [this.icon, "!tw-m-0"];
|
||||
return [this.icon(), "!tw-m-0"];
|
||||
}
|
||||
|
||||
protected disabledAttr = computed(() => {
|
||||
@@ -209,7 +211,7 @@ export class BitIconButtonComponent implements ButtonLikeAbstraction, FocusableE
|
||||
return this.showLoadingStyle() || (this.disabledAttr() && this.loading() === false);
|
||||
});
|
||||
|
||||
loading = model(false);
|
||||
readonly loading = model(false);
|
||||
|
||||
/**
|
||||
* Determine whether it is appropriate to display a loading spinner. We only want to show
|
||||
@@ -227,7 +229,7 @@ export class BitIconButtonComponent implements ButtonLikeAbstraction, FocusableE
|
||||
toObservable(this.loading).pipe(debounce((isLoading) => interval(isLoading ? 75 : 0))),
|
||||
);
|
||||
|
||||
disabled = model<boolean>(false);
|
||||
readonly disabled = model<boolean>(false);
|
||||
|
||||
getFocusTarget() {
|
||||
return this.elementRef.nativeElement;
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { Component, Input } from "@angular/core";
|
||||
import { Component, effect, input } from "@angular/core";
|
||||
import { DomSanitizer, SafeHtml } from "@angular/platform-browser";
|
||||
|
||||
import { Icon, isIcon } from "./icon";
|
||||
@@ -6,8 +6,8 @@ import { Icon, isIcon } from "./icon";
|
||||
@Component({
|
||||
selector: "bit-icon",
|
||||
host: {
|
||||
"[attr.aria-hidden]": "!ariaLabel",
|
||||
"[attr.aria-label]": "ariaLabel",
|
||||
"[attr.aria-hidden]": "!ariaLabel()",
|
||||
"[attr.aria-label]": "ariaLabel()",
|
||||
"[innerHtml]": "innerHtml",
|
||||
},
|
||||
template: ``,
|
||||
@@ -15,16 +15,18 @@ import { Icon, isIcon } from "./icon";
|
||||
export class BitIconComponent {
|
||||
innerHtml: SafeHtml | null = null;
|
||||
|
||||
@Input() set icon(icon: Icon) {
|
||||
if (!isIcon(icon)) {
|
||||
return;
|
||||
}
|
||||
readonly icon = input<Icon>();
|
||||
|
||||
const svg = icon.svg;
|
||||
this.innerHtml = this.domSanitizer.bypassSecurityTrustHtml(svg);
|
||||
readonly ariaLabel = input<string>();
|
||||
|
||||
constructor(private domSanitizer: DomSanitizer) {
|
||||
effect(() => {
|
||||
const icon = this.icon();
|
||||
if (!isIcon(icon)) {
|
||||
return;
|
||||
}
|
||||
const svg = icon.svg;
|
||||
this.innerHtml = this.domSanitizer.bypassSecurityTrustHtml(svg);
|
||||
});
|
||||
}
|
||||
|
||||
@Input() ariaLabel: string | undefined = undefined;
|
||||
|
||||
constructor(private domSanitizer: DomSanitizer) {}
|
||||
}
|
||||
|
||||
@@ -4,7 +4,6 @@ import { Icon, svgIcon } from "./icon";
|
||||
import { BitIconComponent } from "./icon.component";
|
||||
|
||||
describe("IconComponent", () => {
|
||||
let component: BitIconComponent;
|
||||
let fixture: ComponentFixture<BitIconComponent>;
|
||||
|
||||
beforeEach(async () => {
|
||||
@@ -13,14 +12,13 @@ describe("IconComponent", () => {
|
||||
}).compileComponents();
|
||||
|
||||
fixture = TestBed.createComponent(BitIconComponent);
|
||||
component = fixture.componentInstance;
|
||||
fixture.detectChanges();
|
||||
});
|
||||
|
||||
it("should have empty innerHtml when input is not an Icon", () => {
|
||||
const fakeIcon = { svg: "harmful user input" } as Icon;
|
||||
|
||||
component.icon = fakeIcon;
|
||||
fixture.componentRef.setInput("icon", fakeIcon);
|
||||
fixture.detectChanges();
|
||||
|
||||
const el = fixture.nativeElement as HTMLElement;
|
||||
@@ -30,7 +28,7 @@ describe("IconComponent", () => {
|
||||
it("should contain icon when input is a safe Icon", () => {
|
||||
const icon = svgIcon`<svg><text x="0" y="15">safe icon</text></svg>`;
|
||||
|
||||
component.icon = icon;
|
||||
fixture.componentRef.setInput("icon", icon);
|
||||
fixture.detectChanges();
|
||||
|
||||
const el = fixture.nativeElement as HTMLElement;
|
||||
|
||||
@@ -41,12 +41,14 @@ import { IconModule } from "@bitwarden/components";
|
||||
|
||||
- A non-comprehensive list of common colors and their associated classes is below:
|
||||
|
||||
| Hardcoded Value | Tailwind Stroke Class | Tailwind Fill Class | Tailwind Variable |
|
||||
| ---------------------------------------------------------------------------------------------------------------------------------------- | ------------------------- | ----------------------- | ----------------------- |
|
||||
| `#020F66` <span style={{ display: "inline-block", width: "8px", height: "8px", borderRadius: "50%", backgroundColor: "#020F66"}}></span> | `tw-stroke-art-primary` | `tw-fill-art-primary` | `--color-art-primary` |
|
||||
| `#10949D` <span style={{ display: "inline-block", width: "8px", height: "8px", borderRadius: "50%", backgroundColor: "#10949D"}}></span> | `tw-stroke-art-accent` | `tw-fill-art-accent` | `--color-art-accent` |
|
||||
| `#2CDDE9` <span style={{ display: "inline-block", width: "8px", height: "8px", borderRadius: "50%", backgroundColor: "#2CDDE9"}}></span> | `tw-stroke-art-accent` | `tw-fill-art-accent` | `--color-art-accent` |
|
||||
| `#89929F` <span style={{ display: "inline-block", width: "8px", height: "8px", borderRadius: "50%", backgroundColor: "#89929F"}}></span> | `tw-stroke-secondary-600` | `tw-fill-secondary-600` | `--color-secondary-600` |
|
||||
| Hardcoded Value | Tailwind Stroke Class | Tailwind Fill Class | Tailwind Variable |
|
||||
| ---------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------- | ----------------------------------- | ----------------------------------- |
|
||||
| `#020F66` <span style={{ display: "inline-block", width: "8px", height: "8px", borderRadius: "50%", backgroundColor: "#020F66"}}></span> | `tw-stroke-illustration-outline` | `tw-fill-illustration-outline` | `--color-illustration-outline` |
|
||||
| `#DBE5F6` <span style={{ display: "inline-block", width: "8px", height: "8px", borderRadius: "50%", backgroundColor: "#DBE5F6"}}></span> | `tw-stroke-illustration-bg-primary` | `tw-fill-illustration-bg-primary` | `--color-illustration-bg-primary` |
|
||||
| `#AAC3EF` <span style={{ display: "inline-block", width: "8px", height: "8px", borderRadius: "50%", backgroundColor: "#AAC3EF"}}></span> | `tw-stroke-illustration-bg-secondary` | `tw-fill-illustration-bg-secondary` | `--color-illustration-bg-secondary` |
|
||||
| `#FFFFFF` <span style={{ display: "inline-block", width: "8px", height: "8px", borderRadius: "50%", backgroundColor: "#FFFFFF"}}></span> | `tw-stroke-illustration-bg-tertiary` | `tw-fill-illustration-bg-tertiary` | `--color-illustration-bg-tertiary` |
|
||||
| `#FFBF00` <span style={{ display: "inline-block", width: "8px", height: "8px", borderRadius: "50%", backgroundColor: "#FFBF00"}}></span> | `tw-stroke-illustration-tertiary` | `tw-fill-illustration-tertiary` | `--color-illustration-tertiary` |
|
||||
| `#175DDC` <span style={{ display: "inline-block", width: "8px", height: "8px", borderRadius: "50%", backgroundColor: "#175DDC"}}></span> | `tw-stroke-illustration-logo` | `tw-fill-illustration-logo` | `--color-illustration-logo` |
|
||||
|
||||
- If the hex that you have on an SVG path is not listed above, there are a few ways to figure out
|
||||
the appropriate Tailwind class:
|
||||
@@ -56,20 +58,20 @@ import { IconModule } from "@bitwarden/components";
|
||||
- Click on an individual path on the SVG until you see the path's properties in the
|
||||
right-hand panel.
|
||||
- Scroll down to the Colors section.
|
||||
- Example: `Color/Art/Primary`
|
||||
- Example: `Color/Illustration/Outline`
|
||||
- This also includes Hex or RGB values that can be used to find the appropriate Tailwind
|
||||
variable as well if you follow the manual search option below.
|
||||
- Create the appropriate stroke or fill class from the color used.
|
||||
- Example: `Color/Art/Primary` corresponds to `--color-art-primary` which corresponds to
|
||||
`tw-stroke-art-primary` or `tw-fill-art-primary`.
|
||||
- Example: `Color/Illustration/Outline` corresponds to `--color-illustration-outline` which
|
||||
corresponds to `tw-stroke-illustration-outline` or `tw-fill-illustration-outline`.
|
||||
- **Option 2: Manual Search**
|
||||
- Take the path's stroke or fill hex value and convert it to RGB using a tool like
|
||||
[Hex to RGB](https://www.rgbtohex.net/hex-to-rgb/).
|
||||
- Search for the RGB value without commas in our `tw-theme.css` to find the Tailwind variable
|
||||
that corresponds to the color.
|
||||
- Create the appropriate stroke or fill class using the Tailwind variable.
|
||||
- Example: `--color-art-primary` corresponds to `tw-stroke-art-primary` or
|
||||
`tw-fill-art-primary`.
|
||||
- Example: `--color-illustration-outline` corresponds to `tw-stroke-illustration-outline`
|
||||
or `tw-fill-illustration-outline`.
|
||||
|
||||
6. **Remove any hardcoded width or height attributes** if your SVG has a configured
|
||||
[viewBox](https://developer.mozilla.org/en-US/docs/Web/SVG/Attribute/viewBox) attribute in order
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
export * from "./bitwarden-logo.icon";
|
||||
export * from "./bitwarden-shield.icon";
|
||||
export * from "./extension-bitwarden-logo.icon";
|
||||
export * from "./lock.icon";
|
||||
export * from "./generator";
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
export * from "./icon.module";
|
||||
export * from "./icon";
|
||||
export * as Icons from "./icons";
|
||||
export { AdminConsoleLogo, PasswordManagerLogo, SecretsManagerLogo } from "./logos";
|
||||
|
||||
File diff suppressed because one or more lines are too long
4
libs/components/src/icon/logos/bitwarden/index.ts
Normal file
4
libs/components/src/icon/logos/bitwarden/index.ts
Normal file
@@ -0,0 +1,4 @@
|
||||
export { default as AdminConsoleLogo } from "./admin-console";
|
||||
export * from "./shield";
|
||||
export { default as PasswordManagerLogo } from "./password-manager";
|
||||
export { default as SecretsManagerLogo } from "./secrets-manager";
|
||||
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
@@ -1,7 +1,16 @@
|
||||
import { svgIcon } from "../icon";
|
||||
import { svgIcon } from "../../icon";
|
||||
|
||||
export const BitwardenShield = svgIcon`
|
||||
<svg viewBox="0 0 120 132" xmlns="http://www.w3.org/2000/svg">
|
||||
/**
|
||||
* Shield logo with extra space in the viewbox.
|
||||
*/
|
||||
const AnonLayoutBitwardenShield = svgIcon`
|
||||
<svg viewBox="0 0 120 132" xmlns="http://www.w3.org/2000/svg">
|
||||
<path class="tw-fill-marketing-logo" d="M82.2944 69.1899V37.2898H60V93.9624C63.948 91.869 67.4812 89.5927 70.5998 87.1338C78.3962 81.0196 82.2944 75.0383 82.2944 69.1899ZM91.8491 30.9097V69.1899C91.8491 72.0477 91.2934 74.8805 90.182 77.6883C89.0706 80.4962 87.6938 82.9884 86.0516 85.1649C84.4094 87.3415 82.452 89.4598 80.1794 91.5201C77.9068 93.5803 75.8084 95.2916 73.8842 96.654C71.96 98.0164 69.9528 99.304 67.8627 100.517C65.7726 101.73 64.288 102.552 63.4088 102.984C62.5297 103.416 61.8247 103.748 61.2939 103.981C60.8958 104.18 60.4645 104.28 60 104.28C59.5355 104.28 59.1042 104.18 58.7061 103.981C58.1753 103.748 57.4703 103.416 56.5911 102.984C55.712 102.552 54.2273 101.73 52.1372 100.517C50.0471 99.304 48.04 98.0164 46.1158 96.654C44.1916 95.2916 42.0932 93.5803 39.8206 91.5201C37.548 89.4598 35.5906 87.3415 33.9484 85.1649C32.3062 82.9884 30.9294 80.4962 29.818 77.6883C28.7066 74.8805 28.1509 72.0477 28.1509 69.1899V30.9097C28.1509 30.0458 28.4661 29.2981 29.0964 28.6668C29.7267 28.0354 30.4732 27.7197 31.3358 27.7197H88.6642C89.5268 27.7197 90.2732 28.0354 90.9036 28.6668C91.5339 29.2981 91.8491 30.0458 91.8491 30.9097Z" />
|
||||
</svg>
|
||||
`;
|
||||
|
||||
const BitwardenShield = svgIcon`
|
||||
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 28 33"><path d="M26.696.403A1.274 1.274 0 0 0 25.764 0H1.83C1.467 0 1.16.137.898.403a1.294 1.294 0 0 0-.398.944v16.164c0 1.203.235 2.405.697 3.587.462 1.188 1.038 2.24 1.728 3.155.682.922 1.5 1.815 2.453 2.68a28.077 28.077 0 0 0 2.63 2.167 32.181 32.181 0 0 0 2.518 1.628c.875.511 1.493.857 1.863 1.045.37.18.661.324.882.417.163.087.348.13.54.13.192 0 .377-.043.54-.13.221-.1.52-.237.882-.417.37-.18.989-.534 1.863-1.045a34.4 34.4 0 0 0 2.517-1.628c.804-.576 1.679-1.296 2.631-2.168a20.206 20.206 0 0 0 2.454-2.68 13.599 13.599 0 0 0 1.72-3.154c.463-1.189.697-2.384.697-3.587V1.347a1.406 1.406 0 0 0-.42-.944ZM23.61 17.662c0 5.849-9.813 10.89-9.813 10.89V3.458h9.813v14.205Z" class="tw-fill-marketing-logo"/></svg>
|
||||
`;
|
||||
|
||||
export { AnonLayoutBitwardenShield, BitwardenShield };
|
||||
1
libs/components/src/icon/logos/index.ts
Normal file
1
libs/components/src/icon/logos/index.ts
Normal file
@@ -0,0 +1 @@
|
||||
export * from "./bitwarden";
|
||||
@@ -1,6 +1,14 @@
|
||||
// FIXME: Update this file to be type safe and remove this and next line
|
||||
// @ts-strict-ignore
|
||||
import { AfterContentChecked, Directive, ElementRef, Input, NgZone, Optional } from "@angular/core";
|
||||
import {
|
||||
AfterContentChecked,
|
||||
booleanAttribute,
|
||||
Directive,
|
||||
ElementRef,
|
||||
input,
|
||||
NgZone,
|
||||
Optional,
|
||||
} from "@angular/core";
|
||||
import { take } from "rxjs/operators";
|
||||
|
||||
import { Utils } from "@bitwarden/common/platform/misc/utils";
|
||||
@@ -21,11 +29,7 @@ import { FocusableElement } from "../shared/focusable-element";
|
||||
selector: "[appAutofocus], [bitAutofocus]",
|
||||
})
|
||||
export class AutofocusDirective implements AfterContentChecked {
|
||||
@Input() set appAutofocus(condition: boolean | string) {
|
||||
this.autofocus = condition === "" || condition === true;
|
||||
}
|
||||
|
||||
private autofocus: boolean;
|
||||
readonly appAutofocus = input(undefined, { transform: booleanAttribute });
|
||||
|
||||
// Track if we have already focused the element.
|
||||
private focused = false;
|
||||
@@ -46,7 +50,7 @@ export class AutofocusDirective implements AfterContentChecked {
|
||||
*/
|
||||
ngAfterContentChecked() {
|
||||
// We only want to focus the element on initial render and it's not a mobile browser
|
||||
if (this.focused || !this.autofocus || Utils.isMobileBrowser) {
|
||||
if (this.focused || !this.appAutofocus() || Utils.isMobileBrowser) {
|
||||
return;
|
||||
}
|
||||
|
||||
|
||||
21
libs/components/src/input/autofocus.mdx
Normal file
21
libs/components/src/input/autofocus.mdx
Normal file
@@ -0,0 +1,21 @@
|
||||
import { Meta, Canvas, Primary, Controls, Title, Description } from "@storybook/addon-docs";
|
||||
|
||||
import * as stories from "./autofocus.stories";
|
||||
|
||||
<Meta of={stories} />
|
||||
|
||||
```ts
|
||||
import { AutofocusDirective } from "@bitwarden/components";
|
||||
```
|
||||
|
||||
<Title />
|
||||
<Description />
|
||||
|
||||
<Primary />
|
||||
<Controls />
|
||||
|
||||
## Accessibility
|
||||
|
||||
The autofocus directive has accessibility implications, because it will steal focus from wherever it
|
||||
would naturally be placed on page load. Please consider whether or not the user truly needs the
|
||||
element truly needs to be manually focused on their behalf.
|
||||
26
libs/components/src/input/autofocus.stories.ts
Normal file
26
libs/components/src/input/autofocus.stories.ts
Normal file
@@ -0,0 +1,26 @@
|
||||
import { Meta, moduleMetadata, StoryObj } from "@storybook/angular";
|
||||
|
||||
import { FormFieldModule } from "../form-field";
|
||||
|
||||
import { AutofocusDirective } from "./autofocus.directive";
|
||||
|
||||
export default {
|
||||
title: "Component Library/Form/Autofocus Directive",
|
||||
component: AutofocusDirective,
|
||||
decorators: [
|
||||
moduleMetadata({
|
||||
imports: [AutofocusDirective, FormFieldModule],
|
||||
}),
|
||||
],
|
||||
} as Meta;
|
||||
|
||||
export const AutofocusField: StoryObj = {
|
||||
render: (args) => ({
|
||||
template: /*html*/ `
|
||||
<bit-form-field>
|
||||
<bit-label>Email</bit-label>
|
||||
<input bitInput formControlName="email" appAutofocus />
|
||||
</bit-form-field>
|
||||
`,
|
||||
}),
|
||||
};
|
||||
@@ -9,6 +9,8 @@ import {
|
||||
NgZone,
|
||||
Optional,
|
||||
Self,
|
||||
input,
|
||||
model,
|
||||
} from "@angular/core";
|
||||
import { NgControl, Validators } from "@angular/forms";
|
||||
|
||||
@@ -30,9 +32,15 @@ export function inputBorderClasses(error: boolean) {
|
||||
@Directive({
|
||||
selector: "input[bitInput], select[bitInput], textarea[bitInput]",
|
||||
providers: [{ provide: BitFormFieldControl, useExisting: BitInputDirective }],
|
||||
host: {
|
||||
"[class]": "classList()",
|
||||
"[id]": "id()",
|
||||
"[attr.type]": "type()",
|
||||
"[attr.spellcheck]": "spellcheck()",
|
||||
},
|
||||
})
|
||||
export class BitInputDirective implements BitFormFieldControl {
|
||||
@HostBinding("class") @Input() get classList() {
|
||||
classList() {
|
||||
const classes = [
|
||||
"tw-block",
|
||||
"tw-w-full",
|
||||
@@ -52,7 +60,7 @@ export class BitInputDirective implements BitFormFieldControl {
|
||||
return classes.filter((s) => s != "");
|
||||
}
|
||||
|
||||
@HostBinding() @Input() id = `bit-input-${nextId++}`;
|
||||
readonly id = input(`bit-input-${nextId++}`);
|
||||
|
||||
@HostBinding("attr.aria-describedby") ariaDescribedBy: string;
|
||||
|
||||
@@ -60,10 +68,12 @@ export class BitInputDirective implements BitFormFieldControl {
|
||||
return this.hasError ? true : undefined;
|
||||
}
|
||||
|
||||
@HostBinding("attr.type") @Input() type?: InputTypes;
|
||||
readonly type = model<InputTypes>();
|
||||
|
||||
@HostBinding("attr.spellcheck") @Input() spellcheck?: boolean;
|
||||
readonly spellcheck = model<boolean>();
|
||||
|
||||
// TODO: Skipped for signal migration because:
|
||||
// Accessor inputs cannot be migrated as they are too complex.
|
||||
@HostBinding()
|
||||
@Input()
|
||||
get required() {
|
||||
@@ -74,13 +84,13 @@ export class BitInputDirective implements BitFormFieldControl {
|
||||
}
|
||||
private _required: boolean;
|
||||
|
||||
@Input() hasPrefix = false;
|
||||
@Input() hasSuffix = false;
|
||||
readonly hasPrefix = input(false);
|
||||
readonly hasSuffix = input(false);
|
||||
|
||||
@Input() showErrorsWhenDisabled? = false;
|
||||
readonly showErrorsWhenDisabled = input<boolean>(false);
|
||||
|
||||
get labelForId(): string {
|
||||
return this.id;
|
||||
return this.id();
|
||||
}
|
||||
|
||||
@HostListener("input")
|
||||
@@ -89,7 +99,7 @@ export class BitInputDirective implements BitFormFieldControl {
|
||||
}
|
||||
|
||||
get hasError() {
|
||||
if (this.showErrorsWhenDisabled) {
|
||||
if (this.showErrorsWhenDisabled()) {
|
||||
return (
|
||||
(this.ngControl?.status === "INVALID" || this.ngControl?.status === "DISABLED") &&
|
||||
this.ngControl?.touched &&
|
||||
|
||||
@@ -8,8 +8,8 @@
|
||||
>
|
||||
<div
|
||||
[ngClass]="{
|
||||
'tw-truncate': truncate,
|
||||
'tw-text-wrap tw-overflow-auto tw-break-words': !truncate,
|
||||
'tw-truncate': truncate(),
|
||||
'tw-text-wrap tw-overflow-auto tw-break-words': !truncate(),
|
||||
}"
|
||||
>
|
||||
<ng-content></ng-content>
|
||||
@@ -22,8 +22,8 @@
|
||||
bitTypography="helper"
|
||||
class="tw-text-muted tw-w-full"
|
||||
[ngClass]="{
|
||||
'tw-truncate': truncate,
|
||||
'tw-text-wrap tw-overflow-auto tw-break-words': !truncate,
|
||||
'tw-truncate': truncate(),
|
||||
'tw-text-wrap tw-overflow-auto tw-break-words': !truncate(),
|
||||
}"
|
||||
>
|
||||
<ng-content select="[slot=secondary]"></ng-content>
|
||||
|
||||
@@ -7,9 +7,9 @@ import {
|
||||
ChangeDetectionStrategy,
|
||||
Component,
|
||||
ElementRef,
|
||||
Input,
|
||||
signal,
|
||||
ViewChild,
|
||||
input,
|
||||
} from "@angular/core";
|
||||
|
||||
import { TypographyModule } from "../typography";
|
||||
@@ -39,7 +39,7 @@ export class ItemContentComponent implements AfterContentChecked {
|
||||
*
|
||||
* Default behavior is truncation.
|
||||
*/
|
||||
@Input() truncate = true;
|
||||
readonly truncate = input(true);
|
||||
|
||||
ngAfterContentChecked(): void {
|
||||
this.endSlotHasChildren.set(this.endSlot?.nativeElement.childElementCount > 0);
|
||||
|
||||
@@ -18,7 +18,7 @@ import { ItemActionComponent } from "./item-action.component";
|
||||
providers: [{ provide: A11yRowDirective, useExisting: ItemComponent }],
|
||||
host: {
|
||||
class:
|
||||
"tw-block tw-box-border tw-overflow-hidden tw-flex tw-bg-background [&:has([data-item-main-content]_button:hover,[data-item-main-content]_a:hover)]:tw-cursor-pointer [&:has([data-item-main-content]_button:hover,[data-item-main-content]_a:hover)]:tw-bg-primary-100 tw-text-main tw-border-solid tw-border-b tw-border-0 [&:not(bit-layout_*)]:tw-rounded-lg bit-compact:[&:not(bit-layout_*)]:tw-rounded-none bit-compact:[&:not(bit-layout_*)]:last-of-type:tw-rounded-b-lg bit-compact:[&:not(bit-layout_*)]:first-of-type:tw-rounded-t-lg tw-min-h-9 tw-mb-1.5 bit-compact:tw-mb-0",
|
||||
"tw-block tw-box-border tw-overflow-hidden tw-flex tw-bg-background [&:has([data-item-main-content]_button:hover,[data-item-main-content]_a:hover)]:tw-cursor-pointer [&:has([data-item-main-content]_button:hover,[data-item-main-content]_a:hover)]:tw-bg-hover-default tw-text-main tw-border-solid tw-border-b tw-border-0 [&:not(bit-layout_*)]:tw-rounded-lg bit-compact:[&:not(bit-layout_*)]:tw-rounded-none bit-compact:[&:not(bit-layout_*)]:last-of-type:tw-rounded-b-lg bit-compact:[&:not(bit-layout_*)]:first-of-type:tw-rounded-t-lg tw-min-h-9 tw-mb-1.5 bit-compact:tw-mb-0",
|
||||
},
|
||||
})
|
||||
export class ItemComponent extends A11yRowDirective {
|
||||
|
||||
@@ -55,207 +55,15 @@ export const WithContent: Story = {
|
||||
template: /* HTML */ `
|
||||
<bit-layout>
|
||||
<bit-side-nav>
|
||||
<bit-nav-item text="Item A" route="#" icon="bwi-lock"></bit-nav-item>
|
||||
<bit-nav-group text="Tree A" icon="bwi-family" [open]="true">
|
||||
<bit-nav-group
|
||||
text="Level 1 - with children (empty)"
|
||||
route="#"
|
||||
icon="bwi-collection-shared"
|
||||
variant="tree"
|
||||
></bit-nav-group>
|
||||
<bit-nav-item
|
||||
text="Level 1 - no children"
|
||||
route="#"
|
||||
icon="bwi-collection-shared"
|
||||
variant="tree"
|
||||
></bit-nav-item>
|
||||
<bit-nav-group
|
||||
text="Level 1 - with children"
|
||||
route="#"
|
||||
icon="bwi-collection-shared"
|
||||
variant="tree"
|
||||
[open]="true"
|
||||
>
|
||||
<bit-nav-group
|
||||
text="Level 2 - with children"
|
||||
route="#"
|
||||
icon="bwi-collection-shared"
|
||||
variant="tree"
|
||||
[open]="true"
|
||||
>
|
||||
<bit-nav-item
|
||||
text="Level 3 - no children, no icon"
|
||||
route="#"
|
||||
variant="tree"
|
||||
></bit-nav-item>
|
||||
<bit-nav-group
|
||||
text="Level 3 - with children"
|
||||
route="#"
|
||||
icon="bwi-collection-shared"
|
||||
variant="tree"
|
||||
[open]="true"
|
||||
>
|
||||
<bit-nav-item
|
||||
text="Level 4 - no children, no icon"
|
||||
route="#"
|
||||
variant="tree"
|
||||
></bit-nav-item>
|
||||
</bit-nav-group>
|
||||
</bit-nav-group>
|
||||
<bit-nav-group
|
||||
text="Level 2 - with children (empty)"
|
||||
route="#"
|
||||
icon="bwi-collection-shared"
|
||||
variant="tree"
|
||||
[open]="true"
|
||||
></bit-nav-group>
|
||||
<bit-nav-item
|
||||
text="Level 2 - no children"
|
||||
route="#"
|
||||
icon="bwi-collection-shared"
|
||||
variant="tree"
|
||||
></bit-nav-item>
|
||||
</bit-nav-group>
|
||||
<bit-nav-item
|
||||
text="Level 1 - no children"
|
||||
route="#"
|
||||
icon="bwi-collection-shared"
|
||||
variant="tree"
|
||||
></bit-nav-item>
|
||||
<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>
|
||||
<bit-nav-item text="Child B" route="b"></bit-nav-item>
|
||||
<bit-nav-item text="Child C" route="c" icon="bwi-filter"></bit-nav-item>
|
||||
</bit-nav-group>
|
||||
<bit-nav-group text="Tree B" icon="bwi-collection-shared" [open]="true">
|
||||
<bit-nav-group
|
||||
text="Level 1 - with children (empty)"
|
||||
route="#"
|
||||
icon="bwi-collection-shared"
|
||||
variant="tree"
|
||||
></bit-nav-group>
|
||||
<bit-nav-item
|
||||
text="Level 1 - no children"
|
||||
route="#"
|
||||
icon="bwi-collection-shared"
|
||||
variant="tree"
|
||||
></bit-nav-item>
|
||||
<bit-nav-group
|
||||
text="Level 1 - with children"
|
||||
route="#"
|
||||
icon="bwi-collection-shared"
|
||||
variant="tree"
|
||||
[open]="true"
|
||||
>
|
||||
<bit-nav-group
|
||||
text="Level 2 - with children"
|
||||
route="#"
|
||||
icon="bwi-collection-shared"
|
||||
variant="tree"
|
||||
[open]="true"
|
||||
>
|
||||
<bit-nav-item
|
||||
text="Level 3 - no children, no icon"
|
||||
route="#"
|
||||
variant="tree"
|
||||
></bit-nav-item>
|
||||
<bit-nav-group
|
||||
text="Level 3 - with children"
|
||||
route="#"
|
||||
icon="bwi-collection-shared"
|
||||
variant="tree"
|
||||
[open]="true"
|
||||
>
|
||||
<bit-nav-item
|
||||
text="Level 4 - no children, no icon"
|
||||
route="#"
|
||||
variant="tree"
|
||||
></bit-nav-item>
|
||||
</bit-nav-group>
|
||||
</bit-nav-group>
|
||||
<bit-nav-group
|
||||
text="Level 2 - with children (empty)"
|
||||
route="#"
|
||||
icon="bwi-collection-shared"
|
||||
variant="tree"
|
||||
[open]="true"
|
||||
></bit-nav-group>
|
||||
<bit-nav-item
|
||||
text="Level 2 - no children"
|
||||
route="#"
|
||||
icon="bwi-collection-shared"
|
||||
variant="tree"
|
||||
></bit-nav-item>
|
||||
</bit-nav-group>
|
||||
<bit-nav-item
|
||||
text="Level 1 - no children"
|
||||
route="#"
|
||||
icon="bwi-collection-shared"
|
||||
variant="tree"
|
||||
></bit-nav-item>
|
||||
</bit-nav-group>
|
||||
<bit-nav-group text="Tree C" icon="bwi-key" [open]="true">
|
||||
<bit-nav-group
|
||||
text="Level 1 - with children (empty)"
|
||||
route="#"
|
||||
icon="bwi-collection-shared"
|
||||
variant="tree"
|
||||
></bit-nav-group>
|
||||
<bit-nav-item
|
||||
text="Level 1 - no children"
|
||||
route="#"
|
||||
icon="bwi-collection-shared"
|
||||
variant="tree"
|
||||
></bit-nav-item>
|
||||
<bit-nav-group
|
||||
text="Level 1 - with children"
|
||||
route="#"
|
||||
icon="bwi-collection-shared"
|
||||
variant="tree"
|
||||
[open]="true"
|
||||
>
|
||||
<bit-nav-group
|
||||
text="Level 2 - with children"
|
||||
route="#"
|
||||
icon="bwi-collection-shared"
|
||||
variant="tree"
|
||||
[open]="true"
|
||||
>
|
||||
<bit-nav-item
|
||||
text="Level 3 - no children, no icon"
|
||||
route="#"
|
||||
variant="tree"
|
||||
></bit-nav-item>
|
||||
<bit-nav-group
|
||||
text="Level 3 - with children"
|
||||
route="#"
|
||||
icon="bwi-collection-shared"
|
||||
variant="tree"
|
||||
[open]="true"
|
||||
>
|
||||
<bit-nav-item
|
||||
text="Level 4 - no children, no icon"
|
||||
route="#"
|
||||
variant="tree"
|
||||
></bit-nav-item>
|
||||
</bit-nav-group>
|
||||
</bit-nav-group>
|
||||
<bit-nav-group
|
||||
text="Level 2 - with children (empty)"
|
||||
route="#"
|
||||
icon="bwi-collection-shared"
|
||||
variant="tree"
|
||||
[open]="true"
|
||||
></bit-nav-group>
|
||||
<bit-nav-item
|
||||
text="Level 2 - no children"
|
||||
route="#"
|
||||
icon="bwi-collection-shared"
|
||||
variant="tree"
|
||||
></bit-nav-item>
|
||||
</bit-nav-group>
|
||||
<bit-nav-item
|
||||
text="Level 1 - no children"
|
||||
route="#"
|
||||
icon="bwi-collection-shared"
|
||||
variant="tree"
|
||||
></bit-nav-item>
|
||||
<bit-nav-group text="Lorem Ipsum (Button)" icon="bwi-filter">
|
||||
<bit-nav-item text="Child A" icon="bwi-filter"></bit-nav-item>
|
||||
<bit-nav-item text="Child B"></bit-nav-item>
|
||||
<bit-nav-item text="Child C" icon="bwi-filter"></bit-nav-item>
|
||||
</bit-nav-group>
|
||||
</bit-side-nav>
|
||||
<bit-callout title="Foobar"> Hello world! </bit-callout>
|
||||
@@ -277,77 +85,15 @@ export const Secondary: Story = {
|
||||
template: /* HTML */ `
|
||||
<bit-layout>
|
||||
<bit-side-nav variant="secondary">
|
||||
<bit-nav-item text="Item A" icon="bwi-collection-shared"></bit-nav-item>
|
||||
<bit-nav-item text="Item B" icon="bwi-collection-shared"></bit-nav-item>
|
||||
<bit-nav-divider></bit-nav-divider>
|
||||
<bit-nav-item text="Item C" icon="bwi-collection-shared"></bit-nav-item>
|
||||
<bit-nav-item text="Item D" icon="bwi-collection-shared"></bit-nav-item>
|
||||
<bit-nav-group text="Tree example" icon="bwi-collection-shared" [open]="true">
|
||||
<bit-nav-group
|
||||
text="Level 1 - with children (empty)"
|
||||
route="#"
|
||||
icon="bwi-collection-shared"
|
||||
variant="tree"
|
||||
></bit-nav-group>
|
||||
<bit-nav-item
|
||||
text="Level 1 - no children"
|
||||
route="#"
|
||||
icon="bwi-collection-shared"
|
||||
variant="tree"
|
||||
></bit-nav-item>
|
||||
<bit-nav-group
|
||||
text="Level 1 - with children"
|
||||
route="#"
|
||||
icon="bwi-collection-shared"
|
||||
variant="tree"
|
||||
[open]="true"
|
||||
>
|
||||
<bit-nav-group
|
||||
text="Level 2 - with children"
|
||||
route="#"
|
||||
icon="bwi-collection-shared"
|
||||
variant="tree"
|
||||
[open]="true"
|
||||
>
|
||||
<bit-nav-item
|
||||
text="Level 3 - no children, no icon"
|
||||
route="#"
|
||||
variant="tree"
|
||||
></bit-nav-item>
|
||||
<bit-nav-group
|
||||
text="Level 3 - with children"
|
||||
route="#"
|
||||
icon="bwi-collection-shared"
|
||||
variant="tree"
|
||||
[open]="true"
|
||||
>
|
||||
<bit-nav-item
|
||||
text="Level 4 - no children, no icon"
|
||||
route="#"
|
||||
variant="tree"
|
||||
></bit-nav-item>
|
||||
</bit-nav-group>
|
||||
</bit-nav-group>
|
||||
<bit-nav-group
|
||||
text="Level 2 - with children (empty)"
|
||||
route="#"
|
||||
icon="bwi-collection-shared"
|
||||
variant="tree"
|
||||
[open]="true"
|
||||
></bit-nav-group>
|
||||
<bit-nav-item
|
||||
text="Level 2 - no children"
|
||||
route="#"
|
||||
icon="bwi-collection-shared"
|
||||
variant="tree"
|
||||
></bit-nav-item>
|
||||
</bit-nav-group>
|
||||
<bit-nav-item
|
||||
text="Level 1 - no children"
|
||||
route="#"
|
||||
icon="bwi-collection-shared"
|
||||
variant="tree"
|
||||
></bit-nav-item>
|
||||
<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>
|
||||
<bit-nav-item text="Child B" route="b"></bit-nav-item>
|
||||
<bit-nav-item text="Child C" route="c" icon="bwi-filter"></bit-nav-item>
|
||||
</bit-nav-group>
|
||||
<bit-nav-group text="Lorem Ipsum (Button)" icon="bwi-filter">
|
||||
<bit-nav-item text="Child A" icon="bwi-filter"></bit-nav-item>
|
||||
<bit-nav-item text="Child B"></bit-nav-item>
|
||||
<bit-nav-item text="Child C" icon="bwi-filter"></bit-nav-item>
|
||||
</bit-nav-group>
|
||||
</bit-side-nav>
|
||||
<bit-callout title="Foobar"> Hello world! </bit-callout>
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { Input, HostBinding, Directive } from "@angular/core";
|
||||
import { input, HostBinding, Directive } from "@angular/core";
|
||||
|
||||
export type LinkType = "primary" | "secondary" | "contrast" | "light";
|
||||
|
||||
@@ -62,8 +62,7 @@ const commonStyles = [
|
||||
|
||||
@Directive()
|
||||
abstract class LinkDirective {
|
||||
@Input()
|
||||
linkType: LinkType = "primary";
|
||||
readonly linkType = input<LinkType>("primary");
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -81,7 +80,7 @@ export class AnchorLinkDirective extends LinkDirective {
|
||||
@HostBinding("class") get classList() {
|
||||
return ["before:-tw-inset-y-[0.125rem]"]
|
||||
.concat(commonStyles)
|
||||
.concat(linkStyles[this.linkType] ?? []);
|
||||
.concat(linkStyles[this.linkType()] ?? []);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -92,6 +91,6 @@ export class ButtonLinkDirective extends LinkDirective {
|
||||
@HostBinding("class") get classList() {
|
||||
return ["before:-tw-inset-y-[0.25rem]"]
|
||||
.concat(commonStyles)
|
||||
.concat(linkStyles[this.linkType] ?? []);
|
||||
.concat(linkStyles[this.linkType()] ?? []);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -20,7 +20,7 @@ export class MenuItemDirective implements FocusableOption {
|
||||
"tw-border-none",
|
||||
"tw-bg-background",
|
||||
"tw-text-left",
|
||||
"hover:tw-bg-primary-100",
|
||||
"hover:tw-bg-hover-default",
|
||||
"focus-visible:tw-z-50",
|
||||
"focus-visible:tw-outline-none",
|
||||
"focus-visible:tw-ring-2",
|
||||
@@ -39,6 +39,9 @@ export class MenuItemDirective implements FocusableOption {
|
||||
return this.disabled || null; // native disabled attr must be null when false
|
||||
}
|
||||
|
||||
// TODO: Skipped for signal migration because:
|
||||
// This input overrides a field from a superclass, while the superclass field
|
||||
// is not migrated.
|
||||
@Input({ transform: coerceBooleanProperty }) disabled?: boolean = false;
|
||||
|
||||
constructor(public elementRef: ElementRef<HTMLButtonElement>) {}
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
// FIXME: Update this file to be type safe and remove this and next line
|
||||
// @ts-strict-ignore
|
||||
import { hasModifierKey } from "@angular/cdk/keycodes";
|
||||
import { Overlay, OverlayConfig, OverlayRef } from "@angular/cdk/overlay";
|
||||
import { TemplatePortal } from "@angular/cdk/portal";
|
||||
import {
|
||||
@@ -7,26 +8,30 @@ import {
|
||||
ElementRef,
|
||||
HostBinding,
|
||||
HostListener,
|
||||
Input,
|
||||
OnDestroy,
|
||||
ViewContainerRef,
|
||||
input,
|
||||
} from "@angular/core";
|
||||
import { Observable, Subscription } from "rxjs";
|
||||
import { filter, mergeWith } from "rxjs/operators";
|
||||
|
||||
import { MenuComponent } from "./menu.component";
|
||||
|
||||
@Directive({ selector: "[bitMenuTriggerFor]", exportAs: "menuTrigger", standalone: true })
|
||||
@Directive({
|
||||
selector: "[bitMenuTriggerFor]",
|
||||
exportAs: "menuTrigger",
|
||||
standalone: true,
|
||||
host: { "[attr.role]": "this.role()" },
|
||||
})
|
||||
export class MenuTriggerForDirective implements OnDestroy {
|
||||
@HostBinding("attr.aria-expanded") isOpen = false;
|
||||
@HostBinding("attr.aria-haspopup") get hasPopup(): "menu" | "dialog" {
|
||||
return this.menu?.ariaRole || "menu";
|
||||
return this.menu()?.ariaRole() || "menu";
|
||||
}
|
||||
@HostBinding("attr.role")
|
||||
@Input()
|
||||
role = "button";
|
||||
|
||||
@Input("bitMenuTriggerFor") menu: MenuComponent;
|
||||
readonly role = input("button");
|
||||
|
||||
readonly menu = input<MenuComponent>(undefined, { alias: "bitMenuTriggerFor" });
|
||||
|
||||
private overlayRef: OverlayRef;
|
||||
private defaultMenuConfig: OverlayConfig = {
|
||||
@@ -65,28 +70,36 @@ export class MenuTriggerForDirective implements OnDestroy {
|
||||
}
|
||||
|
||||
private openMenu() {
|
||||
if (this.menu == null) {
|
||||
const menu = this.menu();
|
||||
if (menu == null) {
|
||||
throw new Error("Cannot find bit-menu element");
|
||||
}
|
||||
|
||||
this.isOpen = true;
|
||||
this.overlayRef = this.overlay.create(this.defaultMenuConfig);
|
||||
|
||||
const templatePortal = new TemplatePortal(this.menu.templateRef, this.viewContainerRef);
|
||||
const templatePortal = new TemplatePortal(menu.templateRef, this.viewContainerRef);
|
||||
this.overlayRef.attach(templatePortal);
|
||||
|
||||
this.closedEventsSub = this.getClosedEvents().subscribe((event: KeyboardEvent | undefined) => {
|
||||
// Closing the menu is handled in this.destroyMenu, so we want to prevent the escape key
|
||||
// from doing its normal default action, which would otherwise cause a parent component
|
||||
// (like a dialog) or extension window to close
|
||||
if (event?.key === "Escape" && !hasModifierKey(event)) {
|
||||
event.preventDefault();
|
||||
}
|
||||
|
||||
if (["Tab", "Escape"].includes(event?.key)) {
|
||||
// Required to ensure tab order resumes correctly
|
||||
this.elementRef.nativeElement.focus();
|
||||
}
|
||||
this.destroyMenu();
|
||||
});
|
||||
if (this.menu.keyManager) {
|
||||
this.menu.keyManager.setFirstItemActive();
|
||||
if (menu.keyManager) {
|
||||
menu.keyManager.setFirstItemActive();
|
||||
this.keyDownEventsSub = this.overlayRef
|
||||
.keydownEvents()
|
||||
.subscribe((event: KeyboardEvent) => this.menu.keyManager.onKeydown(event));
|
||||
.subscribe((event: KeyboardEvent) => this.menu().keyManager.onKeydown(event));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -97,19 +110,19 @@ export class MenuTriggerForDirective implements OnDestroy {
|
||||
|
||||
this.isOpen = false;
|
||||
this.disposeAll();
|
||||
this.menu.closed.emit();
|
||||
this.menu().closed.emit();
|
||||
}
|
||||
|
||||
private getClosedEvents(): Observable<any> {
|
||||
const detachments = this.overlayRef.detachments();
|
||||
const escKey = this.overlayRef.keydownEvents().pipe(
|
||||
filter((event: KeyboardEvent) => {
|
||||
const keys = this.menu.ariaRole === "menu" ? ["Escape", "Tab"] : ["Escape"];
|
||||
const keys = this.menu().ariaRole() === "menu" ? ["Escape", "Tab"] : ["Escape"];
|
||||
return keys.includes(event.key);
|
||||
}),
|
||||
);
|
||||
const backdrop = this.overlayRef.backdropClick();
|
||||
const menuClosed = this.menu.closed;
|
||||
const menuClosed = this.menu().closed;
|
||||
|
||||
return detachments.pipe(mergeWith(escKey, backdrop, menuClosed));
|
||||
}
|
||||
|
||||
@@ -2,10 +2,10 @@
|
||||
<div
|
||||
(click)="closed.emit()"
|
||||
class="tw-flex tw-shrink-0 tw-flex-col tw-rounded-lg tw-border tw-border-solid tw-border-secondary-500 tw-bg-background tw-bg-clip-padding tw-py-1 tw-overflow-y-auto"
|
||||
[attr.role]="ariaRole"
|
||||
[attr.aria-label]="ariaLabel"
|
||||
[attr.role]="ariaRole()"
|
||||
[attr.aria-label]="ariaLabel()"
|
||||
cdkTrapFocus
|
||||
[cdkTrapFocusAutoCapture]="ariaRole === 'dialog'"
|
||||
[cdkTrapFocusAutoCapture]="ariaRole() === 'dialog'"
|
||||
>
|
||||
<ng-content></ng-content>
|
||||
</div>
|
||||
|
||||
@@ -10,7 +10,7 @@ import {
|
||||
ContentChildren,
|
||||
QueryList,
|
||||
AfterContentInit,
|
||||
Input,
|
||||
input,
|
||||
} from "@angular/core";
|
||||
|
||||
import { MenuItemDirective } from "./menu-item.directive";
|
||||
@@ -28,12 +28,12 @@ export class MenuComponent implements AfterContentInit {
|
||||
menuItems: QueryList<MenuItemDirective>;
|
||||
keyManager?: FocusKeyManager<MenuItemDirective>;
|
||||
|
||||
@Input() ariaRole: "menu" | "dialog" = "menu";
|
||||
readonly ariaRole = input<"menu" | "dialog">("menu");
|
||||
|
||||
@Input() ariaLabel: string;
|
||||
readonly ariaLabel = input<string>();
|
||||
|
||||
ngAfterContentInit() {
|
||||
if (this.ariaRole === "menu") {
|
||||
if (this.ariaRole() === "menu") {
|
||||
this.keyManager = new FocusKeyManager(this.menuItems)
|
||||
.withWrap()
|
||||
.skipPredicate((item) => item.disabled);
|
||||
|
||||
@@ -1,12 +1,12 @@
|
||||
<ng-select
|
||||
[items]="baseItems"
|
||||
[items]="baseItems()"
|
||||
[(ngModel)]="selectedItems"
|
||||
(ngModelChange)="onChange($event)"
|
||||
(blur)="onBlur()"
|
||||
bindLabel="listName"
|
||||
groupBy="parentGrouping"
|
||||
[placeholder]="placeholder"
|
||||
[loading]="loading"
|
||||
[placeholder]="placeholder()"
|
||||
[loading]="loading()"
|
||||
[loadingText]="loadingText"
|
||||
notFoundText="{{ 'multiSelectNotFound' | i18n }}"
|
||||
clearAllText="{{ 'multiSelectClearAll' | i18n }}"
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
// FIXME: Update this file to be type safe and remove this and next line
|
||||
// @ts-strict-ignore
|
||||
import { coerceBooleanProperty } from "@angular/cdk/coercion";
|
||||
import { hasModifierKey } from "@angular/cdk/keycodes";
|
||||
import {
|
||||
Component,
|
||||
@@ -12,6 +11,9 @@ import {
|
||||
HostBinding,
|
||||
Optional,
|
||||
Self,
|
||||
input,
|
||||
model,
|
||||
booleanAttribute,
|
||||
} from "@angular/core";
|
||||
import {
|
||||
ControlValueAccessor,
|
||||
@@ -38,6 +40,9 @@ let nextId = 0;
|
||||
templateUrl: "./multi-select.component.html",
|
||||
providers: [{ provide: BitFormFieldControl, useExisting: MultiSelectComponent }],
|
||||
imports: [NgSelectModule, ReactiveFormsModule, FormsModule, BadgeModule, I18nPipe],
|
||||
host: {
|
||||
"[id]": "this.id()",
|
||||
},
|
||||
})
|
||||
/**
|
||||
* This component has been implemented to only support Multi-select list events
|
||||
@@ -46,12 +51,14 @@ export class MultiSelectComponent implements OnInit, BitFormFieldControl, Contro
|
||||
@ViewChild(NgSelectComponent) select: NgSelectComponent;
|
||||
|
||||
// Parent component should only pass selectable items (complete list - selected items = baseItems)
|
||||
@Input() baseItems: SelectItemView[];
|
||||
readonly baseItems = model<SelectItemView[]>();
|
||||
// Defaults to native ng-select behavior - set to "true" to clear selected items on dropdown close
|
||||
@Input() removeSelectedItems = false;
|
||||
@Input() placeholder: string;
|
||||
@Input() loading = false;
|
||||
@Input({ transform: coerceBooleanProperty }) disabled?: boolean;
|
||||
readonly removeSelectedItems = input(false);
|
||||
readonly placeholder = model<string>();
|
||||
readonly loading = input(false);
|
||||
// TODO: Skipped for signal migration because:
|
||||
// Your application code writes to the input. This prevents migration.
|
||||
@Input({ transform: booleanAttribute }) disabled?: boolean;
|
||||
|
||||
// Internal tracking of selected items
|
||||
protected selectedItems: SelectItemView[];
|
||||
@@ -79,7 +86,9 @@ export class MultiSelectComponent implements OnInit, BitFormFieldControl, Contro
|
||||
|
||||
ngOnInit(): void {
|
||||
// Default Text Values
|
||||
this.placeholder = this.placeholder ?? this.i18nService.t("multiSelectPlaceholder");
|
||||
this.placeholder.update(
|
||||
(placeholder) => placeholder ?? this.i18nService.t("multiSelectPlaceholder"),
|
||||
);
|
||||
this.loadingText = this.i18nService.t("multiSelectLoading");
|
||||
}
|
||||
|
||||
@@ -119,15 +128,15 @@ export class MultiSelectComponent implements OnInit, BitFormFieldControl, Contro
|
||||
this.onItemsConfirmed.emit(this.selectedItems);
|
||||
|
||||
// Remove selected items from base list based on input property
|
||||
if (this.removeSelectedItems) {
|
||||
let updatedBaseItems = this.baseItems;
|
||||
if (this.removeSelectedItems()) {
|
||||
let updatedBaseItems = this.baseItems();
|
||||
this.selectedItems.forEach((selectedItem) => {
|
||||
updatedBaseItems = updatedBaseItems.filter((item) => selectedItem.id !== item.id);
|
||||
});
|
||||
|
||||
// Reset Lists
|
||||
this.selectedItems = null;
|
||||
this.baseItems = updatedBaseItems;
|
||||
this.baseItems.set(updatedBaseItems);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -186,9 +195,11 @@ export class MultiSelectComponent implements OnInit, BitFormFieldControl, Contro
|
||||
}
|
||||
|
||||
/**Implemented as part of BitFormFieldControl */
|
||||
@HostBinding() @Input() id = `bit-multi-select-${nextId++}`;
|
||||
readonly id = input(`bit-multi-select-${nextId++}`);
|
||||
|
||||
/**Implemented as part of BitFormFieldControl */
|
||||
// TODO: Skipped for signal migration because:
|
||||
// Accessor inputs cannot be migrated as they are too complex.
|
||||
@HostBinding("attr.required")
|
||||
@Input()
|
||||
get required() {
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
// FIXME: Update this file to be type safe and remove this and next line
|
||||
// @ts-strict-ignore
|
||||
import { Directive, EventEmitter, Input, Output } from "@angular/core";
|
||||
import { Directive, EventEmitter, Output, input } from "@angular/core";
|
||||
import { RouterLink, RouterLinkActive } from "@angular/router";
|
||||
|
||||
/**
|
||||
@@ -11,17 +11,17 @@ export abstract class NavBaseComponent {
|
||||
/**
|
||||
* Text to display in main content
|
||||
*/
|
||||
@Input() text: string;
|
||||
readonly text = input<string>();
|
||||
|
||||
/**
|
||||
* `aria-label` for main content
|
||||
*/
|
||||
@Input() ariaLabel: string;
|
||||
readonly ariaLabel = input<string>();
|
||||
|
||||
/**
|
||||
* Optional icon, e.g. `"bwi-collection-shared"`
|
||||
*/
|
||||
@Input() icon: string;
|
||||
readonly icon = input<string>();
|
||||
|
||||
/**
|
||||
* Optional route to be passed to internal `routerLink`. If not provided, the nav component will render as a button.
|
||||
@@ -34,41 +34,31 @@ export abstract class NavBaseComponent {
|
||||
*
|
||||
* See: {@link https://github.com/angular/angular/issues/24482}
|
||||
*/
|
||||
@Input() route?: RouterLink["routerLink"];
|
||||
readonly route = input<RouterLink["routerLink"]>();
|
||||
|
||||
/**
|
||||
* Passed to internal `routerLink`
|
||||
*
|
||||
* See {@link RouterLink.relativeTo}
|
||||
*/
|
||||
@Input() relativeTo?: RouterLink["relativeTo"];
|
||||
readonly relativeTo = input<RouterLink["relativeTo"]>();
|
||||
|
||||
/**
|
||||
* Passed to internal `routerLink`
|
||||
*
|
||||
* See {@link RouterLinkActive.routerLinkActiveOptions}
|
||||
*/
|
||||
@Input() routerLinkActiveOptions?: RouterLinkActive["routerLinkActiveOptions"] = {
|
||||
readonly routerLinkActiveOptions = input<RouterLinkActive["routerLinkActiveOptions"]>({
|
||||
paths: "subset",
|
||||
queryParams: "ignored",
|
||||
fragment: "ignored",
|
||||
matrixParams: "ignored",
|
||||
};
|
||||
|
||||
/**
|
||||
* If this item is used within a tree, set `variant` to `"tree"`
|
||||
*/
|
||||
@Input() variant: "default" | "tree" = "default";
|
||||
|
||||
/**
|
||||
* Depth level when nested inside of a `'tree'` variant
|
||||
*/
|
||||
@Input() treeDepth = 0;
|
||||
});
|
||||
|
||||
/**
|
||||
* If `true`, do not change styles when nav item is active.
|
||||
*/
|
||||
@Input() hideActiveStyles = false;
|
||||
readonly hideActiveStyles = input(false);
|
||||
|
||||
/**
|
||||
* Fires when main content is clicked
|
||||
|
||||
@@ -1,3 +1,3 @@
|
||||
@if (sideNavService.open$ | async) {
|
||||
<div class="tw-h-px tw-w-full tw-bg-secondary-300"></div>
|
||||
<div class="tw-h-px tw-w-full tw-my-2 tw-bg-secondary-300"></div>
|
||||
}
|
||||
|
||||
@@ -1,53 +1,41 @@
|
||||
<!-- This a higher order component that composes `NavItemComponent` -->
|
||||
@if (!hideIfEmpty || nestedNavComponents.length > 0) {
|
||||
@if (!hideIfEmpty() || nestedNavComponents.length > 0) {
|
||||
<bit-nav-item
|
||||
[text]="text"
|
||||
[icon]="icon"
|
||||
[route]="route"
|
||||
[relativeTo]="relativeTo"
|
||||
[routerLinkActiveOptions]="routerLinkActiveOptions"
|
||||
[variant]="variant"
|
||||
[treeDepth]="treeDepth"
|
||||
[text]="text()"
|
||||
[icon]="icon()"
|
||||
[route]="route()"
|
||||
[relativeTo]="relativeTo()"
|
||||
[routerLinkActiveOptions]="routerLinkActiveOptions()"
|
||||
(mainContentClicked)="handleMainContentClicked()"
|
||||
[ariaLabel]="ariaLabel"
|
||||
[ariaLabel]="ariaLabel()"
|
||||
[hideActiveStyles]="parentHideActiveStyles"
|
||||
>
|
||||
<ng-template #button>
|
||||
<button
|
||||
type="button"
|
||||
class="tw-ms-auto"
|
||||
[bitIconButton]="
|
||||
open ? 'bwi-angle-up' : variant === 'tree' ? 'bwi-angle-right' : 'bwi-angle-down'
|
||||
"
|
||||
[bitIconButton]="open() ? 'bwi-angle-up' : 'bwi-angle-down'"
|
||||
[buttonType]="'light'"
|
||||
(click)="toggle($event)"
|
||||
size="small"
|
||||
[title]="'toggleCollapse' | i18n"
|
||||
aria-haspopup="true"
|
||||
[attr.aria-expanded]="open.toString()"
|
||||
[attr.aria-expanded]="open().toString()"
|
||||
[attr.aria-controls]="contentId"
|
||||
[attr.aria-label]="['toggleCollapse' | i18n, text].join(' ')"
|
||||
[attr.aria-label]="['toggleCollapse' | i18n, text()].join(' ')"
|
||||
></button>
|
||||
</ng-template>
|
||||
<!-- Show toggle to the left for trees otherwise to the right -->
|
||||
@if (variant === "tree") {
|
||||
<ng-container slot="start">
|
||||
<ng-container *ngTemplateOutlet="button"></ng-container>
|
||||
</ng-container>
|
||||
}
|
||||
<ng-container slot="end">
|
||||
<ng-content select="[slot=end]"></ng-content>
|
||||
@if (variant !== "tree") {
|
||||
<ng-container *ngTemplateOutlet="button"></ng-container>
|
||||
}
|
||||
<ng-container *ngTemplateOutlet="button"></ng-container>
|
||||
</ng-container>
|
||||
</bit-nav-item>
|
||||
<!-- [attr.aria-controls] of the above button expects a unique ID on the controlled element -->
|
||||
@if (sideNavService.open$ | async) {
|
||||
@if (open) {
|
||||
@if (open()) {
|
||||
<div
|
||||
[attr.id]="contentId"
|
||||
[attr.aria-label]="[text, 'submenu' | i18n].join(' ')"
|
||||
[attr.aria-label]="[text(), 'submenu' | i18n].join(' ')"
|
||||
role="group"
|
||||
>
|
||||
<ng-content></ng-content>
|
||||
|
||||
@@ -1,15 +1,15 @@
|
||||
import { CommonModule } from "@angular/common";
|
||||
import {
|
||||
AfterContentInit,
|
||||
booleanAttribute,
|
||||
Component,
|
||||
ContentChildren,
|
||||
EventEmitter,
|
||||
Input,
|
||||
Optional,
|
||||
Output,
|
||||
QueryList,
|
||||
SkipSelf,
|
||||
input,
|
||||
model,
|
||||
} from "@angular/core";
|
||||
|
||||
import { I18nPipe } from "@bitwarden/ui-common";
|
||||
@@ -29,7 +29,7 @@ import { SideNavService } from "./side-nav.service";
|
||||
],
|
||||
imports: [CommonModule, NavItemComponent, IconButtonModule, I18nPipe],
|
||||
})
|
||||
export class NavGroupComponent extends NavBaseComponent implements AfterContentInit {
|
||||
export class NavGroupComponent extends NavBaseComponent {
|
||||
@ContentChildren(NavBaseComponent, {
|
||||
descendants: true,
|
||||
})
|
||||
@@ -37,7 +37,7 @@ export class NavGroupComponent extends NavBaseComponent implements AfterContentI
|
||||
|
||||
/** When the side nav is open, the parent nav item should not show active styles when open. */
|
||||
protected get parentHideActiveStyles(): boolean {
|
||||
return this.hideActiveStyles || (this.open && this.sideNavService.open);
|
||||
return this.hideActiveStyles() || (this.open() && this.sideNavService.open);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -48,14 +48,12 @@ export class NavGroupComponent extends NavBaseComponent implements AfterContentI
|
||||
/**
|
||||
* Is `true` if the expanded content is visible
|
||||
*/
|
||||
@Input()
|
||||
open = false;
|
||||
readonly open = model(false);
|
||||
|
||||
/**
|
||||
* Automatically hide the nav group if there are no child buttons
|
||||
*/
|
||||
@Input({ transform: booleanAttribute })
|
||||
hideIfEmpty = false;
|
||||
readonly hideIfEmpty = input(false, { transform: booleanAttribute });
|
||||
|
||||
@Output()
|
||||
openChange = new EventEmitter<boolean>();
|
||||
@@ -68,43 +66,27 @@ export class NavGroupComponent extends NavBaseComponent implements AfterContentI
|
||||
}
|
||||
|
||||
setOpen(isOpen: boolean) {
|
||||
this.open = isOpen;
|
||||
this.openChange.emit(this.open);
|
||||
this.open.set(isOpen);
|
||||
this.openChange.emit(this.open());
|
||||
// FIXME: Remove when updating file. Eslint update
|
||||
// eslint-disable-next-line @typescript-eslint/no-unused-expressions
|
||||
this.open && this.parentNavGroup?.setOpen(this.open);
|
||||
this.open() && this.parentNavGroup?.setOpen(this.open());
|
||||
}
|
||||
|
||||
protected toggle(event?: MouseEvent) {
|
||||
event?.stopPropagation();
|
||||
this.setOpen(!this.open);
|
||||
}
|
||||
|
||||
/**
|
||||
* - For any nested NavGroupComponents or NavItemComponents, increment the `treeDepth` by 1.
|
||||
*/
|
||||
private initNestedStyles() {
|
||||
if (this.variant !== "tree") {
|
||||
return;
|
||||
}
|
||||
[...this.nestedNavComponents].forEach((navGroupOrItem) => {
|
||||
navGroupOrItem.treeDepth += 1;
|
||||
});
|
||||
this.setOpen(!this.open());
|
||||
}
|
||||
|
||||
protected handleMainContentClicked() {
|
||||
if (!this.sideNavService.open) {
|
||||
if (!this.route) {
|
||||
if (!this.route()) {
|
||||
this.sideNavService.setOpen();
|
||||
}
|
||||
this.open = true;
|
||||
this.open.set(true);
|
||||
} else {
|
||||
this.toggle();
|
||||
}
|
||||
this.mainContentClicked.emit();
|
||||
}
|
||||
|
||||
ngAfterContentInit(): void {
|
||||
this.initNestedStyles();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -111,31 +111,6 @@ export const HideEmptyGroups: StoryObj<NavGroupComponent & { renderChildren: boo
|
||||
}),
|
||||
};
|
||||
|
||||
export const Tree: StoryObj<NavGroupComponent> = {
|
||||
render: (args) => ({
|
||||
props: args,
|
||||
template: /*html*/ `
|
||||
<bit-side-nav>
|
||||
<bit-nav-group text="Tree example" icon="bwi-collection-shared" [open]="true">
|
||||
<bit-nav-group text="Level 1 - with children (empty)" route="t1" icon="bwi-collection-shared" variant="tree"></bit-nav-group>
|
||||
<bit-nav-item text="Level 1 - no children" route="t2" icon="bwi-collection-shared" variant="tree"></bit-nav-item>
|
||||
<bit-nav-group text="Level 1 - with children" route="t3" icon="bwi-collection-shared" variant="tree" [open]="true">
|
||||
<bit-nav-group text="Level 2 - with children" route="t4" icon="bwi-collection-shared" variant="tree" [open]="true">
|
||||
<bit-nav-item text="Level 3 - no children, no icon" route="t5" variant="tree"></bit-nav-item>
|
||||
<bit-nav-group text="Level 3 - with children" route="t6" icon="bwi-collection-shared" variant="tree" [open]="true">
|
||||
<bit-nav-item text="Level 4 - no children, no icon" route="t7" variant="tree"></bit-nav-item>
|
||||
</bit-nav-group>
|
||||
</bit-nav-group>
|
||||
<bit-nav-group text="Level 2 - with children (empty)" route="t8" icon="bwi-collection-shared" variant="tree" [open]="true"></bit-nav-group>
|
||||
<bit-nav-item text="Level 2 - no children" route="t9" icon="bwi-collection-shared" variant="tree"></bit-nav-item>
|
||||
</bit-nav-group>
|
||||
<bit-nav-item text="Level 1 - no children" route="t10" icon="bwi-collection-shared" variant="tree"></bit-nav-item>
|
||||
</bit-nav-group>
|
||||
</bit-side-nav>
|
||||
`,
|
||||
}),
|
||||
};
|
||||
|
||||
export const Secondary: StoryObj<NavGroupComponent> = {
|
||||
render: (args) => ({
|
||||
props: args,
|
||||
|
||||
@@ -1,113 +1,77 @@
|
||||
<ng-container
|
||||
*ngIf="{
|
||||
open: sideNavService.open$ | async,
|
||||
} as data"
|
||||
>
|
||||
<div
|
||||
*ngIf="data.open || icon"
|
||||
class="tw-relative"
|
||||
[ngClass]="[
|
||||
showActiveStyles
|
||||
? 'tw-bg-background-alt4'
|
||||
: 'tw-bg-background-alt3 hover:tw-bg-primary-300/60',
|
||||
fvwStyles$ | async,
|
||||
]"
|
||||
>
|
||||
<div class="tw-ps-2 tw-pe-2">
|
||||
@let open = sideNavService.open$ | async;
|
||||
@if (open || icon()) {
|
||||
<div
|
||||
[ngStyle]="{
|
||||
'padding-left': data.open ? (variant === 'tree' ? 2.5 : 1) + treeDepth * 1.5 + 'rem' : '0',
|
||||
}"
|
||||
class="tw-relative tw-flex"
|
||||
class="tw-relative tw-rounded-md tw-h-10"
|
||||
[ngClass]="[
|
||||
showActiveStyles
|
||||
? 'tw-bg-background-alt4'
|
||||
: 'tw-bg-background-alt3 hover:tw-bg-hover-contrast',
|
||||
fvwStyles$ | async,
|
||||
]"
|
||||
>
|
||||
<div [ngClass]="[variant === 'tree' ? 'tw-py-1' : 'tw-py-2']">
|
||||
<div
|
||||
#slotStart
|
||||
class="[&>*:focus-visible::before]:!tw-ring-text-alt2 [&>*:hover]:!tw-border-text-alt2 [&>*]:!tw-text-alt2"
|
||||
>
|
||||
<ng-content select="[slot=start]"></ng-content>
|
||||
</div>
|
||||
<!-- Default content for #slotStart (for consistent sizing) -->
|
||||
<div
|
||||
*ngIf="slotStart.childElementCount === 0"
|
||||
[ngClass]="{
|
||||
'tw-w-0': variant !== 'tree',
|
||||
}"
|
||||
>
|
||||
<div class="tw-relative tw-flex tw-items-center tw-h-full">
|
||||
<ng-container *ngIf="route; then isAnchor; else isButton"></ng-container>
|
||||
|
||||
<!-- Main content of `NavItem` -->
|
||||
<ng-template #anchorAndButtonContent>
|
||||
<div
|
||||
[title]="text()"
|
||||
class="tw-gap-2 tw-items-center tw-font-bold tw-h-full tw-content-center"
|
||||
[ngClass]="{ 'tw-text-center': !open, 'tw-flex': open }"
|
||||
>
|
||||
<i
|
||||
class="!tw-m-0 tw-w-4 tw-shrink-0 bwi bwi-fw tw-text-alt2 {{ icon() }}"
|
||||
[attr.aria-hidden]="open"
|
||||
[attr.aria-label]="text()"
|
||||
></i>
|
||||
@if (open) {
|
||||
<span class="tw-truncate">{{ text() }}</span>
|
||||
}
|
||||
</div>
|
||||
</ng-template>
|
||||
|
||||
<!-- Show if a value was passed to `this.to` -->
|
||||
<ng-template #isAnchor>
|
||||
<!-- The `data-fvw` attribute passes focus to `this.focusVisibleWithin$` -->
|
||||
<!-- The following `class` field should match the `#isButton` class field below -->
|
||||
<a
|
||||
class="tw-size-full tw-px-3 tw-block tw-truncate tw-border-none tw-bg-transparent tw-text-start !tw-text-alt2 hover:tw-text-alt2 hover:tw-no-underline focus:tw-outline-none"
|
||||
data-fvw
|
||||
[routerLink]="route()"
|
||||
[relativeTo]="relativeTo()"
|
||||
[attr.aria-label]="ariaLabel() || text()"
|
||||
routerLinkActive
|
||||
[routerLinkActiveOptions]="routerLinkActiveOptions()"
|
||||
[ariaCurrentWhenActive]="'page'"
|
||||
(isActiveChange)="setIsActive($event)"
|
||||
(click)="mainContentClicked.emit()"
|
||||
>
|
||||
<ng-container *ngTemplateOutlet="anchorAndButtonContent"></ng-container>
|
||||
</a>
|
||||
</ng-template>
|
||||
|
||||
<!-- Show if `this.to` is falsy -->
|
||||
<ng-template #isButton>
|
||||
<!-- Class field should match `#isAnchor` class field above -->
|
||||
<button
|
||||
type="button"
|
||||
class="tw-invisible"
|
||||
[bitIconButton]="'bwi-angle-down'"
|
||||
size="small"
|
||||
aria-hidden="true"
|
||||
></button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<ng-container *ngIf="route; then isAnchor; else isButton"></ng-container>
|
||||
|
||||
<!-- Main content of `NavItem` -->
|
||||
<ng-template #anchorAndButtonContent>
|
||||
<div
|
||||
[title]="text"
|
||||
class="tw-truncate"
|
||||
[ngClass]="[
|
||||
variant === 'tree' ? 'tw-py-1' : 'tw-py-2',
|
||||
data.open ? 'tw-pe-4' : 'tw-text-center',
|
||||
]"
|
||||
>
|
||||
<i
|
||||
class="bwi bwi-fw tw-text-alt2 tw-mx-1 {{ icon }}"
|
||||
[attr.aria-hidden]="data.open"
|
||||
[attr.aria-label]="text"
|
||||
></i
|
||||
><span
|
||||
*ngIf="data.open"
|
||||
[ngClass]="showActiveStyles ? 'tw-font-bold' : 'tw-font-semibold'"
|
||||
>{{ text }}</span
|
||||
class="tw-size-full tw-px-3 tw-truncate tw-border-none tw-bg-transparent tw-text-start !tw-text-alt2 hover:tw-text-alt2 hover:tw-no-underline focus:tw-outline-none"
|
||||
data-fvw
|
||||
(click)="mainContentClicked.emit()"
|
||||
>
|
||||
</div>
|
||||
</ng-template>
|
||||
<ng-container *ngTemplateOutlet="anchorAndButtonContent"></ng-container>
|
||||
</button>
|
||||
</ng-template>
|
||||
|
||||
<!-- Show if a value was passed to `this.to` -->
|
||||
<ng-template #isAnchor>
|
||||
<!-- The `data-fvw` attribute passes focus to `this.focusVisibleWithin$` -->
|
||||
<!-- The following `class` field should match the `#isButton` class field below -->
|
||||
<a
|
||||
class="tw-w-full tw-truncate tw-border-none tw-bg-transparent tw-p-0 tw-text-start !tw-text-alt2 hover:tw-text-alt2 hover:tw-no-underline focus:tw-outline-none"
|
||||
data-fvw
|
||||
[routerLink]="route"
|
||||
[relativeTo]="relativeTo"
|
||||
[attr.aria-label]="ariaLabel || text"
|
||||
routerLinkActive
|
||||
[routerLinkActiveOptions]="routerLinkActiveOptions"
|
||||
[ariaCurrentWhenActive]="'page'"
|
||||
(isActiveChange)="setIsActive($event)"
|
||||
(click)="mainContentClicked.emit()"
|
||||
>
|
||||
<ng-container *ngTemplateOutlet="anchorAndButtonContent"></ng-container>
|
||||
</a>
|
||||
</ng-template>
|
||||
|
||||
<!-- Show if `this.to` is falsy -->
|
||||
<ng-template #isButton>
|
||||
<!-- Class field should match `#isAnchor` class field above -->
|
||||
<button
|
||||
type="button"
|
||||
class="tw-w-full tw-truncate tw-border-none tw-bg-transparent tw-p-0 tw-text-start !tw-text-alt2 hover:tw-text-alt2 hover:tw-no-underline focus:tw-outline-none"
|
||||
data-fvw
|
||||
(click)="mainContentClicked.emit()"
|
||||
>
|
||||
<ng-container *ngTemplateOutlet="anchorAndButtonContent"></ng-container>
|
||||
</button>
|
||||
</ng-template>
|
||||
|
||||
<div
|
||||
*ngIf="data.open"
|
||||
class="tw-flex -tw-ms-3 tw-pe-4 tw-gap-1 [&>*:focus-visible::before]:!tw-ring-text-alt2 [&>*:hover]:!tw-border-text-alt2 [&>*]:tw-text-alt2 empty:tw-hidden"
|
||||
[ngClass]="[variant === 'tree' ? 'tw-py-1' : 'tw-py-2']"
|
||||
>
|
||||
<ng-content select="[slot=end]"></ng-content>
|
||||
@if (open) {
|
||||
<div
|
||||
class="tw-flex tw-items-center tw-pe-1.5 tw-gap-1 [&>*:focus-visible::before]:!tw-ring-text-alt2 [&>*:hover]:!tw-border-text-alt2 [&>*]:tw-text-alt2 empty:tw-hidden"
|
||||
>
|
||||
<ng-content select="[slot=end]"></ng-content>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</ng-container>
|
||||
}
|
||||
</div>
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { CommonModule } from "@angular/common";
|
||||
import { Component, HostListener, Input, Optional } from "@angular/core";
|
||||
import { Component, HostListener, Optional, input } from "@angular/core";
|
||||
import { RouterModule } from "@angular/router";
|
||||
import { BehaviorSubject, map } from "rxjs";
|
||||
|
||||
@@ -21,7 +21,7 @@ export abstract class NavGroupAbstraction {
|
||||
})
|
||||
export class NavItemComponent extends NavBaseComponent {
|
||||
/** Forces active styles to be shown, regardless of the `routerLinkActiveOptions` */
|
||||
@Input() forceActiveStyles? = false;
|
||||
readonly forceActiveStyles = input<boolean>(false);
|
||||
|
||||
/**
|
||||
* Is `true` if `to` matches the current route
|
||||
@@ -34,7 +34,7 @@ export class NavItemComponent extends NavBaseComponent {
|
||||
}
|
||||
}
|
||||
protected get showActiveStyles() {
|
||||
return this.forceActiveStyles || (this._isActive && !this.hideActiveStyles);
|
||||
return this.forceActiveStyles() || (this._isActive && !this.hideActiveStyles());
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -68,6 +68,13 @@ export const WithoutIcon: Story = {
|
||||
},
|
||||
};
|
||||
|
||||
export const WithLongText: Story = {
|
||||
...Default,
|
||||
args: {
|
||||
text: "Hello World This Is a Cool Item",
|
||||
},
|
||||
};
|
||||
|
||||
export const WithoutRoute: Story = {
|
||||
render: () => ({
|
||||
template: `
|
||||
@@ -80,7 +87,7 @@ export const WithChildButtons: Story = {
|
||||
render: (args) => ({
|
||||
props: args,
|
||||
template: `
|
||||
<bit-nav-item text="Hello World" [route]="['']" icon="bwi-collection-shared">
|
||||
<bit-nav-item text="Hello World Very Cool World" [route]="['']" icon="bwi-collection-shared">
|
||||
<button
|
||||
slot="end"
|
||||
class="tw-ms-auto"
|
||||
@@ -106,8 +113,8 @@ export const MultipleItemsWithDivider: Story = {
|
||||
render: (args) => ({
|
||||
props: args,
|
||||
template: `
|
||||
<bit-nav-item text="Hello World" icon="bwi-collection-shared"></bit-nav-item>
|
||||
<bit-nav-item text="Hello World" icon="bwi-collection-shared"></bit-nav-item>
|
||||
<bit-nav-item text="Hello World"></bit-nav-item>
|
||||
<bit-nav-item text="Hello World Long Text Long"></bit-nav-item>
|
||||
<bit-nav-divider></bit-nav-divider>
|
||||
<bit-nav-item text="Hello World" icon="bwi-collection-shared"></bit-nav-item>
|
||||
<bit-nav-item text="Hello World" icon="bwi-collection-shared"></bit-nav-item>
|
||||
|
||||
@@ -1,23 +1,19 @@
|
||||
@if (sideNavService.open) {
|
||||
<div class="tw-sticky tw-top-0 tw-z-50">
|
||||
<a
|
||||
[routerLink]="route"
|
||||
class="tw-px-5 tw-pb-5 tw-pt-7 tw-block tw-bg-background-alt3 tw-outline-none focus-visible:tw-ring focus-visible:tw-ring-inset focus-visible:tw-ring-text-alt2"
|
||||
[attr.aria-label]="label"
|
||||
[title]="label"
|
||||
routerLinkActive
|
||||
[ariaCurrentWhenActive]="'page'"
|
||||
>
|
||||
<bit-icon [icon]="openIcon"></bit-icon>
|
||||
</a>
|
||||
</div>
|
||||
}
|
||||
@if (!sideNavService.open) {
|
||||
<bit-nav-item
|
||||
class="tw-block tw-pt-7"
|
||||
[hideActiveStyles]="true"
|
||||
[route]="route"
|
||||
[icon]="closedIcon"
|
||||
[text]="label"
|
||||
></bit-nav-item>
|
||||
}
|
||||
<div
|
||||
[ngClass]="{
|
||||
'tw-sticky tw-top-0 tw-z-50 tw-pb-2': sideNavService.open,
|
||||
'tw-pb-5': !sideNavService.open,
|
||||
}"
|
||||
class="tw-px-2 tw-pt-5"
|
||||
>
|
||||
<a
|
||||
[routerLink]="route()"
|
||||
class="tw-p-3 tw-block tw-rounded-md tw-bg-background-alt3 tw-outline-none focus-visible:tw-ring focus-visible:tw-ring-inset focus-visible:tw-ring-text-alt2 hover:tw-bg-hover-contrast [&_path]:tw-fill-text-alt2"
|
||||
[ngClass]="{ '[&_svg]:tw-w-[1.687rem]': !sideNavService.open }"
|
||||
[attr.aria-label]="label()"
|
||||
[title]="label()"
|
||||
routerLinkActive
|
||||
[ariaCurrentWhenActive]="'page'"
|
||||
>
|
||||
<bit-icon [icon]="sideNavService.open ? openIcon() : closedIcon()"></bit-icon>
|
||||
</a>
|
||||
</div>
|
||||
|
||||
@@ -1,34 +1,35 @@
|
||||
// FIXME: Update this file to be type safe and remove this and next line
|
||||
// @ts-strict-ignore
|
||||
|
||||
import { Component, Input } from "@angular/core";
|
||||
import { CommonModule } from "@angular/common";
|
||||
import { Component, input } from "@angular/core";
|
||||
import { RouterLinkActive, RouterLink } from "@angular/router";
|
||||
|
||||
import { Icon } from "../icon";
|
||||
import { BitIconComponent } from "../icon/icon.component";
|
||||
import { BitwardenShield } from "../icon/logos";
|
||||
|
||||
import { NavItemComponent } from "./nav-item.component";
|
||||
import { SideNavService } from "./side-nav.service";
|
||||
|
||||
@Component({
|
||||
selector: "bit-nav-logo",
|
||||
templateUrl: "./nav-logo.component.html",
|
||||
imports: [RouterLinkActive, RouterLink, BitIconComponent, NavItemComponent],
|
||||
imports: [CommonModule, RouterLinkActive, RouterLink, BitIconComponent],
|
||||
})
|
||||
export class NavLogoComponent {
|
||||
/** Icon that is displayed when the side nav is closed */
|
||||
@Input() closedIcon = "bwi-shield";
|
||||
readonly closedIcon = input(BitwardenShield);
|
||||
|
||||
/** Icon that is displayed when the side nav is open */
|
||||
@Input({ required: true }) openIcon: Icon;
|
||||
readonly openIcon = input.required<Icon>();
|
||||
|
||||
/**
|
||||
* Route to be passed to internal `routerLink`
|
||||
*/
|
||||
@Input({ required: true }) route: string | any[];
|
||||
readonly route = input.required<string | any[]>();
|
||||
|
||||
/** Passed to `attr.aria-label` and `attr.title` */
|
||||
@Input({ required: true }) label: string;
|
||||
readonly label = input.required<string>();
|
||||
|
||||
constructor(protected sideNavService: SideNavService) {}
|
||||
}
|
||||
|
||||
@@ -10,10 +10,11 @@
|
||||
class="tw-fixed md:tw-sticky tw-inset-y-0 tw-left-0 tw-z-30 tw-flex tw-h-screen tw-flex-col tw-overscroll-none tw-overflow-auto tw-bg-background-alt3 tw-outline-none"
|
||||
[ngClass]="{ 'tw-w-60': data.open }"
|
||||
[ngStyle]="
|
||||
variant === 'secondary' && {
|
||||
variant() === 'secondary' && {
|
||||
'--color-text-alt2': 'var(--color-text-main)',
|
||||
'--color-background-alt3': 'var(--color-secondary-100)',
|
||||
'--color-background-alt4': 'var(--color-secondary-300)',
|
||||
'--color-hover-contrast': 'var(--color-hover-default)',
|
||||
}
|
||||
"
|
||||
[cdkTrapFocus]="data.isOverlay"
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
// @ts-strict-ignore
|
||||
import { CdkTrapFocus } from "@angular/cdk/a11y";
|
||||
import { CommonModule } from "@angular/common";
|
||||
import { Component, ElementRef, Input, ViewChild } from "@angular/core";
|
||||
import { Component, ElementRef, ViewChild, input } from "@angular/core";
|
||||
|
||||
import { I18nPipe } from "@bitwarden/ui-common";
|
||||
|
||||
@@ -19,7 +19,7 @@ export type SideNavVariant = "primary" | "secondary";
|
||||
imports: [CommonModule, CdkTrapFocus, NavDividerComponent, BitIconButtonComponent, I18nPipe],
|
||||
})
|
||||
export class SideNavComponent {
|
||||
@Input() variant: SideNavVariant = "primary";
|
||||
readonly variant = input<SideNavVariant>("primary");
|
||||
|
||||
@ViewChild("toggleButton", { read: ElementRef, static: true })
|
||||
private toggleButton: ElementRef<HTMLButtonElement>;
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
<div class="tw-mx-auto tw-flex tw-flex-col tw-items-center tw-justify-center tw-pt-6">
|
||||
<div class="tw-max-w-sm tw-flex tw-flex-col tw-items-center">
|
||||
<bit-icon [icon]="icon" aria-hidden="true"></bit-icon>
|
||||
<bit-icon [icon]="icon()" aria-hidden="true"></bit-icon>
|
||||
<h3 class="tw-font-semibold tw-text-center tw-mt-4">
|
||||
<ng-content select="[slot=title]"></ng-content>
|
||||
</h3>
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { Component, Input } from "@angular/core";
|
||||
import { Component, input } from "@angular/core";
|
||||
|
||||
import { Icons } from "..";
|
||||
import { BitIconComponent } from "../icon/icon.component";
|
||||
@@ -12,5 +12,5 @@ import { BitIconComponent } from "../icon/icon.component";
|
||||
imports: [BitIconComponent],
|
||||
})
|
||||
export class NoItemsComponent {
|
||||
@Input() icon = Icons.Search;
|
||||
readonly icon = input(Icons.Search);
|
||||
}
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user