From 20eb585d2b003709e93dfc404d103ef87964105d Mon Sep 17 00:00:00 2001 From: Oscar Hinton Date: Mon, 28 Nov 2022 14:04:41 +0100 Subject: [PATCH] [SM-342] Password Toggle directive (#3850) --- .../src/button/button.component.html | 1 + .../components/src/button/button.component.ts | 9 ++ libs/components/src/button/button.stories.ts | 14 +++ .../src/form-field/form-field-control.ts | 3 + .../src/form-field/form-field.module.ts | 25 +++-- .../src/form-field/form-field.stories.ts | 18 +--- .../password-input-toggle.directive.ts | 54 ++++++++++ .../form-field/password-input-toggle.spec.ts | 100 ++++++++++++++++++ .../password-input-toggle.stories.ts | 77 ++++++++++++++ .../src/icon-button/icon-button.component.ts | 2 +- libs/components/src/input/input.directive.ts | 29 +++-- 11 files changed, 301 insertions(+), 31 deletions(-) create mode 100644 libs/components/src/form-field/password-input-toggle.directive.ts create mode 100644 libs/components/src/form-field/password-input-toggle.spec.ts create mode 100644 libs/components/src/form-field/password-input-toggle.stories.ts diff --git a/libs/components/src/button/button.component.html b/libs/components/src/button/button.component.html index ee4d150dfcc..836eb0f9655 100644 --- a/libs/components/src/button/button.component.html +++ b/libs/components/src/button/button.component.html @@ -1,5 +1,6 @@ + ({ + props: args, + template: ` + + + + `, +}); + +export const Icon = IconTemplate.bind({}); +Icon.args = { + icon: "bwi-eye", +}; diff --git a/libs/components/src/form-field/form-field-control.ts b/libs/components/src/form-field/form-field-control.ts index ee63407a8bd..5f57bdbbac5 100644 --- a/libs/components/src/form-field/form-field-control.ts +++ b/libs/components/src/form-field/form-field-control.ts @@ -5,4 +5,7 @@ export abstract class BitFormFieldControl { required: boolean; hasError: boolean; error: [string, any]; + type?: "text" | "password"; + spellcheck?: boolean; + focus?: () => void; } diff --git a/libs/components/src/form-field/form-field.module.ts b/libs/components/src/form-field/form-field.module.ts index ec2fe456df6..cc18cf4b77f 100644 --- a/libs/components/src/form-field/form-field.module.ts +++ b/libs/components/src/form-field/form-field.module.ts @@ -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 {} diff --git a/libs/components/src/form-field/form-field.stories.ts b/libs/components/src/form-field/form-field.stories.ts index 68d341e6922..1683f515e28 100644 --- a/libs/components/src/form-field/form-field.stories.ts +++ b/libs/components/src/form-field/form-field.stories.ts @@ -166,13 +166,9 @@ const ButtonGroupTemplate: Story = (args: BitFormFieldCom template: ` Label - - - + + + `, }); @@ -188,12 +184,8 @@ const DisabledButtonInputGroupTemplate: Story = ( Label - - + + `, }); diff --git a/libs/components/src/form-field/password-input-toggle.directive.ts b/libs/components/src/form-field/password-input-toggle.directive.ts new file mode 100644 index 00000000000..6189de636ea --- /dev/null +++ b/libs/components/src/form-field/password-input-toggle.directive.ts @@ -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(); + + @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; + } + } +} diff --git a/libs/components/src/form-field/password-input-toggle.spec.ts b/libs/components/src/form-field/password-input-toggle.spec.ts new file mode 100644 index 00000000000..5c6a9d48d00 --- /dev/null +++ b/libs/components/src/form-field/password-input-toggle.spec.ts @@ -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: ` +
+ + Password + + + +
+ `, +}) +class TestFormFieldComponent {} + +describe("PasswordInputToggle", () => { + let fixture: ComponentFixture; + 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); + }); + }); +}); diff --git a/libs/components/src/form-field/password-input-toggle.stories.ts b/libs/components/src/form-field/password-input-toggle.stories.ts new file mode 100644 index 00000000000..ff6e14c0c91 --- /dev/null +++ b/libs/components/src/form-field/password-input-toggle.stories.ts @@ -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 = ( + args: BitPasswordInputToggleDirective +) => ({ + props: { + ...args, + }, + template: ` +
+ + Password + + + +
+ `, +}); + +export const Default = Template.bind({}); +Default.props = {}; + +const TemplateBinding: Story = ( + args: BitPasswordInputToggleDirective +) => ({ + props: { + ...args, + }, + template: ` +
+ + Password + + + + + +
+ `, +}); + +export const Binding = TemplateBinding.bind({}); +Binding.props = { + toggled: false, +}; diff --git a/libs/components/src/icon-button/icon-button.component.ts b/libs/components/src/icon-button/icon-button.component.ts index ef9474bf98d..c7fe53695b6 100644 --- a/libs/components/src/icon-button/icon-button.component.ts +++ b/libs/components/src/icon-button/icon-button.component.ts @@ -79,7 +79,7 @@ const sizes: Record = { }; @Component({ - selector: "button[bitIconButton]", + selector: "button[bitIconButton]:not(button[bitButton])", templateUrl: "icon-button.component.html", providers: [{ provide: ButtonLikeAbstraction, useExisting: BitIconButtonComponent }], }) diff --git a/libs/components/src/input/input.directive.ts b/libs/components/src/input/input.directive.ts index 619b0229057..cb5c97fecc1 100644 --- a/libs/components/src/input/input.directive.ts +++ b/libs/components/src/input/input.directive.ts @@ -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 + ) {} + + focus() { + this.ngZone.runOutsideAngular(() => { + const end = this.elementRef.nativeElement.value.length; + this.elementRef.nativeElement.setSelectionRange(end, end); + this.elementRef.nativeElement.focus(); + }); + } }