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:
@@ -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,3 +1,2 @@
|
||||
export * from "./link.component";
|
||||
export * from "./link.directive";
|
||||
export * from "./link.module";
|
||||
|
||||
@@ -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>
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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 {}
|
||||
|
||||
@@ -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>
|
||||
`,
|
||||
}),
|
||||
|
||||
@@ -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>) {}
|
||||
|
||||
@@ -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>
|
||||
|
||||
|
||||
@@ -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,
|
||||
],
|
||||
})
|
||||
|
||||
Reference in New Issue
Block a user