1
0
mirror of https://github.com/bitwarden/browser synced 2026-02-17 09:59:41 +00:00

create link component to handle anchors and buttons

This commit is contained in:
Bryan Cunningham
2026-01-15 13:41:14 -05:00
parent 329b74084a
commit f159c007ec
10 changed files with 178 additions and 157 deletions

View File

@@ -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>
`,
}),

View File

@@ -1,3 +1,2 @@
export * from "./link.component";
export * from "./link.directive";
export * from "./link.module";

View File

@@ -1,22 +1,12 @@
@let leadIcon = startIcon();
@let tailIcon = endIcon();
<ng-template #linkContent>
@if (leadIcon) {
<i class="{{ startIconClasses() }}" aria-hidden="true"></i>
}
<span>
<ng-content></ng-content>
</ng-template>
<span class="tw-relative">
@if (leadIcon || tailIcon) {
<div class="tw-flex tw-items-center tw-gap-2">
@if (leadIcon) {
<i class="{{ startIconClasses() }}"></i>
}
<ng-container *ngTemplateOutlet="linkContent"></ng-container>
@if (tailIcon) {
<i class="{{ endIconClasses() }}"></i>
}
</div>
} @else {
<ng-container *ngTemplateOutlet="linkContent"></ng-container>
}
</span>
@if (tailIcon) {
<i class="{{ endIconClasses() }}" aria-hidden="true"></i>
}

View File

@@ -1,33 +1,132 @@
import { NgTemplateOutlet } from "@angular/common";
import { ChangeDetectionStrategy, Component, computed, input } from "@angular/core";
import {
ChangeDetectionStrategy,
Component,
computed,
input,
booleanAttribute,
inject,
ElementRef,
} from "@angular/core";
import { BitwardenIcon } from "../shared/icon";
import { ariaDisableElement } from "../utils";
import { getLinkClasses, LinkType } from "./link.directive";
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-inline-flex",
"tw-items-center",
"tw-gap-2",
"tw-transition",
"tw-no-underline",
"tw-cursor-pointer",
"hover:[&>span]:tw-underline",
"hover:[&>span]: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:[&>span]:tw-underline",
"focus-visible:[&>span]: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",
];
export function getLinkClasses({
linkType,
verticalInset,
}: {
linkType: LinkType;
verticalInset: string;
}): string[] {
return [`before:-tw-inset-y-[${verticalInset}]`]
.concat(commonStyles)
.concat(linkStyles[linkType] ?? []);
}
@Component({
selector: "a[bitLink]",
selector: "a[bitLink], button[bitLink]",
templateUrl: "./link.component.html",
changeDetection: ChangeDetectionStrategy.OnPush,
imports: [NgTemplateOutlet],
host: {
"[class]": "classList()",
"[attr.bit-aria-disable]": "isButton() ? true : null",
},
})
export class LinkComponent {
private el = inject(ElementRef<HTMLElement>);
readonly linkType = input<LinkType>("primary");
readonly startIcon = input<BitwardenIcon | undefined>(undefined);
readonly endIcon = input<BitwardenIcon | undefined>(undefined);
readonly disabled = input(false, { transform: booleanAttribute });
protected readonly isButton = computed(() => this.el.nativeElement.tagName === "BUTTON");
readonly classList = computed(() => {
return getLinkClasses({ linkType: this.linkType(), verticalInset: "0.125rem" });
const verticalInset = this.isButton() ? "0.25rem" : "0.125rem";
return getLinkClasses({ linkType: this.linkType(), verticalInset });
});
readonly startIconClasses = computed(() => {
return ["bwi", this.startIcon()];
return ["bwi", "!tw-no-underline", this.startIcon()];
});
readonly endIconClasses = computed(() => {
return ["bwi", this.endIcon()];
return ["bwi", "!tw-no-underline", this.endIcon()];
});
constructor() {
if (this.isButton()) {
ariaDisableElement(this.el.nativeElement, this.disabled);
}
}
}

View File

@@ -1,104 +0,0 @@
import { input, Directive, inject, ElementRef, booleanAttribute, computed } 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",
"tw-cursor-pointer",
"hover:tw-underline",
"hover:tw-decoration-1",
"disabled:tw-no-underline",
"disabled:tw-cursor-not-allowed",
"disabled:!tw-text-secondary-300",
"disabled:hover:!tw-text-secondary-300",
"disabled: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",
];
export function getLinkClasses({
linkType,
verticalInset,
}: {
linkType: LinkType;
verticalInset: string;
}): string[] {
return [`before:-tw-inset-y-[${verticalInset}]`]
.concat(commonStyles)
.concat(linkStyles[linkType] ?? []);
}
@Directive({
selector: "button[bitLink]",
hostDirectives: [AriaDisableDirective],
host: {
"[class]": "classList()",
},
})
export class ButtonLinkDirective {
private el = inject(ElementRef<HTMLButtonElement>);
readonly linkType = input<LinkType>("primary");
readonly disabled = input(false, { transform: booleanAttribute });
readonly classList = computed(() => {
return getLinkClasses({ linkType: this.linkType(), verticalInset: "0.25rem" });
});
constructor() {
ariaDisableElement(this.el.nativeElement, this.disabled);
}
}

View File

@@ -1,10 +1,9 @@
import { NgModule } from "@angular/core";
import { LinkComponent } from "./link.component";
import { ButtonLinkDirective } from "./link.directive";
@NgModule({
imports: [LinkComponent, ButtonLinkDirective],
exports: [LinkComponent, ButtonLinkDirective],
imports: [LinkComponent],
exports: [LinkComponent],
})
export class LinkModule {}

View File

@@ -3,7 +3,6 @@ import { Meta, StoryObj, moduleMetadata } from "@storybook/angular";
import { formatArgsForCodeSnippet } from "../../../../.storybook/format-args-for-code-snippet";
import { LinkComponent } from "./link.component";
import { ButtonLinkDirective } from "./link.directive";
import { LinkModule } from "./link.module";
export default {
@@ -27,12 +26,12 @@ export default {
},
} as Meta;
type Story = StoryObj<ButtonLinkDirective>;
type Story = StoryObj<LinkComponent>;
export const Default: Story = {
render: (args) => ({
template: /*html*/ `
<a bitLink ${formatArgsForCodeSnippet<ButtonLinkDirective>(args)}>Your text here</a>
<a bitLink ${formatArgsForCodeSnippet<LinkComponent>(args)}>Your text here</a>
`,
}),
};
@@ -77,14 +76,12 @@ export const Buttons: Story = {
<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>
@@ -108,14 +105,12 @@ export const Anchors: StoryObj<LinkComponent> = {
<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>
@@ -144,14 +139,51 @@ export const Inline: Story = {
},
};
export const Disabled: Story = {
export const WithIcons: Story = {
render: (args) => ({
props: args,
template: /*html*/ `
<button type="button" bitLink disabled linkType="primary" class="tw-me-2">Primary</button>
<button type="button" bitLink disabled linkType="secondary" class="tw-me-2">Secondary</button>
<div class="tw-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-arrow-left" endIcon="bwi-arrow-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 = {
render: (args) => ({
props: {
...args,
onClick: () => {
alert("Button clicked! (This should not appear when disabled)");
},
},
template: /*html*/ `
<button type="button" bitLink (click)="onClick()" disabled linkType="primary" class="tw-me-2">Primary button</button>
<a bitLink disabled linkType="primary" class="tw-me-2">Links can not be disabled</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>
`,
}),

