1
0
mirror of https://github.com/bitwarden/browser.git synced 2024-12-22 16:29:09 +01:00

[SM-342] Password Toggle directive (#3850)

This commit is contained in:
Oscar Hinton 2022-11-28 14:04:41 +01:00 committed by GitHub
parent cdd9c16778
commit 20eb585d2b
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
11 changed files with 301 additions and 31 deletions

View File

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

View File

@ -76,7 +76,16 @@ export class ButtonComponent implements ButtonLikeAbstraction {
}
@Input() buttonType: ButtonTypes = null;
@Input() block?: boolean;
@Input() loading = false;
@Input() disabled = false;
@Input("bitIconButton") icon: string;
get iconClass() {
return [this.icon, "!tw-m-0"];
}
}

View File

@ -101,3 +101,17 @@ export const Block = BlockTemplate.bind({});
Block.args = {
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

@ -5,4 +5,7 @@ export abstract class BitFormFieldControl {
required: boolean;
hasError: boolean;
error: [string, any];
type?: "text" | "password";
spellcheck?: boolean;
focus?: () => void;
}

View File

@ -11,30 +11,33 @@ import { BitErrorComponent } from "./error.component";
import { BitFormFieldComponent } from "./form-field.component";
import { BitHintComponent } from "./hint.component";
import { BitLabel } from "./label.directive";
import { BitPasswordInputToggleDirective } from "./password-input-toggle.directive";
import { BitPrefixDirective } from "./prefix.directive";
import { BitSuffixDirective } from "./suffix.directive";
@NgModule({
imports: [SharedModule, InputModule, MultiSelectModule],
exports: [
BitErrorComponent,
BitErrorSummary,
BitFormFieldComponent,
BitHintComponent,
BitLabel,
BitPrefixDirective,
BitSuffixDirective,
BitInputDirective,
MultiSelectComponent,
],
declarations: [
BitErrorComponent,
BitErrorSummary,
BitFormFieldComponent,
BitHintComponent,
BitLabel,
BitPasswordInputToggleDirective,
BitPrefixDirective,
BitSuffixDirective,
],
exports: [
BitErrorComponent,
BitErrorSummary,
BitFormFieldComponent,
BitHintComponent,
BitInputDirective,
BitLabel,
BitPasswordInputToggleDirective,
BitPrefixDirective,
BitSuffixDirective,
MultiSelectComponent,
],
})
export class FormFieldModule {}

View File

@ -166,13 +166,9 @@ const ButtonGroupTemplate: Story<BitFormFieldComponent> = (args: BitFormFieldCom
template: `
<bit-form-field>
<bit-label>Label</bit-label>
<input bitInput placeholder="Placeholder" />
<button bitSuffix bitButton>
<i aria-hidden="true" class="bwi bwi-lg bwi-eye"></i>
</button>
<button bitSuffix bitButton>
<i aria-hidden="true" class="bwi bwi-lg bwi-clone"></i>
</button>
<input bitInput placeholder="Placeholder" type="password" />
<button bitSuffix bitButton bitIconButton="bwi-eye"></button>
<button bitSuffix bitButton bitIconButton="bwi-clone"></button>
</bit-form-field>
`,
});
@ -188,12 +184,8 @@ const DisabledButtonInputGroupTemplate: Story<BitFormFieldComponent> = (
<bit-form-field>
<bit-label>Label</bit-label>
<input bitInput placeholder="Placeholder" disabled />
<button bitSuffix bitButton disabled>
<i aria-hidden="true" class="bwi bwi-lg bwi-eye"></i>
</button>
<button bitSuffix bitButton>
<i aria-hidden="true" class="bwi bwi-lg bwi-clone"></i>
</button>
<button bitSuffix bitButton bitIconButton="bwi-eye" disabled></button>
<button bitSuffix bitButton bitIconButton="bwi-clone"></button>
</bit-form-field>
`,
});

View File

@ -0,0 +1,54 @@
import {
AfterContentInit,
Directive,
EventEmitter,
Host,
HostListener,
Input,
OnChanges,
Output,
} from "@angular/core";
import { ButtonComponent } from "../button";
import { BitFormFieldComponent } from "./form-field.component";
@Directive({
selector: "[bitPasswordInputToggle]",
})
export class BitPasswordInputToggleDirective implements AfterContentInit, OnChanges {
@Input() toggled = false;
@Output() toggledChange = new EventEmitter<boolean>();
@HostListener("click") onClick() {
this.toggled = !this.toggled;
this.toggledChange.emit(this.toggled);
this.update();
this.formField.input?.focus();
}
constructor(@Host() private button: ButtonComponent, private formField: BitFormFieldComponent) {}
get icon() {
return this.toggled ? "bwi-eye-slash" : "bwi-eye";
}
ngOnChanges(): void {
this.update();
}
ngAfterContentInit(): void {
this.toggled = this.formField.input.type !== "password";
this.button.icon = this.icon;
}
private update() {
this.button.icon = this.icon;
if (this.formField.input?.type != null) {
this.formField.input.type = this.toggled ? "text" : "password";
this.formField.input.spellcheck = this.toggled ? false : undefined;
}
}
}

View File

@ -0,0 +1,100 @@
import { Component, DebugElement } from "@angular/core";
import { ComponentFixture, TestBed } from "@angular/core/testing";
import { By } from "@angular/platform-browser";
import { ButtonComponent, ButtonModule } from "../button";
import { InputModule } from "../input/input.module";
import { BitFormFieldControl } from "./form-field-control";
import { BitFormFieldComponent } from "./form-field.component";
import { FormFieldModule } from "./form-field.module";
import { BitPasswordInputToggleDirective } from "./password-input-toggle.directive";
@Component({
selector: "test-form-field",
template: `
<form>
<bit-form-field>
<bit-label>Password</bit-label>
<input bitInput type="password" />
<button type="button" bitButton bitSuffix bitPasswordInputToggle></button>
</bit-form-field>
</form>
`,
})
class TestFormFieldComponent {}
describe("PasswordInputToggle", () => {
let fixture: ComponentFixture<TestFormFieldComponent>;
let button: ButtonComponent;
let input: BitFormFieldControl;
let toggle: DebugElement;
beforeEach(async () => {
await TestBed.configureTestingModule({
imports: [FormFieldModule, ButtonModule, InputModule],
declarations: [TestFormFieldComponent],
}).compileComponents();
fixture = TestBed.createComponent(TestFormFieldComponent);
fixture.detectChanges();
toggle = fixture.debugElement.query(By.directive(BitPasswordInputToggleDirective));
const buttonEl = fixture.debugElement.query(By.directive(ButtonComponent));
button = buttonEl.componentInstance;
const formFieldEl = fixture.debugElement.query(By.directive(BitFormFieldComponent));
const formField: BitFormFieldComponent = formFieldEl.componentInstance;
input = formField.input;
});
describe("initial state", () => {
it("has correct icon", () => {
expect(button.icon).toBe("bwi-eye");
});
it("input is type password", () => {
expect(input.type).toBe("password");
});
it("spellcheck is disabled", () => {
expect(input.spellcheck).toBe(undefined);
});
});
describe("when toggled", () => {
beforeEach(() => {
toggle.triggerEventHandler("click");
});
it("has correct icon", () => {
expect(button.icon).toBe("bwi-eye-slash");
});
it("input is type text", () => {
expect(input.type).toBe("text");
});
it("spellcheck is disabled", () => {
expect(input.spellcheck).toBe(false);
});
});
describe("when toggled twice", () => {
beforeEach(() => {
toggle.triggerEventHandler("click");
toggle.triggerEventHandler("click");
});
it("has correct icon", () => {
expect(button.icon).toBe("bwi-eye");
});
it("input is type password", () => {
expect(input.type).toBe("password");
});
it("spellcheck is disabled", () => {
expect(input.spellcheck).toBe(undefined);
});
});
});

View File

@ -0,0 +1,77 @@
import { FormsModule, ReactiveFormsModule } from "@angular/forms";
import { Meta, moduleMetadata, Story } from "@storybook/angular";
import { ButtonModule } from "../button";
import { InputModule } from "../input/input.module";
import { FormFieldModule } from "./form-field.module";
import { BitPasswordInputToggleDirective } from "./password-input-toggle.directive";
export default {
title: "Component Library/Form/Password Toggle",
component: BitPasswordInputToggleDirective,
decorators: [
moduleMetadata({
imports: [FormsModule, ReactiveFormsModule, FormFieldModule, InputModule, ButtonModule],
}),
],
parameters: {
design: {
type: "figma",
url: "https://www.figma.com/file/f32LSg3jaegICkMu7rPARm/Tailwind-Component-Library-Update?node-id=1881%3A17689",
},
docs: {
description: {
component:
"Directive for toggling the visibility of a password input. Works by either having living inside a `bit-form-field` or by using the `toggled` two-way binding.",
},
},
},
} as Meta;
const Template: Story<BitPasswordInputToggleDirective> = (
args: BitPasswordInputToggleDirective
) => ({
props: {
...args,
},
template: `
<form>
<bit-form-field>
<bit-label>Password</bit-label>
<input bitInput type="password" />
<button type="button" bitButton bitSuffix bitPasswordInputToggle></button>
</bit-form-field>
</form>
`,
});
export const Default = Template.bind({});
Default.props = {};
const TemplateBinding: Story<BitPasswordInputToggleDirective> = (
args: BitPasswordInputToggleDirective
) => ({
props: {
...args,
},
template: `
<form>
<bit-form-field>
<bit-label>Password</bit-label>
<input bitInput type="password" />
<button type="button" bitButton bitSuffix bitPasswordInputToggle [(toggled)]="toggled"></button>
</bit-form-field>
<label class="tw-text-main">
Checked:
<input type="checkbox" [(ngModel)]="toggled" [ngModelOptions]="{standalone: true}" />
</label>
</form>
`,
});
export const Binding = TemplateBinding.bind({});
Binding.props = {
toggled: false,
};

View File

@ -79,7 +79,7 @@ const sizes: Record<IconButtonSize, string[]> = {
};
@Component({
selector: "button[bitIconButton]",
selector: "button[bitIconButton]:not(button[bitButton])",
templateUrl: "icon-button.component.html",
providers: [{ provide: ButtonLikeAbstraction, useExisting: BitIconButtonComponent }],
})

View File

@ -1,4 +1,4 @@
import { Directive, HostBinding, Input, Optional, Self } from "@angular/core";
import { Directive, ElementRef, HostBinding, Input, NgZone, Optional, Self } from "@angular/core";
import { NgControl, Validators } from "@angular/forms";
import { BitFormFieldControl } from "../form-field/form-field-control";
@ -41,14 +41,14 @@ export class BitInputDirective implements BitFormFieldControl {
@HostBinding("attr.aria-describedby") ariaDescribedBy: string;
get labelForId(): string {
return this.id;
}
@HostBinding("attr.aria-invalid") get ariaInvalid() {
return this.hasError ? true : undefined;
}
@HostBinding("attr.type") @Input() type?: "text" | "password";
@HostBinding("attr.spellcheck") @Input() spellcheck?: boolean;
@HostBinding()
@Input()
get required() {
@ -62,6 +62,10 @@ export class BitInputDirective implements BitFormFieldControl {
@Input() hasPrefix = false;
@Input() hasSuffix = false;
get labelForId(): string {
return this.id;
}
get hasError() {
return this.ngControl?.status === "INVALID" && this.ngControl?.touched;
}
@ -70,5 +74,18 @@ export class BitInputDirective implements BitFormFieldControl {
const key = Object.keys(this.ngControl.errors)[0];
return [key, this.ngControl.errors[key]];
}
constructor(@Optional() @Self() private ngControl: NgControl) {}
constructor(
@Optional() @Self() private ngControl: NgControl,
private ngZone: NgZone,
private elementRef: ElementRef<HTMLInputElement>
) {}
focus() {
this.ngZone.runOutsideAngular(() => {
const end = this.elementRef.nativeElement.value.length;
this.elementRef.nativeElement.setSelectionRange(end, end);
this.elementRef.nativeElement.focus();
});
}
}