1
0
mirror of https://github.com/bitwarden/browser synced 2026-02-06 03:33:30 +00:00

[CL-637] icon api buttons links (#18388)

* update button api to accept icons

* use template outlet in button

* add link component

* create link component to handle anchors and buttons

* remove unnecessary let variables

* fix link focus state styling

* update link underline style

* fix broken skip link focus

* add focus method to link component

* fix typo

* fix off center loading state

* move flex styles to template to fix some minor style overrides

* remove unnecessary variables

* fix interaction states and add styles for test class to work properly

* refactor classes and make variable sreadonly

* fix classes not being applied correctly

* fix bad merge conflict resolution

* simplified button template
This commit is contained in:
Bryan Cunningham
2026-02-04 14:20:44 -05:00
committed by GitHub
parent 2b06f6ace3
commit a07c9ebf6b
17 changed files with 183 additions and 93 deletions

View File

@@ -6,7 +6,7 @@ import { firstValueFrom, switchMap } from "rxjs";
import { JslibModule } from "@bitwarden/angular/jslib.module";
import { AccountService } from "@bitwarden/common/auth/abstractions/account.service";
import { getUserId } from "@bitwarden/common/auth/services/account.service";
import { AnchorLinkDirective, CalloutModule, BannerModule } from "@bitwarden/components";
import { LinkComponent, CalloutModule, BannerModule } from "@bitwarden/components";
import { I18nPipe } from "@bitwarden/ui-common";
import { AtRiskPasswordCalloutData, AtRiskPasswordCalloutService } from "@bitwarden/vault";
@@ -15,7 +15,7 @@ import { AtRiskPasswordCalloutData, AtRiskPasswordCalloutService } from "@bitwar
@Component({
selector: "vault-at-risk-password-callout",
imports: [
AnchorLinkDirective,
LinkComponent,
CommonModule,
RouterModule,
CalloutModule,

View File

@@ -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">

View File

@@ -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

View File

@@ -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>

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,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: {

View File

@@ -5,7 +5,7 @@ import { booleanAttribute, Component, ElementRef, inject, input, viewChild } fro
import { RouterModule } from "@angular/router";
import { DrawerService } from "../dialog/drawer.service";
import { LinkModule } from "../link";
import { LinkComponent, LinkModule } from "../link";
import { SideNavService } from "../navigation/side-nav.service";
import { SharedModule } from "../shared";
@@ -52,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();
}
}
}

View File

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

View 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>

View File

@@ -1,6 +1,14 @@
import { input, HostBinding, Directive, inject, ElementRef, booleanAttribute } from "@angular/core";
import {
ChangeDetectionStrategy,
Component,
computed,
input,
booleanAttribute,
inject,
ElementRef,
} from "@angular/core";
import { AriaDisableDirective } from "../a11y";
import { BitwardenIcon } from "../shared/icon";
import { ariaDisableElement } from "../utils";
export const LinkTypes = [
@@ -46,16 +54,16 @@ const commonStyles = [
"tw-transition",
"tw-no-underline",
"tw-cursor-pointer",
"hover:tw-underline",
"hover:tw-decoration-1",
"[&:hover_span]:tw-underline",
"[&.tw-test-hover_span]:tw-underline",
"[&:hover_span]:tw-decoration-[.125em]",
"[&.tw-test-hover_span]:tw-decoration-[.125em]",
"disabled:tw-no-underline",
"disabled:tw-cursor-not-allowed",
"disabled:!tw-text-fg-disabled",
"disabled:hover:!tw-text-fg-disabled",
"disabled:hover:tw-no-underline",
"focus-visible:tw-outline-none",
"focus-visible:tw-underline",
"focus-visible:tw-decoration-1",
"focus-visible:before:tw-ring-border-focus",
// Workaround for html button tag not being able to be set to `display: inline`
@@ -72,8 +80,12 @@ const commonStyles = [
"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",
@@ -83,47 +95,57 @@ const commonStyles = [
"aria-disabled:hover:tw-no-underline",
];
@Directive()
abstract class LinkDirective {
readonly linkType = input<LinkType>("default");
}
/**
* 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]",
@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 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>);
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 });
@HostBinding("class") get classList() {
return ["before:-tw-inset-y-[0.25rem]"]
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() {
super();
ariaDisableElement(this.el.nativeElement, this.disabled);
if (this.isButton) {
ariaDisableElement(this.el.nativeElement, this.disabled);
}
}
}

View File

@@ -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 {}

View File

@@ -2,7 +2,7 @@ import { Meta, StoryObj, moduleMetadata } from "@storybook/angular";
import { formatArgsForCodeSnippet } from "../../../../.storybook/format-args-for-code-snippet";
import { AnchorLinkDirective, ButtonLinkDirective, LinkTypes } from "./link.directive";
import { LinkComponent, LinkTypes } from "./link.component";
import { LinkModule } from "./link.module";
export default {
@@ -26,7 +26,7 @@ export default {
},
} as Meta;
type Story = StoryObj<ButtonLinkDirective>;
type Story = StoryObj<LinkComponent>;
export const Default: Story = {
render: (args) => ({
@@ -40,9 +40,9 @@ export const Default: Story = {
: "tw-bg-transparent",
},
template: /*html*/ `
<div class="tw-p-2" [class]="backgroundClass">
<a bitLink href="" ${formatArgsForCodeSnippet<ButtonLinkDirective>(args)}>Your text here</a>
</div>
<div class="tw-p-2" [class]="backgroundClass">
<a bitLink href="#" ${formatArgsForCodeSnippet<LinkComponent>(args)}>Your text here</a>
</div>
`,
}),
args: {
@@ -181,14 +181,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>
@@ -203,7 +201,7 @@ export const Buttons: Story = {
},
};
export const Anchors: StoryObj<AnchorLinkDirective> = {
export const Anchors: StoryObj<LinkComponent> = {
render: (args) => ({
props: {
linkType: args.linkType,
@@ -220,14 +218,12 @@ export const Anchors: StoryObj<AnchorLinkDirective> = {
<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>
@@ -247,20 +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 Inactive: 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-bg-bg-contrast tw-p-2 tw-inline-block">
<button type="button" bitLink disabled linkType="contrast">Contrast</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-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 Inactive: 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 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>
</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

@@ -30,7 +30,7 @@ import {
CalloutModule,
SearchModule,
TypographyModule,
AnchorLinkDirective,
LinkComponent,
} from "@bitwarden/components";
import { ChangeLoginPasswordService } from "../abstractions/change-login-password.service";
@@ -66,7 +66,7 @@ import { ViewIdentitySectionsComponent } from "./view-identity-sections/view-ide
ViewIdentitySectionsComponent,
LoginCredentialsViewComponent,
AutofillOptionsViewComponent,
AnchorLinkDirective,
LinkComponent,
TypographyModule,
],
})

View File

@@ -19,9 +19,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";
@@ -39,7 +39,7 @@ import { OrgIconDirective } from "../../components/org-icon.directive";
TypographyModule,
OrgIconDirective,
FormFieldModule,
ButtonLinkDirective,
LinkComponent,
BadgeModule,
],
})

View File

@@ -7,7 +7,7 @@ import { CipherId } from "@bitwarden/common/types/guid";
import {
DIALOG_DATA,
DialogRef,
AnchorLinkDirective,
LinkComponent,
AsyncActionsModule,
ButtonModule,
DialogModule,
@@ -32,7 +32,7 @@ export type DecryptionFailureDialogParams = {
JslibModule,
AsyncActionsModule,
ButtonModule,
AnchorLinkDirective,
LinkComponent,
],
})
export class DecryptionFailureDialogComponent {