View File

@@ -3,13 +3,13 @@ import { Component, inject } from "@angular/core";
import { JslibModule } from "@bitwarden/angular/jslib.module";
import { PlatformUtilsService } from "@bitwarden/common/platform/abstractions/platform-utils.service";
import {
ButtonLinkDirective,
ButtonModule,
CenterPositionStrategy,
DialogModule,
DialogRef,
DialogService,
DIALOG_DATA,
DialogRef,
CenterPositionStrategy,
LinkComponent,
} from "@bitwarden/components";
export type AdvancedUriOptionDialogParams = {
@@ -22,7 +22,7 @@ export type AdvancedUriOptionDialogParams = {
// eslint-disable-next-line @angular-eslint/prefer-on-push-component-change-detection
@Component({
templateUrl: "advanced-uri-option-dialog.component.html",
imports: [ButtonLinkDirective, ButtonModule, DialogModule, JslibModule],
imports: [LinkComponent, ButtonModule, DialogModule, JslibModule],
})
export class AdvancedUriOptionDialogComponent {
constructor(private dialogRef: DialogRef<boolean>) {}

View File

@@ -12,9 +12,15 @@
</bit-callout>
<bit-callout *ngIf="showChangePasswordLink()" type="warning" [title]="''">
<a bitLink href="#" appStopClick (click)="launchChangePassword()" linkType="secondary">
<a
bitLink
href="#"
appStopClick
(click)="launchChangePassword()"
linkType="secondary"
endIcon="bwi-external-link"
>
{{ "changeAtRiskPassword" | i18n }}
<i class="bwi bwi-external-link tw-ml-1" aria-hidden="true"></i>
</a>
</bit-callout>

View File

@@ -17,9 +17,9 @@ import { CipherView } from "@bitwarden/common/vault/models/view/cipher.view";
import { FolderView } from "@bitwarden/common/vault/models/view/folder.view";
import {
BadgeModule,
ButtonLinkDirective,
CardComponent,
FormFieldModule,
LinkComponent,
TypographyModule,
} from "@bitwarden/components";
@@ -37,7 +37,7 @@ import { OrgIconDirective } from "../../components/org-icon.directive";
TypographyModule,
OrgIconDirective,
FormFieldModule,
ButtonLinkDirective,
LinkComponent,
BadgeModule,
],
})