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:
parent
cdd9c16778
commit
20eb585d2b
@ -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
|
||||
|
@ -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"];
|
||||
}
|
||||
}
|
||||
|
@ -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",
|
||||
};
|
||||
|
@ -5,4 +5,7 @@ export abstract class BitFormFieldControl {
|
||||
required: boolean;
|
||||
hasError: boolean;
|
||||
error: [string, any];
|
||||
type?: "text" | "password";
|
||||
spellcheck?: boolean;
|
||||
focus?: () => void;
|
||||
}
|
||||
|
@ -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 {}
|
||||
|
@ -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>
|
||||
`,
|
||||
});
|
||||
|
@ -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;
|
||||
}
|
||||
}
|
||||
}
|
100
libs/components/src/form-field/password-input-toggle.spec.ts
Normal file
100
libs/components/src/form-field/password-input-toggle.spec.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
});
|
@ -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,
|
||||
};
|
@ -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 }],
|
||||
})
|
||||
|
@ -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();
|
||||
});
|
||||
}
|
||||
}
|
||||
|
Loading…
Reference in New Issue
Block a user