mirror of
https://github.com/bitwarden/browser
synced 2026-02-26 01:23:24 +00:00
Merge branch 'main' into pm-25909-commercial-desktop
This commit is contained in:
@@ -3,14 +3,14 @@ import { takeUntilDestroyed } from "@angular/core/rxjs-interop";
|
||||
import { ActivatedRoute, Data, NavigationEnd, Router, RouterModule } from "@angular/router";
|
||||
import { Subject, filter, of, switchMap, tap } from "rxjs";
|
||||
|
||||
import { Icon } from "@bitwarden/assets/svg";
|
||||
import { BitSvg } from "@bitwarden/assets/svg";
|
||||
import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service";
|
||||
|
||||
import { Translation } from "../dialog";
|
||||
import { LandingContentMaxWidthType } from "../landing-layout";
|
||||
|
||||
import { AnonLayoutWrapperDataService } from "./anon-layout-wrapper-data.service";
|
||||
import { AnonLayoutComponent, AnonLayoutMaxWidth } from "./anon-layout.component";
|
||||
|
||||
import { AnonLayoutComponent } from "./anon-layout.component";
|
||||
export interface AnonLayoutWrapperData {
|
||||
/**
|
||||
* The optional title of the page.
|
||||
@@ -27,7 +27,7 @@ export interface AnonLayoutWrapperData {
|
||||
/**
|
||||
* The icon to display on the page. Pass null to hide the icon.
|
||||
*/
|
||||
pageIcon: Icon | null;
|
||||
pageIcon: BitSvg | null;
|
||||
/**
|
||||
* Optional flag to either show the optional environment selector (false) or just a readonly hostname (true).
|
||||
*/
|
||||
@@ -35,7 +35,7 @@ export interface AnonLayoutWrapperData {
|
||||
/**
|
||||
* Optional flag to set the max-width of the page. Defaults to 'md' if not provided.
|
||||
*/
|
||||
maxWidth?: AnonLayoutMaxWidth;
|
||||
maxWidth?: LandingContentMaxWidthType;
|
||||
/**
|
||||
* Hide the card that wraps the default content. Defaults to false.
|
||||
*/
|
||||
@@ -57,9 +57,9 @@ export class AnonLayoutWrapperComponent implements OnInit {
|
||||
|
||||
protected pageTitle?: string | null;
|
||||
protected pageSubtitle?: string | null;
|
||||
protected pageIcon: Icon | null = null;
|
||||
protected pageIcon: BitSvg | null = null;
|
||||
protected showReadonlyHostname?: boolean | null;
|
||||
protected maxWidth?: AnonLayoutMaxWidth | null;
|
||||
protected maxWidth?: LandingContentMaxWidthType | null;
|
||||
protected hideCardWrapper?: boolean | null;
|
||||
protected hideBackgroundIllustration?: boolean | null;
|
||||
|
||||
|
||||
@@ -1,76 +1,26 @@
|
||||
<main
|
||||
class="tw-relative tw-flex tw-w-full tw-mx-auto tw-flex-col tw-bg-background-alt tw-p-5 tw-text-main"
|
||||
[ngClass]="{
|
||||
'tw-min-h-screen': clientType === 'web',
|
||||
'tw-min-h-full': clientType === 'browser' || clientType === 'desktop',
|
||||
}"
|
||||
>
|
||||
<div
|
||||
[class]="
|
||||
'tw-flex tw-justify-between tw-items-center tw-w-full' + (!hideLogo() ? ' tw-mb-12' : '')
|
||||
"
|
||||
>
|
||||
@if (!hideLogo()) {
|
||||
<a
|
||||
[routerLink]="['/']"
|
||||
class="tw-w-32 sm:tw-w-[200px] tw-self-center sm:tw-self-start tw-block [&>*]:tw-align-top"
|
||||
>
|
||||
<bit-icon [icon]="logo" [ariaLabel]="'appLogoLabel' | i18n"></bit-icon>
|
||||
</a>
|
||||
}
|
||||
<div class="tw-ms-auto">
|
||||
<ng-content select="[slot=header-actions]"></ng-content>
|
||||
</div>
|
||||
</div>
|
||||
<bit-landing-layout [hideBackgroundIllustration]="hideBackgroundIllustration()">
|
||||
<bit-landing-header [hideLogo]="hideLogo()">
|
||||
<ng-content select="[slot=header-actions]"></ng-content>
|
||||
</bit-landing-header>
|
||||
|
||||
<div class="tw-text-center tw-mb-4 sm:tw-mb-6 tw-mx-auto" [ngClass]="maxWidthClass">
|
||||
@let iconInput = icon();
|
||||
|
||||
<!-- In some scenarios this icon's size is not limited by container width correctly -->
|
||||
<!-- Targeting the SVG here to try and ensure it never grows too large in even the media queries are not working as expected -->
|
||||
<div
|
||||
*ngIf="iconInput !== null"
|
||||
class="tw-size-20 sm:tw-size-24 [&_svg]:tw-w-full [&_svg]:tw-max-w-24 tw-mx-auto tw-content-center"
|
||||
>
|
||||
<bit-icon [icon]="iconInput"></bit-icon>
|
||||
</div>
|
||||
|
||||
@if (title()) {
|
||||
<!-- Small screens -->
|
||||
<h1 bitTypography="h2" class="tw-mt-2 sm:tw-hidden">
|
||||
{{ title() }}
|
||||
</h1>
|
||||
<!-- Medium to Larger screens -->
|
||||
<h1 bitTypography="h1" class="tw-mt-2 tw-hidden sm:tw-block">
|
||||
{{ title() }}
|
||||
</h1>
|
||||
}
|
||||
|
||||
@if (subtitle()) {
|
||||
<div class="tw-text-sm sm:tw-text-base">{{ subtitle() }}</div>
|
||||
}
|
||||
</div>
|
||||
|
||||
<div
|
||||
class="tw-z-10 tw-grow tw-w-full tw-mx-auto tw-flex tw-flex-col tw-items-center sm:tw-min-w-[28rem]"
|
||||
[ngClass]="maxWidthClass"
|
||||
>
|
||||
<bit-landing-content [maxWidth]="maxWidth()">
|
||||
<bit-landing-hero [icon]="icon()" [title]="title()" [subtitle]="subtitle()"></bit-landing-hero>
|
||||
@if (hideCardWrapper()) {
|
||||
<div class="tw-mb-6 sm:tw-mb-10">
|
||||
<ng-container *ngTemplateOutlet="defaultContent"></ng-container>
|
||||
</div>
|
||||
} @else {
|
||||
<bit-base-card
|
||||
class="!tw-rounded-2xl tw-mb-6 sm:tw-mb-10 tw-mx-auto tw-w-full tw-bg-transparent tw-border-none tw-shadow-none sm:tw-bg-background sm:tw-border sm:tw-border-solid sm:tw-border-secondary-100 sm:tw-shadow sm:tw-p-8"
|
||||
>
|
||||
<bit-landing-card>
|
||||
<ng-container *ngTemplateOutlet="defaultContent"></ng-container>
|
||||
</bit-base-card>
|
||||
</bit-landing-card>
|
||||
}
|
||||
<ng-content select="[slot=secondary]"></ng-content>
|
||||
</div>
|
||||
<div class="tw-flex tw-flex-col tw-items-center">
|
||||
<ng-content select="[slot=secondary]"></ng-content>
|
||||
</div>
|
||||
</bit-landing-content>
|
||||
|
||||
@if (!hideFooter()) {
|
||||
<footer class="tw-text-center tw-mt-4 sm:tw-mt-6">
|
||||
<bit-landing-footer>
|
||||
@if (showReadonlyHostname()) {
|
||||
<div bitTypography="body2">{{ "accessing" | i18n }} {{ hostname }}</div>
|
||||
} @else {
|
||||
@@ -81,22 +31,9 @@
|
||||
<div bitTypography="body2">© {{ year }} Bitwarden Inc.</div>
|
||||
<div bitTypography="body2">{{ version }}</div>
|
||||
}
|
||||
</footer>
|
||||
</bit-landing-footer>
|
||||
}
|
||||
|
||||
@if (!hideBackgroundIllustration()) {
|
||||
<div
|
||||
class="tw-hidden md:tw-block [&_svg]:tw-absolute tw-z-[1] tw-opacity-[.11] [&_svg]:tw-bottom-0 [&_svg]:tw-start-0 [&_svg]:tw-w-[35%] [&_svg]:tw-max-w-[450px]"
|
||||
>
|
||||
<bit-icon [icon]="leftIllustration"></bit-icon>
|
||||
</div>
|
||||
<div
|
||||
class="tw-hidden md:tw-block [&_svg]:tw-absolute tw-z-[1] tw-opacity-[.11] [&_svg]:tw-bottom-0 [&_svg]:tw-end-0 [&_svg]:tw-w-[35%] [&_svg]:tw-max-w-[450px]"
|
||||
>
|
||||
<bit-icon [icon]="rightIllustration"></bit-icon>
|
||||
</div>
|
||||
}
|
||||
</main>
|
||||
</bit-landing-layout>
|
||||
|
||||
<ng-template #defaultContent>
|
||||
<ng-content></ng-content>
|
||||
|
||||
@@ -11,35 +11,29 @@ import {
|
||||
import { RouterModule } from "@angular/router";
|
||||
import { firstValueFrom } from "rxjs";
|
||||
|
||||
import {
|
||||
BackgroundLeftIllustration,
|
||||
BackgroundRightIllustration,
|
||||
BitwardenLogo,
|
||||
Icon,
|
||||
} from "@bitwarden/assets/svg";
|
||||
import { BitwardenLogo, BitSvg } from "@bitwarden/assets/svg";
|
||||
import { ClientType } from "@bitwarden/common/enums";
|
||||
import { EnvironmentService } from "@bitwarden/common/platform/abstractions/environment.service";
|
||||
import { PlatformUtilsService } from "@bitwarden/common/platform/abstractions/platform-utils.service";
|
||||
|
||||
import { BaseCardComponent } from "../card";
|
||||
import { IconModule } from "../icon";
|
||||
import { LandingContentMaxWidthType } from "../landing-layout";
|
||||
import { LandingLayoutModule } from "../landing-layout/landing-layout.module";
|
||||
import { SharedModule } from "../shared";
|
||||
import { SvgModule } from "../svg";
|
||||
import { TypographyModule } from "../typography";
|
||||
|
||||
export type AnonLayoutMaxWidth = "md" | "lg" | "xl" | "2xl" | "3xl" | "4xl";
|
||||
|
||||
// FIXME(https://bitwarden.atlassian.net/browse/CL-764): Migrate to OnPush
|
||||
// eslint-disable-next-line @angular-eslint/prefer-on-push-component-change-detection
|
||||
@Component({
|
||||
selector: "auth-anon-layout",
|
||||
templateUrl: "./anon-layout.component.html",
|
||||
imports: [
|
||||
IconModule,
|
||||
SvgModule,
|
||||
CommonModule,
|
||||
TypographyModule,
|
||||
SharedModule,
|
||||
RouterModule,
|
||||
BaseCardComponent,
|
||||
LandingLayoutModule,
|
||||
],
|
||||
})
|
||||
export class AnonLayoutComponent implements OnInit, OnChanges {
|
||||
@@ -49,12 +43,9 @@ export class AnonLayoutComponent implements OnInit, OnChanges {
|
||||
return ["tw-h-full"];
|
||||
}
|
||||
|
||||
readonly leftIllustration = BackgroundLeftIllustration;
|
||||
readonly rightIllustration = BackgroundRightIllustration;
|
||||
|
||||
readonly title = input<string>();
|
||||
readonly subtitle = input<string>();
|
||||
readonly icon = model.required<Icon | null>();
|
||||
readonly icon = model.required<BitSvg | null>();
|
||||
readonly showReadonlyHostname = input<boolean>(false);
|
||||
readonly hideLogo = input<boolean>(false);
|
||||
readonly hideFooter = input<boolean>(false);
|
||||
@@ -66,7 +57,7 @@ export class AnonLayoutComponent implements OnInit, OnChanges {
|
||||
*
|
||||
* @default 'md'
|
||||
*/
|
||||
readonly maxWidth = model<AnonLayoutMaxWidth>("md");
|
||||
readonly maxWidth = model<LandingContentMaxWidthType>("md");
|
||||
|
||||
protected logo = BitwardenLogo;
|
||||
protected year: string;
|
||||
@@ -76,24 +67,6 @@ export class AnonLayoutComponent implements OnInit, OnChanges {
|
||||
|
||||
protected hideYearAndVersion = false;
|
||||
|
||||
get maxWidthClass(): string {
|
||||
const maxWidth = this.maxWidth();
|
||||
switch (maxWidth) {
|
||||
case "md":
|
||||
return "tw-max-w-md";
|
||||
case "lg":
|
||||
return "tw-max-w-lg";
|
||||
case "xl":
|
||||
return "tw-max-w-xl";
|
||||
case "2xl":
|
||||
return "tw-max-w-2xl";
|
||||
case "3xl":
|
||||
return "tw-max-w-3xl";
|
||||
case "4xl":
|
||||
return "tw-max-w-4xl";
|
||||
}
|
||||
}
|
||||
|
||||
constructor(
|
||||
private environmentService: EnvironmentService,
|
||||
private platformUtilsService: PlatformUtilsService,
|
||||
|
||||
@@ -2,7 +2,7 @@ import { ActivatedRoute, RouterModule } from "@angular/router";
|
||||
import { Meta, StoryObj, moduleMetadata } from "@storybook/angular";
|
||||
import { BehaviorSubject, of } from "rxjs";
|
||||
|
||||
import { Icon, LockIcon } from "@bitwarden/assets/svg";
|
||||
import { BitSvg, LockIcon } from "@bitwarden/assets/svg";
|
||||
import { ClientType } from "@bitwarden/common/enums";
|
||||
import { EnvironmentService } from "@bitwarden/common/platform/abstractions/environment.service";
|
||||
import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service";
|
||||
@@ -23,7 +23,7 @@ type StoryArgs = AnonLayoutComponent & {
|
||||
contentLength: "normal" | "long" | "thin";
|
||||
showSecondary: boolean;
|
||||
useDefaultIcon: boolean;
|
||||
icon: Icon;
|
||||
icon: BitSvg;
|
||||
includeHeaderActions: boolean;
|
||||
};
|
||||
|
||||
|
||||
3
libs/components/src/berry/berry.component.html
Normal file
3
libs/components/src/berry/berry.component.html
Normal file
@@ -0,0 +1,3 @@
|
||||
@if (type() === "status" || content()) {
|
||||
<span [class]="containerClasses()">{{ content() }}</span>
|
||||
}
|
||||
80
libs/components/src/berry/berry.component.ts
Normal file
80
libs/components/src/berry/berry.component.ts
Normal file
@@ -0,0 +1,80 @@
|
||||
import { ChangeDetectionStrategy, Component, computed, input } from "@angular/core";
|
||||
|
||||
export type BerryVariant =
|
||||
| "primary"
|
||||
| "subtle"
|
||||
| "success"
|
||||
| "warning"
|
||||
| "danger"
|
||||
| "accentPrimary"
|
||||
| "contrast";
|
||||
|
||||
/**
|
||||
* The berry component is a compact visual indicator used to display short,
|
||||
* supplemental status information about another element,
|
||||
* like a navigation item, button, or icon button.
|
||||
* They draw users’ attention to status changes or new notifications.
|
||||
*
|
||||
* > `NOTE:` The maximum displayed value is 999. If the value is over 999, a “+” character is appended to indicate more.
|
||||
*/
|
||||
@Component({
|
||||
selector: "bit-berry",
|
||||
templateUrl: "berry.component.html",
|
||||
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||
})
|
||||
export class BerryComponent {
|
||||
protected readonly variant = input<BerryVariant>("primary");
|
||||
protected readonly value = input<number>();
|
||||
protected readonly type = input<"status" | "count">("count");
|
||||
|
||||
protected readonly content = computed(() => {
|
||||
const value = this.value();
|
||||
const type = this.type();
|
||||
|
||||
if (type === "status" || !value || value < 0) {
|
||||
return undefined;
|
||||
}
|
||||
return value > 999 ? "999+" : `${value}`;
|
||||
});
|
||||
|
||||
protected readonly textColor = computed(() => {
|
||||
return this.variant() === "contrast" ? "tw-text-fg-dark" : "tw-text-fg-white";
|
||||
});
|
||||
|
||||
protected readonly padding = computed(() => {
|
||||
return (this.value()?.toString().length ?? 0) > 2 ? "tw-px-1.5 tw-py-0.5" : "";
|
||||
});
|
||||
|
||||
protected readonly containerClasses = computed(() => {
|
||||
const baseClasses = [
|
||||
"tw-inline-flex",
|
||||
"tw-items-center",
|
||||
"tw-justify-center",
|
||||
"tw-align-middle",
|
||||
"tw-text-xxs",
|
||||
"tw-rounded-full",
|
||||
];
|
||||
|
||||
const typeClasses = {
|
||||
status: ["tw-h-2", "tw-w-2"],
|
||||
count: ["tw-h-4", "tw-min-w-4", this.padding()],
|
||||
};
|
||||
|
||||
const variantClass = {
|
||||
primary: "tw-bg-bg-brand",
|
||||
subtle: "tw-bg-bg-contrast",
|
||||
success: "tw-bg-bg-success",
|
||||
warning: "tw-bg-bg-warning",
|
||||
danger: "tw-bg-bg-danger",
|
||||
accentPrimary: "tw-bg-fg-accent-primary-strong",
|
||||
contrast: "tw-bg-bg-white",
|
||||
};
|
||||
|
||||
return [
|
||||
...baseClasses,
|
||||
...typeClasses[this.type()],
|
||||
variantClass[this.variant()],
|
||||
this.textColor(),
|
||||
].join(" ");
|
||||
});
|
||||
}
|
||||
48
libs/components/src/berry/berry.mdx
Normal file
48
libs/components/src/berry/berry.mdx
Normal file
@@ -0,0 +1,48 @@
|
||||
import { Meta, Canvas, Primary, Controls, Title, Description } from "@storybook/addon-docs/blocks";
|
||||
|
||||
import * as stories from "./berry.stories";
|
||||
|
||||
<Meta of={stories} />
|
||||
|
||||
```ts
|
||||
import { BerryComponent } from "@bitwarden/components";
|
||||
```
|
||||
|
||||
<Title />
|
||||
<Description />
|
||||
|
||||
<Primary />
|
||||
<Controls />
|
||||
|
||||
## Usage
|
||||
|
||||
### Status
|
||||
|
||||
- Use a status berry to indicate a new notification of a status change that is not related to a
|
||||
specific count.
|
||||
|
||||
<Canvas of={stories.statusType} />
|
||||
|
||||
### Count
|
||||
|
||||
- Use a count berry with text to indicate item count information for multiple new notifications.
|
||||
|
||||
<Canvas of={stories.countType} />
|
||||
|
||||
### All Variants
|
||||
|
||||
<Canvas of={stories.AllVariants} />
|
||||
|
||||
## Count Behavior
|
||||
|
||||
- Counts of **1-99**: Display in a compact circular shape
|
||||
- Counts of **100-999**: Display in a pill shape with padding
|
||||
- Counts **over 999**: Display as "999+" to prevent overflow
|
||||
|
||||
## Accessibility
|
||||
|
||||
- Use berries as **supplemental visual indicators** alongside descriptive text
|
||||
- Ensure sufficient color contrast with surrounding elements
|
||||
- For screen readers, provide appropriate labels on parent elements that describe the berry's
|
||||
meaning
|
||||
- Berries are decorative; important information should not rely solely on the berry color
|
||||
167
libs/components/src/berry/berry.stories.ts
Normal file
167
libs/components/src/berry/berry.stories.ts
Normal file
@@ -0,0 +1,167 @@
|
||||
import { Meta, moduleMetadata, StoryObj } from "@storybook/angular";
|
||||
|
||||
import { BerryComponent } from "./berry.component";
|
||||
|
||||
export default {
|
||||
title: "Component Library/Berry",
|
||||
component: BerryComponent,
|
||||
decorators: [
|
||||
moduleMetadata({
|
||||
imports: [BerryComponent],
|
||||
}),
|
||||
],
|
||||
args: {
|
||||
type: "count",
|
||||
variant: "primary",
|
||||
value: 5,
|
||||
},
|
||||
argTypes: {
|
||||
type: {
|
||||
control: "select",
|
||||
options: ["status", "count"],
|
||||
description: "The type of the berry, which determines its size and content",
|
||||
table: {
|
||||
category: "Inputs",
|
||||
type: { summary: '"status" | "count"' },
|
||||
defaultValue: { summary: '"count"' },
|
||||
},
|
||||
},
|
||||
variant: {
|
||||
control: "select",
|
||||
options: ["primary", "subtle", "success", "warning", "danger", "accentPrimary", "contrast"],
|
||||
description: "The visual style variant of the berry",
|
||||
table: {
|
||||
category: "Inputs",
|
||||
type: { summary: "BerryVariant" },
|
||||
defaultValue: { summary: "primary" },
|
||||
},
|
||||
},
|
||||
value: {
|
||||
control: "number",
|
||||
description:
|
||||
"Optional value to display for berries with type 'count'. Maximum displayed is 999, values above show '999+'. If undefined, a small small berry is shown. If 0 or negative, the berry is hidden.",
|
||||
table: {
|
||||
category: "Inputs",
|
||||
type: { summary: "number | undefined" },
|
||||
defaultValue: { summary: "undefined" },
|
||||
},
|
||||
},
|
||||
},
|
||||
parameters: {
|
||||
design: {
|
||||
type: "figma",
|
||||
url: "https://www.figma.com/design/Zt3YSeb6E6lebAffrNLa0h/branch/rKUVGKb7Kw3d6YGoQl6Ho7/Tailwind-Component-Library?node-id=38367-199458&p=f&m=dev",
|
||||
},
|
||||
},
|
||||
} as Meta<BerryComponent>;
|
||||
|
||||
type Story = StoryObj<BerryComponent>;
|
||||
|
||||
export const Primary: Story = {
|
||||
render: (args) => ({
|
||||
props: args,
|
||||
template: `<bit-berry [type]="type" [variant]="variant" [value]="value"></bit-berry>`,
|
||||
}),
|
||||
};
|
||||
|
||||
export const statusType: Story = {
|
||||
render: (args) => ({
|
||||
props: args,
|
||||
template: `
|
||||
<div class="tw-flex tw-items-center tw-gap-4">
|
||||
<bit-berry [type]="'status'" variant="primary"></bit-berry>
|
||||
<bit-berry [type]="'status'" variant="subtle"></bit-berry>
|
||||
<bit-berry [type]="'status'" variant="success"></bit-berry>
|
||||
<bit-berry [type]="'status'" variant="warning"></bit-berry>
|
||||
<bit-berry [type]="'status'" variant="danger"></bit-berry>
|
||||
<bit-berry [type]="'status'" variant="accentPrimary"></bit-berry>
|
||||
<bit-berry [type]="'status'" variant="contrast"></bit-berry>
|
||||
</div>
|
||||
`,
|
||||
}),
|
||||
};
|
||||
|
||||
export const countType: Story = {
|
||||
render: (args) => ({
|
||||
props: args,
|
||||
template: `
|
||||
<div class="tw-flex tw-items-center tw-gap-4">
|
||||
<bit-berry [value]="5"></bit-berry>
|
||||
<bit-berry [value]="50"></bit-berry>
|
||||
<bit-berry [value]="500"></bit-berry>
|
||||
<bit-berry [value]="5000"></bit-berry>
|
||||
</div>
|
||||
`,
|
||||
}),
|
||||
};
|
||||
|
||||
export const AllVariants: Story = {
|
||||
render: () => ({
|
||||
template: `
|
||||
<div class="tw-flex tw-flex-col tw-gap-4">
|
||||
<div class="tw-flex tw-items-center tw-gap-4">
|
||||
<span class="tw-w-20">Primary:</span>
|
||||
<bit-berry type="status" variant="primary"></bit-berry>
|
||||
<bit-berry variant="primary" [value]="5"></bit-berry>
|
||||
<bit-berry variant="primary" [value]="50"></bit-berry>
|
||||
<bit-berry variant="primary" [value]="500"></bit-berry>
|
||||
<bit-berry variant="primary" [value]="5000"></bit-berry>
|
||||
</div>
|
||||
|
||||
<div class="tw-flex tw-items-center tw-gap-4">
|
||||
<span class="tw-w-20">Subtle:</span>
|
||||
<bit-berry type="status"variant="subtle"></bit-berry>
|
||||
<bit-berry variant="subtle" [value]="5"></bit-berry>
|
||||
<bit-berry variant="subtle" [value]="50"></bit-berry>
|
||||
<bit-berry variant="subtle" [value]="500"></bit-berry>
|
||||
<bit-berry variant="subtle" [value]="5000"></bit-berry>
|
||||
</div>
|
||||
|
||||
<div class="tw-flex tw-items-center tw-gap-4">
|
||||
<span class="tw-w-20">Success:</span>
|
||||
<bit-berry type="status" variant="success"></bit-berry>
|
||||
<bit-berry variant="success" [value]="5"></bit-berry>
|
||||
<bit-berry variant="success" [value]="50"></bit-berry>
|
||||
<bit-berry variant="success" [value]="500"></bit-berry>
|
||||
<bit-berry variant="success" [value]="5000"></bit-berry>
|
||||
</div>
|
||||
|
||||
<div class="tw-flex tw-items-center tw-gap-4">
|
||||
<span class="tw-w-20">Warning:</span>
|
||||
<bit-berry type="status" variant="warning"></bit-berry>
|
||||
<bit-berry variant="warning" [value]="5"></bit-berry>
|
||||
<bit-berry variant="warning" [value]="50"></bit-berry>
|
||||
<bit-berry variant="warning" [value]="500"></bit-berry>
|
||||
<bit-berry variant="warning" [value]="5000"></bit-berry>
|
||||
</div>
|
||||
|
||||
<div class="tw-flex tw-items-center tw-gap-4">
|
||||
<span class="tw-w-20">Danger:</span>
|
||||
<bit-berry type="status" variant="danger"></bit-berry>
|
||||
<bit-berry variant="danger" [value]="5"></bit-berry>
|
||||
<bit-berry variant="danger" [value]="50"></bit-berry>
|
||||
<bit-berry variant="danger" [value]="500"></bit-berry>
|
||||
<bit-berry variant="danger" [value]="5000"></bit-berry>
|
||||
</div>
|
||||
|
||||
<div class="tw-flex tw-items-center tw-gap-4">
|
||||
<span class="tw-w-20">Accent primary:</span>
|
||||
<bit-berry type="status" variant="accentPrimary"></bit-berry>
|
||||
<bit-berry variant="accentPrimary" [value]="5"></bit-berry>
|
||||
<bit-berry variant="accentPrimary" [value]="50"></bit-berry>
|
||||
<bit-berry variant="accentPrimary" [value]="500"></bit-berry>
|
||||
<bit-berry variant="accentPrimary" [value]="5000"></bit-berry>
|
||||
</div>
|
||||
|
||||
<div class="tw-flex tw-items-center tw-gap-4 tw-bg-bg-dark">
|
||||
<span class="tw-w-20 tw-text-fg-white">Contrast:</span>
|
||||
<bit-berry type="status" variant="contrast"></bit-berry>
|
||||
<bit-berry variant="contrast" [value]="5"></bit-berry>
|
||||
<bit-berry variant="contrast" [value]="50"></bit-berry>
|
||||
<bit-berry variant="contrast" [value]="500"></bit-berry>
|
||||
<bit-berry variant="contrast" [value]="5000"></bit-berry>
|
||||
</div>
|
||||
</div>
|
||||
`,
|
||||
}),
|
||||
};
|
||||
1
libs/components/src/berry/index.ts
Normal file
1
libs/components/src/berry/index.ts
Normal file
@@ -0,0 +1 @@
|
||||
export * from "./berry.component";
|
||||
@@ -2,7 +2,6 @@
|
||||
@if (breadcrumb.route(); as route) {
|
||||
<a
|
||||
bitLink
|
||||
linkType="primary"
|
||||
class="tw-my-2 tw-inline-block"
|
||||
[routerLink]="route"
|
||||
[queryParams]="breadcrumb.queryParams()"
|
||||
@@ -14,7 +13,6 @@
|
||||
<button
|
||||
type="button"
|
||||
bitLink
|
||||
linkType="primary"
|
||||
class="tw-my-2 tw-inline-block"
|
||||
(click)="breadcrumb.onClick($event)"
|
||||
>
|
||||
@@ -42,7 +40,6 @@
|
||||
@if (breadcrumb.route(); as route) {
|
||||
<a
|
||||
bitMenuItem
|
||||
linkType="primary"
|
||||
[routerLink]="route"
|
||||
[queryParams]="breadcrumb.queryParams()"
|
||||
[queryParamsHandling]="breadcrumb.queryParamsHandling()"
|
||||
@@ -50,7 +47,7 @@
|
||||
<ng-container [ngTemplateOutlet]="breadcrumb.content()"></ng-container>
|
||||
</a>
|
||||
} @else {
|
||||
<button type="button" bitMenuItem linkType="primary" (click)="breadcrumb.onClick($event)">
|
||||
<button type="button" bitMenuItem (click)="breadcrumb.onClick($event)">
|
||||
<ng-container [ngTemplateOutlet]="breadcrumb.content()"></ng-container>
|
||||
</button>
|
||||
}
|
||||
@@ -61,7 +58,6 @@
|
||||
@if (breadcrumb.route(); as route) {
|
||||
<a
|
||||
bitLink
|
||||
linkType="primary"
|
||||
class="tw-my-2 tw-inline-block"
|
||||
[routerLink]="route"
|
||||
[queryParams]="breadcrumb.queryParams()"
|
||||
@@ -73,7 +69,6 @@
|
||||
<button
|
||||
type="button"
|
||||
bitLink
|
||||
linkType="primary"
|
||||
class="tw-my-2 tw-inline-block"
|
||||
(click)="breadcrumb.onClick($event)"
|
||||
>
|
||||
|
||||
@@ -1,6 +1,14 @@
|
||||
<span class="tw-relative">
|
||||
<span [ngClass]="{ 'tw-invisible': showLoadingStyle() }">
|
||||
<ng-content></ng-content>
|
||||
<span class="tw-relative tw-flex tw-items-center tw-justify-center">
|
||||
<span [class.tw-invisible]="showLoadingStyle()" class="tw-flex tw-items-center tw-gap-2">
|
||||
@if (startIcon()) {
|
||||
<i class="{{ startIconClasses() }}"></i>
|
||||
}
|
||||
<div>
|
||||
<ng-content></ng-content>
|
||||
</div>
|
||||
@if (endIcon()) {
|
||||
<i class="{{ endIconClasses() }}"></i>
|
||||
}
|
||||
</span>
|
||||
@if (showLoadingStyle()) {
|
||||
<span class="tw-absolute tw-inset-0 tw-flex tw-items-center tw-justify-center">
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { NgClass } from "@angular/common";
|
||||
import { NgClass, NgTemplateOutlet } from "@angular/common";
|
||||
import {
|
||||
input,
|
||||
HostBinding,
|
||||
@@ -14,6 +14,7 @@ import { debounce, interval } from "rxjs";
|
||||
|
||||
import { AriaDisableDirective } from "../a11y";
|
||||
import { ButtonLikeAbstraction, ButtonType, ButtonSize } from "../shared/button-like.abstraction";
|
||||
import { BitwardenIcon } from "../shared/icon";
|
||||
import { SpinnerComponent } from "../spinner";
|
||||
import { ariaDisableElement } from "../utils";
|
||||
|
||||
@@ -71,7 +72,7 @@ const buttonStyles: Record<ButtonType, string[]> = {
|
||||
selector: "button[bitButton], a[bitButton]",
|
||||
templateUrl: "button.component.html",
|
||||
providers: [{ provide: ButtonLikeAbstraction, useExisting: ButtonComponent }],
|
||||
imports: [NgClass, SpinnerComponent],
|
||||
imports: [NgClass, NgTemplateOutlet, SpinnerComponent],
|
||||
hostDirectives: [AriaDisableDirective],
|
||||
})
|
||||
export class ButtonComponent implements ButtonLikeAbstraction {
|
||||
@@ -125,12 +126,23 @@ export class ButtonComponent implements ButtonLikeAbstraction {
|
||||
|
||||
readonly buttonType = input<ButtonType>("secondary");
|
||||
|
||||
readonly startIcon = input<BitwardenIcon | undefined>(undefined);
|
||||
|
||||
readonly endIcon = input<BitwardenIcon | undefined>(undefined);
|
||||
|
||||
readonly size = input<ButtonSize>("default");
|
||||
|
||||
readonly block = input(false, { transform: booleanAttribute });
|
||||
|
||||
readonly loading = model<boolean>(false);
|
||||
|
||||
readonly startIconClasses = computed(() => {
|
||||
return ["bwi", this.startIcon()];
|
||||
});
|
||||
|
||||
readonly endIconClasses = computed(() => {
|
||||
return ["bwi", this.endIcon()];
|
||||
});
|
||||
/**
|
||||
* Determine whether it is appropriate to display a loading spinner. We only want to show
|
||||
* a spinner if it's been more than 75 ms since the `loading` state began. This prevents
|
||||
|
||||
@@ -152,15 +152,13 @@ export const WithIcon: Story = {
|
||||
template: /*html*/ `
|
||||
<span class="tw-flex tw-gap-8">
|
||||
<div>
|
||||
<button type="button" bitButton [buttonType]="buttonType" [block]="block">
|
||||
<i class="bwi bwi-plus tw-me-2"></i>
|
||||
<button type="button" startIcon="bwi-plus" bitButton [buttonType]="buttonType" [block]="block">
|
||||
Button label
|
||||
</button>
|
||||
</div>
|
||||
<div>
|
||||
<button type="button" bitButton [buttonType]="buttonType" [block]="block">
|
||||
<button type="button" endIcon="bwi-plus" bitButton [buttonType]="buttonType" [block]="block">
|
||||
Button label
|
||||
<i class="bwi bwi-plus tw-ms-2"></i>
|
||||
</button>
|
||||
</div>
|
||||
</span>
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { Meta, StoryObj, moduleMetadata } from "@storybook/angular";
|
||||
|
||||
import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service";
|
||||
import { LinkModule, IconModule } from "@bitwarden/components";
|
||||
import { LinkModule, SvgModule } from "@bitwarden/components";
|
||||
|
||||
import { formatArgsForCodeSnippet } from "../../../../.storybook/format-args-for-code-snippet";
|
||||
import { I18nMockService } from "../utils/i18n-mock.service";
|
||||
@@ -13,7 +13,7 @@ export default {
|
||||
component: CalloutComponent,
|
||||
decorators: [
|
||||
moduleMetadata({
|
||||
imports: [LinkModule, IconModule],
|
||||
imports: [LinkModule, SvgModule],
|
||||
providers: [
|
||||
{
|
||||
provide: I18nService,
|
||||
@@ -113,7 +113,7 @@ export const WithTextButton: Story = {
|
||||
template: `
|
||||
<bit-callout ${formatArgsForCodeSnippet<CalloutComponent>(args)}>
|
||||
<p class="tw-mb-2">The content of the callout</p>
|
||||
<a bitLink> Visit the help center<i aria-hidden="true" class="bwi bwi-fw bwi-sm bwi-angle-right"></i> </a>
|
||||
<a bitLink endIcon="bwi-angle-right">Visit the help center</a>
|
||||
</bit-callout>
|
||||
`,
|
||||
}),
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { Meta, StoryObj, moduleMetadata } from "@storybook/angular";
|
||||
|
||||
import { AnchorLinkDirective } from "../../link";
|
||||
import { LinkComponent } from "../../link";
|
||||
import { TypographyModule } from "../../typography";
|
||||
|
||||
import { BaseCardComponent } from "./base-card.component";
|
||||
@@ -10,7 +10,7 @@ export default {
|
||||
component: BaseCardComponent,
|
||||
decorators: [
|
||||
moduleMetadata({
|
||||
imports: [AnchorLinkDirective, TypographyModule],
|
||||
imports: [LinkComponent, TypographyModule],
|
||||
}),
|
||||
],
|
||||
parameters: {
|
||||
|
||||
@@ -1,5 +1,14 @@
|
||||
import { Component, computed, HostBinding, input } from "@angular/core";
|
||||
import {
|
||||
Component,
|
||||
computed,
|
||||
ElementRef,
|
||||
HostBinding,
|
||||
HostListener,
|
||||
inject,
|
||||
input,
|
||||
} from "@angular/core";
|
||||
|
||||
import { PlatformUtilsService } from "@bitwarden/common/platform/abstractions/platform-utils.service";
|
||||
import { Utils } from "@bitwarden/common/platform/misc/utils";
|
||||
|
||||
type CharacterType = "letter" | "emoji" | "special" | "number";
|
||||
@@ -14,7 +23,7 @@ type CharacterType = "letter" | "emoji" | "special" | "number";
|
||||
@Component({
|
||||
selector: "bit-color-password",
|
||||
template: `@for (character of passwordCharArray(); track $index; let i = $index) {
|
||||
<span [class]="getCharacterClass(character)" class="tw-font-mono">
|
||||
<span [class]="getCharacterClass(character)" class="tw-font-mono" data-password-character>
|
||||
<span>{{ character }}</span>
|
||||
@if (showCount()) {
|
||||
<span class="tw-whitespace-nowrap tw-text-xs tw-leading-5 tw-text-main">{{ i + 1 }}</span>
|
||||
@@ -31,6 +40,9 @@ export class ColorPasswordComponent {
|
||||
return Array.from(this.password() ?? "");
|
||||
});
|
||||
|
||||
private platformUtilsService = inject(PlatformUtilsService);
|
||||
private elementRef = inject(ElementRef);
|
||||
|
||||
characterStyles: Record<CharacterType, string[]> = {
|
||||
emoji: [],
|
||||
letter: ["tw-text-main"],
|
||||
@@ -78,4 +90,28 @@ export class ColorPasswordComponent {
|
||||
|
||||
return "letter";
|
||||
}
|
||||
|
||||
@HostListener("copy", ["$event"])
|
||||
onCopy(event: ClipboardEvent) {
|
||||
event.preventDefault();
|
||||
const selection = window.getSelection();
|
||||
if (!selection || selection.rangeCount === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
const spanElements = this.elementRef.nativeElement.querySelectorAll(
|
||||
"span[data-password-character]",
|
||||
);
|
||||
let copiedText = "";
|
||||
|
||||
spanElements.forEach((span: HTMLElement, index: number) => {
|
||||
if (selection.containsNode(span, true)) {
|
||||
copiedText += this.passwordCharArray()[index];
|
||||
}
|
||||
});
|
||||
|
||||
if (copiedText) {
|
||||
this.platformUtilsService.copyToClipboard(copiedText);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,4 +1,6 @@
|
||||
import { Meta, StoryObj } from "@storybook/angular";
|
||||
import { applicationConfig, Meta, StoryObj } from "@storybook/angular";
|
||||
|
||||
import { PlatformUtilsService } from "@bitwarden/common/platform/abstractions/platform-utils.service";
|
||||
|
||||
import { formatArgsForCodeSnippet } from "../../../../.storybook/format-args-for-code-snippet";
|
||||
|
||||
@@ -9,6 +11,19 @@ const examplePassword = "Wq$Jk😀7jlI DX#rS5Sdi!z0O ";
|
||||
export default {
|
||||
title: "Component Library/Color Password",
|
||||
component: ColorPasswordComponent,
|
||||
decorators: [
|
||||
applicationConfig({
|
||||
providers: [
|
||||
{
|
||||
provide: PlatformUtilsService,
|
||||
useValue: {
|
||||
// eslint-disable-next-line
|
||||
copyToClipboard: (text: string) => console.log(`${text} copied to clipboard`),
|
||||
},
|
||||
},
|
||||
],
|
||||
}),
|
||||
],
|
||||
args: {
|
||||
password: examplePassword,
|
||||
showCount: false,
|
||||
|
||||
@@ -10,15 +10,15 @@ import { ComponentPortal, Portal } from "@angular/cdk/portal";
|
||||
import { Injectable, Injector, TemplateRef, inject } from "@angular/core";
|
||||
import { takeUntilDestroyed } from "@angular/core/rxjs-interop";
|
||||
import { NavigationEnd, Router } from "@angular/router";
|
||||
import { filter, firstValueFrom, map, Observable, Subject, switchMap } from "rxjs";
|
||||
import { filter, firstValueFrom, map, Observable, Subject, switchMap, take } from "rxjs";
|
||||
|
||||
import { AuthService } from "@bitwarden/common/auth/abstractions/auth.service";
|
||||
import { AuthenticationStatus } from "@bitwarden/common/auth/enums/authentication-status";
|
||||
import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service";
|
||||
|
||||
import { DrawerService } from "../drawer/drawer.service";
|
||||
import { isAtOrLargerThanBreakpoint } from "../utils/responsive-utils";
|
||||
|
||||
import { DrawerService } from "./drawer.service";
|
||||
import { SimpleConfigurableDialogComponent } from "./simple-dialog/simple-configurable-dialog/simple-configurable-dialog.component";
|
||||
import { SimpleDialogOptions } from "./simple-dialog/types";
|
||||
|
||||
@@ -62,7 +62,7 @@ export abstract class DialogRef<R = unknown, C = unknown> implements Pick<
|
||||
|
||||
export type DialogConfig<D = unknown, R = unknown> = Pick<
|
||||
CdkDialogConfig<D, R>,
|
||||
"data" | "disableClose" | "ariaModal" | "positionStrategy" | "height" | "width"
|
||||
"data" | "disableClose" | "ariaModal" | "positionStrategy" | "height" | "width" | "restoreFocus"
|
||||
>;
|
||||
|
||||
/**
|
||||
@@ -242,6 +242,11 @@ export class DialogService {
|
||||
};
|
||||
|
||||
ref.cdkDialogRefBase = this.dialog.open<R, D, C>(componentOrTemplateRef, _config);
|
||||
|
||||
if (config?.restoreFocus === undefined) {
|
||||
this.setRestoreFocusEl<R, C>(ref);
|
||||
}
|
||||
|
||||
return ref;
|
||||
}
|
||||
|
||||
@@ -305,6 +310,48 @@ export class DialogService {
|
||||
return this.activeDrawer?.close();
|
||||
}
|
||||
|
||||
/**
|
||||
* Configure the dialog to return focus to the previous active element upon closing.
|
||||
* @param ref CdkDialogRef
|
||||
*
|
||||
* The cdk dialog already has the optional directive `cdkTrapFocusAutoCapture` to capture the
|
||||
* current active element and return focus to it upon close. However, it does not have a way to
|
||||
* delay the capture of the element. We need this delay in some situations, where the active
|
||||
* element may be changing as the dialog is opening, and we want to wait for that to settle.
|
||||
*
|
||||
* For example -- the menu component often contains menu items that open dialogs. When the dialog
|
||||
* opens, the menu is closing and is setting focus back to the menu trigger since the menu item no
|
||||
* longer exists. We want to capture the menu trigger as the active element, not the about-to-be-
|
||||
* nonexistent menu item. If we wait a tick, we can let the menu finish that focus move.
|
||||
*/
|
||||
private setRestoreFocusEl<R = unknown, C = unknown>(ref: CdkDialogRef<R, C>) {
|
||||
/**
|
||||
* First, capture the current active el with no delay so that we can support normal use cases
|
||||
* where we are not doing manual focus management
|
||||
*/
|
||||
const activeEl = document.activeElement;
|
||||
|
||||
const restoreFocusTimeout = setTimeout(() => {
|
||||
let restoreFocusEl = activeEl;
|
||||
|
||||
/**
|
||||
* If the original active element is no longer connected, it's because we purposely removed it
|
||||
* from the DOM and have moved focus. Select the new active element instead.
|
||||
*/
|
||||
if (!restoreFocusEl?.isConnected) {
|
||||
restoreFocusEl = document.activeElement;
|
||||
}
|
||||
|
||||
if (restoreFocusEl instanceof HTMLElement) {
|
||||
ref.cdkDialogRefBase.config.restoreFocus = restoreFocusEl;
|
||||
}
|
||||
}, 0);
|
||||
|
||||
ref.closed.pipe(take(1)).subscribe(() => {
|
||||
clearTimeout(restoreFocusTimeout);
|
||||
});
|
||||
}
|
||||
|
||||
/** The injector that is passed to the opened dialog */
|
||||
private createInjector(opts: { data: unknown; dialogRef: DialogRef }): Injector {
|
||||
return Injector.create({
|
||||
|
||||
@@ -6,7 +6,6 @@
|
||||
isDrawer ? 'tw-h-full tw-border-t-0' : 'tw-rounded-t-xl md:tw-rounded-xl tw-shadow-lg',
|
||||
]"
|
||||
cdkTrapFocus
|
||||
cdkTrapFocusAutoCapture
|
||||
>
|
||||
@let showHeaderBorder = bodyHasScrolledFrom().top;
|
||||
<header
|
||||
@@ -23,8 +22,8 @@
|
||||
bitTypography="h3"
|
||||
noMargin
|
||||
class="tw-text-main tw-mb-0 tw-line-clamp-2 tw-text-ellipsis tw-break-words focus-visible:tw-outline-none"
|
||||
cdkFocusInitial
|
||||
tabindex="-1"
|
||||
#dialogHeader
|
||||
>
|
||||
{{ title() }}
|
||||
@if (subtitle(); as subtitleText) {
|
||||
|
||||
@@ -11,9 +11,11 @@ import {
|
||||
DestroyRef,
|
||||
computed,
|
||||
signal,
|
||||
AfterViewInit,
|
||||
NgZone,
|
||||
} from "@angular/core";
|
||||
import { toObservable } from "@angular/core/rxjs-interop";
|
||||
import { combineLatest, switchMap } from "rxjs";
|
||||
import { combineLatest, firstValueFrom, switchMap } from "rxjs";
|
||||
|
||||
import { I18nPipe } from "@bitwarden/ui-common";
|
||||
|
||||
@@ -43,7 +45,7 @@ const drawerSizeToWidth = {
|
||||
// FIXME(https://bitwarden.atlassian.net/browse/CL-764): Migrate to OnPush
|
||||
// eslint-disable-next-line @angular-eslint/prefer-on-push-component-change-detection
|
||||
@Component({
|
||||
selector: "bit-dialog",
|
||||
selector: "bit-dialog, [bit-dialog]",
|
||||
templateUrl: "./dialog.component.html",
|
||||
host: {
|
||||
"[class]": "classes()",
|
||||
@@ -62,8 +64,13 @@ const drawerSizeToWidth = {
|
||||
SpinnerComponent,
|
||||
],
|
||||
})
|
||||
export class DialogComponent {
|
||||
export class DialogComponent implements AfterViewInit {
|
||||
private readonly destroyRef = inject(DestroyRef);
|
||||
private readonly ngZone = inject(NgZone);
|
||||
private readonly el = inject(ElementRef);
|
||||
|
||||
private readonly dialogHeader =
|
||||
viewChild.required<ElementRef<HTMLHeadingElement>>("dialogHeader");
|
||||
private readonly scrollableBody = viewChild.required(CdkScrollable);
|
||||
private readonly scrollBottom = viewChild.required<ElementRef<HTMLDivElement>>("scrollBottom");
|
||||
|
||||
@@ -151,4 +158,55 @@ export class DialogComponent {
|
||||
onAnimationEnd() {
|
||||
this.animationCompleted.set(true);
|
||||
}
|
||||
|
||||
async ngAfterViewInit() {
|
||||
/**
|
||||
* Wait for the zone to stabilize before performing any focus behaviors. This ensures that all
|
||||
* child elements are rendered and stable.
|
||||
*/
|
||||
if (this.ngZone.isStable) {
|
||||
this.handleAutofocus();
|
||||
} else {
|
||||
await firstValueFrom(this.ngZone.onStable);
|
||||
this.handleAutofocus();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Ensure that the user's focus is in the dialog by autofocusing the appropriate element.
|
||||
*
|
||||
* If there is a descendant of the dialog with the AutofocusDirective applied, we defer to that.
|
||||
* If not, we want to fallback to a default behavior of focusing the dialog's header element. We
|
||||
* choose the dialog header as the default fallback for dialog focus because it is always present,
|
||||
* unlike possible interactive elements.
|
||||
*/
|
||||
handleAutofocus() {
|
||||
/**
|
||||
* Angular's contentChildren query cannot see into the internal templates of child components.
|
||||
* We need to use a regular DOM query instead to see if there are descendants using the
|
||||
* AutofocusDirective.
|
||||
*/
|
||||
const dialogRef = this.el.nativeElement;
|
||||
// Must match selectors of AutofocusDirective
|
||||
const autofocusDescendants = dialogRef.querySelectorAll("[appAutofocus], [bitAutofocus]");
|
||||
const hasAutofocusDescendants = autofocusDescendants.length > 0;
|
||||
|
||||
if (!hasAutofocusDescendants) {
|
||||
/**
|
||||
* Wait a tick for any focus management to occur on the trigger element before moving focus
|
||||
* to the dialog header.
|
||||
*
|
||||
* We are doing this manually instead of using Angular's built-in focus management
|
||||
* directives (`cdkTrapFocusAutoCapture` and `cdkFocusInitial`) because we need this delay
|
||||
* behavior.
|
||||
*
|
||||
* And yes, we need the timeout even though we are already waiting for ngZone to stabilize.
|
||||
*/
|
||||
const headerFocusTimeout = setTimeout(() => {
|
||||
this.dialogHeader().nativeElement.focus();
|
||||
}, 0);
|
||||
|
||||
this.destroyRef.onDestroy(() => clearTimeout(headerFocusTimeout));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -82,3 +82,12 @@ The `background` input can be set to `alt` to change the background color. This
|
||||
dialogs that contain multiple card sections.
|
||||
|
||||
<Canvas of={stories.WithCards} />
|
||||
|
||||
## Using Forms with Dialogs
|
||||
|
||||
When using forms with dialogs, apply the `bit-dialog` attribute directly to the `<form>` element
|
||||
instead of wrapping the dialog in a form. This ensures proper styling.
|
||||
|
||||
```html
|
||||
<form bit-dialog>...</form>
|
||||
```
|
||||
|
||||
@@ -225,8 +225,7 @@ export const WithCards: Story = {
|
||||
...args,
|
||||
},
|
||||
template: /*html*/ `
|
||||
<form [formGroup]="formObj">
|
||||
<bit-dialog [dialogSize]="dialogSize" [background]="background" [title]="title" [subtitle]="subtitle" [loading]="loading" [disablePadding]="disablePadding" [disableAnimations]="disableAnimations">
|
||||
<form [formGroup]="formObj" bit-dialog [dialogSize]="dialogSize" [background]="background" [title]="title" [subtitle]="subtitle" [loading]="loading" [disablePadding]="disablePadding" [disableAnimations]="disableAnimations">
|
||||
<ng-container bitDialogContent>
|
||||
<bit-section>
|
||||
<bit-section-header>
|
||||
@@ -270,7 +269,7 @@ export const WithCards: Story = {
|
||||
</bit-section>
|
||||
</ng-container>
|
||||
<ng-container bitDialogFooter>
|
||||
<button type="button" bitButton buttonType="primary" [disabled]="loading">Save</button>
|
||||
<button type="submit" bitButton buttonType="primary" [disabled]="loading">Save</button>
|
||||
<button type="button" bitButton buttonType="secondary" [disabled]="loading">Cancel</button>
|
||||
<button
|
||||
type="button"
|
||||
@@ -281,8 +280,7 @@ export const WithCards: Story = {
|
||||
size="default"
|
||||
label="Delete"></button>
|
||||
</ng-container>
|
||||
</bit-dialog>
|
||||
</form>
|
||||
</form>
|
||||
`,
|
||||
}),
|
||||
args: {
|
||||
|
||||
@@ -25,6 +25,35 @@ interruptive if overused.
|
||||
For non-blocking, supplementary content, open dialogs as a
|
||||
[Drawer](?path=/story/component-library-dialogs-service--drawer) (requires `bit-layout`).
|
||||
|
||||
### Closing Drawers on Navigation
|
||||
|
||||
When using drawers, you may want to close them automatically when the user navigates to another page
|
||||
to prevent the drawer from persisting across route changes. To implement this functionality:
|
||||
|
||||
1. Store a reference to the dialog when opening it
|
||||
2. Implement `OnDestroy` and close the dialog in `ngOnDestroy`
|
||||
|
||||
```ts
|
||||
import { Component, OnDestroy } from "@angular/core";
|
||||
import { DialogRef } from "@bitwarden/components";
|
||||
|
||||
export class MyComponent implements OnDestroy {
|
||||
private myDialogRef: DialogRef;
|
||||
|
||||
ngOnDestroy() {
|
||||
this.myDialogRef?.close();
|
||||
}
|
||||
|
||||
openDrawer() {
|
||||
this.myDialogRef = this.dialogService.open(MyDialogComponent, {
|
||||
// dialog options
|
||||
});
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
This ensures drawers are closed when the component is destroyed during navigation.
|
||||
|
||||
## Placement
|
||||
|
||||
Dialogs should be centered vertically and horizontally on screen. Dialogs height should expand to
|
||||
@@ -63,3 +92,12 @@ Once closed, focus should remain on the element which triggered the Dialog.
|
||||
|
||||
**Note:** If a Simple Dialog is triggered from a main Dialog, be sure to make sure focus is moved to
|
||||
the Simple Dialog.
|
||||
|
||||
## Using Forms with Dialogs
|
||||
|
||||
When using forms with dialogs, apply the `bit-dialog` attribute directly to the `<form>` element
|
||||
instead of wrapping the dialog in a form. This ensures proper styling.
|
||||
|
||||
```html
|
||||
<form bit-dialog>...</form>
|
||||
```
|
||||
|
||||
@@ -2,3 +2,4 @@ export * from "./dialog.module";
|
||||
export * from "./simple-dialog/types";
|
||||
export * from "./dialog.service";
|
||||
export { DIALOG_DATA } from "@angular/cdk/dialog";
|
||||
export { DialogComponent } from "./dialog/dialog.component";
|
||||
|
||||
@@ -1,27 +1,25 @@
|
||||
<form [formGroup]="formGroup" [bitSubmit]="accept">
|
||||
<bit-simple-dialog>
|
||||
<i bitDialogIcon class="bwi tw-text-3xl" [class]="iconClasses" aria-hidden="true"></i>
|
||||
<form [formGroup]="formGroup" [bitSubmit]="accept" bit-simple-dialog>
|
||||
<i bitDialogIcon class="bwi tw-text-3xl" [class]="iconClasses" aria-hidden="true"></i>
|
||||
|
||||
<span bitDialogTitle>{{ title }}</span>
|
||||
<span bitDialogTitle>{{ title }}</span>
|
||||
|
||||
<div bitDialogContent>{{ content }}</div>
|
||||
<div bitDialogContent>{{ content }}</div>
|
||||
|
||||
<ng-container bitDialogFooter>
|
||||
<button type="submit" bitButton bitFormButton buttonType="primary">
|
||||
{{ acceptButtonText }}
|
||||
<ng-container bitDialogFooter>
|
||||
<button type="submit" bitButton bitFormButton buttonType="primary">
|
||||
{{ acceptButtonText }}
|
||||
</button>
|
||||
|
||||
@if (showCancelButton) {
|
||||
<button
|
||||
type="button"
|
||||
bitButton
|
||||
bitFormButton
|
||||
buttonType="secondary"
|
||||
(click)="dialogRef.close(false)"
|
||||
>
|
||||
{{ cancelButtonText }}
|
||||
</button>
|
||||
|
||||
@if (showCancelButton) {
|
||||
<button
|
||||
type="button"
|
||||
bitButton
|
||||
bitFormButton
|
||||
buttonType="secondary"
|
||||
(click)="dialogRef.close(false)"
|
||||
>
|
||||
{{ cancelButtonText }}
|
||||
</button>
|
||||
}
|
||||
</ng-container>
|
||||
</bit-simple-dialog>
|
||||
}
|
||||
</ng-container>
|
||||
</form>
|
||||
|
||||
@@ -12,7 +12,7 @@ export class IconDirective {}
|
||||
// FIXME(https://bitwarden.atlassian.net/browse/CL-764): Migrate to OnPush
|
||||
// eslint-disable-next-line @angular-eslint/prefer-on-push-component-change-detection
|
||||
@Component({
|
||||
selector: "bit-simple-dialog",
|
||||
selector: "bit-simple-dialog, [bit-simple-dialog]",
|
||||
templateUrl: "./simple-dialog.component.html",
|
||||
animations: [fadeIn],
|
||||
imports: [DialogTitleContainerDirective, TypographyDirective],
|
||||
|
||||
@@ -49,3 +49,12 @@ Simple dialogs can support scrolling content if necessary, but typically with la
|
||||
content a [Dialog component](?path=/docs/component-library-dialogs-dialog--docs).
|
||||
|
||||
<Canvas of={stories.ScrollingContent} />
|
||||
|
||||
## Using Forms with Dialogs
|
||||
|
||||
When using forms with dialogs, apply the `bit-simple-dialog` attribute directly to the `<form>`
|
||||
element instead of wrapping the dialog in a form. This ensures proper styling.
|
||||
|
||||
```html
|
||||
<form bit-simple-dialog>...</form>
|
||||
```
|
||||
|
||||
@@ -126,3 +126,21 @@ export const TextOverflow: Story = {
|
||||
`,
|
||||
}),
|
||||
};
|
||||
|
||||
export const WithForm: Story = {
|
||||
render: (args) => ({
|
||||
props: args,
|
||||
template: /*html*/ `
|
||||
<form bit-simple-dialog>
|
||||
<span bitDialogTitle>Confirm Action</span>
|
||||
<span bitDialogContent>
|
||||
Are you sure you want to proceed with this action? This cannot be undone.
|
||||
</span>
|
||||
<ng-container bitDialogFooter>
|
||||
<button type="submit" bitButton buttonType="primary">Confirm</button>
|
||||
<button type="button" bitButton buttonType="secondary">Cancel</button>
|
||||
</ng-container>
|
||||
</form>
|
||||
`,
|
||||
}),
|
||||
};
|
||||
|
||||
@@ -1,27 +0,0 @@
|
||||
import { CdkScrollable } from "@angular/cdk/scrolling";
|
||||
import { ChangeDetectionStrategy, Component } from "@angular/core";
|
||||
|
||||
import { hasScrolledFrom } from "../utils/has-scrolled-from";
|
||||
|
||||
/**
|
||||
* Body container for `bit-drawer`
|
||||
*/
|
||||
@Component({
|
||||
selector: "bit-drawer-body",
|
||||
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||
imports: [],
|
||||
host: {
|
||||
class:
|
||||
"tw-p-4 tw-pt-0 tw-flex-1 tw-overflow-auto tw-border-solid tw-border tw-border-transparent tw-transition-colors tw-duration-200",
|
||||
"[class.tw-border-t-secondary-300]": "this.hasScrolledFrom().top",
|
||||
},
|
||||
hostDirectives: [
|
||||
{
|
||||
directive: CdkScrollable,
|
||||
},
|
||||
],
|
||||
template: ` <ng-content></ng-content> `,
|
||||
})
|
||||
export class DrawerBodyComponent {
|
||||
protected hasScrolledFrom = hasScrolledFrom();
|
||||
}
|
||||
@@ -1,28 +0,0 @@
|
||||
import { Directive, inject } from "@angular/core";
|
||||
|
||||
import { DrawerComponent } from "./drawer.component";
|
||||
|
||||
/**
|
||||
* Closes the ancestor drawer
|
||||
*
|
||||
* @example
|
||||
*
|
||||
* ```html
|
||||
* <bit-drawer>
|
||||
* <button type="button" bitButton bitDrawerClose>Close</button>
|
||||
* </bit-drawer>
|
||||
* ```
|
||||
**/
|
||||
@Directive({
|
||||
selector: "button[bitDrawerClose]",
|
||||
host: {
|
||||
"(click)": "onClick()",
|
||||
},
|
||||
})
|
||||
export class DrawerCloseDirective {
|
||||
private drawer = inject(DrawerComponent, { optional: true });
|
||||
|
||||
protected onClick() {
|
||||
this.drawer?.open.set(false);
|
||||
}
|
||||
}
|
||||
@@ -1,9 +0,0 @@
|
||||
<header class="tw-flex tw-justify-between tw-items-center tw-gap-4">
|
||||
<div class="tw-flex tw-items-center tw-gap-4 tw-overflow-auto">
|
||||
<ng-content select="[slot=start]"></ng-content>
|
||||
<h2 bitTypography="h3" noMargin class="tw-text-main tw-mb-0 tw-truncate" [attr.title]="title()">
|
||||
{{ title() }}
|
||||
</h2>
|
||||
</div>
|
||||
<button bitIconButton="bwi-close" type="button" bitDrawerClose [label]="'close' | i18n"></button>
|
||||
</header>
|
||||
@@ -1,34 +0,0 @@
|
||||
import { CommonModule } from "@angular/common";
|
||||
import { ChangeDetectionStrategy, Component, HostBinding, input } from "@angular/core";
|
||||
|
||||
import { I18nPipe } from "@bitwarden/ui-common";
|
||||
|
||||
import { IconButtonModule } from "../icon-button";
|
||||
import { TypographyModule } from "../typography";
|
||||
|
||||
import { DrawerCloseDirective } from "./drawer-close.directive";
|
||||
|
||||
/**
|
||||
* Header container for `bit-drawer`
|
||||
**/
|
||||
@Component({
|
||||
selector: "bit-drawer-header",
|
||||
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||
imports: [CommonModule, DrawerCloseDirective, TypographyModule, IconButtonModule, I18nPipe],
|
||||
templateUrl: "drawer-header.component.html",
|
||||
host: {
|
||||
class: "tw-block tw-ps-4 tw-pe-2 tw-py-2",
|
||||
},
|
||||
})
|
||||
export class DrawerHeaderComponent {
|
||||
/**
|
||||
* The title to display
|
||||
*/
|
||||
readonly title = input.required<string>();
|
||||
|
||||
/** We don't want to set the HTML title attribute with `this.title` */
|
||||
@HostBinding("attr.title")
|
||||
protected get getTitle(): null {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
@@ -1,27 +0,0 @@
|
||||
import { Portal } from "@angular/cdk/portal";
|
||||
import { Directive, signal } from "@angular/core";
|
||||
|
||||
/**
|
||||
* Host that renders a drawer
|
||||
*
|
||||
* @internal
|
||||
*/
|
||||
@Directive({
|
||||
selector: "[bitDrawerHost]",
|
||||
})
|
||||
export class DrawerHostDirective {
|
||||
private readonly _portal = signal<Portal<unknown> | undefined>(undefined);
|
||||
|
||||
/** The portal to display */
|
||||
portal = this._portal.asReadonly();
|
||||
|
||||
open(portal: Portal<unknown>) {
|
||||
this._portal.set(portal);
|
||||
}
|
||||
|
||||
close(portal: Portal<unknown>) {
|
||||
if (portal === this.portal()) {
|
||||
this._portal.set(undefined);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,8 +0,0 @@
|
||||
<ng-container *cdkPortal>
|
||||
<section
|
||||
[attr.role]="role()"
|
||||
class="tw-w-[23rem] tw-sticky tw-top-0 tw-h-full tw-flex tw-flex-col tw-overflow-auto tw-border-0 tw-border-l tw-border-solid tw-border-secondary-300 tw-bg-background"
|
||||
>
|
||||
<ng-content></ng-content>
|
||||
</section>
|
||||
</ng-container>
|
||||
@@ -1,75 +0,0 @@
|
||||
import { CdkPortal, PortalModule } from "@angular/cdk/portal";
|
||||
import { CommonModule } from "@angular/common";
|
||||
import {
|
||||
ChangeDetectionStrategy,
|
||||
Component,
|
||||
effect,
|
||||
inject,
|
||||
input,
|
||||
model,
|
||||
viewChild,
|
||||
} from "@angular/core";
|
||||
|
||||
import { DrawerService } from "./drawer.service";
|
||||
|
||||
/**
|
||||
* A drawer is a panel of supplementary content that is adjacent to the page's main content.
|
||||
*
|
||||
* Drawers render in `bit-layout`. Drawers must be a descendant of `bit-layout`, but they do not need to be a direct descendant.
|
||||
*/
|
||||
@Component({
|
||||
selector: "bit-drawer",
|
||||
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||
imports: [CommonModule, PortalModule],
|
||||
templateUrl: "drawer.component.html",
|
||||
})
|
||||
export class DrawerComponent {
|
||||
private drawerHost = inject(DrawerService);
|
||||
private readonly portal = viewChild.required(CdkPortal);
|
||||
|
||||
/**
|
||||
* Whether or not the drawer is open.
|
||||
*
|
||||
* Note: Does not support implicit boolean transform due to Angular limitation. Must be bound explicitly `[open]="true"` instead of just `open`.
|
||||
* https://github.com/angular/angular/issues/55166#issuecomment-2032150999
|
||||
**/
|
||||
readonly open = model<boolean>(false);
|
||||
|
||||
/**
|
||||
* The ARIA role of the drawer.
|
||||
*
|
||||
* - [complementary](https://developer.mozilla.org/en-US/docs/Web/Accessibility/ARIA/Roles/complementary_role)
|
||||
* - For drawers that contain content that is complementary to the page's main content. (default)
|
||||
* - [navigation](https://developer.mozilla.org/en-US/docs/Web/Accessibility/ARIA/Roles/navigation_role)
|
||||
* - For drawers that primary contain links to other content.
|
||||
*/
|
||||
readonly role = input<"complementary" | "navigation">("complementary");
|
||||
|
||||
constructor() {
|
||||
effect(
|
||||
() => {
|
||||
this.open() ? this.drawerHost.open(this.portal()) : this.drawerHost.close(this.portal());
|
||||
},
|
||||
{
|
||||
allowSignalWrites: true,
|
||||
},
|
||||
);
|
||||
|
||||
// Set `open` to `false` when another drawer is opened.
|
||||
effect(
|
||||
() => {
|
||||
if (this.drawerHost.portal() !== this.portal()) {
|
||||
this.open.set(false);
|
||||
}
|
||||
},
|
||||
{
|
||||
allowSignalWrites: true,
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
/** Toggle the drawer between open & closed */
|
||||
toggle() {
|
||||
this.open.update((prev) => !prev);
|
||||
}
|
||||
}
|
||||
@@ -1,122 +0,0 @@
|
||||
import { Meta, Canvas, Primary, Controls } from "@storybook/addon-docs/blocks";
|
||||
|
||||
import * as stories from "./drawer.stories";
|
||||
|
||||
import { DrawerOpen as KitchenSink } from "../stories/kitchen-sink/kitchen-sink.stories";
|
||||
|
||||
<Meta of={stories} />
|
||||
|
||||
```ts
|
||||
import { DrawerComponent } from "@bitwarden/components";
|
||||
```
|
||||
|
||||
# Drawer
|
||||
|
||||
**Note: `bit-drawer` is deprecated. Use `bit-dialog` and `DialogService.openDrawer(...)` instead.**
|
||||
|
||||
A drawer is a panel of supplementary content that is adjacent to the page's main content.
|
||||
|
||||
<Primary />
|
||||
|
||||
<Controls />
|
||||
|
||||
## Usage
|
||||
|
||||
A `bit-drawer` in a template will not render inline, but rather will render adjacent to the main
|
||||
page content.
|
||||
|
||||
```html
|
||||
<bit-drawer [open]="true">
|
||||
<bit-drawer-header title="Hello Bitwaaaaaaaaaaaaaaaaaaaaaaaaarden!"></bit-drawer-header>
|
||||
<bit-drawer-body>
|
||||
<p>Lorem ipsum dolor...</p>
|
||||
</bit-drawer-body>
|
||||
</bit-drawer>
|
||||
```
|
||||
|
||||
`bit-drawer` must be a descendant of `bit-layout`, but it does not need to be a direct descendant.
|
||||
|
||||
## Header and body
|
||||
|
||||
Header and body content can be provided with the `bit-drawer-header` and `bit-drawer-body`
|
||||
components, respectively.
|
||||
|
||||
A title can be passed to the header by input:
|
||||
`<bit-drawer-header title="Foobar"></bit-drawer-header>`
|
||||
|
||||
Custom content can be rendered before the title with the header's `start` slot:
|
||||
|
||||
```html
|
||||
<bit-drawer-header title="Foobar">
|
||||
<i slot="start" class="bwi bwi-key" aria-hidden="true"></i>
|
||||
</bit-drawer-header>
|
||||
```
|
||||
|
||||
## Opening and closing
|
||||
|
||||
`bit-drawer` opens when its `open` input is `true`:
|
||||
|
||||
```html
|
||||
<bit-drawer [open]="true">...</bit-drawer>
|
||||
```
|
||||
|
||||
Note: Model inputs do not support implicit boolean transformation (see Angular reasoning
|
||||
[here](https://github.com/angular/angular/issues/55166#issuecomment-2032150999)). `open` must be
|
||||
bound explicitly `<bit-drawer [open]="true">` instead of just `<bit-drawer open>`.
|
||||
|
||||
Buttons can be made to open/toggle drawers by referencing a template variable, or by manipulating
|
||||
state that is bound to `open`:
|
||||
|
||||
```html
|
||||
<button (click)="myDrawer.toggle()"></button> <bit-drawer #myDrawer>...</bit-drawer>
|
||||
```
|
||||
|
||||
For convenience, close buttons can be created _inside_ the drawer with the `bitDrawerClose`
|
||||
directive:
|
||||
|
||||
```html
|
||||
<bit-drawer>
|
||||
<button type="button" bitDrawerClose>Close</button>
|
||||
</bit-drawer>
|
||||
```
|
||||
|
||||
## Multiple Drawers
|
||||
|
||||
Only one drawer can be open at a time, and they do not stack. If a drawer is already open, opening
|
||||
another will close and replace the one already open.
|
||||
|
||||
<Canvas of={stories.MultipleDrawers} />
|
||||
|
||||
## Headless
|
||||
|
||||
Omitting `bit-drawer-header` and `bit-drawer-body` allows for fully customizable content.
|
||||
|
||||
<Canvas of={stories.Headless} />
|
||||
|
||||
## Accessibility
|
||||
|
||||
- The drawer should contain an h2 element. If you are using `bit-drawer-header`, this is created for
|
||||
you via the `title` input:
|
||||
|
||||
```html
|
||||
<bit-drawer>
|
||||
<h2 bitTypography="h2">Hello world!</h2>
|
||||
</bit-drawer>
|
||||
|
||||
<!-- or -->
|
||||
|
||||
<bit-drawer>
|
||||
<bit-drawer-header title="Hello world!"></bit-drawer-header>
|
||||
</bit-drawer>
|
||||
```
|
||||
|
||||
- The ARIA role of the drawer can be set with the `role` attribute:
|
||||
- [complementary](https://developer.mozilla.org/en-US/docs/Web/Accessibility/ARIA/Roles/complementary_role)
|
||||
(default)
|
||||
- For drawers that contain content that is complementary to the page's main content.
|
||||
- [navigation](https://developer.mozilla.org/en-US/docs/Web/Accessibility/ARIA/Roles/navigation_role)
|
||||
- For drawers that primary contain links to other content.
|
||||
|
||||
## Kitchen Sink
|
||||
|
||||
<Canvas of={KitchenSink} autoplay />
|
||||
@@ -1,12 +0,0 @@
|
||||
import { NgModule } from "@angular/core";
|
||||
|
||||
import { DrawerBodyComponent } from "./drawer-body.component";
|
||||
import { DrawerCloseDirective } from "./drawer-close.directive";
|
||||
import { DrawerHeaderComponent } from "./drawer-header.component";
|
||||
import { DrawerComponent } from "./drawer.component";
|
||||
|
||||
@NgModule({
|
||||
imports: [DrawerComponent, DrawerHeaderComponent, DrawerBodyComponent, DrawerCloseDirective],
|
||||
exports: [DrawerComponent, DrawerHeaderComponent, DrawerBodyComponent, DrawerCloseDirective],
|
||||
})
|
||||
export class DrawerModule {}
|
||||
@@ -1,128 +0,0 @@
|
||||
import { RouterTestingModule } from "@angular/router/testing";
|
||||
import { Meta, StoryObj, applicationConfig, moduleMetadata } from "@storybook/angular";
|
||||
|
||||
import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service";
|
||||
import { GlobalStateProvider } from "@bitwarden/state";
|
||||
|
||||
import { ButtonModule } from "../button";
|
||||
import { CalloutModule } from "../callout";
|
||||
import { LayoutComponent } from "../layout";
|
||||
import { mockLayoutI18n } from "../layout/mocks";
|
||||
import { positionFixedWrapperDecorator } from "../stories/storybook-decorators";
|
||||
import { TypographyModule } from "../typography";
|
||||
import { I18nMockService, StorybookGlobalStateProvider } from "../utils";
|
||||
|
||||
import { DrawerBodyComponent } from "./drawer-body.component";
|
||||
import { DrawerHeaderComponent } from "./drawer-header.component";
|
||||
import { DrawerComponent } from "./drawer.component";
|
||||
import { DrawerModule } from "./drawer.module";
|
||||
|
||||
export default {
|
||||
title: "Component Library/Drawer",
|
||||
component: DrawerComponent,
|
||||
subcomponents: {
|
||||
DrawerHeaderComponent,
|
||||
DrawerBodyComponent,
|
||||
},
|
||||
decorators: [
|
||||
positionFixedWrapperDecorator(),
|
||||
moduleMetadata({
|
||||
imports: [
|
||||
RouterTestingModule,
|
||||
LayoutComponent,
|
||||
DrawerModule,
|
||||
ButtonModule,
|
||||
CalloutModule,
|
||||
TypographyModule,
|
||||
],
|
||||
providers: [
|
||||
{
|
||||
provide: I18nService,
|
||||
useFactory: () => {
|
||||
return new I18nMockService({
|
||||
...mockLayoutI18n,
|
||||
close: "Close",
|
||||
loading: "Loading",
|
||||
});
|
||||
},
|
||||
},
|
||||
],
|
||||
}),
|
||||
applicationConfig({
|
||||
providers: [
|
||||
{
|
||||
provide: GlobalStateProvider,
|
||||
useClass: StorybookGlobalStateProvider,
|
||||
},
|
||||
],
|
||||
}),
|
||||
],
|
||||
} as Meta<DrawerComponent>;
|
||||
|
||||
type Story = StoryObj<DrawerComponent>;
|
||||
|
||||
export const Default: Story = {
|
||||
render: (args) => ({
|
||||
props: args,
|
||||
template: /*html*/ `
|
||||
<bit-layout class="tw-text-main">
|
||||
<p>The drawer is {{ open ? "open" : "closed" }}.<p>
|
||||
<button type="button" bitButton (click)="drawer.toggle()">Toggle</button>
|
||||
|
||||
<!-- Note: bit-drawer does *not* need to be a direct descendant of bit-layout. -->
|
||||
<bit-drawer [(open)]="open" #drawer>
|
||||
<bit-drawer-header title="Hello Bitwaaaaaaaaaaaaaaaaaaaaaaaaarden!">
|
||||
<i slot="start" class="bwi bwi-key" aria-hidden="true"></i>
|
||||
</bit-drawer-header>
|
||||
<bit-drawer-body>
|
||||
<p bitTypography="body1">
|
||||
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. Duis aute irure dolor in reprehenderit in voluptate velit esse cillum dolore eu fugiat nulla pariatur. Excepteur sint occaecat cupidatat non proident, sunt in culpa qui officia deserunt mollit anim id est laborum.
|
||||
</p>
|
||||
</bit-drawer-body>
|
||||
</bit-drawer>
|
||||
</bit-layout>
|
||||
`,
|
||||
}),
|
||||
args: {
|
||||
open: true,
|
||||
},
|
||||
};
|
||||
|
||||
export const Headless: Story = {
|
||||
render: (args) => ({
|
||||
props: args,
|
||||
template: /*html*/ `
|
||||
<bit-layout class="tw-text-main">
|
||||
<p>The drawer is {{ open ? "open" : "closed" }}.<p>
|
||||
<button type="button" bitButton (click)="drawer.toggle()">Toggle</button>
|
||||
<bit-drawer [(open)]="open" #drawer>
|
||||
<h2 bitTypography="h2"></h2>
|
||||
Hello world!
|
||||
</bit-drawer>
|
||||
</bit-layout>
|
||||
`,
|
||||
}),
|
||||
args: {
|
||||
open: true,
|
||||
},
|
||||
};
|
||||
|
||||
export const MultipleDrawers: Story = {
|
||||
render: (args) => ({
|
||||
props: args,
|
||||
template: /*html*/ `
|
||||
<bit-layout class="tw-text-main">
|
||||
<button type="button" bitButton (click)="foo.toggle()">{{ !foo.open() ? "Open" : "Close" }} Foo</button>
|
||||
<button type="button" bitButton (click)="bar.toggle()">{{ !bar.open() ? "Open" : "Close" }} Bar</button>
|
||||
|
||||
<bit-drawer #foo>
|
||||
Foo
|
||||
</bit-drawer>
|
||||
|
||||
<bit-drawer #bar [open]="true">
|
||||
Bar
|
||||
</bit-drawer>
|
||||
</bit-layout>
|
||||
`,
|
||||
}),
|
||||
};
|
||||
@@ -1,5 +0,0 @@
|
||||
export * from "./drawer.module";
|
||||
export * from "./drawer.component";
|
||||
export * from "./drawer-body.component";
|
||||
export * from "./drawer-close.directive";
|
||||
export * from "./drawer-header.component";
|
||||
@@ -14,7 +14,7 @@ import {
|
||||
BreadcrumbsModule,
|
||||
ButtonModule,
|
||||
IconButtonModule,
|
||||
IconModule,
|
||||
SvgModule,
|
||||
InputModule,
|
||||
MenuModule,
|
||||
NavigationModule,
|
||||
@@ -40,7 +40,7 @@ export default {
|
||||
BreadcrumbsModule,
|
||||
ButtonModule,
|
||||
IconButtonModule,
|
||||
IconModule,
|
||||
SvgModule,
|
||||
InputModule,
|
||||
MenuModule,
|
||||
NavigationModule,
|
||||
|
||||
@@ -71,9 +71,9 @@ const styles: Record<IconButtonType, string[]> = {
|
||||
primary: ["!tw-text-primary-600", "focus-visible:before:tw-ring-primary-600", ...focusRing],
|
||||
danger: ["!tw-text-danger-600", "focus-visible:before:tw-ring-primary-600", ...focusRing],
|
||||
"nav-contrast": [
|
||||
"!tw-text-alt2",
|
||||
"!tw-text-fg-sidenav-text",
|
||||
"hover:!tw-bg-hover-contrast",
|
||||
"focus-visible:before:tw-ring-text-alt2",
|
||||
"focus-visible:before:tw-ring-border-focus",
|
||||
...focusRing,
|
||||
],
|
||||
};
|
||||
|
||||
@@ -1,35 +1,30 @@
|
||||
import { Component, effect, input } from "@angular/core";
|
||||
import { DomSanitizer, SafeHtml } from "@angular/platform-browser";
|
||||
import { ChangeDetectionStrategy, Component, computed, input } from "@angular/core";
|
||||
|
||||
import { Icon, isIcon } from "@bitwarden/assets/svg";
|
||||
import { BitwardenIcon } from "../shared/icon";
|
||||
|
||||
// FIXME(https://bitwarden.atlassian.net/browse/CL-764): Migrate to OnPush
|
||||
// eslint-disable-next-line @angular-eslint/prefer-on-push-component-change-detection
|
||||
@Component({
|
||||
selector: "bit-icon",
|
||||
standalone: true,
|
||||
host: {
|
||||
"[attr.aria-hidden]": "!ariaLabel()",
|
||||
"[class]": "classList()",
|
||||
"[attr.aria-hidden]": "ariaLabel() ? null : true",
|
||||
"[attr.aria-label]": "ariaLabel()",
|
||||
"[innerHtml]": "innerHtml",
|
||||
class: "tw-max-h-full tw-flex tw-justify-center",
|
||||
},
|
||||
template: ``,
|
||||
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||
})
|
||||
export class BitIconComponent {
|
||||
innerHtml: SafeHtml | null = null;
|
||||
|
||||
readonly icon = input<Icon>();
|
||||
export class IconComponent {
|
||||
/**
|
||||
* The Bitwarden icon name (e.g., "bwi-lock", "bwi-user")
|
||||
*/
|
||||
readonly name = input.required<BitwardenIcon>();
|
||||
|
||||
/**
|
||||
* Accessible label for the icon
|
||||
*/
|
||||
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);
|
||||
});
|
||||
}
|
||||
protected readonly classList = computed(() => {
|
||||
return ["bwi", this.name()].join(" ");
|
||||
});
|
||||
}
|
||||
|
||||
@@ -8,113 +8,40 @@ import * as stories from "./icon.stories";
|
||||
import { IconModule } from "@bitwarden/components";
|
||||
```
|
||||
|
||||
# Icon Use Instructions
|
||||
# Icon
|
||||
|
||||
- Icons will generally be attached to the associated Jira task.
|
||||
- Designers should minify any SVGs before attaching them to Jira using a tool like
|
||||
[SVGOMG](https://jakearchibald.github.io/svgomg/).
|
||||
- **Note:** Ensure the "Remove viewbox" option is toggled off if responsive resizing of the icon
|
||||
is desired.
|
||||
The `bit-icon` component renders Bitwarden Web Icons (bwi) using icon font classes.
|
||||
|
||||
## Developer Instructions
|
||||
## Basic Usage
|
||||
|
||||
1. **Download the SVG** and import it as an `.svg` initially into the IDE of your choice.
|
||||
- The SVG should be formatted using either a built-in formatter or an external tool like
|
||||
[SVG Formatter Beautifier](https://codebeautify.org/svg-formatter-beautifier) to make applying
|
||||
classes easier.
|
||||
```html
|
||||
<bit-icon name="bwi-lock"></bit-icon>
|
||||
```
|
||||
|
||||
2. **Rename the file** as a `<name>.icon.ts` TypeScript file and place it in the `libs/assets/svg`
|
||||
lib.
|
||||
## Icon Names
|
||||
|
||||
3. **Import** `svgIcon` from `./icon-service`.
|
||||
All available icon names are defined in the `BitwardenIcon` type. Icons use the `bwi-*` naming
|
||||
convention (e.g., `bwi-lock`, `bwi-user`, `bwi-key`).
|
||||
|
||||
4. **Define and export** a `const` to represent your `svgIcon`.
|
||||
## Accessibility
|
||||
|
||||
```typescript
|
||||
export const ExampleIcon = svgIcon`<svg … </svg>`;
|
||||
```
|
||||
By default, icons are decorative and marked with `aria-hidden="true"`. To make an icon accessible,
|
||||
provide an `ariaLabel`:
|
||||
|
||||
5. **Replace any hardcoded strokes or fills** with the appropriate Tailwind class.
|
||||
- **Note:** Stroke is used when styling the outline of an SVG path, while fill is used when
|
||||
styling the inside of an SVG path.
|
||||
```html
|
||||
<bit-icon name="bwi-lock" [ariaLabel]="'Secure lock'"></bit-icon>
|
||||
```
|
||||
|
||||
- A non-comprehensive list of common colors and their associated classes is below:
|
||||
## Styling
|
||||
|
||||
| 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` |
|
||||
The component renders as an inline element. Apply standard CSS classes or styles to customize
|
||||
appearance:
|
||||
|
||||
- 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:
|
||||
- **Option 1: Figma**
|
||||
- Open the SVG in Figma.
|
||||
- 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/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/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-illustration-outline` corresponds to `tw-stroke-illustration-outline`
|
||||
or `tw-fill-illustration-outline`.
|
||||
```html
|
||||
<bit-icon name="bwi-lock" class="tw-text-primary-500 tw-text-2xl"></bit-icon>
|
||||
```
|
||||
|
||||
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
|
||||
to allow the SVG to scale to fit its container.
|
||||
- **Note:** Scaling is required for any SVG used as an
|
||||
[AnonLayout](?path=/docs/component-library-anon-layout--docs) `pageIcon`.
|
||||
## Note on SVG Icons
|
||||
|
||||
7. **Replace any generic `clipPath` ids** (such as `id="a"`) with a unique id, and update the
|
||||
referencing element to use the new id (such as `clip-path="url(#unique-id-here)"`).
|
||||
|
||||
8. **Import your SVG const** anywhere you want to use the SVG.
|
||||
- **Angular Component Example:**
|
||||
- **TypeScript:**
|
||||
|
||||
```typescript
|
||||
import { Component } from "@angular/core";
|
||||
import { IconModule } from '@bitwarden/components';
|
||||
import { ExampleIcon, Example2Icon } from "@bitwarden/assets/svg";
|
||||
|
||||
@Component({
|
||||
selector: "app-example",
|
||||
standalone: true,
|
||||
imports: [IconModule],
|
||||
templateUrl: "./example.component.html",
|
||||
})
|
||||
export class ExampleComponent {
|
||||
readonly Icons = { ExampleIcon, Example2Icon };
|
||||
...
|
||||
}
|
||||
```
|
||||
|
||||
- **HTML:**
|
||||
|
||||
> NOTE: SVG icons are treated as decorative by default and will be `aria-hidden` unless an
|
||||
> `ariaLabel` is explicitly provided to the `<bit-icon>` component
|
||||
|
||||
```html
|
||||
<bit-icon [icon]="Icons.ExampleIcon"></bit-icon>
|
||||
```
|
||||
|
||||
With `ariaLabel`
|
||||
|
||||
```html
|
||||
<bit-icon [icon]="Icons.ExampleIcon" [ariaLabel]="Your custom label text here"></bit-icon>
|
||||
```
|
||||
|
||||
9. **Ensure your SVG renders properly** according to Figma in both light and dark modes on a client
|
||||
which supports multiple style modes.
|
||||
For SVG illustrations (not font icons), use the `bit-svg` component instead. See the Svg component
|
||||
documentation for details.
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
import { NgModule } from "@angular/core";
|
||||
|
||||
import { BitIconComponent } from "./icon.component";
|
||||
import { IconComponent } from "./icon.component";
|
||||
|
||||
@NgModule({
|
||||
imports: [BitIconComponent],
|
||||
exports: [BitIconComponent],
|
||||
imports: [IconComponent],
|
||||
exports: [IconComponent],
|
||||
})
|
||||
export class IconModule {}
|
||||
|
||||
@@ -1,50 +1,61 @@
|
||||
import { Meta } from "@storybook/angular";
|
||||
import { Meta, StoryObj } from "@storybook/angular";
|
||||
|
||||
import * as SvgIcons from "@bitwarden/assets/svg";
|
||||
import { BITWARDEN_ICONS } from "../shared/icon";
|
||||
|
||||
import { BitIconComponent } from "./icon.component";
|
||||
import { IconComponent } from "./icon.component";
|
||||
|
||||
export default {
|
||||
title: "Component Library/Icon",
|
||||
component: BitIconComponent,
|
||||
component: IconComponent,
|
||||
parameters: {
|
||||
design: {
|
||||
type: "figma",
|
||||
url: "https://www.figma.com/design/Zt3YSeb6E6lebAffrNLa0h/Tailwind-Component-Library?node-id=21662-50335&t=k6OTDDPZOTtypRqo-11",
|
||||
},
|
||||
},
|
||||
} as Meta;
|
||||
argTypes: {
|
||||
name: {
|
||||
control: { type: "select" },
|
||||
options: BITWARDEN_ICONS,
|
||||
},
|
||||
},
|
||||
} as Meta<IconComponent>;
|
||||
|
||||
const {
|
||||
// Filtering out the few non-icons in the libs/assets/svg import
|
||||
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
||||
DynamicContentNotAllowedError: _DynamicContentNotAllowedError,
|
||||
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
||||
isIcon,
|
||||
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
||||
svgIcon,
|
||||
...Icons
|
||||
}: {
|
||||
[key: string]: any;
|
||||
} = SvgIcons;
|
||||
type Story = StoryObj<IconComponent>;
|
||||
|
||||
export const Default = {
|
||||
render: (args: { icons: [string, any][] }) => ({
|
||||
props: args,
|
||||
template: /*html*/ `
|
||||
<div class="tw-bg-secondary-100 tw-p-2 tw-grid tw-grid-cols-[repeat(auto-fit,minmax(224px,1fr))] tw-gap-2">
|
||||
@for (icon of icons; track icon[0]) {
|
||||
<div class="tw-size-56 tw-border tw-border-secondary-300 tw-rounded-md">
|
||||
<div class="tw-text-xs tw-text-center">{{icon[0]}}</div>
|
||||
<div class="tw-size-52 tw-w-full tw-content-center">
|
||||
<bit-icon [icon]="icon[1]"></bit-icon>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
`,
|
||||
}),
|
||||
export const Default: Story = {
|
||||
args: {
|
||||
icons: Object.entries(Icons),
|
||||
name: "bwi-lock",
|
||||
},
|
||||
};
|
||||
|
||||
export const AllIcons: Story = {
|
||||
render: () => ({
|
||||
template: `
|
||||
<div class="tw-grid tw-grid-cols-[repeat(auto-fit,minmax(150px,1fr))] tw-gap-4 tw-p-4">
|
||||
@for (icon of icons; track icon) {
|
||||
<div class="tw-flex tw-flex-col tw-items-center tw-p-2 tw-border tw-border-secondary-300 tw-rounded">
|
||||
<bit-icon [name]="icon" class="tw-text-2xl tw-mb-2"></bit-icon>
|
||||
<span class="tw-text-xs tw-text-center">{{ icon }}</span>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
`,
|
||||
props: {
|
||||
icons: BITWARDEN_ICONS,
|
||||
},
|
||||
}),
|
||||
};
|
||||
|
||||
export const WithAriaLabel: Story = {
|
||||
args: {
|
||||
name: "bwi-lock",
|
||||
ariaLabel: "Secure lock icon",
|
||||
},
|
||||
};
|
||||
|
||||
export const CompareWithLegacy: Story = {
|
||||
render: () => ({
|
||||
template: `<bit-icon name="bwi-lock"></bit-icon> <i class="bwi bwi-lock"></i>`,
|
||||
}),
|
||||
};
|
||||
|
||||
@@ -1 +1,2 @@
|
||||
export * from "./icon.module";
|
||||
export * from "./icon.component";
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
export { ButtonType, ButtonLikeAbstraction } from "./shared/button-like.abstraction";
|
||||
export { BitwardenIcon } from "./shared/icon";
|
||||
export * from "./a11y";
|
||||
export * from "./anon-layout";
|
||||
export * from "./async-actions";
|
||||
@@ -6,6 +7,7 @@ export * from "./avatar";
|
||||
export * from "./badge-list";
|
||||
export * from "./badge";
|
||||
export * from "./banner";
|
||||
export * from "./berry";
|
||||
export * from "./breadcrumbs";
|
||||
export * from "./button";
|
||||
export * from "./callout";
|
||||
@@ -17,14 +19,15 @@ export * from "./container";
|
||||
export * from "./copy-click";
|
||||
export * from "./dialog";
|
||||
export * from "./disclosure";
|
||||
export * from "./drawer";
|
||||
export * from "./form-field";
|
||||
export * from "./header";
|
||||
export * from "./icon-button";
|
||||
export * from "./icon";
|
||||
export * from "./svg";
|
||||
export * from "./icon-tile";
|
||||
export * from "./input";
|
||||
export * from "./item";
|
||||
export * from "./landing-layout";
|
||||
export * from "./layout";
|
||||
export * from "./link";
|
||||
export * from "./menu";
|
||||
|
||||
@@ -22,6 +22,8 @@ import { FocusableElement } from "../shared/focusable-element";
|
||||
*
|
||||
* If the component provides the `FocusableElement` interface, the `focus`
|
||||
* method will be called. Otherwise, the native element will be focused.
|
||||
*
|
||||
* If selector changes, `dialog.component.ts` must also be updated
|
||||
*/
|
||||
@Directive({
|
||||
selector: "[appAutofocus], [bitAutofocus]",
|
||||
|
||||
7
libs/components/src/landing-layout/index.ts
Normal file
7
libs/components/src/landing-layout/index.ts
Normal file
@@ -0,0 +1,7 @@
|
||||
export * from "./landing-layout.component";
|
||||
export * from "./landing-layout.module";
|
||||
export * from "./landing-card.component";
|
||||
export * from "./landing-content.component";
|
||||
export * from "./landing-footer.component";
|
||||
export * from "./landing-header.component";
|
||||
export * from "./landing-hero.component";
|
||||
@@ -0,0 +1,5 @@
|
||||
<bit-base-card
|
||||
class="tw-z-[2] tw-relative !tw-rounded-2xl tw-mb-6 sm:tw-mb-10 tw-mx-auto tw-w-full tw-bg-transparent tw-border-none tw-shadow-none sm:tw-bg-background sm:tw-border sm:tw-border-solid sm:tw-border-secondary-100 sm:tw-shadow sm:tw-p-8"
|
||||
>
|
||||
<ng-content></ng-content>
|
||||
</bit-base-card>
|
||||
33
libs/components/src/landing-layout/landing-card.component.ts
Normal file
33
libs/components/src/landing-layout/landing-card.component.ts
Normal file
@@ -0,0 +1,33 @@
|
||||
import { ChangeDetectionStrategy, Component } from "@angular/core";
|
||||
|
||||
import { BaseCardComponent } from "../card";
|
||||
|
||||
/**
|
||||
* Card component for landing pages that wraps content in a styled container.
|
||||
*
|
||||
* @remarks
|
||||
* This component provides:
|
||||
* - Card-based layout with consistent styling
|
||||
* - Content projection for forms, text, or other content
|
||||
* - Proper elevation and border styling
|
||||
*
|
||||
* Use this component inside `bit-landing-content` to wrap forms, content sections,
|
||||
* or any content that should appear in a contained, elevated card.
|
||||
*
|
||||
* @example
|
||||
* ```html
|
||||
* <bit-landing-card>
|
||||
* <form>
|
||||
* <!-- Your form fields here -->
|
||||
* </form>
|
||||
* </bit-landing-card>
|
||||
* ```
|
||||
*/
|
||||
@Component({
|
||||
selector: "bit-landing-card",
|
||||
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||
standalone: true,
|
||||
imports: [BaseCardComponent],
|
||||
templateUrl: "./landing-card.component.html",
|
||||
})
|
||||
export class LandingCardComponent {}
|
||||
@@ -0,0 +1,8 @@
|
||||
<div
|
||||
class="tw-flex tw-flex-col tw-flex-1 tw-items-center tw-bg-background-alt tw-p-5 tw-pt-12 tw-text-main"
|
||||
>
|
||||
<div [class]="maxWidthClasses()">
|
||||
<ng-content select="bit-landing-hero"></ng-content>
|
||||
<ng-content></ng-content>
|
||||
</div>
|
||||
</div>
|
||||
@@ -0,0 +1,63 @@
|
||||
import { ChangeDetectionStrategy, Component, computed, input } from "@angular/core";
|
||||
|
||||
export const LandingContentMaxWidth = ["md", "lg", "xl", "2xl", "3xl", "4xl"] as const;
|
||||
|
||||
export type LandingContentMaxWidthType = (typeof LandingContentMaxWidth)[number];
|
||||
|
||||
/**
|
||||
* Main content container for landing pages with configurable max-width constraints.
|
||||
*
|
||||
* @remarks
|
||||
* This component provides:
|
||||
* - Centered content area with alternative background color
|
||||
* - Configurable maximum width to control content readability
|
||||
* - Content projection slots for hero section and main content
|
||||
* - Responsive padding and layout
|
||||
*
|
||||
* Use this component inside `bit-landing-layout` to wrap your main page content.
|
||||
* Optionally include a `bit-landing-hero` as the first child for consistent hero section styling.
|
||||
*
|
||||
* @example
|
||||
* ```html
|
||||
* <bit-landing-content [maxWidth]="'xl'">
|
||||
* <bit-landing-hero
|
||||
* [icon]="lockIcon"
|
||||
* [title]="'Welcome'"
|
||||
* [subtitle]="'Get started with your account'"
|
||||
* ></bit-landing-hero>
|
||||
* <bit-landing-card>
|
||||
* <!-- Your form or content here -->
|
||||
* </bit-landing-card>
|
||||
* </bit-landing-content>
|
||||
* ```
|
||||
*/
|
||||
@Component({
|
||||
selector: "bit-landing-content",
|
||||
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||
templateUrl: "./landing-content.component.html",
|
||||
host: {
|
||||
class: "tw-grow tw-flex tw-flex-col",
|
||||
},
|
||||
})
|
||||
export class LandingContentComponent {
|
||||
/**
|
||||
* Max width of the landing layout container.
|
||||
*
|
||||
* @default "md"
|
||||
*/
|
||||
readonly maxWidth = input<LandingContentMaxWidthType>("md");
|
||||
|
||||
private readonly maxWidthClassMap: Record<LandingContentMaxWidthType, string> = {
|
||||
md: "tw-max-w-md",
|
||||
lg: "tw-max-w-lg",
|
||||
xl: "tw-max-w-xl",
|
||||
"2xl": "tw-max-w-2xl",
|
||||
"3xl": "tw-max-w-3xl",
|
||||
"4xl": "tw-max-w-4xl",
|
||||
};
|
||||
|
||||
readonly maxWidthClasses = computed(() => {
|
||||
const maxWidthClass = this.maxWidthClassMap[this.maxWidth()];
|
||||
return `tw-flex tw-flex-col tw-w-full ${maxWidthClass}`;
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
<footer class="tw-bg-background-alt tw-text-center tw-p-5 tw-pt-4 sm:tw-pt-6">
|
||||
<ng-content></ng-content>
|
||||
</footer>
|
||||
@@ -0,0 +1,29 @@
|
||||
import { ChangeDetectionStrategy, Component } from "@angular/core";
|
||||
|
||||
/**
|
||||
* Footer component for landing pages.
|
||||
*
|
||||
* @remarks
|
||||
* This component provides:
|
||||
* - Content projection for custom footer content (e.g., links, copyright, legal)
|
||||
* - Consistent footer positioning at the bottom of the page
|
||||
* - Proper z-index to appear above background illustrations
|
||||
*
|
||||
* Use this component inside `bit-landing-layout` as the last child to position it at the bottom.
|
||||
*
|
||||
* @example
|
||||
* ```html
|
||||
* <bit-landing-footer>
|
||||
* <div class="tw-text-center tw-text-sm">
|
||||
* <a routerLink="/privacy">Privacy</a>
|
||||
* <span>© 2024 Bitwarden</span>
|
||||
* </div>
|
||||
* </bit-landing-footer>
|
||||
* ```
|
||||
*/
|
||||
@Component({
|
||||
selector: "bit-landing-footer",
|
||||
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||
templateUrl: "./landing-footer.component.html",
|
||||
})
|
||||
export class LandingFooterComponent {}
|
||||
@@ -0,0 +1,13 @@
|
||||
<header class="tw-flex tw-w-full tw-bg-background-alt tw-px-5">
|
||||
@if (!hideLogo()) {
|
||||
<a
|
||||
[routerLink]="['/']"
|
||||
class="tw-w-32 tw-py-5 sm:tw-w-[200px] tw-self-center sm:tw-self-start tw-block [&>*]:tw-align-top"
|
||||
>
|
||||
<bit-svg [content]="logo" [ariaLabel]="'appLogoLabel' | i18n"></bit-svg>
|
||||
</a>
|
||||
}
|
||||
<div class="[&:has(*)]:tw-ms-auto [&:has(*)]:tw-py-5">
|
||||
<ng-content></ng-content>
|
||||
</div>
|
||||
</header>
|
||||
@@ -0,0 +1,42 @@
|
||||
import { ChangeDetectionStrategy, Component, input } from "@angular/core";
|
||||
import { RouterModule } from "@angular/router";
|
||||
|
||||
import { BitwardenLogo } from "@bitwarden/assets/svg";
|
||||
|
||||
import { SharedModule } from "../shared";
|
||||
import { SvgModule } from "../svg";
|
||||
|
||||
/**
|
||||
* Header component for landing pages with optional Bitwarden logo and header actions slot.
|
||||
*
|
||||
* @remarks
|
||||
* This component provides:
|
||||
* - Optional Bitwarden logo with link to home page (left-aligned)
|
||||
* - Default content projection slot for header actions (right-aligned, auto-margin left)
|
||||
* - Consistent header styling across landing pages
|
||||
* - Responsive layout that adapts logo size
|
||||
*
|
||||
* Use this component inside `bit-landing-layout` as the first child to position it at the top.
|
||||
* Content projected into this component will automatically align to the right side of the header.
|
||||
*
|
||||
* @example
|
||||
* ```html
|
||||
* <bit-landing-header [hideLogo]="false">
|
||||
* <!-- Content here appears in the right-aligned actions slot -->
|
||||
* <nav>
|
||||
* <a routerLink="/login">Log in</a>
|
||||
* <button type="button">Sign up</button>
|
||||
* </nav>
|
||||
* </bit-landing-header>
|
||||
* ```
|
||||
*/
|
||||
@Component({
|
||||
selector: "bit-landing-header",
|
||||
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||
templateUrl: "./landing-header.component.html",
|
||||
imports: [RouterModule, SvgModule, SharedModule],
|
||||
})
|
||||
export class LandingHeaderComponent {
|
||||
readonly hideLogo = input<boolean>(false);
|
||||
protected readonly logo = BitwardenLogo;
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
@if (icon() || title() || subtitle()) {
|
||||
<div class="tw-text-center tw-mb-4 sm:tw-mb-6 tw-mx-auto">
|
||||
@if (icon()) {
|
||||
<!-- In some scenarios this icon's size is not limited by container width correctly -->
|
||||
<!-- Targeting the SVG here to try and ensure it never grows too large in even the media queries are not working as expected -->
|
||||
<div
|
||||
class="tw-size-20 sm:tw-size-24 [&_svg]:tw-w-full [&_svg]:tw-max-w-24 tw-mx-auto tw-content-center"
|
||||
>
|
||||
<bit-svg [content]="icon()"></bit-svg>
|
||||
</div>
|
||||
}
|
||||
|
||||
@if (title()) {
|
||||
<!-- Small screens -->
|
||||
<h1 bitTypography="h2" class="tw-mt-2 sm:tw-hidden">
|
||||
{{ title() }}
|
||||
</h1>
|
||||
<!-- Medium to Larger screens -->
|
||||
<h1 bitTypography="h1" class="tw-mt-2 tw-hidden sm:tw-block">
|
||||
{{ title() }}
|
||||
</h1>
|
||||
}
|
||||
|
||||
@if (subtitle()) {
|
||||
<div class="tw-text-sm sm:tw-text-base">{{ subtitle() }}</div>
|
||||
}
|
||||
</div>
|
||||
}
|
||||
40
libs/components/src/landing-layout/landing-hero.component.ts
Normal file
40
libs/components/src/landing-layout/landing-hero.component.ts
Normal file
@@ -0,0 +1,40 @@
|
||||
import { ChangeDetectionStrategy, Component, input } from "@angular/core";
|
||||
|
||||
import { BitSvg } from "@bitwarden/assets/svg";
|
||||
|
||||
import { SvgModule } from "../svg";
|
||||
import { TypographyModule } from "../typography";
|
||||
|
||||
/**
|
||||
* Hero section component for landing pages featuring an optional icon, title, and subtitle.
|
||||
*
|
||||
* @remarks
|
||||
* This component provides:
|
||||
* - Optional icon display (e.g., feature icons, status icons)
|
||||
* - Large title text with consistent typography
|
||||
* - Subtitle text for additional context
|
||||
* - Centered layout with proper spacing
|
||||
*
|
||||
* Use this component as the first child inside `bit-landing-content` to create a prominent
|
||||
* hero section that introduces the page's purpose.
|
||||
*
|
||||
* @example
|
||||
* ```html
|
||||
* <bit-landing-hero
|
||||
* [icon]="lockIcon"
|
||||
* [title]="'Secure Your Passwords'"
|
||||
* [subtitle]="'Create your account to get started'"
|
||||
* ></bit-landing-hero>
|
||||
* ```
|
||||
*/
|
||||
@Component({
|
||||
selector: "bit-landing-hero",
|
||||
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||
templateUrl: "./landing-hero.component.html",
|
||||
imports: [SvgModule, TypographyModule],
|
||||
})
|
||||
export class LandingHeroComponent {
|
||||
readonly icon = input<BitSvg | null>(null);
|
||||
readonly title = input<string | undefined>();
|
||||
readonly subtitle = input<string | undefined>();
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
<div
|
||||
class="tw-relative tw-flex tw-size-full tw-mx-auto tw-flex-col"
|
||||
[class]="{
|
||||
'tw-min-h-screen': clientType === 'web',
|
||||
'tw-min-h-full': clientType === 'browser' || clientType === 'desktop',
|
||||
}"
|
||||
>
|
||||
<ng-content select="bit-landing-header"></ng-content>
|
||||
<main class="tw-relative tw-flex tw-flex-1 tw-size-full tw-mx-auto tw-flex-col">
|
||||
<ng-content></ng-content>
|
||||
</main>
|
||||
@if (!hideBackgroundIllustration()) {
|
||||
<div
|
||||
class="tw-hidden md:tw-block [&_svg]:tw-absolute tw-z-[1] tw-opacity-[.11] [&_svg]:tw-bottom-0 [&_svg]:tw-start-0 [&_svg]:tw-w-[35%] [&_svg]:tw-max-w-[450px]"
|
||||
>
|
||||
<bit-svg [content]="leftIllustration"></bit-svg>
|
||||
</div>
|
||||
<div
|
||||
class="tw-hidden md:tw-block [&_svg]:tw-absolute tw-z-[1] tw-opacity-[.11] [&_svg]:tw-bottom-0 [&_svg]:tw-end-0 [&_svg]:tw-w-[35%] [&_svg]:tw-max-w-[450px]"
|
||||
>
|
||||
<bit-svg [content]="rightIllustration"></bit-svg>
|
||||
</div>
|
||||
}
|
||||
<ng-content select="bit-landing-footer"></ng-content>
|
||||
</div>
|
||||
@@ -0,0 +1,40 @@
|
||||
import { Component, ChangeDetectionStrategy, inject, input } from "@angular/core";
|
||||
|
||||
import { BackgroundLeftIllustration, BackgroundRightIllustration } from "@bitwarden/assets/svg";
|
||||
import { PlatformUtilsService } from "@bitwarden/common/platform/abstractions/platform-utils.service";
|
||||
|
||||
import { SvgModule } from "../svg";
|
||||
|
||||
/**
|
||||
* Root layout component for landing pages providing a full-screen container with optional decorative background illustrations.
|
||||
*
|
||||
* @remarks
|
||||
* This component serves as the outermost wrapper for landing pages and provides:
|
||||
* - Full-screen layout that adapts to different client types (web, browser, desktop)
|
||||
* - Optional decorative background illustrations in the bottom corners
|
||||
* - Content projection slots for header, main content, and footer
|
||||
*
|
||||
* @example
|
||||
* ```html
|
||||
* <bit-landing-layout [hideBackgroundIllustration]="false">
|
||||
* <bit-landing-header>...</bit-landing-header>
|
||||
* <bit-landing-content>...</bit-landing-content>
|
||||
* <bit-landing-footer>...</bit-landing-footer>
|
||||
* </bit-landing-layout>
|
||||
* ```
|
||||
*/
|
||||
@Component({
|
||||
selector: "bit-landing-layout",
|
||||
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||
templateUrl: "./landing-layout.component.html",
|
||||
imports: [SvgModule],
|
||||
})
|
||||
export class LandingLayoutComponent {
|
||||
readonly hideBackgroundIllustration = input<boolean>(false);
|
||||
|
||||
protected readonly leftIllustration = BackgroundLeftIllustration;
|
||||
protected readonly rightIllustration = BackgroundRightIllustration;
|
||||
|
||||
private readonly platformUtilsService: PlatformUtilsService = inject(PlatformUtilsService);
|
||||
protected readonly clientType = this.platformUtilsService.getClientType();
|
||||
}
|
||||
28
libs/components/src/landing-layout/landing-layout.module.ts
Normal file
28
libs/components/src/landing-layout/landing-layout.module.ts
Normal file
@@ -0,0 +1,28 @@
|
||||
import { NgModule } from "@angular/core";
|
||||
|
||||
import { LandingCardComponent } from "./landing-card.component";
|
||||
import { LandingContentComponent } from "./landing-content.component";
|
||||
import { LandingFooterComponent } from "./landing-footer.component";
|
||||
import { LandingHeaderComponent } from "./landing-header.component";
|
||||
import { LandingHeroComponent } from "./landing-hero.component";
|
||||
import { LandingLayoutComponent } from "./landing-layout.component";
|
||||
|
||||
@NgModule({
|
||||
imports: [
|
||||
LandingLayoutComponent,
|
||||
LandingHeaderComponent,
|
||||
LandingHeroComponent,
|
||||
LandingFooterComponent,
|
||||
LandingContentComponent,
|
||||
LandingCardComponent,
|
||||
],
|
||||
exports: [
|
||||
LandingLayoutComponent,
|
||||
LandingHeaderComponent,
|
||||
LandingHeroComponent,
|
||||
LandingFooterComponent,
|
||||
LandingContentComponent,
|
||||
LandingCardComponent,
|
||||
],
|
||||
})
|
||||
export class LandingLayoutModule {}
|
||||
162
libs/components/src/landing-layout/landing-layout.stories.ts
Normal file
162
libs/components/src/landing-layout/landing-layout.stories.ts
Normal file
@@ -0,0 +1,162 @@
|
||||
import { Meta, StoryObj, moduleMetadata } from "@storybook/angular";
|
||||
|
||||
import { ClientType } from "@bitwarden/common/enums";
|
||||
import { PlatformUtilsService } from "@bitwarden/common/platform/abstractions/platform-utils.service";
|
||||
|
||||
import { ButtonModule } from "../button";
|
||||
|
||||
import { LandingLayoutComponent } from "./landing-layout.component";
|
||||
|
||||
class MockPlatformUtilsService implements Partial<PlatformUtilsService> {
|
||||
getClientType = () => ClientType.Web;
|
||||
}
|
||||
|
||||
type StoryArgs = LandingLayoutComponent & {
|
||||
contentLength: "normal" | "long" | "thin";
|
||||
includeHeader: boolean;
|
||||
includeFooter: boolean;
|
||||
};
|
||||
|
||||
export default {
|
||||
title: "Component Library/Landing Layout",
|
||||
component: LandingLayoutComponent,
|
||||
decorators: [
|
||||
moduleMetadata({
|
||||
imports: [ButtonModule],
|
||||
providers: [
|
||||
{
|
||||
provide: PlatformUtilsService,
|
||||
useClass: MockPlatformUtilsService,
|
||||
},
|
||||
],
|
||||
}),
|
||||
],
|
||||
render: (args) => {
|
||||
return {
|
||||
props: args,
|
||||
template: /*html*/ `
|
||||
<bit-landing-layout
|
||||
[hideBackgroundIllustration]="hideBackgroundIllustration"
|
||||
>
|
||||
@if (includeHeader) {
|
||||
<bit-landing-header>
|
||||
<div class="tw-p-4">
|
||||
<div class="tw-flex tw-items-center tw-gap-4">
|
||||
<div class="tw-text-xl tw-font-semibold">Header Content</div>
|
||||
</div>
|
||||
</div>
|
||||
</bit-landing-header>
|
||||
}
|
||||
|
||||
<div>
|
||||
@switch (contentLength) {
|
||||
@case ('thin') {
|
||||
<div class="tw-text-center tw-p-8">
|
||||
<div class="tw-font-medium">Thin Content</div>
|
||||
</div>
|
||||
}
|
||||
@case ('long') {
|
||||
<div class="tw-p-8">
|
||||
<div class="tw-font-medium tw-mb-4">Long Content</div>
|
||||
<div class="tw-mb-4">Sed ut perspiciatis unde omnis iste natus error sit voluptatem accusantium doloremque laudantium, totam rem aperiam, eaque ipsa quae ab illo inventore veritatis et quasi architecto beatae vitae dicta sunt explicabo. Nemo enim ipsam voluptatem quia voluptas sit aspernatur aut odit aut fugit, sed quia consequuntur magni dolores eos qui ratione voluptatem sequi nesciunt. Neque porro quisquam est, qui dolorem ipsum quia dolor sit amet, consectetur, adipisci velit, sed quia non numquam eius modi tempora incidunt ut labore et dolore magnam aliquam quaerat voluptatem. Ut enim ad minima veniam, quis nostrum exercitationem ullam corporis suscipit laboriosam, nisi ut aliquid ex ea commodi consequatur? Quis autem vel eum iure reprehenderit qui in ea voluptate velit esse quam nihil molestiae consequatur, vel illum qui dolorem eum fugiat quo voluptas nulla pariatur?</div>
|
||||
<div class="tw-mb-4">Sed ut perspiciatis unde omnis iste natus error sit voluptatem accusantium doloremque laudantium, totam rem aperiam, eaque ipsa quae ab illo inventore veritatis et quasi architecto beatae vitae dicta sunt explicabo. Nemo enim ipsam voluptatem quia voluptas sit aspernatur aut odit aut fugit, sed quia consequuntur magni dolores eos qui ratione voluptatem sequi nesciunt. Neque porro quisquam est, qui dolorem ipsum quia dolor sit amet, consectetur, adipisci velit, sed quia non numquam eius modi tempora incidunt ut labore et dolore magnam aliquam quaerat voluptatem. Ut enim ad minima veniam, quis nostrum exercitationem ullam corporis suscipit laboriosam, nisi ut aliquid ex ea commodi consequatur? Quis autem vel eum iure reprehenderit qui in ea voluptate velit esse quam nihil molestiae consequatur, vel illum qui dolorem eum fugiat quo voluptas nulla pariatur?</div>
|
||||
</div>
|
||||
}
|
||||
@default {
|
||||
<div class="tw-p-8">
|
||||
<div class="tw-font-medium tw-mb-4">Normal Content</div>
|
||||
<div>Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua. Ut enim ad minim veniam, quis nostrud exercitation ullamco laboris nisi ut aliquip ex ea commodo consequat.</div>
|
||||
</div>
|
||||
}
|
||||
}
|
||||
</div>
|
||||
|
||||
@if (includeFooter) {
|
||||
<bit-landing-footer>
|
||||
<div class="tw-text-center tw-text-sm tw-text-muted">
|
||||
<div>Footer Content</div>
|
||||
</div>
|
||||
</bit-landing-footer>
|
||||
}
|
||||
</bit-landing-layout>
|
||||
`,
|
||||
};
|
||||
},
|
||||
|
||||
argTypes: {
|
||||
hideBackgroundIllustration: { control: "boolean" },
|
||||
contentLength: {
|
||||
control: "radio",
|
||||
options: ["normal", "long", "thin"],
|
||||
},
|
||||
includeHeader: { control: "boolean" },
|
||||
includeFooter: { control: "boolean" },
|
||||
},
|
||||
|
||||
args: {
|
||||
hideBackgroundIllustration: false,
|
||||
contentLength: "normal",
|
||||
includeHeader: false,
|
||||
includeFooter: false,
|
||||
},
|
||||
} satisfies Meta<StoryArgs>;
|
||||
|
||||
type Story = StoryObj<StoryArgs>;
|
||||
|
||||
export const Default: Story = {
|
||||
args: {
|
||||
contentLength: "normal",
|
||||
},
|
||||
};
|
||||
|
||||
export const WithHeader: Story = {
|
||||
args: {
|
||||
includeHeader: true,
|
||||
},
|
||||
};
|
||||
|
||||
export const WithFooter: Story = {
|
||||
args: {
|
||||
includeFooter: true,
|
||||
},
|
||||
};
|
||||
|
||||
export const WithHeaderAndFooter: Story = {
|
||||
args: {
|
||||
includeHeader: true,
|
||||
includeFooter: true,
|
||||
},
|
||||
};
|
||||
|
||||
export const LongContent: Story = {
|
||||
args: {
|
||||
contentLength: "long",
|
||||
includeHeader: true,
|
||||
includeFooter: true,
|
||||
},
|
||||
};
|
||||
|
||||
export const ThinContent: Story = {
|
||||
args: {
|
||||
contentLength: "thin",
|
||||
includeHeader: true,
|
||||
includeFooter: true,
|
||||
},
|
||||
};
|
||||
|
||||
export const NoBackgroundIllustration: Story = {
|
||||
args: {
|
||||
hideBackgroundIllustration: true,
|
||||
includeHeader: true,
|
||||
includeFooter: true,
|
||||
},
|
||||
};
|
||||
|
||||
export const MinimalState: Story = {
|
||||
args: {
|
||||
contentLength: "thin",
|
||||
hideBackgroundIllustration: true,
|
||||
includeHeader: false,
|
||||
includeFooter: false,
|
||||
},
|
||||
};
|
||||
@@ -1,5 +1,5 @@
|
||||
@let mainContentId = "main-content";
|
||||
<div class="tw-flex tw-size-full">
|
||||
<div class="tw-flex tw-size-full" [class.tw-bg-background-alt3]="rounded()">
|
||||
<div class="tw-flex tw-size-full" cdkTrapFocus>
|
||||
<div
|
||||
class="tw-fixed tw-z-50 tw-w-full tw-flex tw-justify-center tw-opacity-0 focus-within:tw-opacity-100 tw-pointer-events-none focus-within:tw-pointer-events-auto"
|
||||
@@ -24,26 +24,20 @@
|
||||
tabindex="-1"
|
||||
bitScrollLayoutHost
|
||||
class="tw-overflow-auto tw-max-h-full tw-min-w-0 tw-flex-1 tw-bg-background tw-p-8 tw-pt-6 tw-@container"
|
||||
[class.tw-rounded-tl-2xl]="rounded()"
|
||||
>
|
||||
<!-- ^ If updating this padding, also update the padding correction in bit-banner! ^ -->
|
||||
<ng-content></ng-content>
|
||||
</main>
|
||||
<!-- overlay backdrop for side-nav -->
|
||||
@if (
|
||||
{
|
||||
open: sideNavService.open$ | async,
|
||||
};
|
||||
as data
|
||||
) {
|
||||
<div
|
||||
class="tw-pointer-events-none tw-fixed tw-inset-0 tw-z-10 tw-bg-black tw-bg-opacity-0 motion-safe:tw-transition-colors md:tw-hidden"
|
||||
[ngClass]="[data.open ? 'tw-bg-opacity-30 md:tw-bg-opacity-0' : 'tw-bg-opacity-0']"
|
||||
>
|
||||
@if (data.open) {
|
||||
<div (click)="sideNavService.toggle()" class="tw-pointer-events-auto tw-size-full"></div>
|
||||
}
|
||||
</div>
|
||||
}
|
||||
<div
|
||||
class="tw-pointer-events-none tw-fixed tw-inset-0 tw-z-10 tw-bg-black tw-bg-opacity-0 motion-safe:tw-transition-colors md:tw-hidden"
|
||||
[class]="sideNavService.open() ? 'tw-bg-opacity-30 md:tw-bg-opacity-0' : 'tw-bg-opacity-0'"
|
||||
>
|
||||
@if (sideNavService.open()) {
|
||||
<div (click)="sideNavService.toggle()" class="tw-pointer-events-auto tw-size-full"></div>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
<div class="tw-absolute tw-z-50 tw-left-0 md:tw-sticky tw-top-0 tw-h-full md:tw-w-auto">
|
||||
<ng-template [cdkPortalOutlet]="drawerPortal()"></ng-template>
|
||||
|
||||
@@ -1,12 +1,11 @@
|
||||
import { A11yModule, CdkTrapFocus } from "@angular/cdk/a11y";
|
||||
import { PortalModule } from "@angular/cdk/portal";
|
||||
import { CommonModule } from "@angular/common";
|
||||
import { Component, ElementRef, inject, viewChild } from "@angular/core";
|
||||
import { booleanAttribute, Component, ElementRef, inject, input, viewChild } from "@angular/core";
|
||||
import { RouterModule } from "@angular/router";
|
||||
|
||||
import { DrawerHostDirective } from "../drawer/drawer-host.directive";
|
||||
import { DrawerService } from "../drawer/drawer.service";
|
||||
import { LinkModule } from "../link";
|
||||
import { DrawerService } from "../dialog/drawer.service";
|
||||
import { LinkComponent, LinkModule } from "../link";
|
||||
import { SideNavService } from "../navigation/side-nav.service";
|
||||
import { SharedModule } from "../shared";
|
||||
|
||||
@@ -31,13 +30,18 @@ import { ScrollLayoutHostDirective } from "./scroll-layout.directive";
|
||||
"(document:keydown.tab)": "handleKeydown($event)",
|
||||
class: "tw-block tw-h-screen",
|
||||
},
|
||||
hostDirectives: [DrawerHostDirective],
|
||||
})
|
||||
export class LayoutComponent {
|
||||
protected sideNavService = inject(SideNavService);
|
||||
protected drawerPortal = inject(DrawerService).portal;
|
||||
|
||||
private readonly mainContent = viewChild.required<ElementRef<HTMLElement>>("main");
|
||||
|
||||
/**
|
||||
* Rounded top left corner for the main content area
|
||||
*/
|
||||
readonly rounded = input(false, { transform: booleanAttribute });
|
||||
|
||||
protected focusMainContent() {
|
||||
this.mainContent().nativeElement.focus();
|
||||
}
|
||||
@@ -48,11 +52,11 @@ export class LayoutComponent {
|
||||
*
|
||||
* @see https://github.com/angular/components/issues/10247#issuecomment-384060265
|
||||
**/
|
||||
private readonly skipLink = viewChild.required<ElementRef<HTMLElement>>("skipLink");
|
||||
private readonly skipLink = viewChild.required<LinkComponent>("skipLink");
|
||||
handleKeydown(ev: KeyboardEvent) {
|
||||
if (isNothingFocused()) {
|
||||
ev.preventDefault();
|
||||
this.skipLink().nativeElement.focus();
|
||||
this.skipLink().el.nativeElement.focus();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -14,6 +14,8 @@ import { StorybookGlobalStateProvider } from "../utils/state-mock";
|
||||
import { LayoutComponent } from "./layout.component";
|
||||
import { mockLayoutI18n } from "./mocks";
|
||||
|
||||
import { formatArgsForCodeSnippet } from ".storybook/format-args-for-code-snippet";
|
||||
|
||||
export default {
|
||||
title: "Component Library/Layout",
|
||||
component: LayoutComponent,
|
||||
@@ -63,7 +65,7 @@ export const WithContent: Story = {
|
||||
render: (args) => ({
|
||||
props: args,
|
||||
template: /* HTML */ `
|
||||
<bit-layout>
|
||||
<bit-layout ${formatArgsForCodeSnippet<LayoutComponent>(args)}>
|
||||
<bit-side-nav>
|
||||
<bit-nav-group text="Hello World (Anchor)" [route]="['a']" icon="bwi-filter">
|
||||
<bit-nav-item text="Child A" route="a" icon="bwi-filter"></bit-nav-item>
|
||||
@@ -111,3 +113,10 @@ export const Secondary: Story = {
|
||||
`,
|
||||
}),
|
||||
};
|
||||
|
||||
export const Rounded: Story = {
|
||||
...WithContent,
|
||||
args: {
|
||||
rounded: true,
|
||||
},
|
||||
};
|
||||
|
||||
@@ -1,2 +1,2 @@
|
||||
export * from "./link.directive";
|
||||
export * from "./link.component";
|
||||
export * from "./link.module";
|
||||
|
||||
11
libs/components/src/link/link.component.html
Normal file
11
libs/components/src/link/link.component.html
Normal file
@@ -0,0 +1,11 @@
|
||||
<div class="tw-flex tw-gap-2 tw-items-center">
|
||||
@if (startIcon()) {
|
||||
<i [class]="['bwi', startIcon()]" aria-hidden="true"></i>
|
||||
}
|
||||
<span>
|
||||
<ng-content></ng-content>
|
||||
</span>
|
||||
@if (endIcon()) {
|
||||
<i [class]="['bwi', endIcon()]" aria-hidden="true"></i>
|
||||
}
|
||||
</div>
|
||||
151
libs/components/src/link/link.component.ts
Normal file
151
libs/components/src/link/link.component.ts
Normal file
@@ -0,0 +1,151 @@
|
||||
import {
|
||||
ChangeDetectionStrategy,
|
||||
Component,
|
||||
computed,
|
||||
input,
|
||||
booleanAttribute,
|
||||
inject,
|
||||
ElementRef,
|
||||
} from "@angular/core";
|
||||
|
||||
import { BitwardenIcon } from "../shared/icon";
|
||||
import { ariaDisableElement } from "../utils";
|
||||
|
||||
export const LinkTypes = [
|
||||
"primary",
|
||||
"secondary",
|
||||
"contrast",
|
||||
"light",
|
||||
"default",
|
||||
"subtle",
|
||||
"success",
|
||||
"warning",
|
||||
"danger",
|
||||
] as const;
|
||||
|
||||
export type LinkType = (typeof LinkTypes)[number];
|
||||
|
||||
const linkStyles: Record<LinkType, string[]> = {
|
||||
primary: ["tw-text-fg-brand", "hover:tw-text-fg-brand-strong"],
|
||||
default: ["tw-text-fg-brand", "hover:tw-text-fg-brand-strong"],
|
||||
secondary: ["tw-text-fg-heading", "hover:tw-text-fg-heading"],
|
||||
light: ["tw-text-fg-white", "hover:tw-text-fg-white", "focus-visible:before:tw-ring-fg-contrast"],
|
||||
subtle: ["!tw-text-fg-heading", "hover:tw-text-fg-heading"],
|
||||
success: ["tw-text-fg-success", "hover:tw-text-fg-success-strong"],
|
||||
warning: ["tw-text-fg-warning", "hover:tw-text-fg-warning-strong"],
|
||||
danger: ["tw-text-fg-danger", "hover:tw-text-fg-danger-strong"],
|
||||
contrast: [
|
||||
"tw-text-fg-contrast",
|
||||
"hover:tw-text-fg-contrast",
|
||||
"focus-visible:before:tw-ring-fg-contrast",
|
||||
],
|
||||
};
|
||||
|
||||
const commonStyles = [
|
||||
"tw-text-unset",
|
||||
"tw-leading-none",
|
||||
"tw-px-0",
|
||||
"tw-py-0.5",
|
||||
"tw-font-semibold",
|
||||
"tw-bg-transparent",
|
||||
"tw-border-0",
|
||||
"tw-border-none",
|
||||
"tw-rounded",
|
||||
"tw-transition",
|
||||
"tw-no-underline",
|
||||
"tw-cursor-pointer",
|
||||
"[&:hover_span]:tw-underline",
|
||||
"[&.tw-test-hover_span]:tw-underline",
|
||||
"[&:hover_span]:tw-decoration-[.125em]",
|
||||
"[&.tw-test-hover_span]:tw-decoration-[.125em]",
|
||||
"focus-visible:tw-outline-none",
|
||||
"focus-visible:before:tw-ring-border-focus",
|
||||
"[&:focus-visible_span]:tw-underline",
|
||||
"[&:focus-visible_span]:tw-decoration-[.125em]",
|
||||
"[&.tw-test-focus-visible_span]:tw-underline",
|
||||
"[&.tw-test-focus-visible_span]:tw-decoration-[.125em]",
|
||||
|
||||
// Workaround for html button tag not being able to be set to `display: inline`
|
||||
// and at the same time not being able to use `tw-ring-offset` because of box-shadow issue.
|
||||
// https://github.com/w3c/csswg-drafts/issues/3226
|
||||
// Add `tw-inline`, add `tw-py-0.5` and use regular `tw-ring` if issue is fixed.
|
||||
//
|
||||
// https://github.com/tailwindlabs/tailwindcss/issues/3595
|
||||
// Remove `before:` and use regular `tw-ring` when browser no longer has bug, or better:
|
||||
// switch to `outline` with `outline-offset` when Safari supports border radius on outline.
|
||||
// Using `box-shadow` to create outlines is a hack and as such `outline` should be preferred.
|
||||
"tw-relative",
|
||||
"before:tw-content-['']",
|
||||
"before:tw-block",
|
||||
"before:tw-absolute",
|
||||
"before:-tw-inset-x-[0.1em]",
|
||||
"before:-tw-inset-y-[0]",
|
||||
"before:tw-rounded-md",
|
||||
"before:tw-transition",
|
||||
"before:tw-h-full",
|
||||
"before:tw-w-[calc(100%_+_.25rem)]",
|
||||
"before:tw-pointer-events-none",
|
||||
"focus-visible:before:tw-ring-2",
|
||||
"focus-visible:tw-z-10",
|
||||
"aria-disabled:tw-no-underline",
|
||||
"aria-disabled:tw-pointer-events-none",
|
||||
"aria-disabled:!tw-text-fg-disabled",
|
||||
"aria-disabled:hover:!tw-text-fg-disabled",
|
||||
"aria-disabled:hover:tw-no-underline",
|
||||
"[&[aria-disabled]:focus-visible_span]:!tw-no-underline",
|
||||
];
|
||||
|
||||
@Component({
|
||||
selector: "a[bitLink], button[bitLink]",
|
||||
templateUrl: "./link.component.html",
|
||||
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||
host: {
|
||||
"[class]": "classList()",
|
||||
// This is for us to be able to correctly aria-disable the button and capture clicks.
|
||||
// It's normally added via the AriaDisableDirective as a host directive.
|
||||
// But, we're not able to conditionally apply the host directive based on if this is a button or not
|
||||
"[attr.bit-aria-disable]": "isButton ? true : null",
|
||||
},
|
||||
})
|
||||
export class LinkComponent {
|
||||
readonly el = inject(ElementRef<HTMLElement>);
|
||||
/**
|
||||
* The variant of link you want to render
|
||||
* @default "primary"
|
||||
*/
|
||||
readonly linkType = input<LinkType>("primary");
|
||||
/**
|
||||
* The leading icon to display within the link
|
||||
* @default undefined
|
||||
*/
|
||||
readonly startIcon = input<BitwardenIcon | undefined>(undefined);
|
||||
/**
|
||||
* The trailing icon to display within the link
|
||||
* @default undefined
|
||||
*/
|
||||
readonly endIcon = input<BitwardenIcon | undefined>(undefined);
|
||||
/**
|
||||
* Whether the button is disabled
|
||||
* @default false
|
||||
* @note Only applicable if the link is rendered as a button
|
||||
*/
|
||||
readonly disabled = input(false, { transform: booleanAttribute });
|
||||
|
||||
protected readonly isButton = this.el.nativeElement.tagName === "BUTTON";
|
||||
|
||||
readonly classList = computed(() => {
|
||||
return [!this.isButton && "tw-inline-flex"]
|
||||
.concat(commonStyles)
|
||||
.concat(linkStyles[this.linkType()] ?? []);
|
||||
});
|
||||
|
||||
focus() {
|
||||
this.el.nativeElement.focus();
|
||||
}
|
||||
|
||||
constructor() {
|
||||
if (this.isButton) {
|
||||
ariaDisableElement(this.el.nativeElement, this.disabled);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,114 +0,0 @@
|
||||
import { input, HostBinding, Directive, inject, ElementRef, booleanAttribute } from "@angular/core";
|
||||
|
||||
import { AriaDisableDirective } from "../a11y";
|
||||
import { ariaDisableElement } from "../utils";
|
||||
|
||||
export type LinkType = "primary" | "secondary" | "contrast" | "light";
|
||||
|
||||
const linkStyles: Record<LinkType, string[]> = {
|
||||
primary: [
|
||||
"!tw-text-primary-600",
|
||||
"hover:!tw-text-primary-700",
|
||||
"focus-visible:before:tw-ring-primary-600",
|
||||
],
|
||||
secondary: ["!tw-text-main", "hover:!tw-text-main", "focus-visible:before:tw-ring-primary-600"],
|
||||
contrast: [
|
||||
"!tw-text-contrast",
|
||||
"hover:!tw-text-contrast",
|
||||
"focus-visible:before:tw-ring-text-contrast",
|
||||
],
|
||||
light: ["!tw-text-alt2", "hover:!tw-text-alt2", "focus-visible:before:tw-ring-text-alt2"],
|
||||
};
|
||||
|
||||
const commonStyles = [
|
||||
"tw-text-unset",
|
||||
"tw-leading-none",
|
||||
"tw-px-0",
|
||||
"tw-py-0.5",
|
||||
"tw-font-semibold",
|
||||
"tw-bg-transparent",
|
||||
"tw-border-0",
|
||||
"tw-border-none",
|
||||
"tw-rounded",
|
||||
"tw-transition",
|
||||
"tw-no-underline",
|
||||
"hover:tw-underline",
|
||||
"hover:tw-decoration-1",
|
||||
"disabled:tw-no-underline",
|
||||
"disabled:tw-cursor-not-allowed",
|
||||
"disabled:!tw-text-secondary-300",
|
||||
"disabled:hover:!tw-text-secondary-300",
|
||||
"disabled:hover:tw-no-underline",
|
||||
"focus-visible:tw-outline-none",
|
||||
"focus-visible:tw-underline",
|
||||
"focus-visible:tw-decoration-1",
|
||||
|
||||
// Workaround for html button tag not being able to be set to `display: inline`
|
||||
// and at the same time not being able to use `tw-ring-offset` because of box-shadow issue.
|
||||
// https://github.com/w3c/csswg-drafts/issues/3226
|
||||
// Add `tw-inline`, add `tw-py-0.5` and use regular `tw-ring` if issue is fixed.
|
||||
//
|
||||
// https://github.com/tailwindlabs/tailwindcss/issues/3595
|
||||
// Remove `before:` and use regular `tw-ring` when browser no longer has bug, or better:
|
||||
// switch to `outline` with `outline-offset` when Safari supports border radius on outline.
|
||||
// Using `box-shadow` to create outlines is a hack and as such `outline` should be preferred.
|
||||
"tw-relative",
|
||||
"before:tw-content-['']",
|
||||
"before:tw-block",
|
||||
"before:tw-absolute",
|
||||
"before:-tw-inset-x-[0.1em]",
|
||||
"before:tw-rounded-md",
|
||||
"before:tw-transition",
|
||||
"focus-visible:before:tw-ring-2",
|
||||
"focus-visible:tw-z-10",
|
||||
"aria-disabled:tw-no-underline",
|
||||
"aria-disabled:tw-pointer-events-none",
|
||||
"aria-disabled:!tw-text-secondary-300",
|
||||
"aria-disabled:hover:!tw-text-secondary-300",
|
||||
"aria-disabled:hover:tw-no-underline",
|
||||
];
|
||||
|
||||
@Directive()
|
||||
abstract class LinkDirective {
|
||||
readonly linkType = input<LinkType>("primary");
|
||||
}
|
||||
|
||||
/**
|
||||
* Text Links and Buttons can use either the `<a>` or `<button>` tags. Choose which based on the action the button takes:
|
||||
|
||||
* - if navigating to a new page, use a `<a>`
|
||||
* - if taking an action on the current page, use a `<button>`
|
||||
|
||||
* Text buttons or links are most commonly used in paragraphs of text or in forms to customize actions or show/hide additional form options.
|
||||
*/
|
||||
@Directive({
|
||||
selector: "a[bitLink]",
|
||||
})
|
||||
export class AnchorLinkDirective extends LinkDirective {
|
||||
@HostBinding("class") get classList() {
|
||||
return ["before:-tw-inset-y-[0.125rem]"]
|
||||
.concat(commonStyles)
|
||||
.concat(linkStyles[this.linkType()] ?? []);
|
||||
}
|
||||
}
|
||||
|
||||
@Directive({
|
||||
selector: "button[bitLink]",
|
||||
hostDirectives: [AriaDisableDirective],
|
||||
})
|
||||
export class ButtonLinkDirective extends LinkDirective {
|
||||
private el = inject(ElementRef<HTMLButtonElement>);
|
||||
|
||||
readonly disabled = input(false, { transform: booleanAttribute });
|
||||
|
||||
@HostBinding("class") get classList() {
|
||||
return ["before:-tw-inset-y-[0.25rem]"]
|
||||
.concat(commonStyles)
|
||||
.concat(linkStyles[this.linkType()] ?? []);
|
||||
}
|
||||
|
||||
constructor() {
|
||||
super();
|
||||
ariaDisableElement(this.el.nativeElement, this.disabled);
|
||||
}
|
||||
}
|
||||
@@ -18,10 +18,15 @@ import { LinkModule } from "@bitwarden/components";
|
||||
|
||||
You can use one of the following variants by providing it as the `linkType` input:
|
||||
|
||||
- `primary` - most common, uses brand color
|
||||
- `secondary` - matches the main text color
|
||||
- @deprecated `primary` => use `default` instead
|
||||
- @deprecated `secondary` => use `subtle` instead
|
||||
- `default` - most common, uses brand color
|
||||
- `subtle` - matches the main text color
|
||||
- `contrast` - for high contrast against a dark background (or a light background in dark mode)
|
||||
- `light` - always a light color, even in dark mode
|
||||
- `warning` - used in association with warning callouts/banners
|
||||
- `success` - used in association with success callouts/banners
|
||||
- `danger` - used in association with danger callouts/banners
|
||||
|
||||
## Sizes
|
||||
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
import { NgModule } from "@angular/core";
|
||||
|
||||
import { AnchorLinkDirective, ButtonLinkDirective } from "./link.directive";
|
||||
import { LinkComponent } from "./link.component";
|
||||
|
||||
@NgModule({
|
||||
imports: [AnchorLinkDirective, ButtonLinkDirective],
|
||||
exports: [AnchorLinkDirective, ButtonLinkDirective],
|
||||
imports: [LinkComponent],
|
||||
exports: [LinkComponent],
|
||||
})
|
||||
export class LinkModule {}
|
||||
|
||||
@@ -2,7 +2,7 @@ import { Meta, StoryObj, moduleMetadata } from "@storybook/angular";
|
||||
|
||||
import { formatArgsForCodeSnippet } from "../../../../.storybook/format-args-for-code-snippet";
|
||||
|
||||
import { AnchorLinkDirective, ButtonLinkDirective } from "./link.directive";
|
||||
import { LinkComponent, LinkTypes } from "./link.component";
|
||||
import { LinkModule } from "./link.module";
|
||||
|
||||
export default {
|
||||
@@ -14,7 +14,7 @@ export default {
|
||||
],
|
||||
argTypes: {
|
||||
linkType: {
|
||||
options: ["primary", "secondary", "contrast"],
|
||||
options: LinkTypes.map((type) => type),
|
||||
control: { type: "radio" },
|
||||
},
|
||||
},
|
||||
@@ -26,64 +26,167 @@ export default {
|
||||
},
|
||||
} as Meta;
|
||||
|
||||
type Story = StoryObj<ButtonLinkDirective>;
|
||||
type Story = StoryObj<LinkComponent>;
|
||||
|
||||
export const Default: Story = {
|
||||
render: (args) => ({
|
||||
props: {
|
||||
linkType: args.linkType,
|
||||
backgroundClass:
|
||||
args.linkType === "contrast"
|
||||
? "tw-bg-bg-contrast"
|
||||
: args.linkType === "light"
|
||||
? "tw-bg-bg-brand"
|
||||
: "tw-bg-transparent",
|
||||
},
|
||||
template: /*html*/ `
|
||||
<a bitLink ${formatArgsForCodeSnippet<ButtonLinkDirective>(args)}>Your text here</a>
|
||||
<div class="tw-p-2" [class]="backgroundClass">
|
||||
<a bitLink href="#" ${formatArgsForCodeSnippet<LinkComponent>(args)}>Your text here</a>
|
||||
</div>
|
||||
`,
|
||||
}),
|
||||
args: {
|
||||
linkType: "primary",
|
||||
},
|
||||
parameters: {
|
||||
chromatic: { disableSnapshot: true },
|
||||
},
|
||||
};
|
||||
|
||||
export const AllVariations: Story = {
|
||||
render: () => ({
|
||||
template: /*html*/ `
|
||||
<div class="tw-flex tw-flex-col tw-gap-6">
|
||||
<div class="tw-flex tw-gap-4 tw-p-2">
|
||||
<a bitLink linkType="primary" href="#">Primary</a>
|
||||
</div>
|
||||
<div class="tw-flex tw-gap-4 tw-p-2">
|
||||
<a bitLink linkType="secondary" href="#">Secondary</a>
|
||||
</div>
|
||||
<div class="tw-flex tw-gap-4 tw-p-2 tw-bg-bg-contrast">
|
||||
<a bitLink linkType="contrast" href="#">Contrast</a>
|
||||
</div>
|
||||
<div class="tw-flex tw-gap-4 tw-p-2 tw-bg-bg-brand">
|
||||
<a bitLink linkType="light" href="#">Light</a>
|
||||
</div>
|
||||
<div class="tw-flex tw-gap-4 tw-p-2">
|
||||
<a bitLink linkType="default" href="#">Default</a>
|
||||
</div>
|
||||
<div class="tw-flex tw-gap-4 tw-p-2">
|
||||
<a bitLink linkType="subtle" href="#">Subtle</a>
|
||||
</div>
|
||||
<div class="tw-flex tw-gap-4 tw-p-2">
|
||||
<a bitLink linkType="success" href="#">Success</a>
|
||||
</div>
|
||||
<div class="tw-flex tw-gap-4 tw-p-2">
|
||||
<a bitLink linkType="warning" href="#">Warning</a>
|
||||
</div>
|
||||
<div class="tw-flex tw-gap-4 tw-p-2">
|
||||
<a bitLink linkType="danger" href="#">Danger</a>
|
||||
</div>
|
||||
</div>
|
||||
`,
|
||||
}),
|
||||
parameters: {
|
||||
controls: {
|
||||
exclude: ["linkType"],
|
||||
hideNoControlsWarning: true,
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
export const InteractionStates: Story = {
|
||||
render: () => ({
|
||||
template: /*html*/ `
|
||||
<div class="tw-flex tw-gap-4 tw-p-2 tw-mb-6">
|
||||
<div class="tw-flex tw-flex-col tw-gap-6">
|
||||
<div class="tw-flex tw-gap-4 tw-p-2">
|
||||
<a bitLink linkType="primary" href="#">Primary</a>
|
||||
<a bitLink linkType="primary" href="#" class="tw-test-hover">Primary</a>
|
||||
<a bitLink linkType="primary" href="#" class="tw-test-focus-visible">Primary</a>
|
||||
<a bitLink linkType="primary" href="#" class="tw-test-hover tw-test-focus-visible">Primary</a>
|
||||
</div>
|
||||
<div class="tw-flex tw-gap-4 tw-p-2 tw-mb-6">
|
||||
<div class="tw-flex tw-gap-4 tw-p-2">
|
||||
<a bitLink linkType="secondary" href="#">Secondary</a>
|
||||
<a bitLink linkType="secondary" href="#" class="tw-test-hover">Secondary</a>
|
||||
<a bitLink linkType="secondary" href="#" class="tw-test-focus-visible">Secondary</a>
|
||||
<a bitLink linkType="secondary" href="#" class="tw-test-hover tw-test-focus-visible">Secondary</a>
|
||||
</div>
|
||||
<div class="tw-flex tw-gap-4 tw-p-2 tw-mb-6 tw-bg-primary-600">
|
||||
<div class="tw-flex tw-gap-4 tw-p-2 tw-bg-bg-contrast">
|
||||
<a bitLink linkType="contrast" href="#">Contrast</a>
|
||||
<a bitLink linkType="contrast" href="#" class="tw-test-hover">Contrast</a>
|
||||
<a bitLink linkType="contrast" href="#" class="tw-test-focus-visible">Contrast</a>
|
||||
<a bitLink linkType="contrast" href="#" class="tw-test-hover tw-test-focus-visible">Contrast</a>
|
||||
</div>
|
||||
<div class="tw-flex tw-gap-4 tw-p-2 tw-mb-6 tw-bg-primary-600">
|
||||
<div class="tw-flex tw-gap-4 tw-p-2 tw-bg-bg-brand">
|
||||
<a bitLink linkType="light" href="#">Light</a>
|
||||
<a bitLink linkType="light" href="#" class="tw-test-hover">Light</a>
|
||||
<a bitLink linkType="light" href="#" class="tw-test-focus-visible">Light</a>
|
||||
<a bitLink linkType="light" href="#" class="tw-test-hover tw-test-focus-visible">Light</a>
|
||||
</div>
|
||||
<div class="tw-flex tw-gap-4 tw-p-2">
|
||||
<a bitLink linkType="default" href="#">Default</a>
|
||||
<a bitLink linkType="default" href="#" class="tw-test-hover">Default</a>
|
||||
<a bitLink linkType="default" href="#" class="tw-test-focus-visible">Default</a>
|
||||
<a bitLink linkType="default" href="#" class="tw-test-hover tw-test-focus-visible">Default</a>
|
||||
</div>
|
||||
<div class="tw-flex tw-gap-4 tw-p-2">
|
||||
<a bitLink linkType="subtle" href="#">Subtle</a>
|
||||
<a bitLink linkType="subtle" href="#" class="tw-test-hover">Subtle</a>
|
||||
<a bitLink linkType="subtle" href="#" class="tw-test-focus-visible">Subtle</a>
|
||||
<a bitLink linkType="subtle" href="#" class="tw-test-hover tw-test-focus-visible">Subtle</a>
|
||||
</div>
|
||||
<div class="tw-flex tw-gap-4 tw-p-2">
|
||||
<a bitLink linkType="success" href="#">Success</a>
|
||||
<a bitLink linkType="success" href="#" class="tw-test-hover">Success</a>
|
||||
<a bitLink linkType="success" href="#" class="tw-test-focus-visible">Success</a>
|
||||
<a bitLink linkType="success" href="#" class="tw-test-hover tw-test-focus-visible">Success</a>
|
||||
</div>
|
||||
<div class="tw-flex tw-gap-4 tw-p-2">
|
||||
<a bitLink linkType="warning" href="#">Warning</a>
|
||||
<a bitLink linkType="warning" href="#" class="tw-test-hover">Warning</a>
|
||||
<a bitLink linkType="warning" href="#" class="tw-test-focus-visible">Warning</a>
|
||||
<a bitLink linkType="warning" href="#" class="tw-test-hover tw-test-focus-visible">Warning</a>
|
||||
</div>
|
||||
<div class="tw-flex tw-gap-4 tw-p-2">
|
||||
<a bitLink linkType="danger" href="#">Danger</a>
|
||||
<a bitLink linkType="danger" href="#" class="tw-test-hover">Danger</a>
|
||||
<a bitLink linkType="danger" href="#" class="tw-test-focus-visible">Danger</a>
|
||||
<a bitLink linkType="danger" href="#" class="tw-test-hover tw-test-focus-visible">Danger</a>
|
||||
</div>
|
||||
</div>
|
||||
`,
|
||||
}),
|
||||
parameters: {
|
||||
controls: {
|
||||
exclude: ["linkType"],
|
||||
hideNoControlsWarning: true,
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
export const Buttons: Story = {
|
||||
render: (args) => ({
|
||||
props: args,
|
||||
props: {
|
||||
linkType: args.linkType,
|
||||
backgroundClass:
|
||||
args.linkType === "contrast"
|
||||
? "tw-bg-bg-contrast"
|
||||
: args.linkType === "light"
|
||||
? "tw-bg-bg-brand"
|
||||
: "tw-bg-transparent",
|
||||
},
|
||||
template: /*html*/ `
|
||||
<div class="tw-p-2" [ngClass]="{ 'tw-bg-transparent': linkType != 'contrast', 'tw-bg-primary-600': linkType === 'contrast' }">
|
||||
<div class="tw-p-2" [class]="backgroundClass">
|
||||
<div class="tw-block tw-p-2">
|
||||
<button type="button" bitLink [linkType]="linkType">Button</button>
|
||||
</div>
|
||||
<div class="tw-block tw-p-2">
|
||||
<button type="button" bitLink [linkType]="linkType">
|
||||
<i class="bwi bwi-fw bwi-plus-circle" aria-hidden="true"></i>
|
||||
<button type="button" bitLink [linkType]="linkType" startIcon="bwi-plus-circle">
|
||||
Add Icon Button
|
||||
</button>
|
||||
</div>
|
||||
<div class="tw-block tw-p-2">
|
||||
<button type="button" bitLink [linkType]="linkType">
|
||||
<i class="bwi bwi-fw bwi-sm bwi-angle-right" aria-hidden="true"></i>
|
||||
<button type="button" bitLink [linkType]="linkType" endIcon="bwi-angle-right">
|
||||
Chevron Icon Button
|
||||
</button>
|
||||
</div>
|
||||
@@ -98,23 +201,29 @@ export const Buttons: Story = {
|
||||
},
|
||||
};
|
||||
|
||||
export const Anchors: StoryObj<AnchorLinkDirective> = {
|
||||
export const Anchors: StoryObj<LinkComponent> = {
|
||||
render: (args) => ({
|
||||
props: args,
|
||||
props: {
|
||||
linkType: args.linkType,
|
||||
backgroundClass:
|
||||
args.linkType === "contrast"
|
||||
? "tw-bg-bg-contrast"
|
||||
: args.linkType === "light"
|
||||
? "tw-bg-bg-brand"
|
||||
: "tw-bg-transparent",
|
||||
},
|
||||
template: /*html*/ `
|
||||
<div class="tw-p-2" [ngClass]="{ 'tw-bg-transparent': linkType != 'contrast', 'tw-bg-primary-600': linkType === 'contrast' }">
|
||||
<div class="tw-p-2" [class]="backgroundClass">
|
||||
<div class="tw-block tw-p-2">
|
||||
<a bitLink [linkType]="linkType" href="#">Anchor</a>
|
||||
</div>
|
||||
<div class="tw-block tw-p-2">
|
||||
<a bitLink [linkType]="linkType" href="#">
|
||||
<i class="bwi bwi-fw bwi-plus-circle" aria-hidden="true"></i>
|
||||
<a bitLink [linkType]="linkType" href="#" startIcon="bwi-plus-circle">
|
||||
Add Icon Anchor
|
||||
</a>
|
||||
</div>
|
||||
<div class="tw-block tw-p-2">
|
||||
<a bitLink [linkType]="linkType" href="#">
|
||||
<i class="bwi bwi-fw bwi-sm bwi-angle-right" aria-hidden="true"></i>
|
||||
<a bitLink [linkType]="linkType" href="#" endIcon="bwi-angle-right">
|
||||
Chevron Icon Anchor
|
||||
</a>
|
||||
</div>
|
||||
@@ -134,23 +243,57 @@ export const Inline: Story = {
|
||||
props: args,
|
||||
template: /*html*/ `
|
||||
<span class="tw-text-main">
|
||||
On the internet paragraphs often contain <a bitLink href="#">inline links</a>, but few know that <button type="button" bitLink>buttons</button> can be used for similar purposes.
|
||||
On the internet paragraphs often contain <a bitLink href="#">inline links with very long text that might break</a>, but few know that <button type="button" bitLink>buttons</button> can be used for similar purposes.
|
||||
</span>
|
||||
`,
|
||||
}),
|
||||
};
|
||||
|
||||
export const WithIcons: Story = {
|
||||
render: (args) => ({
|
||||
props: args,
|
||||
template: /*html*/ `
|
||||
<div class="tw-p-2" [ngClass]="{ 'tw-bg-transparent': linkType != 'contrast', 'tw-bg-primary-600': linkType === 'contrast' }">
|
||||
<div class="tw-block tw-p-2">
|
||||
<a bitLink [linkType]="linkType" href="#" startIcon="bwi-star">Start icon link</a>
|
||||
</div>
|
||||
<div class="tw-block tw-p-2">
|
||||
<a bitLink [linkType]="linkType" href="#" endIcon="bwi-external-link">External link</a>
|
||||
</div>
|
||||
<div class="tw-block tw-p-2">
|
||||
<a bitLink [linkType]="linkType" href="#" startIcon="bwi-angle-left" endIcon="bwi-angle-right">Both icons</a>
|
||||
</div>
|
||||
<div class="tw-block tw-p-2">
|
||||
<button type="button" bitLink [linkType]="linkType" startIcon="bwi-plus-circle">Add item</button>
|
||||
</div>
|
||||
<div class="tw-block tw-p-2">
|
||||
<button type="button" bitLink [linkType]="linkType" endIcon="bwi-angle-right">Next</button>
|
||||
</div>
|
||||
<div class="tw-block tw-p-2">
|
||||
<button type="button" bitLink [linkType]="linkType" startIcon="bwi-download" endIcon="bwi-check">Download complete</button>
|
||||
</div>
|
||||
</div>
|
||||
`,
|
||||
}),
|
||||
args: {
|
||||
linkType: "primary",
|
||||
},
|
||||
};
|
||||
|
||||
export const Disabled: Story = {
|
||||
export const Inactive: Story = {
|
||||
render: (args) => ({
|
||||
props: args,
|
||||
props: {
|
||||
...args,
|
||||
onClick: () => {
|
||||
alert("Button clicked! (This should not appear when disabled)");
|
||||
},
|
||||
},
|
||||
template: /*html*/ `
|
||||
<button type="button" bitLink disabled linkType="primary" class="tw-me-2">Primary</button>
|
||||
<button type="button" bitLink disabled linkType="secondary" class="tw-me-2">Secondary</button>
|
||||
<button type="button" bitLink (click)="onClick()" disabled linkType="primary" class="tw-me-2">Primary button</button>
|
||||
<a bitLink href="" disabled linkType="primary" class="tw-me-2">Links can not be inactive</a>
|
||||
<button type="button" bitLink disabled linkType="secondary" class="tw-me-2">Secondary button</button>
|
||||
<div class="tw-bg-primary-600 tw-p-2 tw-inline-block">
|
||||
<button type="button" bitLink disabled linkType="contrast">Contrast</button>
|
||||
<button type="button" bitLink disabled linkType="contrast">Contrast button</button>
|
||||
</div>
|
||||
`,
|
||||
}),
|
||||
|
||||
@@ -192,7 +192,7 @@ export class MenuTriggerForDirective implements OnDestroy {
|
||||
return;
|
||||
}
|
||||
|
||||
const escKey = this.overlayRef.keydownEvents().pipe(
|
||||
const keyEvents = this.overlayRef.keydownEvents().pipe(
|
||||
filter((event: KeyboardEvent) => {
|
||||
const keys = this.menu().ariaRole() === "menu" ? ["Escape", "Tab"] : ["Escape"];
|
||||
return keys.includes(event.key);
|
||||
@@ -202,8 +202,8 @@ export class MenuTriggerForDirective implements OnDestroy {
|
||||
const detachments = this.overlayRef.detachments();
|
||||
|
||||
const closeEvents = isContextMenu
|
||||
? merge(detachments, escKey, menuClosed)
|
||||
: merge(detachments, escKey, this.overlayRef.backdropClick(), menuClosed);
|
||||
? merge(detachments, keyEvents, menuClosed)
|
||||
: merge(detachments, keyEvents, this.overlayRef.backdropClick(), menuClosed);
|
||||
|
||||
this.closedEventsSub = closeEvents
|
||||
.pipe(takeUntil(this.overlayRef.detachments()))
|
||||
@@ -215,9 +215,9 @@ export class MenuTriggerForDirective implements OnDestroy {
|
||||
event.preventDefault();
|
||||
}
|
||||
|
||||
if (event instanceof KeyboardEvent && (event.key === "Tab" || event.key === "Escape")) {
|
||||
this.elementRef.nativeElement.focus();
|
||||
}
|
||||
// Move focus to the menu trigger, since any active menu items are about to be destroyed
|
||||
this.elementRef.nativeElement.focus();
|
||||
|
||||
this.destroyMenu();
|
||||
});
|
||||
}
|
||||
|
||||
@@ -1,8 +1,11 @@
|
||||
import { Directive, EventEmitter, Output, input, model } from "@angular/core";
|
||||
import { Directive, output, input, model } from "@angular/core";
|
||||
import { RouterLink, RouterLinkActive } from "@angular/router";
|
||||
|
||||
/**
|
||||
* `NavGroupComponent` builds upon `NavItemComponent`. This class represents the properties that are passed down to `NavItemComponent`.
|
||||
* Base class for navigation components in the side navigation.
|
||||
*
|
||||
* `NavGroupComponent` builds upon `NavItemComponent`. This class represents the properties
|
||||
* that are passed down to `NavItemComponent`.
|
||||
*/
|
||||
@Directive()
|
||||
export abstract class NavBaseComponent {
|
||||
@@ -38,23 +41,26 @@ export abstract class NavBaseComponent {
|
||||
*
|
||||
* ---
|
||||
*
|
||||
* @remarks
|
||||
* We can't name this "routerLink" because Angular will mount the `RouterLink` directive.
|
||||
*
|
||||
* See: {@link https://github.com/angular/angular/issues/24482}
|
||||
* @see {@link RouterLink.routerLink}
|
||||
* @see {@link https://github.com/angular/angular/issues/24482}
|
||||
*/
|
||||
readonly route = input<RouterLink["routerLink"]>();
|
||||
|
||||
/**
|
||||
* Passed to internal `routerLink`
|
||||
*
|
||||
* See {@link RouterLink.relativeTo}
|
||||
* @see {@link RouterLink.relativeTo}
|
||||
*/
|
||||
readonly relativeTo = input<RouterLink["relativeTo"]>();
|
||||
|
||||
/**
|
||||
* Passed to internal `routerLink`
|
||||
*
|
||||
* See {@link RouterLinkActive.routerLinkActiveOptions}
|
||||
* @default { paths: "subset", queryParams: "ignored", fragment: "ignored", matrixParams: "ignored" }
|
||||
* @see {@link RouterLinkActive.routerLinkActiveOptions}
|
||||
*/
|
||||
readonly routerLinkActiveOptions = input<RouterLinkActive["routerLinkActiveOptions"]>({
|
||||
paths: "subset",
|
||||
@@ -71,7 +77,5 @@ export abstract class NavBaseComponent {
|
||||
/**
|
||||
* Fires when main content is clicked
|
||||
*/
|
||||
// FIXME(https://bitwarden.atlassian.net/browse/CL-903): Migrate to Signals
|
||||
// eslint-disable-next-line @angular-eslint/prefer-output-emitter-ref
|
||||
@Output() mainContentClicked: EventEmitter<MouseEvent> = new EventEmitter();
|
||||
readonly mainContentClicked = output<void>();
|
||||
}
|
||||
|
||||
@@ -1,3 +1,3 @@
|
||||
@if (sideNavService.open$ | async) {
|
||||
@if (sideNavService.open()) {
|
||||
<div class="tw-h-px tw-w-full tw-my-2 tw-bg-secondary-300"></div>
|
||||
}
|
||||
|
||||
@@ -1,15 +1,15 @@
|
||||
import { CommonModule } from "@angular/common";
|
||||
import { Component } from "@angular/core";
|
||||
import { ChangeDetectionStrategy, Component, inject } from "@angular/core";
|
||||
|
||||
import { SideNavService } from "./side-nav.service";
|
||||
|
||||
// FIXME(https://bitwarden.atlassian.net/browse/CL-764): Migrate to OnPush
|
||||
// eslint-disable-next-line @angular-eslint/prefer-on-push-component-change-detection
|
||||
/**
|
||||
* A visual divider for separating navigation items in the side navigation.
|
||||
*/
|
||||
@Component({
|
||||
selector: "bit-nav-divider",
|
||||
templateUrl: "./nav-divider.component.html",
|
||||
imports: [CommonModule],
|
||||
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||
})
|
||||
export class NavDividerComponent {
|
||||
constructor(protected sideNavService: SideNavService) {}
|
||||
protected readonly sideNavService = inject(SideNavService);
|
||||
}
|
||||
|
||||
@@ -19,10 +19,8 @@
|
||||
<ng-template #button>
|
||||
<button
|
||||
type="button"
|
||||
class="tw-ms-auto"
|
||||
[ngClass]="{
|
||||
'tw-transform tw-rotate-[90deg]': variantValue === 'tree' && !open(),
|
||||
}"
|
||||
class="tw-ms-auto tw-text-fg-sidenav-text"
|
||||
[class]="variantValue === 'tree' && !open() ? 'tw-transform tw-rotate-[90deg]' : ''"
|
||||
[bitIconButton]="toggleButtonIcon()"
|
||||
buttonType="nav-contrast"
|
||||
(click)="toggle($event)"
|
||||
|
||||
@@ -1,17 +1,14 @@
|
||||
import { CommonModule } from "@angular/common";
|
||||
import { NgTemplateOutlet } from "@angular/common";
|
||||
import {
|
||||
booleanAttribute,
|
||||
Component,
|
||||
EventEmitter,
|
||||
Optional,
|
||||
Output,
|
||||
SkipSelf,
|
||||
inject,
|
||||
input,
|
||||
model,
|
||||
contentChildren,
|
||||
ChangeDetectionStrategy,
|
||||
computed,
|
||||
} from "@angular/core";
|
||||
import { toSignal } from "@angular/core/rxjs-interop";
|
||||
import { RouterLinkActive } from "@angular/router";
|
||||
|
||||
import { I18nPipe } from "@bitwarden/ui-common";
|
||||
@@ -22,8 +19,6 @@ import { NavBaseComponent } from "./nav-base.component";
|
||||
import { NavGroupAbstraction, NavItemComponent } from "./nav-item.component";
|
||||
import { SideNavService } from "./side-nav.service";
|
||||
|
||||
// FIXME(https://bitwarden.atlassian.net/browse/CL-764): Migrate to OnPush
|
||||
// eslint-disable-next-line @angular-eslint/prefer-on-push-component-change-detection
|
||||
@Component({
|
||||
selector: "bit-nav-group",
|
||||
templateUrl: "./nav-group.component.html",
|
||||
@@ -31,20 +26,24 @@ import { SideNavService } from "./side-nav.service";
|
||||
{ provide: NavBaseComponent, useExisting: NavGroupComponent },
|
||||
{ provide: NavGroupAbstraction, useExisting: NavGroupComponent },
|
||||
],
|
||||
imports: [CommonModule, NavItemComponent, IconButtonModule, I18nPipe],
|
||||
imports: [NgTemplateOutlet, NavItemComponent, IconButtonModule, I18nPipe],
|
||||
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||
})
|
||||
export class NavGroupComponent extends NavBaseComponent {
|
||||
protected readonly sideNavService = inject(SideNavService);
|
||||
private readonly parentNavGroup = inject(NavGroupComponent, { optional: true, skipSelf: true });
|
||||
|
||||
// Query direct children for hideIfEmpty functionality
|
||||
readonly nestedNavComponents = contentChildren(NavBaseComponent, { descendants: false });
|
||||
|
||||
readonly sideNavOpen = toSignal(this.sideNavService.open$);
|
||||
protected readonly sideNavOpen = this.sideNavService.open;
|
||||
|
||||
readonly sideNavAndGroupOpen = computed(() => {
|
||||
return this.open() && this.sideNavOpen();
|
||||
});
|
||||
|
||||
/** When the side nav is open, the parent nav item should not show active styles when open. */
|
||||
readonly parentHideActiveStyles = computed(() => {
|
||||
protected readonly parentHideActiveStyles = computed(() => {
|
||||
return this.hideActiveStyles() || this.sideNavAndGroupOpen();
|
||||
});
|
||||
|
||||
@@ -80,7 +79,7 @@ export class NavGroupComponent extends NavBaseComponent {
|
||||
/**
|
||||
* UID for `[attr.aria-controls]`
|
||||
*/
|
||||
protected contentId = Math.random().toString(36).substring(2);
|
||||
protected readonly contentId = Math.random().toString(36).substring(2);
|
||||
|
||||
/**
|
||||
* Is `true` if the expanded content is visible
|
||||
@@ -98,15 +97,7 @@ export class NavGroupComponent extends NavBaseComponent {
|
||||
/** Does not toggle the expanded state on click */
|
||||
readonly disableToggleOnClick = input(false, { transform: booleanAttribute });
|
||||
|
||||
// FIXME(https://bitwarden.atlassian.net/browse/CL-903): Migrate to Signals
|
||||
// eslint-disable-next-line @angular-eslint/prefer-output-emitter-ref
|
||||
@Output()
|
||||
openChange = new EventEmitter<boolean>();
|
||||
|
||||
constructor(
|
||||
protected sideNavService: SideNavService,
|
||||
@Optional() @SkipSelf() private parentNavGroup: NavGroupComponent,
|
||||
) {
|
||||
constructor() {
|
||||
super();
|
||||
|
||||
// Set tree depth based on parent's depth
|
||||
@@ -118,9 +109,8 @@ export class NavGroupComponent extends NavBaseComponent {
|
||||
|
||||
setOpen(isOpen: boolean) {
|
||||
this.open.set(isOpen);
|
||||
this.openChange.emit(this.open());
|
||||
if (this.open()) {
|
||||
this.parentNavGroup?.setOpen(this.open());
|
||||
if (this.open() && this.parentNavGroup) {
|
||||
this.parentNavGroup.setOpen(this.open());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -130,9 +120,9 @@ export class NavGroupComponent extends NavBaseComponent {
|
||||
}
|
||||
|
||||
protected handleMainContentClicked() {
|
||||
if (!this.sideNavService.open) {
|
||||
if (!this.sideNavService.open()) {
|
||||
if (!this.route()) {
|
||||
this.sideNavService.setOpen();
|
||||
this.sideNavService.open.set(true);
|
||||
}
|
||||
this.open.set(true);
|
||||
} else if (!this.disableToggleOnClick()) {
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { Component, importProvidersFrom } from "@angular/core";
|
||||
import { ChangeDetectionStrategy, Component, importProvidersFrom } from "@angular/core";
|
||||
import { RouterModule } from "@angular/router";
|
||||
import { StoryObj, Meta, moduleMetadata, applicationConfig } from "@storybook/angular";
|
||||
|
||||
@@ -14,10 +14,9 @@ import { StorybookGlobalStateProvider } from "../utils/state-mock";
|
||||
import { NavGroupComponent } from "./nav-group.component";
|
||||
import { NavigationModule } from "./navigation.module";
|
||||
|
||||
// FIXME(https://bitwarden.atlassian.net/browse/CL-764): Migrate to OnPush
|
||||
// eslint-disable-next-line @angular-eslint/prefer-on-push-component-change-detection
|
||||
@Component({
|
||||
template: "",
|
||||
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||
})
|
||||
class DummyContentComponent {}
|
||||
|
||||
|
||||
@@ -1,53 +1,27 @@
|
||||
<div class="tw-ps-2 tw-pe-2">
|
||||
@let open = sideNavService.open$ | async;
|
||||
@let open = sideNavService.open();
|
||||
@if (open || icon()) {
|
||||
<div
|
||||
[style.padding-inline-start]="navItemIndentationPadding()"
|
||||
class="tw-relative tw-rounded-md tw-h-10"
|
||||
[class.tw-bg-background-alt4]="showActiveStyles"
|
||||
[class.tw-bg-background-alt3]="!showActiveStyles"
|
||||
[class.hover:tw-bg-hover-contrast]="!showActiveStyles"
|
||||
[class]="fvwStyles$ | async"
|
||||
[class]="fvwStyles()"
|
||||
[class.tw-bg-bg-sidenav-active-item]="showActiveStyles"
|
||||
[class.tw-bg-bg-sidenav-background]="!showActiveStyles"
|
||||
[class.hover:tw-bg-bg-sidenav-item-hover]="!showActiveStyles"
|
||||
>
|
||||
<div class="tw-relative tw-flex tw-items-center tw-h-full">
|
||||
@if (open) {
|
||||
<div
|
||||
class="tw-absolute tw-left-[0px] tw-transform tw--translate-x-[calc(100%_+_4px)] [&>*:focus-visible::before]:!tw-ring-text-alt2 [&>*:hover]:!tw-border-text-alt2 [&>*]:tw-text-alt2"
|
||||
class="tw-absolute tw-left-[0px] tw-transform tw--translate-x-[calc(100%_+_4px)] [&>*:focus-visible::before]:!tw-ring-border-focus [&>*:hover]:!tw-border-text-alt2 [&>*]:tw-text-alt2"
|
||||
>
|
||||
<ng-content select="[slot=start]"></ng-content>
|
||||
</div>
|
||||
}
|
||||
<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-flex tw-items-center tw-font-medium tw-h-full"
|
||||
[class.tw-py-0]="variant() === 'tree' || treeDepth() > 0"
|
||||
[class.tw-py-2]="variant() !== 'tree' && treeDepth() === 0"
|
||||
[class.tw-text-center]="!open"
|
||||
[class.tw-justify-center]="!open"
|
||||
>
|
||||
@if (icon()) {
|
||||
<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.route` -->
|
||||
<ng-template #isAnchor>
|
||||
<!-- The `data-fvw` attribute passes focus to `this.focusVisibleWithin$` -->
|
||||
<!-- The following `class` field should match the `#isButton` class field below -->
|
||||
@if (route()) {
|
||||
<!-- The `data-fvw` attribute passes focus to `this.focusVisibleWithin` -->
|
||||
<!-- The following `class` field should match the button class field below -->
|
||||
<a
|
||||
class="tw-size-full tw-px-4 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 [&_i]:tw-leading-[1.5rem]"
|
||||
class="tw-size-full tw-px-4 tw-block tw-truncate tw-border-none tw-bg-transparent tw-text-start !tw-text-fg-sidenav-text hover:tw-text-fg-sidenav-text hover:tw-no-underline focus:tw-outline-none [&_i]:tw-leading-[1.5rem]"
|
||||
[class.!tw-ps-0]="variant() === 'tree' || treeDepth() > 0"
|
||||
data-fvw
|
||||
[routerLink]="route()"
|
||||
@@ -61,25 +35,22 @@
|
||||
>
|
||||
<ng-container *ngTemplateOutlet="anchorAndButtonContent"></ng-container>
|
||||
</a>
|
||||
</ng-template>
|
||||
|
||||
<!-- Show if `this.route` is falsy -->
|
||||
<ng-template #isButton>
|
||||
<!-- Class field should match `#isAnchor` class field above -->
|
||||
} @else {
|
||||
<!-- Class field should match anchor class field above -->
|
||||
<button
|
||||
type="button"
|
||||
class="tw-size-full tw-px-4 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 [&_i]:tw-leading-[1.5rem]"
|
||||
class="tw-size-full tw-px-4 tw-block tw-truncate tw-border-none tw-bg-transparent tw-text-start !tw-text-fg-sidenav-text hover:tw-text-fg-sidenav-text hover:tw-no-underline focus:tw-outline-none [&_i]:tw-leading-[1.5rem]"
|
||||
[class.!tw-ps-0]="variant() === 'tree' || treeDepth() > 0"
|
||||
data-fvw
|
||||
(click)="mainContentClicked.emit()"
|
||||
>
|
||||
<ng-container *ngTemplateOutlet="anchorAndButtonContent"></ng-container>
|
||||
</button>
|
||||
</ng-template>
|
||||
}
|
||||
|
||||
@if (open) {
|
||||
<div
|
||||
class="tw-flex tw-items-center tw-pe-1 tw-gap-1 [&>*:focus-visible::before]:!tw-ring-text-alt2 [&>*:hover]:!tw-border-text-alt2 [&>*]:tw-text-alt2 empty:tw-hidden"
|
||||
class="tw-flex tw-items-center tw-pe-1 tw-gap-1 [&>*:focus-visible::before]:!tw-ring-border-focus [&>*:hover]:!tw-border-border-focus [&>*]:tw-text-fg-sidenav-text empty:tw-hidden"
|
||||
>
|
||||
<ng-content select="[slot=end]"></ng-content>
|
||||
</div>
|
||||
@@ -88,3 +59,27 @@
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
|
||||
<!-- Main content of `NavItem` -->
|
||||
<ng-template #anchorAndButtonContent>
|
||||
<div
|
||||
[title]="text()"
|
||||
class="tw-gap-2 tw-flex tw-items-center tw-font-medium tw-h-full"
|
||||
[class.tw-py-0]="variant() === 'tree' || treeDepth() > 0"
|
||||
[class.tw-py-2]="variant() !== 'tree' && treeDepth() === 0"
|
||||
[class.tw-text-center]="!open"
|
||||
[class.tw-justify-center]="!open"
|
||||
>
|
||||
@if (icon()) {
|
||||
<i
|
||||
class="!tw-m-0 tw-w-4 tw-shrink-0 bwi bwi-fw tw-text-fg-sidenav-text"
|
||||
[class]="icon()"
|
||||
[attr.aria-hidden]="open"
|
||||
[attr.aria-label]="text()"
|
||||
></i>
|
||||
}
|
||||
@if (open) {
|
||||
<span class="tw-truncate">{{ text() }}</span>
|
||||
}
|
||||
</div>
|
||||
</ng-template>
|
||||
|
||||
@@ -1,7 +1,14 @@
|
||||
import { CommonModule } from "@angular/common";
|
||||
import { Component, HostListener, Optional, computed, input, model } from "@angular/core";
|
||||
import { RouterLinkActive, RouterModule } from "@angular/router";
|
||||
import { BehaviorSubject, map } from "rxjs";
|
||||
import { NgTemplateOutlet } from "@angular/common";
|
||||
import {
|
||||
ChangeDetectionStrategy,
|
||||
Component,
|
||||
input,
|
||||
inject,
|
||||
signal,
|
||||
computed,
|
||||
model,
|
||||
} from "@angular/core";
|
||||
import { RouterModule, RouterLinkActive } from "@angular/router";
|
||||
|
||||
import { IconButtonModule } from "../icon-button";
|
||||
|
||||
@@ -14,13 +21,16 @@ export abstract class NavGroupAbstraction {
|
||||
abstract treeDepth: ReturnType<typeof model<number>>;
|
||||
}
|
||||
|
||||
// FIXME(https://bitwarden.atlassian.net/browse/CL-764): Migrate to OnPush
|
||||
// eslint-disable-next-line @angular-eslint/prefer-on-push-component-change-detection
|
||||
@Component({
|
||||
selector: "bit-nav-item",
|
||||
templateUrl: "./nav-item.component.html",
|
||||
providers: [{ provide: NavBaseComponent, useExisting: NavItemComponent }],
|
||||
imports: [CommonModule, IconButtonModule, RouterModule],
|
||||
imports: [NgTemplateOutlet, IconButtonModule, RouterModule],
|
||||
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||
host: {
|
||||
"(focusin)": "onFocusIn($event.target)",
|
||||
"(focusout)": "onFocusOut()",
|
||||
},
|
||||
})
|
||||
export class NavItemComponent extends NavBaseComponent {
|
||||
/**
|
||||
@@ -35,9 +45,14 @@ export class NavItemComponent extends NavBaseComponent {
|
||||
*/
|
||||
protected readonly TREE_DEPTH_PADDING = 1.75;
|
||||
|
||||
/** Forces active styles to be shown, regardless of the `routerLinkActiveOptions` */
|
||||
/**
|
||||
* Forces active styles to be shown, regardless of the `routerLinkActiveOptions`
|
||||
*/
|
||||
readonly forceActiveStyles = input<boolean>(false);
|
||||
|
||||
protected readonly sideNavService = inject(SideNavService);
|
||||
private readonly parentNavGroup = inject(NavGroupAbstraction, { optional: true });
|
||||
|
||||
/**
|
||||
* Is `true` if `to` matches the current route
|
||||
*/
|
||||
@@ -56,7 +71,7 @@ export class NavItemComponent extends NavBaseComponent {
|
||||
* adding calculation for tree variant due to needing visual alignment on different indentation levels needed between the first level and subsequent levels
|
||||
*/
|
||||
protected readonly navItemIndentationPadding = computed(() => {
|
||||
const open = this.sideNavService.open;
|
||||
const open = this.sideNavService.open();
|
||||
const depth = this.treeDepth() ?? 0;
|
||||
|
||||
if (open && this.variant() === "tree") {
|
||||
@@ -87,25 +102,22 @@ export class NavItemComponent extends NavBaseComponent {
|
||||
* (denoted with the data-fvw attribute) matches :focus-visible. We then map that state to some
|
||||
* styles, so the entire component can have an outline.
|
||||
*/
|
||||
protected focusVisibleWithin$ = new BehaviorSubject(false);
|
||||
protected fvwStyles$ = this.focusVisibleWithin$.pipe(
|
||||
map((value) =>
|
||||
value ? "tw-z-10 tw-rounded tw-outline-none tw-ring tw-ring-inset tw-ring-text-alt2" : "",
|
||||
),
|
||||
protected readonly focusVisibleWithin = signal(false);
|
||||
protected readonly fvwStyles = computed(() =>
|
||||
this.focusVisibleWithin()
|
||||
? "tw-z-10 tw-rounded tw-outline-none tw-ring tw-ring-inset tw-ring-border-focus"
|
||||
: "",
|
||||
);
|
||||
@HostListener("focusin", ["$event.target"])
|
||||
onFocusIn(target: HTMLElement) {
|
||||
this.focusVisibleWithin$.next(target.matches("[data-fvw]:focus-visible"));
|
||||
}
|
||||
@HostListener("focusout")
|
||||
onFocusOut() {
|
||||
this.focusVisibleWithin$.next(false);
|
||||
|
||||
protected onFocusIn(target: HTMLElement) {
|
||||
this.focusVisibleWithin.set(target.matches("[data-fvw]:focus-visible"));
|
||||
}
|
||||
|
||||
constructor(
|
||||
protected sideNavService: SideNavService,
|
||||
@Optional() private parentNavGroup: NavGroupAbstraction,
|
||||
) {
|
||||
protected onFocusOut() {
|
||||
this.focusVisibleWithin.set(false);
|
||||
}
|
||||
|
||||
constructor() {
|
||||
super();
|
||||
|
||||
// Set tree depth based on parent's depth
|
||||
|
||||
@@ -1,22 +1,21 @@
|
||||
<div
|
||||
[ngClass]="{
|
||||
'tw-sticky tw-top-0 tw-z-50 tw-pb-4': sideNavService.open,
|
||||
'tw-pb-[calc(theme(spacing.8)_+_2px)]': !sideNavService.open,
|
||||
}"
|
||||
class="tw-px-2 tw-pt-2"
|
||||
[class]="
|
||||
sideNavService.open()
|
||||
? 'tw-sticky tw-top-0 tw-z-50 tw-pb-4'
|
||||
: 'tw-pb-[calc(theme(spacing.8)_+_2px)]'
|
||||
"
|
||||
>
|
||||
<!-- absolutely position the link svg to avoid shifting layout when sidenav is closed -->
|
||||
<a
|
||||
[routerLink]="route()"
|
||||
class="tw-relative 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 tw-h-[73px] [&_svg]:tw-absolute [&_svg]:tw-inset-[.6875rem] [&_svg]:tw-w-[200px]"
|
||||
[ngClass]="{
|
||||
'!tw-h-[55px] [&_svg]:!tw-w-[26px]': !sideNavService.open,
|
||||
}"
|
||||
class="tw-relative tw-p-3 tw-block tw-rounded-md tw-bg-bg-sidenav tw-outline-none focus-visible:tw-ring focus-visible:tw-ring-inset focus-visible:tw-ring-border-focus hover:tw-bg-bg-sidenav-item-hover tw-h-[73px] [&_svg]:tw-absolute [&_svg]:tw-inset-[.6875rem] [&_svg]:tw-w-[200px]"
|
||||
[class]="!sideNavService.open() ? '!tw-h-[55px] [&_svg]:!tw-w-[26px]' : ''"
|
||||
[attr.aria-label]="label()"
|
||||
[title]="label()"
|
||||
routerLinkActive
|
||||
ariaCurrentWhenActive="page"
|
||||
>
|
||||
<bit-icon [icon]="sideNavService.open ? openIcon() : closedIcon()"></bit-icon>
|
||||
<bit-svg [content]="sideNavService.open() ? openIcon() : closedIcon()"></bit-svg>
|
||||
</a>
|
||||
</div>
|
||||
|
||||
@@ -1,34 +1,40 @@
|
||||
import { CommonModule } from "@angular/common";
|
||||
import { Component, input } from "@angular/core";
|
||||
import { ChangeDetectionStrategy, Component, input, inject } from "@angular/core";
|
||||
import { RouterLinkActive, RouterLink } from "@angular/router";
|
||||
|
||||
import { BitwardenShield, Icon } from "@bitwarden/assets/svg";
|
||||
import { BitwardenShield, BitSvg } from "@bitwarden/assets/svg";
|
||||
|
||||
import { BitIconComponent } from "../icon/icon.component";
|
||||
import { SvgComponent } from "../svg/svg.component";
|
||||
|
||||
import { SideNavService } from "./side-nav.service";
|
||||
|
||||
// FIXME(https://bitwarden.atlassian.net/browse/CL-764): Migrate to OnPush
|
||||
// eslint-disable-next-line @angular-eslint/prefer-on-push-component-change-detection
|
||||
@Component({
|
||||
selector: "bit-nav-logo",
|
||||
templateUrl: "./nav-logo.component.html",
|
||||
imports: [CommonModule, RouterLinkActive, RouterLink, BitIconComponent],
|
||||
imports: [RouterLinkActive, RouterLink, SvgComponent],
|
||||
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||
})
|
||||
export class NavLogoComponent {
|
||||
/** Icon that is displayed when the side nav is closed */
|
||||
protected readonly sideNavService = inject(SideNavService);
|
||||
|
||||
/**
|
||||
* Icon that is displayed when the side nav is closed
|
||||
*
|
||||
* @default BitwardenShield
|
||||
*/
|
||||
readonly closedIcon = input(BitwardenShield);
|
||||
|
||||
/** Icon that is displayed when the side nav is open */
|
||||
readonly openIcon = input.required<Icon>();
|
||||
/**
|
||||
* Icon that is displayed when the side nav is open
|
||||
*/
|
||||
readonly openIcon = input.required<BitSvg>();
|
||||
|
||||
/**
|
||||
* Route to be passed to internal `routerLink`
|
||||
*/
|
||||
readonly route = input.required<string | any[]>();
|
||||
|
||||
/** Passed to `attr.aria-label` and `attr.title` */
|
||||
/**
|
||||
* Passed to `attr.aria-label` and `attr.title`
|
||||
*/
|
||||
readonly label = input.required<string>();
|
||||
|
||||
constructor(protected sideNavService: SideNavService) {}
|
||||
}
|
||||
|
||||
@@ -1,68 +1,60 @@
|
||||
@if (
|
||||
{
|
||||
open: sideNavService.open$ | async,
|
||||
isOverlay: sideNavService.isOverlay$ | async,
|
||||
};
|
||||
as data
|
||||
) {
|
||||
<div class="tw-relative tw-h-full">
|
||||
<nav
|
||||
id="bit-side-nav"
|
||||
class="tw-sticky tw-inset-y-0 tw-left-0 tw-z-30 tw-flex tw-h-full tw-flex-col tw-overscroll-none tw-overflow-auto tw-bg-background-alt3 tw-outline-none"
|
||||
[style.width.rem]="data.open ? (sideNavService.width$ | async) : undefined"
|
||||
[ngStyle]="
|
||||
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"
|
||||
[attr.role]="data.isOverlay ? 'dialog' : null"
|
||||
[attr.aria-modal]="data.isOverlay ? 'true' : null"
|
||||
(keydown)="handleKeyDown($event)"
|
||||
>
|
||||
<ng-content></ng-content>
|
||||
<!-- 53rem = ~850px -->
|
||||
<!-- This is a magic number. This number was selected by going to the UI and finding the number that felt the best to me and design. No real rhyme or reason :) -->
|
||||
<div
|
||||
class="[@media(min-height:53rem)]:tw-sticky tw-bottom-0 tw-left-0 tw-z-20 tw-mt-auto tw-w-full tw-bg-background-alt3"
|
||||
>
|
||||
<bit-nav-divider></bit-nav-divider>
|
||||
@if (data.open) {
|
||||
<ng-content select="[slot=footer]"></ng-content>
|
||||
}
|
||||
<div class="tw-mx-0.5 tw-my-4 tw-w-[3.75rem]">
|
||||
<button
|
||||
#toggleButton
|
||||
type="button"
|
||||
class="tw-mx-auto tw-block tw-max-w-fit"
|
||||
[bitIconButton]="data.open ? 'bwi-angle-left' : 'bwi-angle-right'"
|
||||
buttonType="nav-contrast"
|
||||
size="small"
|
||||
(click)="sideNavService.toggle()"
|
||||
[label]="'toggleSideNavigation' | i18n"
|
||||
[attr.aria-expanded]="data.open"
|
||||
aria-controls="bit-side-nav"
|
||||
></button>
|
||||
</div>
|
||||
</div>
|
||||
</nav>
|
||||
@let open = sideNavService.open();
|
||||
@let isOverlay = sideNavService.isOverlay();
|
||||
|
||||
<div class="tw-relative tw-h-full">
|
||||
<nav
|
||||
id="bit-side-nav"
|
||||
class="tw-sticky tw-inset-y-0 tw-left-0 tw-z-30 tw-flex tw-h-full tw-flex-col tw-overscroll-none tw-overflow-auto tw-bg-bg-sidenav tw-text-fg-sidenav-text tw-outline-none"
|
||||
[style.width.rem]="open ? (sideNavService.width$ | async) : undefined"
|
||||
[style]="
|
||||
variant() === 'secondary'
|
||||
? '--color-sidenav-text: var(--color-admin-sidenav-text); --color-sidenav-background: var(--color-admin-sidenav-background); --color-sidenav-active-item: var(--color-admin-sidenav-active-item); --color-sidenav-item-hover: var(--color-admin-sidenav-item-hover);'
|
||||
: ''
|
||||
"
|
||||
[cdkTrapFocus]="isOverlay"
|
||||
[attr.role]="isOverlay ? 'dialog' : null"
|
||||
[attr.aria-modal]="isOverlay ? 'true' : null"
|
||||
(keydown)="handleKeyDown($event)"
|
||||
>
|
||||
<ng-content></ng-content>
|
||||
<!-- 53rem = ~850px -->
|
||||
<!-- This is a magic number. This number was selected by going to the UI and finding the number that felt the best to me and design. No real rhyme or reason :) -->
|
||||
<div
|
||||
cdkDrag
|
||||
(cdkDragMoved)="onDragMoved($event)"
|
||||
class="tw-absolute tw-top-0 -tw-right-0.5 tw-z-30 tw-h-full tw-w-1 tw-cursor-col-resize tw-transition-colors tw-duration-[250ms] hover:tw-ease-in-out hover:tw-delay-[250ms] hover:tw-bg-primary-300 focus:tw-outline-none focus-visible:tw-bg-primary-300 before:tw-content-[''] before:tw-absolute before:tw-block before:tw-inset-y-0 before:-tw-left-0.5 before:-tw-right-1"
|
||||
[class.tw-hidden]="!data.open"
|
||||
tabindex="0"
|
||||
(keydown)="onKeydown($event)"
|
||||
role="separator"
|
||||
[attr.aria-valuenow]="sideNavService.width$ | async"
|
||||
[attr.aria-valuemax]="sideNavService.MAX_OPEN_WIDTH"
|
||||
[attr.aria-valuemin]="sideNavService.MIN_OPEN_WIDTH"
|
||||
aria-orientation="vertical"
|
||||
aria-controls="bit-side-nav"
|
||||
[attr.aria-label]="'resizeSideNavigation' | i18n"
|
||||
></div>
|
||||
</div>
|
||||
}
|
||||
class="[@media(min-height:53rem)]:tw-sticky tw-bottom-0 tw-left-0 tw-z-20 tw-mt-auto tw-w-full tw-bg-bg-sidenav"
|
||||
>
|
||||
<bit-nav-divider></bit-nav-divider>
|
||||
@if (open) {
|
||||
<ng-content select="[slot=footer]"></ng-content>
|
||||
}
|
||||
<div class="tw-mx-0.5 tw-my-4 tw-w-[3.75rem]">
|
||||
<button
|
||||
#toggleButton
|
||||
type="button"
|
||||
class="tw-mx-auto tw-block tw-max-w-fit"
|
||||
[bitIconButton]="open ? 'bwi-angle-left' : 'bwi-angle-right'"
|
||||
buttonType="nav-contrast"
|
||||
size="small"
|
||||
(click)="sideNavService.toggle()"
|
||||
[label]="'toggleSideNavigation' | i18n"
|
||||
[attr.aria-expanded]="open"
|
||||
aria-controls="bit-side-nav"
|
||||
></button>
|
||||
</div>
|
||||
</div>
|
||||
</nav>
|
||||
<div
|
||||
cdkDrag
|
||||
(cdkDragMoved)="onDragMoved($event)"
|
||||
class="tw-absolute tw-top-0 -tw-right-0.5 tw-z-30 tw-h-full tw-w-1 tw-cursor-col-resize tw-transition-colors tw-duration-[250ms] hover:tw-ease-in-out hover:tw-delay-[250ms] hover:tw-bg-primary-300 focus:tw-outline-none focus-visible:tw-bg-primary-300 before:tw-content-[''] before:tw-absolute before:tw-block before:tw-inset-y-0 before:-tw-left-0.5 before:-tw-right-1"
|
||||
[class.tw-hidden]="!open"
|
||||
tabindex="0"
|
||||
(keydown)="onKeydown($event)"
|
||||
role="separator"
|
||||
[attr.aria-valuenow]="sideNavService.width$ | async"
|
||||
[attr.aria-valuemax]="sideNavService.MAX_OPEN_WIDTH"
|
||||
[attr.aria-valuemin]="sideNavService.MIN_OPEN_WIDTH"
|
||||
aria-orientation="vertical"
|
||||
aria-controls="bit-side-nav"
|
||||
[attr.aria-label]="'resizeSideNavigation' | i18n"
|
||||
></div>
|
||||
</div>
|
||||
|
||||
@@ -1,7 +1,14 @@
|
||||
import { CdkTrapFocus } from "@angular/cdk/a11y";
|
||||
import { DragDropModule, CdkDragMove } from "@angular/cdk/drag-drop";
|
||||
import { CommonModule } from "@angular/common";
|
||||
import { Component, ElementRef, inject, input, viewChild } from "@angular/core";
|
||||
import { AsyncPipe } from "@angular/common";
|
||||
import {
|
||||
ChangeDetectionStrategy,
|
||||
Component,
|
||||
ElementRef,
|
||||
input,
|
||||
viewChild,
|
||||
inject,
|
||||
} from "@angular/core";
|
||||
|
||||
import { I18nPipe } from "@bitwarden/ui-common";
|
||||
|
||||
@@ -12,35 +19,42 @@ import { SideNavService } from "./side-nav.service";
|
||||
|
||||
export type SideNavVariant = "primary" | "secondary";
|
||||
|
||||
// FIXME(https://bitwarden.atlassian.net/browse/CL-764): Migrate to OnPush
|
||||
// eslint-disable-next-line @angular-eslint/prefer-on-push-component-change-detection
|
||||
/**
|
||||
* Side navigation component that provides a collapsible navigation menu.
|
||||
*/
|
||||
@Component({
|
||||
selector: "bit-side-nav",
|
||||
templateUrl: "side-nav.component.html",
|
||||
imports: [
|
||||
CommonModule,
|
||||
CdkTrapFocus,
|
||||
NavDividerComponent,
|
||||
BitIconButtonComponent,
|
||||
I18nPipe,
|
||||
DragDropModule,
|
||||
AsyncPipe,
|
||||
],
|
||||
host: {
|
||||
class: "tw-block tw-h-full",
|
||||
},
|
||||
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||
})
|
||||
export class SideNavComponent {
|
||||
protected sideNavService = inject(SideNavService);
|
||||
protected readonly sideNavService = inject(SideNavService);
|
||||
|
||||
/**
|
||||
* Visual variant of the side navigation
|
||||
*
|
||||
* @default "primary"
|
||||
*/
|
||||
readonly variant = input<SideNavVariant>("primary");
|
||||
|
||||
private readonly toggleButton = viewChild("toggleButton", { read: ElementRef });
|
||||
|
||||
private elementRef = inject<ElementRef<HTMLElement>>(ElementRef);
|
||||
|
||||
protected handleKeyDown = (event: KeyboardEvent) => {
|
||||
protected readonly handleKeyDown = (event: KeyboardEvent) => {
|
||||
if (event.key === "Escape") {
|
||||
this.sideNavService.setClose();
|
||||
this.sideNavService.open.set(false);
|
||||
this.toggleButton()?.nativeElement.focus();
|
||||
return false;
|
||||
}
|
||||
|
||||
@@ -1,15 +1,6 @@
|
||||
import { inject, Injectable } from "@angular/core";
|
||||
import { takeUntilDestroyed } from "@angular/core/rxjs-interop";
|
||||
import {
|
||||
BehaviorSubject,
|
||||
Observable,
|
||||
combineLatest,
|
||||
fromEvent,
|
||||
map,
|
||||
startWith,
|
||||
debounceTime,
|
||||
first,
|
||||
} from "rxjs";
|
||||
import { computed, effect, inject, Injectable, signal } from "@angular/core";
|
||||
import { takeUntilDestroyed, toSignal } from "@angular/core/rxjs-interop";
|
||||
import { BehaviorSubject, Observable, fromEvent, map, startWith, debounceTime, first } from "rxjs";
|
||||
|
||||
import { BIT_SIDE_NAV_DISK, GlobalStateProvider, KeyDefinition } from "@bitwarden/state";
|
||||
|
||||
@@ -32,16 +23,17 @@ export class SideNavService {
|
||||
|
||||
private rootFontSizePx: number;
|
||||
|
||||
private _open$ = new BehaviorSubject<boolean>(isAtOrLargerThanBreakpoint("md"));
|
||||
open$ = this._open$.asObservable();
|
||||
/**
|
||||
* Whether the side navigation is open or closed.
|
||||
*/
|
||||
readonly open = signal(isAtOrLargerThanBreakpoint("md"));
|
||||
|
||||
private isLargeScreen$ = media(`(min-width: ${BREAKPOINTS.md}px)`);
|
||||
private _userCollapsePreference$ = new BehaviorSubject<CollapsePreference>(null);
|
||||
userCollapsePreference$ = this._userCollapsePreference$.asObservable();
|
||||
readonly isLargeScreen = toSignal(this.isLargeScreen$, { requireSync: true });
|
||||
|
||||
isOverlay$ = combineLatest([this.open$, this.isLargeScreen$]).pipe(
|
||||
map(([open, isLargeScreen]) => open && !isLargeScreen),
|
||||
);
|
||||
readonly userCollapsePreference = signal<CollapsePreference>(null);
|
||||
|
||||
readonly isOverlay = computed(() => this.open() && !this.isLargeScreen());
|
||||
|
||||
/**
|
||||
* Local component state width
|
||||
@@ -67,16 +59,14 @@ export class SideNavService {
|
||||
this.rootFontSizePx = parseFloat(getComputedStyle(document.documentElement).fontSize || "16");
|
||||
|
||||
// Handle open/close state
|
||||
combineLatest([this.isLargeScreen$, this.userCollapsePreference$])
|
||||
.pipe(takeUntilDestroyed())
|
||||
.subscribe(([isLargeScreen, userCollapsePreference]) => {
|
||||
if (!isLargeScreen) {
|
||||
this.setClose();
|
||||
} else if (userCollapsePreference !== "closed") {
|
||||
// Auto-open when user hasn't set preference (null) or prefers open
|
||||
this.setOpen();
|
||||
}
|
||||
});
|
||||
effect(() => {
|
||||
if (!this.isLargeScreen()) {
|
||||
this.open.set(false);
|
||||
} else if (this.userCollapsePreference() !== "closed") {
|
||||
// Auto-open when user hasn't set preference (null) or prefers open
|
||||
this.open.set(true);
|
||||
}
|
||||
});
|
||||
|
||||
// Initialize the resizable width from state provider
|
||||
this.widthState$.pipe(first()).subscribe((width: number) => {
|
||||
@@ -89,31 +79,14 @@ export class SideNavService {
|
||||
});
|
||||
}
|
||||
|
||||
get open() {
|
||||
return this._open$.getValue();
|
||||
}
|
||||
|
||||
setOpen() {
|
||||
this._open$.next(true);
|
||||
}
|
||||
|
||||
setClose() {
|
||||
this._open$.next(false);
|
||||
}
|
||||
|
||||
/**
|
||||
* Toggle the open/close state of the side nav
|
||||
*/
|
||||
toggle() {
|
||||
const curr = this._open$.getValue();
|
||||
// Store user's preference based on what state they're toggling TO
|
||||
this._userCollapsePreference$.next(curr ? "closed" : "open");
|
||||
this.userCollapsePreference.set(this.open() ? "closed" : "open");
|
||||
|
||||
if (curr) {
|
||||
this.setClose();
|
||||
} else {
|
||||
this.setOpen();
|
||||
}
|
||||
this.open.set(!this.open());
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
<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">
|
||||
<div class="tw-size-24 tw-content-center">
|
||||
<bit-icon [icon]="icon()" aria-hidden="true"></bit-icon>
|
||||
<bit-svg [content]="icon()" aria-hidden="true"></bit-svg>
|
||||
</div>
|
||||
<h3 class="tw-font-medium tw-text-center tw-mt-4">
|
||||
<ng-content select="[slot=title]"></ng-content>
|
||||
|
||||
@@ -1,18 +1,17 @@
|
||||
import { Component, input } from "@angular/core";
|
||||
import { ChangeDetectionStrategy, Component, input } from "@angular/core";
|
||||
|
||||
import { NoResults } from "@bitwarden/assets/svg";
|
||||
|
||||
import { BitIconComponent } from "../icon/icon.component";
|
||||
import { SvgComponent } from "../svg/svg.component";
|
||||
|
||||
/**
|
||||
* Component for displaying a message when there are no items to display. Expects title, description and button slots.
|
||||
*/
|
||||
// FIXME(https://bitwarden.atlassian.net/browse/CL-764): Migrate to OnPush
|
||||
// eslint-disable-next-line @angular-eslint/prefer-on-push-component-change-detection
|
||||
@Component({
|
||||
selector: "bit-no-items",
|
||||
templateUrl: "./no-items.component.html",
|
||||
imports: [BitIconComponent],
|
||||
imports: [SvgComponent],
|
||||
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||
})
|
||||
export class NoItemsComponent {
|
||||
readonly icon = input(NoResults);
|
||||
|
||||
@@ -73,7 +73,6 @@ import { KitchenSinkSharedModule } from "../kitchen-sink-shared.module";
|
||||
A random password
|
||||
<button
|
||||
bitLink
|
||||
linkType="primary"
|
||||
[bitPopoverTriggerFor]="myPopover"
|
||||
#triggerRef="popoverTrigger"
|
||||
type="button"
|
||||
|
||||
@@ -112,13 +112,12 @@ class KitchenSinkDialogComponent {
|
||||
|
||||
<div class="tw-my-6">
|
||||
<h1 bitTypography="h1">Bitwarden Kitchen Sink<bit-avatar text="Bit Warden"></bit-avatar></h1>
|
||||
<a bitLink linkType="primary" href="#">This is a link</a>
|
||||
<a bitLink href="#">This is a link</a>
|
||||
<p bitTypography="body1" class="tw-inline">
|
||||
and this is a link button popover trigger:
|
||||
</p>
|
||||
<button
|
||||
bitLink
|
||||
linkType="primary"
|
||||
[bitPopoverTriggerFor]="myPopover"
|
||||
#triggerRef="popoverTrigger"
|
||||
type="button"
|
||||
|
||||
@@ -13,10 +13,8 @@ import { CalloutModule } from "../../callout";
|
||||
import { CheckboxModule } from "../../checkbox";
|
||||
import { ColorPasswordModule } from "../../color-password";
|
||||
import { DialogModule } from "../../dialog";
|
||||
import { DrawerModule } from "../../drawer";
|
||||
import { FormControlModule } from "../../form-control";
|
||||
import { FormFieldModule } from "../../form-field";
|
||||
import { IconModule } from "../../icon";
|
||||
import { IconButtonModule } from "../../icon-button";
|
||||
import { InputModule } from "../../input";
|
||||
import { LayoutComponent } from "../../layout";
|
||||
@@ -31,6 +29,7 @@ import { SearchModule } from "../../search";
|
||||
import { SectionComponent } from "../../section";
|
||||
import { SelectModule } from "../../select";
|
||||
import { SharedModule } from "../../shared";
|
||||
import { SvgModule } from "../../svg";
|
||||
import { TableModule } from "../../table";
|
||||
import { TabsModule } from "../../tabs";
|
||||
import { ToggleGroupModule } from "../../toggle-group";
|
||||
@@ -49,12 +48,11 @@ import { TypographyModule } from "../../typography";
|
||||
ColorPasswordModule,
|
||||
CommonModule,
|
||||
DialogModule,
|
||||
DrawerModule,
|
||||
FormControlModule,
|
||||
FormFieldModule,
|
||||
FormsModule,
|
||||
IconButtonModule,
|
||||
IconModule,
|
||||
SvgModule,
|
||||
InputModule,
|
||||
LayoutComponent,
|
||||
LinkModule,
|
||||
@@ -87,12 +85,11 @@ import { TypographyModule } from "../../typography";
|
||||
ColorPasswordModule,
|
||||
CommonModule,
|
||||
DialogModule,
|
||||
DrawerModule,
|
||||
FormControlModule,
|
||||
FormFieldModule,
|
||||
FormsModule,
|
||||
IconButtonModule,
|
||||
IconModule,
|
||||
SvgModule,
|
||||
InputModule,
|
||||
LayoutComponent,
|
||||
LinkModule,
|
||||
|
||||
@@ -13,6 +13,7 @@ import {
|
||||
|
||||
import { PasswordManagerLogo } from "@bitwarden/assets/svg";
|
||||
import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service";
|
||||
import { PlatformUtilsService } from "@bitwarden/common/platform/abstractions/platform-utils.service";
|
||||
import { GlobalStateProvider } from "@bitwarden/state";
|
||||
|
||||
import { LayoutComponent } from "../../layout";
|
||||
@@ -71,6 +72,13 @@ export default {
|
||||
});
|
||||
},
|
||||
},
|
||||
{
|
||||
provide: PlatformUtilsService,
|
||||
useValue: {
|
||||
// eslint-disable-next-line
|
||||
copyToClipboard: (text: string) => console.log(`${text} copied to clipboard`),
|
||||
},
|
||||
},
|
||||
{
|
||||
provide: GlobalStateProvider,
|
||||
useClass: StorybookGlobalStateProvider,
|
||||
|
||||
2
libs/components/src/svg/index.ts
Normal file
2
libs/components/src/svg/index.ts
Normal file
@@ -0,0 +1,2 @@
|
||||
export * from "./svg.module";
|
||||
export * from "./svg.component";
|
||||
31
libs/components/src/svg/svg.component.ts
Normal file
31
libs/components/src/svg/svg.component.ts
Normal file
@@ -0,0 +1,31 @@
|
||||
import { ChangeDetectionStrategy, Component, computed, inject, input } from "@angular/core";
|
||||
import { DomSanitizer, SafeHtml } from "@angular/platform-browser";
|
||||
|
||||
import { BitSvg, isBitSvg } from "@bitwarden/assets/svg";
|
||||
|
||||
@Component({
|
||||
selector: "bit-svg",
|
||||
host: {
|
||||
"[attr.aria-hidden]": "!ariaLabel()",
|
||||
"[attr.aria-label]": "ariaLabel()",
|
||||
"[innerHtml]": "innerHtml()",
|
||||
class: "tw-max-h-full tw-flex tw-justify-center",
|
||||
},
|
||||
template: ``,
|
||||
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||
})
|
||||
export class SvgComponent {
|
||||
private domSanitizer = inject(DomSanitizer);
|
||||
|
||||
readonly content = input<BitSvg>();
|
||||
readonly ariaLabel = input<string>();
|
||||
|
||||
protected readonly innerHtml = computed<SafeHtml | null>(() => {
|
||||
const content = this.content();
|
||||
if (!isBitSvg(content)) {
|
||||
return null;
|
||||
}
|
||||
const svg = content.svg;
|
||||
return this.domSanitizer.bypassSecurityTrustHtml(svg);
|
||||
});
|
||||
}
|
||||
@@ -1,25 +1,25 @@
|
||||
import { ComponentFixture, TestBed } from "@angular/core/testing";
|
||||
|
||||
import { Icon, svgIcon } from "@bitwarden/assets/svg";
|
||||
import { BitSvg, svg } from "@bitwarden/assets/svg";
|
||||
|
||||
import { BitIconComponent } from "./icon.component";
|
||||
import { SvgComponent } from "./svg.component";
|
||||
|
||||
describe("IconComponent", () => {
|
||||
let fixture: ComponentFixture<BitIconComponent>;
|
||||
describe("SvgComponent", () => {
|
||||
let fixture: ComponentFixture<SvgComponent>;
|
||||
|
||||
beforeEach(async () => {
|
||||
await TestBed.configureTestingModule({
|
||||
imports: [BitIconComponent],
|
||||
imports: [SvgComponent],
|
||||
}).compileComponents();
|
||||
|
||||
fixture = TestBed.createComponent(BitIconComponent);
|
||||
fixture = TestBed.createComponent(SvgComponent);
|
||||
fixture.detectChanges();
|
||||
});
|
||||
|
||||
it("should have empty innerHtml when input is not an Icon", () => {
|
||||
const fakeIcon = { svg: "harmful user input" } as Icon;
|
||||
const fakeIcon = { svg: "harmful user input" } as BitSvg;
|
||||
|
||||
fixture.componentRef.setInput("icon", fakeIcon);
|
||||
fixture.componentRef.setInput("content", fakeIcon);
|
||||
fixture.detectChanges();
|
||||
|
||||
const el = fixture.nativeElement as HTMLElement;
|
||||
@@ -27,9 +27,9 @@ 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>`;
|
||||
const icon = svg`<svg><text x="0" y="15">safe icon</text></svg>`;
|
||||
|
||||
fixture.componentRef.setInput("icon", icon);
|
||||
fixture.componentRef.setInput("content", icon);
|
||||
fixture.detectChanges();
|
||||
|
||||
const el = fixture.nativeElement as HTMLElement;
|
||||
120
libs/components/src/svg/svg.mdx
Normal file
120
libs/components/src/svg/svg.mdx
Normal file
@@ -0,0 +1,120 @@
|
||||
import { Meta, Story, Controls } from "@storybook/addon-docs/blocks";
|
||||
|
||||
import * as stories from "./svg.stories";
|
||||
|
||||
<Meta of={stories} />
|
||||
|
||||
```ts
|
||||
import { SvgModule } from "@bitwarden/components";
|
||||
```
|
||||
|
||||
# Svg Use Instructions
|
||||
|
||||
- Icons will generally be attached to the associated Jira task.
|
||||
- Designers should minify any SVGs before attaching them to Jira using a tool like
|
||||
[SVGOMG](https://jakearchibald.github.io/svgomg/).
|
||||
- **Note:** Ensure the "Remove viewbox" option is toggled off if responsive resizing of the icon
|
||||
is desired.
|
||||
|
||||
## Developer Instructions
|
||||
|
||||
1. **Download the SVG** and import it as an `.svg` initially into the IDE of your choice.
|
||||
- The SVG should be formatted using either a built-in formatter or an external tool like
|
||||
[SVG Formatter Beautifier](https://codebeautify.org/svg-formatter-beautifier) to make applying
|
||||
classes easier.
|
||||
|
||||
2. **Rename the file** as a `<name>.icon.ts` TypeScript file and place it in the `libs/assets/svg`
|
||||
lib.
|
||||
|
||||
3. **Import** `svg` from `./svg`.
|
||||
|
||||
4. **Define and export** a `const` to represent your `svg`.
|
||||
|
||||
```typescript
|
||||
export const ExampleIcon = svg`<svg … </svg>`;
|
||||
```
|
||||
|
||||
5. **Replace any hardcoded strokes or fills** with the appropriate Tailwind class.
|
||||
- **Note:** Stroke is used when styling the outline of an SVG path, while fill is used when
|
||||
styling the inside of an SVG path.
|
||||
|
||||
- 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-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:
|
||||
- **Option 1: Figma**
|
||||
- Open the SVG in Figma.
|
||||
- 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/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/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-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
|
||||
to allow the SVG to scale to fit its container.
|
||||
- **Note:** Scaling is required for any SVG used as an
|
||||
[AnonLayout](?path=/docs/component-library-anon-layout--docs) `pageIcon`.
|
||||
|
||||
7. **Replace any generic `clipPath` ids** (such as `id="a"`) with a unique id, and update the
|
||||
referencing element to use the new id (such as `clip-path="url(#unique-id-here)"`).
|
||||
|
||||
8. **Import your SVG const** anywhere you want to use the SVG.
|
||||
- **Angular Component Example:**
|
||||
- **TypeScript:**
|
||||
|
||||
```typescript
|
||||
import { Component } from "@angular/core";
|
||||
import { SvgModule } from '@bitwarden/components';
|
||||
import { ExampleIcon, Example2Icon } from "@bitwarden/assets/svg";
|
||||
|
||||
@Component({
|
||||
selector: "app-example",
|
||||
standalone: true,
|
||||
imports: [SvgModule],
|
||||
templateUrl: "./example.component.html",
|
||||
})
|
||||
export class ExampleComponent {
|
||||
readonly Icons = { ExampleIcon, Example2Icon };
|
||||
...
|
||||
}
|
||||
```
|
||||
|
||||
- **HTML:**
|
||||
|
||||
> NOTE: SVG icons are treated as decorative by default and will be `aria-hidden` unless an
|
||||
> `ariaLabel` is explicitly provided to the `<bit-svg>` component
|
||||
|
||||
```html
|
||||
<bit-svg [content]="Icons.ExampleIcon"></bit-svg>
|
||||
```
|
||||
|
||||
With `ariaLabel`
|
||||
|
||||
```html
|
||||
<bit-svg [content]="Icons.ExampleIcon" [ariaLabel]="Your custom label text here"></bit-svg>
|
||||
```
|
||||
|
||||
9. **Ensure your SVG renders properly** according to Figma in both light and dark modes on a client
|
||||
which supports multiple style modes.
|
||||
9
libs/components/src/svg/svg.module.ts
Normal file
9
libs/components/src/svg/svg.module.ts
Normal file
@@ -0,0 +1,9 @@
|
||||
import { NgModule } from "@angular/core";
|
||||
|
||||
import { SvgComponent } from "./svg.component";
|
||||
|
||||
@NgModule({
|
||||
imports: [SvgComponent],
|
||||
exports: [SvgComponent],
|
||||
})
|
||||
export class SvgModule {}
|
||||
50
libs/components/src/svg/svg.stories.ts
Normal file
50
libs/components/src/svg/svg.stories.ts
Normal file
@@ -0,0 +1,50 @@
|
||||
import { Meta } from "@storybook/angular";
|
||||
|
||||
import * as SvgIcons from "@bitwarden/assets/svg";
|
||||
|
||||
import { SvgComponent } from "./svg.component";
|
||||
|
||||
export default {
|
||||
title: "Component Library/Svg",
|
||||
component: SvgComponent,
|
||||
parameters: {
|
||||
design: {
|
||||
type: "figma",
|
||||
url: "https://www.figma.com/design/Zt3YSeb6E6lebAffrNLa0h/Tailwind-Component-Library?node-id=21662-50335&t=k6OTDDPZOTtypRqo-11",
|
||||
},
|
||||
},
|
||||
} as Meta;
|
||||
|
||||
const {
|
||||
// Filtering out the few non-icons in the libs/assets/svg import
|
||||
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
||||
DynamicContentNotAllowedError: _DynamicContentNotAllowedError,
|
||||
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
||||
isBitSvg,
|
||||
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
||||
svg,
|
||||
...Icons
|
||||
}: {
|
||||
[key: string]: any;
|
||||
} = SvgIcons;
|
||||
|
||||
export const Default = {
|
||||
render: (args: { icons: [string, any][] }) => ({
|
||||
props: args,
|
||||
template: /*html*/ `
|
||||
<div class="tw-bg-secondary-100 tw-p-2 tw-grid tw-grid-cols-[repeat(auto-fit,minmax(224px,1fr))] tw-gap-2">
|
||||
@for (icon of icons; track icon[0]) {
|
||||
<div class="tw-size-56 tw-border tw-border-secondary-300 tw-rounded-md">
|
||||
<div class="tw-text-xs tw-text-center">{{icon[0]}}</div>
|
||||
<div class="tw-size-52 tw-w-full tw-content-center">
|
||||
<bit-svg [content]="icon[1]"></bit-svg>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
`,
|
||||
}),
|
||||
args: {
|
||||
icons: Object.entries(Icons),
|
||||
},
|
||||
};
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user