[CL-58] Make icon button compatible with bit suffix directive (#4057)

* [CL-58] feat: add support for modyfing button types from directives

* [CL-58] feat: set button type secondary when used as prefix/suffix

* [CL-58] chore: add example using suffix to async actions story

* [CL-58] feat: update story with examples

* [CL-58] feat: allow buttons to have their style unset

* [CL-58] feat: move all styling into prefix/suffix

* [CL-58] fix: static content prefix/suffix

* [CL-58] fix: add missing bitFormButton to bitAction

* [CL-58] fix: disabled opacity not overriding correctly

* [CL-58] feat: change hover color to muted

* [CL-58] feat: replace undefined with unstyled

* [CL-58] fix: focus borders on input and prefix/suffix

* [CL-58] feat: update production code to use icon button correctly

* [CL-58] refactor: move out button type to common place

* [CL-58] fix: buttons not migrated correctly

* [CL-58] feat: use icon button in password toggle

* [CL-58] fix: remove button icon stories

* [SM-358] Migrate password toggles (#4129)

* [CL-58] fix: missing i18n service in story

* [CL-58] fix: missing bitIconButton directive in export comp

Co-authored-by: Oscar Hinton <Hinton@users.noreply.github.com>
Co-authored-by: Thomas Rittson <trittson@bitwarden.com>
This commit is contained in:
Andreas Coroiu 2022-12-19 23:14:29 +01:00 committed by GitHub
parent 8c8d4b3e3e
commit 32ec5bdba1
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
23 changed files with 278 additions and 252 deletions

View File

@ -77,18 +77,12 @@
<bit-label>{{ "masterPass" | i18n }}</bit-label> <bit-label>{{ "masterPass" | i18n }}</bit-label>
<input <input
id="login_input_master-password" id="login_input_master-password"
type="password"
bitInput bitInput
[type]="showPassword ? 'text' : 'password'"
formControlName="masterPassword" formControlName="masterPassword"
appAutofocus appAutofocus
/> />
<button type="button" bitSuffix bitButton (click)="togglePassword()"> <button type="button" bitSuffix bitIconButton bitPasswordInputToggle></button>
<i
aria-hidden="true"
class="bwi bwi-lg bwi-eye"
[ngClass]="{ 'bwi-eye': !showPassword, 'bwi-eye-slash': showPassword }"
></i>
</button>
</bit-form-field> </bit-form-field>
<a class="-tw-mt-2" routerLink="/hint" (mousedown)="goToHint()" (click)="setFormValues()">{{ <a class="-tw-mt-2" routerLink="/hint" (mousedown)="goToHint()" (click)="setFormValues()">{{
"getMasterPasswordHint" | i18n "getMasterPasswordHint" | i18n

View File

@ -34,16 +34,16 @@
<input <input
id="register-form_input_master-password" id="register-form_input_master-password"
bitInput bitInput
[type]="showPassword ? 'text' : 'password'" type="password"
formControlName="masterPassword" formControlName="masterPassword"
/> />
<button type="button" bitSuffix bitButton (click)="togglePassword()"> <button
<i type="button"
aria-hidden="true" bitSuffix
class="bwi bwi-lg bwi-eye" bitIconButton
[ngClass]="{ 'bwi-eye': !showPassword, 'bwi-eye-slash': showPassword }" bitPasswordInputToggle
></i> [(toggled)]="showPassword"
</button> ></button>
<bit-hint> <bit-hint>
<span class="tw-font-semibold">Important:</span> <span class="tw-font-semibold">Important:</span>
{{ "masterPassImportant" | i18n }} {{ "masterPassImportant" | i18n }}
@ -65,16 +65,16 @@
<input <input
id="register-form_input_confirm-master-password" id="register-form_input_confirm-master-password"
bitInput bitInput
[type]="showPassword ? 'text' : 'password'" type="password"
formControlName="confirmMasterPassword" formControlName="confirmMasterPassword"
/> />
<button type="button" bitSuffix bitButton (click)="togglePassword()"> <button
<i type="button"
aria-hidden="true" bitSuffix
class="bwi bwi-lg bwi-eye" bitIconButton
[ngClass]="{ 'bwi-eye': !showPassword, 'bwi-eye-slash': showPassword }" bitPasswordInputToggle
></i> [(toggled)]="showPassword"
</button> ></button>
</bit-form-field> </bit-form-field>
</div> </div>

View File

@ -84,73 +84,41 @@
<br /> <br />
<ng-container *ngIf="fileEncryptionType == encryptedExportType.FileEncrypted"> <ng-container *ngIf="fileEncryptionType == encryptedExportType.FileEncrypted">
<div class="input-group"> <bit-form-field>
<bit-form-field class="tw-w-full"> <bit-label>{{ "filePassword" | i18n }}</bit-label>
<bit-label>{{ "filePassword" | i18n }}</bit-label> <input
<input bitInput
bitInput type="password"
[type]="showFilePassword ? 'text' : 'password'" id="filePassword"
id="filePassword" formControlName="filePassword"
formControlName="filePassword" name="password"
name="password" />
/> <button
type="button"
<div class="input-group-append"> bitSuffix
<button bitIconButton
bitSuffix bitPasswordInputToggle
bitButton [(toggled)]="showFilePassword"
buttonType="secondary" ></button>
appStopClick <bit-hint>{{ "exportPasswordDescription" | i18n }}</bit-hint>
appA11yTitle="{{ 'toggleVisibility' | i18n }}" </bit-form-field>
[attr.aria-pressed]="showFilePassword" <bit-form-field>
(click)="toggleFilePassword()" <bit-label>{{ "confirmFilePassword" | i18n }}</bit-label>
type="button" <input
> bitInput
<i type="password"
class="bwi bwi-lg" id="confirmFilePassword"
aria-hidden="true" formControlName="confirmFilePassword"
[ngClass]="{ 'bwi-eye': !showFilePassword, 'bwi-eye-slash': showFilePassword }" name="confirmFilePassword"
></i> />
</button> <button
</div> type="button"
</bit-form-field> bitSuffix
<div class="small text-muted"> bitIconButton
{{ "exportPasswordDescription" | i18n }} bitPasswordInputToggle
</div> [(toggled)]="showFilePassword"
</div> ></button>
<div class="input-group tw-mt-4"> </bit-form-field>
<bit-form-field class="tw-w-full">
<bit-label>{{ "confirmFilePassword" | i18n }}</bit-label>
<input
bitInput
[type]="showConfirmFilePassword ? 'text' : 'password'"
id="confirmFilePassword"
formControlName="confirmFilePassword"
name="confirmFilePassword"
/>
<div class="input-group-append">
<button
bitSuffix
bitButton
buttonType="secondary"
appStopClick
appA11yTitle="{{ 'toggleVisibility' | i18n }}"
[attr.aria-pressed]="showConfirmFilePassword"
(click)="toggleConfirmFilePassword()"
type="button"
>
<i
class="bwi bwi-lg"
aria-hidden="true"
[ngClass]="{
'bwi-eye': !showConfirmFilePassword,
'bwi-eye-slash': showConfirmFilePassword
}"
></i>
</button>
</div>
</bit-form-field>
</div>
</ng-container> </ng-container>
</ng-container> </ng-container>

View File

@ -23,6 +23,7 @@ import { UserVerificationPromptComponent } from "../../components/user-verificat
export class ExportComponent extends BaseExportComponent { export class ExportComponent extends BaseExportComponent {
organizationId: string; organizationId: string;
encryptedExportType = EncryptedExportType; encryptedExportType = EncryptedExportType;
protected showFilePassword: boolean;
constructor( constructor(
cryptoService: CryptoService, cryptoService: CryptoService,

View File

@ -14,32 +14,17 @@
class="tw-border-0 tw-border-t tw-border-solid tw-border-secondary-300 tw-pr-3.5 tw-pt-3.5 tw-pl-3.5" class="tw-border-0 tw-border-t tw-border-solid tw-border-secondary-300 tw-pr-3.5 tw-pt-3.5 tw-pl-3.5"
> >
{{ "confirmVaultImportDesc" | i18n }} {{ "confirmVaultImportDesc" | i18n }}
<bit-form-field class="tw-w-full tw-pt-3.5"> <bit-form-field class="tw-pt-3.5">
<bit-label>{{ "confirmFilePassword" | i18n }}</bit-label> <bit-label>{{ "confirmFilePassword" | i18n }}</bit-label>
<input <input
bitInput bitInput
required type="password"
[type]="showFilePassword ? 'text' : 'password'"
name="filePassword" name="filePassword"
[formControl]="filePassword" [formControl]="filePassword"
appAutofocus appAutofocus
appInputVerbatim appInputVerbatim
/> />
<button <button type="button" bitSuffix bitIconButton bitPasswordInputToggle></button>
bitSuffix
bitButton
appStopClick
appA11yTitle="{{ 'toggleVisibility' | i18n }}"
[attr.aria-pressed]="showFilePassword"
(click)="toggleFilePassword()"
type="button"
>
<i
class="bwi bwi-lg"
aria-hidden="true"
[ngClass]="{ 'bwi-eye': !showFilePassword, 'bwi-eye-slash': showFilePassword }"
></i>
</button>
</bit-form-field> </bit-form-field>
</div> </div>
<div <div

View File

@ -7,15 +7,10 @@ import { ModalRef } from "@bitwarden/angular/components/modal/modal.ref";
templateUrl: "file-password-prompt.component.html", templateUrl: "file-password-prompt.component.html",
}) })
export class FilePasswordPromptComponent { export class FilePasswordPromptComponent {
showFilePassword: boolean;
filePassword = new FormControl("", Validators.required); filePassword = new FormControl("", Validators.required);
constructor(private modalRef: ModalRef) {} constructor(private modalRef: ModalRef) {}
toggleFilePassword() {
this.showFilePassword = !this.showFilePassword;
}
submit() { submit() {
this.filePassword.markAsTouched(); this.filePassword.markAsTouched();
if (!this.filePassword.valid) { if (!this.filePassword.valid) {

View File

@ -42,12 +42,10 @@
<button <button
type="button" type="button"
bitSuffix bitSuffix
bitButton bitIconButton="bwi-clone"
(click)="copyScimUrl()" (click)="copyScimUrl()"
[appA11yTitle]="'copyScimUrl' | i18n" [appA11yTitle]="'copyScimUrl' | i18n"
> ></button>
<i aria-hidden="true" class="bwi bwi-lg bwi-clone"></i>
</button>
</bit-form-field> </bit-form-field>
<bit-form-field *ngIf="showScimSettings"> <bit-form-field *ngIf="showScimSettings">
@ -59,40 +57,33 @@
id="clientSecret" id="clientSecret"
/> />
<ng-container> <ng-container>
<button type="button" bitSuffix bitButton (click)="toggleScimKey()">
<i
aria-hidden="true"
class="bwi bwi-lg bwi-eye"
[ngClass]="{ 'bwi-eye': !showScimKey, 'bwi-eye-slash': showScimKey }"
[appA11yTitle]="'toggleVisibility' | i18n"
></i>
</button>
</ng-container>
<ng-container #rotateButton [appApiAction]="rotatePromise">
<button <button
[disabled]="$any(rotateButton).loading"
type="button" type="button"
bitSuffix bitSuffix
bitButton [disabled]="$any(rotateButton).loading"
[bitIconButton]="showScimKey ? 'bwi-eye-slash' : 'bwi-eye'"
(click)="toggleScimKey()"
[appA11yTitle]="'toggleVisibility' | i18n"
></button>
</ng-container>
<ng-container #rotateButton [appApiAction]="rotatePromise">
<!-- TODO: Convert to async actions -->
<button
[loading]="$any(rotateButton).loading"
type="button"
bitSuffix
bitIconButton="bwi-generate"
(click)="rotateScimKey()" (click)="rotateScimKey()"
[appA11yTitle]="'rotateScimKey' | i18n" [appA11yTitle]="'rotateScimKey' | i18n"
> ></button>
<i
aria-hidden="true"
class="bwi bwi-lg bwi-generate"
[ngClass]="{ 'bwi-spin': $any(rotateButton).loading }"
></i>
</button>
</ng-container> </ng-container>
<button <button
type="button" type="button"
bitSuffix bitSuffix
bitButton bitIconButton="bwi-clone"
(click)="copyScimKey()" (click)="copyScimKey()"
[appA11yTitle]="'copyScimKey' | i18n" [appA11yTitle]="'copyScimKey' | i18n"
> ></button>
<i aria-hidden="true" class="bwi bwi-lg bwi-clone"></i>
</button>
<bit-hint>{{ "scimApiKeyHelperText" | i18n }}</bit-hint> <bit-hint>{{ "scimApiKeyHelperText" | i18n }}</bit-hint>
</bit-form-field> </bit-form-field>

View File

@ -151,28 +151,24 @@
<bit-label>{{ "callbackPath" | i18n }}</bit-label> <bit-label>{{ "callbackPath" | i18n }}</bit-label>
<input bitInput disabled [value]="callbackPath" /> <input bitInput disabled [value]="callbackPath" />
<button <button
bitButton bitIconButton="bwi-clone"
bitSuffix bitSuffix
type="button" type="button"
[appCopyClick]="callbackPath" [appCopyClick]="callbackPath"
[appA11yTitle]="'copyValue' | i18n" [appA11yTitle]="'copyValue' | i18n"
> ></button>
<i class="bwi bwi-lg bwi-clone" aria-hidden="true"></i>
</button>
</bit-form-field> </bit-form-field>
<bit-form-field> <bit-form-field>
<bit-label>{{ "signedOutCallbackPath" | i18n }}</bit-label> <bit-label>{{ "signedOutCallbackPath" | i18n }}</bit-label>
<input bitInput disabled [value]="signedOutCallbackPath" /> <input bitInput disabled [value]="signedOutCallbackPath" />
<button <button
bitButton bitIconButton="bwi-clone"
bitSuffix bitSuffix
type="button" type="button"
[appCopyClick]="signedOutCallbackPath" [appCopyClick]="signedOutCallbackPath"
[appA11yTitle]="'copyValue' | i18n" [appA11yTitle]="'copyValue' | i18n"
> ></button>
<i class="bwi bwi-lg bwi-clone" aria-hidden="true"></i>
</button>
</bit-form-field> </bit-form-field>
<bit-form-field> <bit-form-field>
@ -292,14 +288,12 @@
<bit-label>{{ "spEntityId" | i18n }}</bit-label> <bit-label>{{ "spEntityId" | i18n }}</bit-label>
<input bitInput disabled [value]="spEntityId" /> <input bitInput disabled [value]="spEntityId" />
<button <button
bitButton bitIconButton="bwi-clone"
bitSuffix bitSuffix
type="button" type="button"
[appCopyClick]="spEntityId" [appCopyClick]="spEntityId"
[appA11yTitle]="'copyValue' | i18n" [appA11yTitle]="'copyValue' | i18n"
> ></button>
<i class="bwi bwi-lg bwi-clone" aria-hidden="true"></i>
</button>
</bit-form-field> </bit-form-field>
<bit-form-field> <bit-form-field>
@ -315,28 +309,24 @@
<i class="bwi bwi-lg bwi-external-link" aria-hidden="true"></i> <i class="bwi bwi-lg bwi-external-link" aria-hidden="true"></i>
</button> </button>
<button <button
bitButton bitIconButton="bwi-clone"
bitSuffix bitSuffix
type="button" type="button"
[appCopyClick]="spMetadataUrl" [appCopyClick]="spMetadataUrl"
[appA11yTitle]="'copyValue' | i18n" [appA11yTitle]="'copyValue' | i18n"
> ></button>
<i class="bwi bwi-lg bwi-clone" aria-hidden="true"></i>
</button>
</bit-form-field> </bit-form-field>
<bit-form-field> <bit-form-field>
<bit-label>{{ "spAcsUrl" | i18n }}</bit-label> <bit-label>{{ "spAcsUrl" | i18n }}</bit-label>
<input bitInput disabled [value]="spAcsUrl" /> <input bitInput disabled [value]="spAcsUrl" />
<button <button
bitButton bitIconButton="bwi-clone"
bitSuffix bitSuffix
type="button" type="button"
[appCopyClick]="spAcsUrl" [appCopyClick]="spAcsUrl"
[appA11yTitle]="'copyValue' | i18n" [appA11yTitle]="'copyValue' | i18n"
> ></button>
<i class="bwi bwi-lg bwi-clone" aria-hidden="true"></i>
</button>
</bit-form-field> </bit-form-field>
<bit-form-field> <bit-form-field>

View File

@ -21,8 +21,6 @@ export class ExportComponent implements OnInit, OnDestroy {
formPromise: Promise<string>; formPromise: Promise<string>;
disabledByPolicy = false; disabledByPolicy = false;
showFilePassword: boolean;
showConfirmFilePassword: boolean;
exportForm = this.formBuilder.group({ exportForm = this.formBuilder.group({
format: ["json"], format: ["json"],
@ -199,16 +197,6 @@ export class ExportComponent implements OnInit, OnDestroy {
return this.exportForm.get("fileEncryptionType").value; return this.exportForm.get("fileEncryptionType").value;
} }
toggleFilePassword() {
this.showFilePassword = !this.showFilePassword;
document.getElementById("filePassword").focus();
}
toggleConfirmFilePassword() {
this.showConfirmFilePassword = !this.showConfirmFilePassword;
document.getElementById("confirmFilePassword").focus();
}
adjustValidators() { adjustValidators() {
this.exportForm.get("confirmFilePassword").reset(); this.exportForm.get("confirmFilePassword").reset();
this.exportForm.get("filePassword").reset(); this.exportForm.get("filePassword").reset();

View File

@ -27,6 +27,7 @@ const template = `
<bit-form-field> <bit-form-field>
<bit-label>Email</bit-label> <bit-label>Email</bit-label>
<input bitInput formControlName="email" /> <input bitInput formControlName="email" />
<button type="button" bitSuffix bitIconButton="bwi-refresh" bitFormButton [bitAction]="refresh"></button>
</bit-form-field> </bit-form-field>
<button class="tw-mr-2" type="submit" buttonType="primary" bitButton bitFormButton>Submit</button> <button class="tw-mr-2" type="submit" buttonType="primary" bitButton bitFormButton>Submit</button>
@ -47,6 +48,12 @@ class PromiseExampleComponent {
constructor(private formBuilder: FormBuilder) {} constructor(private formBuilder: FormBuilder) {}
refresh = async () => {
await new Promise<void>((resolve, reject) => {
setTimeout(resolve, 2000);
});
};
submit = async () => { submit = async () => {
this.formObj.markAllAsTouched(); this.formObj.markAllAsTouched();
@ -78,6 +85,10 @@ class ObservableExampleComponent {
constructor(private formBuilder: FormBuilder) {} constructor(private formBuilder: FormBuilder) {}
refresh = () => {
return of("fake observable").pipe(delay(2000));
};
submit = () => { submit = () => {
this.formObj.markAllAsTouched(); this.formObj.markAllAsTouched();

View File

@ -1,6 +1,5 @@
<span class="tw-relative"> <span class="tw-relative">
<span [ngClass]="{ 'tw-invisible': loading }"> <span [ngClass]="{ 'tw-invisible': loading }">
<i class="bwi bwi-lg" [ngClass]="iconClass" aria-hidden="true" *ngIf="icon"></i>
<ng-content></ng-content> <ng-content></ng-content>
</span> </span>
<span <span

View File

@ -41,6 +41,19 @@ describe("Button", () => {
expect(buttonDebugElement.nativeElement.classList.contains("tw-border-danger-500")).toBe(true); expect(buttonDebugElement.nativeElement.classList.contains("tw-border-danger-500")).toBe(true);
expect(linkDebugElement.nativeElement.classList.contains("tw-border-danger-500")).toBe(true); expect(linkDebugElement.nativeElement.classList.contains("tw-border-danger-500")).toBe(true);
testAppComponent.buttonType = "unstyled";
fixture.detectChanges();
expect(
Array.from(buttonDebugElement.nativeElement.classList).some((klass: string) =>
klass.startsWith("tw-bg")
)
).toBe(false);
expect(
Array.from(linkDebugElement.nativeElement.classList).some((klass: string) =>
klass.startsWith("tw-bg")
)
).toBe(false);
testAppComponent.buttonType = null; testAppComponent.buttonType = null;
fixture.detectChanges(); fixture.detectChanges();
expect(buttonDebugElement.nativeElement.classList.contains("tw-border-text-muted")).toBe(true); expect(buttonDebugElement.nativeElement.classList.contains("tw-border-text-muted")).toBe(true);

View File

@ -1,10 +1,15 @@
import { Input, HostBinding, Component } from "@angular/core"; import { Input, HostBinding, Component } from "@angular/core";
import { ButtonLikeAbstraction } from "../shared/button-like.abstraction"; import { ButtonLikeAbstraction, ButtonType } from "../shared/button-like.abstraction";
export type ButtonTypes = "primary" | "secondary" | "danger"; const focusRing = [
"focus-visible:tw-ring",
"focus-visible:tw-ring-offset-2",
"focus-visible:tw-ring-primary-700",
"focus-visible:tw-z-10",
];
const buttonStyles: Record<ButtonTypes, string[]> = { const buttonStyles: Record<ButtonType, string[]> = {
primary: [ primary: [
"tw-border-primary-500", "tw-border-primary-500",
"tw-bg-primary-500", "tw-bg-primary-500",
@ -15,6 +20,7 @@ const buttonStyles: Record<ButtonTypes, string[]> = {
"disabled:tw-border-primary-500/60", "disabled:tw-border-primary-500/60",
"disabled:!tw-text-contrast/60", "disabled:!tw-text-contrast/60",
"disabled:tw-bg-clip-padding", "disabled:tw-bg-clip-padding",
...focusRing,
], ],
secondary: [ secondary: [
"tw-bg-transparent", "tw-bg-transparent",
@ -26,6 +32,7 @@ const buttonStyles: Record<ButtonTypes, string[]> = {
"disabled:tw-bg-transparent", "disabled:tw-bg-transparent",
"disabled:tw-border-text-muted/60", "disabled:tw-border-text-muted/60",
"disabled:!tw-text-muted/60", "disabled:!tw-text-muted/60",
...focusRing,
], ],
danger: [ danger: [
"tw-bg-transparent", "tw-bg-transparent",
@ -37,7 +44,9 @@ const buttonStyles: Record<ButtonTypes, string[]> = {
"disabled:tw-bg-transparent", "disabled:tw-bg-transparent",
"disabled:tw-border-danger-500/60", "disabled:tw-border-danger-500/60",
"disabled:!tw-text-danger/60", "disabled:!tw-text-danger/60",
...focusRing,
], ],
unstyled: [],
}; };
@Component({ @Component({
@ -58,10 +67,6 @@ export class ButtonComponent implements ButtonLikeAbstraction {
"tw-text-center", "tw-text-center",
"hover:tw-no-underline", "hover:tw-no-underline",
"focus:tw-outline-none", "focus:tw-outline-none",
"focus-visible:tw-ring",
"focus-visible:tw-ring-offset-2",
"focus-visible:tw-ring-primary-700",
"focus-visible:tw-z-10",
] ]
.concat( .concat(
this.block == null || this.block === false ? ["tw-inline-block"] : ["tw-w-full", "tw-block"] this.block == null || this.block === false ? ["tw-inline-block"] : ["tw-w-full", "tw-block"]
@ -75,17 +80,14 @@ export class ButtonComponent implements ButtonLikeAbstraction {
return disabled || this.loading ? true : null; return disabled || this.loading ? true : null;
} }
@Input() buttonType: ButtonTypes = null; @Input() buttonType: ButtonType;
@Input() block?: boolean; @Input() block?: boolean;
@Input() loading = false; @Input() loading = false;
@Input() disabled = false; @Input() disabled = false;
@Input("bitIconButton") icon: string; setButtonType(value: "primary" | "secondary" | "danger" | "unstyled") {
this.buttonType = value;
get iconClass() {
return [this.icon, "!tw-m-0"];
} }
} }

View File

@ -101,17 +101,3 @@ export const Block = BlockTemplate.bind({});
Block.args = { Block.args = {
block: true, block: true,
}; };
const IconTemplate: Story = (args) => ({
props: args,
template: `
<button bitButton [bitIconButton]="icon" buttonType="primary" class="tw-mr-2"></button>
<button bitButton [bitIconButton]="icon"buttonType="secondary" class="tw-mr-2"></button>
<button bitButton [bitIconButton]="icon" buttonType="danger" class="tw-mr-2"></button>
`,
});
export const Icon = IconTemplate.bind({});
Icon.args = {
icon: "bwi-eye",
};

View File

@ -11,8 +11,10 @@ import { Meta, moduleMetadata, Story } from "@storybook/angular";
import { I18nService } from "@bitwarden/common/abstractions/i18n.service"; import { I18nService } from "@bitwarden/common/abstractions/i18n.service";
import { AsyncActionsModule } from "../async-actions";
import { ButtonModule } from "../button"; import { ButtonModule } from "../button";
import { CheckboxModule } from "../checkbox"; import { CheckboxModule } from "../checkbox";
import { IconButtonModule } from "../icon-button";
import { InputModule } from "../input/input.module"; import { InputModule } from "../input/input.module";
import { RadioButtonModule } from "../radio-button"; import { RadioButtonModule } from "../radio-button";
import { I18nMockService } from "../utils/i18n-mock.service"; import { I18nMockService } from "../utils/i18n-mock.service";
@ -31,6 +33,8 @@ export default {
FormFieldModule, FormFieldModule,
InputModule, InputModule,
ButtonModule, ButtonModule,
IconButtonModule,
AsyncActionsModule,
CheckboxModule, CheckboxModule,
RadioButtonModule, RadioButtonModule,
], ],
@ -177,10 +181,13 @@ const ButtonGroupTemplate: Story<BitFormFieldComponent> = (args: BitFormFieldCom
props: args, props: args,
template: ` template: `
<bit-form-field> <bit-form-field>
<bit-label>Label</bit-label> <button bitPrefix bitIconButton="bwi-star"></button>
<input bitInput placeholder="Placeholder" type="password" /> <input bitInput placeholder="Placeholder" />
<button bitSuffix bitButton bitIconButton="bwi-eye"></button> <button bitSuffix bitIconButton="bwi-eye"></button>
<button bitSuffix bitButton bitIconButton="bwi-clone"></button> <button bitSuffix bitIconButton="bwi-clone"></button>
<button bitSuffix bitButton>
Apply
</button>
</bit-form-field> </bit-form-field>
`, `,
}); });
@ -195,9 +202,13 @@ const DisabledButtonInputGroupTemplate: Story<BitFormFieldComponent> = (
template: ` template: `
<bit-form-field> <bit-form-field>
<bit-label>Label</bit-label> <bit-label>Label</bit-label>
<button bitPrefix bitIconButton="bwi-star" disabled></button>
<input bitInput placeholder="Placeholder" disabled /> <input bitInput placeholder="Placeholder" disabled />
<button bitSuffix bitButton bitIconButton="bwi-eye" disabled></button> <button bitSuffix bitIconButton="bwi-eye" disabled></button>
<button bitSuffix bitButton bitIconButton="bwi-clone"></button> <button bitSuffix bitIconButton="bwi-clone" disabled></button>
<button bitSuffix bitButton disabled>
Apply
</button>
</bit-form-field> </bit-form-field>
`, `,
}); });

View File

@ -3,13 +3,16 @@ import {
Directive, Directive,
EventEmitter, EventEmitter,
Host, Host,
HostBinding,
HostListener, HostListener,
Input, Input,
OnChanges, OnChanges,
Output, Output,
} from "@angular/core"; } from "@angular/core";
import { ButtonComponent } from "../button"; import { I18nService } from "@bitwarden/common/abstractions/i18n.service";
import { BitIconButtonComponent } from "../icon-button/icon-button.component";
import { BitFormFieldComponent } from "./form-field.component"; import { BitFormFieldComponent } from "./form-field.component";
@ -17,9 +20,18 @@ import { BitFormFieldComponent } from "./form-field.component";
selector: "[bitPasswordInputToggle]", selector: "[bitPasswordInputToggle]",
}) })
export class BitPasswordInputToggleDirective implements AfterContentInit, OnChanges { export class BitPasswordInputToggleDirective implements AfterContentInit, OnChanges {
@Input() toggled = false; /**
* Whether the input is toggled to show the password.
*/
@HostBinding("attr.aria-pressed") @Input() toggled = false;
@Output() toggledChange = new EventEmitter<boolean>(); @Output() toggledChange = new EventEmitter<boolean>();
@HostBinding("attr.title") title = this.i18nService.t("toggleVisibility");
@HostBinding("attr.aria-label") label = this.i18nService.t("toggleVisibility");
/**
* Click handler to toggle the state of the input type.
*/
@HostListener("click") onClick() { @HostListener("click") onClick() {
this.toggled = !this.toggled; this.toggled = !this.toggled;
this.toggledChange.emit(this.toggled); this.toggledChange.emit(this.toggled);
@ -29,7 +41,11 @@ export class BitPasswordInputToggleDirective implements AfterContentInit, OnChan
this.formField.input?.focus(); this.formField.input?.focus();
} }
constructor(@Host() private button: ButtonComponent, private formField: BitFormFieldComponent) {} constructor(
@Host() private button: BitIconButtonComponent,
private formField: BitFormFieldComponent,
private i18nService: I18nService
) {}
get icon() { get icon() {
return this.toggled ? "bwi-eye-slash" : "bwi-eye"; return this.toggled ? "bwi-eye-slash" : "bwi-eye";

View File

@ -2,8 +2,12 @@ import { Component, DebugElement } from "@angular/core";
import { ComponentFixture, TestBed } from "@angular/core/testing"; import { ComponentFixture, TestBed } from "@angular/core/testing";
import { By } from "@angular/platform-browser"; import { By } from "@angular/platform-browser";
import { ButtonComponent, ButtonModule } from "../button"; import { I18nService } from "@bitwarden/common/abstractions/i18n.service";
import { IconButtonModule } from "../icon-button";
import { BitIconButtonComponent } from "../icon-button/icon-button.component";
import { InputModule } from "../input/input.module"; import { InputModule } from "../input/input.module";
import { I18nMockService } from "../utils/i18n-mock.service";
import { BitFormFieldControl } from "./form-field-control"; import { BitFormFieldControl } from "./form-field-control";
import { BitFormFieldComponent } from "./form-field.component"; import { BitFormFieldComponent } from "./form-field.component";
@ -17,7 +21,7 @@ import { BitPasswordInputToggleDirective } from "./password-input-toggle.directi
<bit-form-field> <bit-form-field>
<bit-label>Password</bit-label> <bit-label>Password</bit-label>
<input bitInput type="password" /> <input bitInput type="password" />
<button type="button" bitButton bitSuffix bitPasswordInputToggle></button> <button type="button" bitIconButton bitSuffix bitPasswordInputToggle></button>
</bit-form-field> </bit-form-field>
</form> </form>
`, `,
@ -26,21 +30,22 @@ class TestFormFieldComponent {}
describe("PasswordInputToggle", () => { describe("PasswordInputToggle", () => {
let fixture: ComponentFixture<TestFormFieldComponent>; let fixture: ComponentFixture<TestFormFieldComponent>;
let button: ButtonComponent; let button: BitIconButtonComponent;
let input: BitFormFieldControl; let input: BitFormFieldControl;
let toggle: DebugElement; let toggle: DebugElement;
beforeEach(async () => { beforeEach(async () => {
await TestBed.configureTestingModule({ await TestBed.configureTestingModule({
imports: [FormFieldModule, ButtonModule, InputModule], imports: [FormFieldModule, IconButtonModule, InputModule],
declarations: [TestFormFieldComponent], declarations: [TestFormFieldComponent],
providers: [{ provide: I18nService, useValue: new I18nMockService({}) }],
}).compileComponents(); }).compileComponents();
fixture = TestBed.createComponent(TestFormFieldComponent); fixture = TestBed.createComponent(TestFormFieldComponent);
fixture.detectChanges(); fixture.detectChanges();
toggle = fixture.debugElement.query(By.directive(BitPasswordInputToggleDirective)); toggle = fixture.debugElement.query(By.directive(BitPasswordInputToggleDirective));
const buttonEl = fixture.debugElement.query(By.directive(ButtonComponent)); const buttonEl = fixture.debugElement.query(By.directive(BitIconButtonComponent));
button = buttonEl.componentInstance; button = buttonEl.componentInstance;
const formFieldEl = fixture.debugElement.query(By.directive(BitFormFieldComponent)); const formFieldEl = fixture.debugElement.query(By.directive(BitFormFieldComponent));
const formField: BitFormFieldComponent = formFieldEl.componentInstance; const formField: BitFormFieldComponent = formFieldEl.componentInstance;

View File

@ -1,8 +1,11 @@
import { FormsModule, ReactiveFormsModule } from "@angular/forms"; import { FormsModule, ReactiveFormsModule } from "@angular/forms";
import { Meta, moduleMetadata, Story } from "@storybook/angular"; import { Meta, moduleMetadata, Story } from "@storybook/angular";
import { ButtonModule } from "../button"; import { I18nService } from "@bitwarden/common/abstractions/i18n.service";
import { IconButtonModule } from "../icon-button";
import { InputModule } from "../input/input.module"; import { InputModule } from "../input/input.module";
import { I18nMockService } from "../utils/i18n-mock.service";
import { FormFieldModule } from "./form-field.module"; import { FormFieldModule } from "./form-field.module";
import { BitPasswordInputToggleDirective } from "./password-input-toggle.directive"; import { BitPasswordInputToggleDirective } from "./password-input-toggle.directive";
@ -12,7 +15,13 @@ export default {
component: BitPasswordInputToggleDirective, component: BitPasswordInputToggleDirective,
decorators: [ decorators: [
moduleMetadata({ moduleMetadata({
imports: [FormsModule, ReactiveFormsModule, FormFieldModule, InputModule, ButtonModule], imports: [FormsModule, ReactiveFormsModule, FormFieldModule, InputModule, IconButtonModule],
providers: [
{
provide: I18nService,
useValue: new I18nMockService({ toggleVisibility: "Toggle visibility" }),
},
],
}), }),
], ],
parameters: { parameters: {
@ -40,7 +49,7 @@ const Template: Story<BitPasswordInputToggleDirective> = (
<bit-form-field> <bit-form-field>
<bit-label>Password</bit-label> <bit-label>Password</bit-label>
<input bitInput type="password" /> <input bitInput type="password" />
<button type="button" bitButton bitSuffix bitPasswordInputToggle></button> <button type="button" bitIconButton bitSuffix bitPasswordInputToggle></button>
</bit-form-field> </bit-form-field>
</form> </form>
`, `,
@ -60,7 +69,7 @@ const TemplateBinding: Story<BitPasswordInputToggleDirective> = (
<bit-form-field> <bit-form-field>
<bit-label>Password</bit-label> <bit-label>Password</bit-label>
<input bitInput type="password" /> <input bitInput type="password" />
<button type="button" bitButton bitSuffix bitPasswordInputToggle [(toggled)]="toggled"></button> <button type="button" bitIconButton bitSuffix bitPasswordInputToggle [(toggled)]="toggled"></button>
</bit-form-field> </bit-form-field>
<label class="tw-text-main"> <label class="tw-text-main">

View File

@ -1,24 +1,51 @@
import { Directive, HostBinding, Input } from "@angular/core"; import { Directive, HostBinding, Input, OnInit, Optional } from "@angular/core";
import { ButtonLikeAbstraction } from "../shared/button-like.abstraction";
export const PrefixClasses = [ export const PrefixClasses = [
"tw-block",
"tw-px-3",
"tw-py-1.5",
"tw-bg-background-alt", "tw-bg-background-alt",
"tw-border", "tw-border",
"tw-border-solid", "tw-border-solid",
"tw-border-secondary-500", "tw-border-secondary-500",
"tw-text-muted", "tw-text-muted",
"tw-rounded-none", "tw-rounded-none",
"disabled:!tw-text-muted",
"disabled:tw-border-secondary-500",
]; ];
export const PrefixButtonClasses = [
"hover:tw-bg-text-muted",
"hover:tw-text-contrast",
"disabled:tw-opacity-100",
"disabled:tw-bg-secondary-100",
"disabled:hover:tw-bg-secondary-100",
"disabled:hover:tw-text-muted",
"focus-visible:tw-ring-primary-700",
"focus-visible:tw-border-primary-700",
"focus-visible:tw-ring-1",
"focus-visible:tw-ring-inset",
"focus-visible:tw-ring-primary-700",
"focus-visible:tw-z-10",
];
export const PrefixStaticContentClasses = ["tw-block", "tw-px-3", "tw-py-1.5"];
@Directive({ @Directive({
selector: "[bitPrefix]", selector: "[bitPrefix]",
}) })
export class BitPrefixDirective { export class BitPrefixDirective implements OnInit {
constructor(@Optional() private buttonComponent: ButtonLikeAbstraction) {}
@HostBinding("class") @Input() get classList() { @HostBinding("class") @Input() get classList() {
return PrefixClasses.concat(["tw-border-r-0", "first:tw-rounded-l"]); return PrefixClasses.concat([
"tw-border-r-0",
"first:tw-rounded-l",
"focus-visible:tw-border-r",
"focus-visible:tw-mr-[-1px]",
]).concat(this.buttonComponent != undefined ? PrefixButtonClasses : PrefixStaticContentClasses);
}
ngOnInit(): void {
this.buttonComponent?.setButtonType("unstyled");
} }
} }

View File

@ -1,12 +1,26 @@
import { Directive, HostBinding, Input } from "@angular/core"; import { Directive, HostBinding, Input, Optional } from "@angular/core";
import { PrefixClasses } from "./prefix.directive"; import { ButtonLikeAbstraction } from "../shared/button-like.abstraction";
import { PrefixButtonClasses, PrefixClasses, PrefixStaticContentClasses } from "./prefix.directive";
@Directive({ @Directive({
selector: "[bitSuffix]", selector: "[bitSuffix]",
}) })
export class BitSuffixDirective { export class BitSuffixDirective {
constructor(@Optional() private buttonComponent: ButtonLikeAbstraction) {}
@HostBinding("class") @Input() get classList() { @HostBinding("class") @Input() get classList() {
return PrefixClasses.concat(["tw-border-l-0", "last:tw-rounded-r"]); return PrefixClasses.concat([
"tw-border-l-0",
"last:tw-rounded-r",
"focus-visible:tw-border-l",
"focus-visible:tw-ml-[-1px]",
]).concat(this.buttonComponent != undefined ? PrefixButtonClasses : PrefixStaticContentClasses);
}
ngOnInit(): void {
this.buttonComponent?.setButtonType("unstyled");
} }
} }

View File

@ -1,8 +1,26 @@
import { Component, HostBinding, Input } from "@angular/core"; import { Component, HostBinding, Input } from "@angular/core";
import { ButtonLikeAbstraction } from "../shared/button-like.abstraction"; import { ButtonLikeAbstraction, ButtonType } from "../shared/button-like.abstraction";
export type IconButtonType = "contrast" | "main" | "muted" | "primary" | "secondary" | "danger"; export type IconButtonType = ButtonType | "contrast" | "main" | "muted";
const focusRing = [
// Workaround for box-shadow with transparent offset issue:
// 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-[3px]",
"before:tw-rounded-md",
"before:tw-transition",
"before:tw-ring",
"before:tw-ring-transparent",
"focus-visible:tw-z-10",
];
const styles: Record<IconButtonType, string[]> = { const styles: Record<IconButtonType, string[]> = {
contrast: [ contrast: [
@ -12,8 +30,10 @@ const styles: Record<IconButtonType, string[]> = {
"hover:tw-bg-transparent-hover", "hover:tw-bg-transparent-hover",
"hover:tw-border-text-contrast", "hover:tw-border-text-contrast",
"focus-visible:before:tw-ring-text-contrast", "focus-visible:before:tw-ring-text-contrast",
"disabled:tw-opacity-60",
"disabled:hover:tw-border-transparent", "disabled:hover:tw-border-transparent",
"disabled:hover:tw-bg-transparent", "disabled:hover:tw-bg-transparent",
...focusRing,
], ],
main: [ main: [
"tw-bg-transparent", "tw-bg-transparent",
@ -22,8 +42,10 @@ const styles: Record<IconButtonType, string[]> = {
"hover:tw-bg-transparent-hover", "hover:tw-bg-transparent-hover",
"hover:tw-border-text-main", "hover:tw-border-text-main",
"focus-visible:before:tw-ring-text-main", "focus-visible:before:tw-ring-text-main",
"disabled:tw-opacity-60",
"disabled:hover:tw-border-transparent", "disabled:hover:tw-border-transparent",
"disabled:hover:tw-bg-transparent", "disabled:hover:tw-bg-transparent",
...focusRing,
], ],
muted: [ muted: [
"tw-bg-transparent", "tw-bg-transparent",
@ -32,8 +54,10 @@ const styles: Record<IconButtonType, string[]> = {
"hover:tw-bg-transparent-hover", "hover:tw-bg-transparent-hover",
"hover:tw-border-primary-700", "hover:tw-border-primary-700",
"focus-visible:before:tw-ring-primary-700", "focus-visible:before:tw-ring-primary-700",
"disabled:tw-opacity-60",
"disabled:hover:tw-border-transparent", "disabled:hover:tw-border-transparent",
"disabled:hover:tw-bg-transparent", "disabled:hover:tw-bg-transparent",
...focusRing,
], ],
primary: [ primary: [
"tw-bg-primary-500", "tw-bg-primary-500",
@ -42,8 +66,10 @@ const styles: Record<IconButtonType, string[]> = {
"hover:tw-bg-primary-700", "hover:tw-bg-primary-700",
"hover:tw-border-primary-700", "hover:tw-border-primary-700",
"focus-visible:before:tw-ring-primary-700", "focus-visible:before:tw-ring-primary-700",
"disabled:tw-opacity-60",
"disabled:hover:tw-border-primary-500", "disabled:hover:tw-border-primary-500",
"disabled:hover:tw-bg-primary-500", "disabled:hover:tw-bg-primary-500",
...focusRing,
], ],
secondary: [ secondary: [
"tw-bg-transparent", "tw-bg-transparent",
@ -52,10 +78,12 @@ const styles: Record<IconButtonType, string[]> = {
"hover:!tw-text-contrast", "hover:!tw-text-contrast",
"hover:tw-bg-text-muted", "hover:tw-bg-text-muted",
"focus-visible:before:tw-ring-primary-700", "focus-visible:before:tw-ring-primary-700",
"disabled:tw-opacity-60",
"disabled:hover:tw-border-text-muted", "disabled:hover:tw-border-text-muted",
"disabled:hover:tw-bg-transparent", "disabled:hover:tw-bg-transparent",
"disabled:hover:!tw-text-muted", "disabled:hover:!tw-text-muted",
"disabled:hover:tw-border-text-muted", "disabled:hover:tw-border-text-muted",
...focusRing,
], ],
danger: [ danger: [
"tw-bg-transparent", "tw-bg-transparent",
@ -64,11 +92,14 @@ const styles: Record<IconButtonType, string[]> = {
"hover:!tw-text-contrast", "hover:!tw-text-contrast",
"hover:tw-bg-danger-500", "hover:tw-bg-danger-500",
"focus-visible:before:tw-ring-primary-700", "focus-visible:before:tw-ring-primary-700",
"disabled:tw-opacity-60",
"disabled:hover:tw-border-danger-500", "disabled:hover:tw-border-danger-500",
"disabled:hover:tw-bg-transparent", "disabled:hover:tw-bg-transparent",
"disabled:hover:!tw-text-danger", "disabled:hover:!tw-text-danger",
"disabled:hover:tw-border-danger-500", "disabled:hover:tw-border-danger-500",
...focusRing,
], ],
unstyled: [],
}; };
export type IconButtonSize = "default" | "small"; export type IconButtonSize = "default" | "small";
@ -86,7 +117,7 @@ const sizes: Record<IconButtonSize, string[]> = {
export class BitIconButtonComponent implements ButtonLikeAbstraction { export class BitIconButtonComponent implements ButtonLikeAbstraction {
@Input("bitIconButton") icon: string; @Input("bitIconButton") icon: string;
@Input() buttonType: IconButtonType = "main"; @Input() buttonType: IconButtonType;
@Input() size: IconButtonSize = "default"; @Input() size: IconButtonSize = "default";
@ -98,27 +129,9 @@ export class BitIconButtonComponent implements ButtonLikeAbstraction {
"tw-rounded", "tw-rounded",
"tw-transition", "tw-transition",
"hover:tw-no-underline", "hover:tw-no-underline",
"disabled:tw-opacity-60",
"focus:tw-outline-none", "focus:tw-outline-none",
// Workaround for box-shadow with transparent offset issue:
// 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-[3px]",
"before:tw-rounded-md",
"before:tw-transition",
"before:tw-ring",
"before:tw-ring-transparent",
"focus-visible:before:tw-ring-text-contrast",
"focus-visible:tw-z-10",
] ]
.concat(styles[this.buttonType]) .concat(styles[this.buttonType ?? "main"])
.concat(sizes[this.size]); .concat(sizes[this.size]);
} }
@ -134,4 +147,8 @@ export class BitIconButtonComponent implements ButtonLikeAbstraction {
@Input() loading = false; @Input() loading = false;
@Input() disabled = false; @Input() disabled = false;
setButtonType(value: "primary" | "secondary" | "danger" | "unstyled") {
this.buttonType = value;
}
} }

View File

@ -31,6 +31,7 @@ export class BitInputDirective implements BitFormFieldControl {
"focus:tw-outline-none", "focus:tw-outline-none",
"focus:tw-border-primary-700", "focus:tw-border-primary-700",
"focus:tw-ring-1", "focus:tw-ring-1",
"focus:tw-ring-inset",
"focus:tw-ring-primary-700", "focus:tw-ring-primary-700",
"focus:tw-z-10", "focus:tw-z-10",
"disabled:tw-bg-secondary-100", "disabled:tw-bg-secondary-100",

View File

@ -1,4 +1,7 @@
export type ButtonType = "primary" | "secondary" | "danger" | "unstyled";
export abstract class ButtonLikeAbstraction { export abstract class ButtonLikeAbstraction {
loading: boolean; loading: boolean;
disabled: boolean; disabled: boolean;
setButtonType: (value: ButtonType) => void;
} }