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 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
|
||||||
|
@ -76,7 +76,16 @@ export class ButtonComponent implements ButtonLikeAbstraction {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@Input() buttonType: ButtonTypes = null;
|
@Input() buttonType: ButtonTypes = null;
|
||||||
|
|
||||||
@Input() block?: boolean;
|
@Input() block?: boolean;
|
||||||
|
|
||||||
@Input() loading = false;
|
@Input() loading = false;
|
||||||
|
|
||||||
@Input() disabled = 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.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",
|
||||||
|
};
|
||||||
|
@ -5,4 +5,7 @@ export abstract class BitFormFieldControl {
|
|||||||
required: boolean;
|
required: boolean;
|
||||||
hasError: boolean;
|
hasError: boolean;
|
||||||
error: [string, any];
|
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 { BitFormFieldComponent } from "./form-field.component";
|
||||||
import { BitHintComponent } from "./hint.component";
|
import { BitHintComponent } from "./hint.component";
|
||||||
import { BitLabel } from "./label.directive";
|
import { BitLabel } from "./label.directive";
|
||||||
|
import { BitPasswordInputToggleDirective } from "./password-input-toggle.directive";
|
||||||
import { BitPrefixDirective } from "./prefix.directive";
|
import { BitPrefixDirective } from "./prefix.directive";
|
||||||
import { BitSuffixDirective } from "./suffix.directive";
|
import { BitSuffixDirective } from "./suffix.directive";
|
||||||
|
|
||||||
@NgModule({
|
@NgModule({
|
||||||
imports: [SharedModule, InputModule, MultiSelectModule],
|
imports: [SharedModule, InputModule, MultiSelectModule],
|
||||||
exports: [
|
|
||||||
BitErrorComponent,
|
|
||||||
BitErrorSummary,
|
|
||||||
BitFormFieldComponent,
|
|
||||||
BitHintComponent,
|
|
||||||
BitLabel,
|
|
||||||
BitPrefixDirective,
|
|
||||||
BitSuffixDirective,
|
|
||||||
BitInputDirective,
|
|
||||||
MultiSelectComponent,
|
|
||||||
],
|
|
||||||
declarations: [
|
declarations: [
|
||||||
BitErrorComponent,
|
BitErrorComponent,
|
||||||
BitErrorSummary,
|
BitErrorSummary,
|
||||||
BitFormFieldComponent,
|
BitFormFieldComponent,
|
||||||
BitHintComponent,
|
BitHintComponent,
|
||||||
BitLabel,
|
BitLabel,
|
||||||
|
BitPasswordInputToggleDirective,
|
||||||
BitPrefixDirective,
|
BitPrefixDirective,
|
||||||
BitSuffixDirective,
|
BitSuffixDirective,
|
||||||
],
|
],
|
||||||
|
exports: [
|
||||||
|
BitErrorComponent,
|
||||||
|
BitErrorSummary,
|
||||||
|
BitFormFieldComponent,
|
||||||
|
BitHintComponent,
|
||||||
|
BitInputDirective,
|
||||||
|
BitLabel,
|
||||||
|
BitPasswordInputToggleDirective,
|
||||||
|
BitPrefixDirective,
|
||||||
|
BitSuffixDirective,
|
||||||
|
MultiSelectComponent,
|
||||||
|
],
|
||||||
})
|
})
|
||||||
export class FormFieldModule {}
|
export class FormFieldModule {}
|
||||||
|
@ -166,13 +166,9 @@ const ButtonGroupTemplate: Story<BitFormFieldComponent> = (args: BitFormFieldCom
|
|||||||
template: `
|
template: `
|
||||||
<bit-form-field>
|
<bit-form-field>
|
||||||
<bit-label>Label</bit-label>
|
<bit-label>Label</bit-label>
|
||||||
<input bitInput placeholder="Placeholder" />
|
<input bitInput placeholder="Placeholder" type="password" />
|
||||||
<button bitSuffix bitButton>
|
<button bitSuffix bitButton bitIconButton="bwi-eye"></button>
|
||||||
<i aria-hidden="true" class="bwi bwi-lg bwi-eye"></i>
|
<button bitSuffix bitButton bitIconButton="bwi-clone"></button>
|
||||||
</button>
|
|
||||||
<button bitSuffix bitButton>
|
|
||||||
<i aria-hidden="true" class="bwi bwi-lg bwi-clone"></i>
|
|
||||||
</button>
|
|
||||||
</bit-form-field>
|
</bit-form-field>
|
||||||
`,
|
`,
|
||||||
});
|
});
|
||||||
@ -188,12 +184,8 @@ const DisabledButtonInputGroupTemplate: Story<BitFormFieldComponent> = (
|
|||||||
<bit-form-field>
|
<bit-form-field>
|
||||||
<bit-label>Label</bit-label>
|
<bit-label>Label</bit-label>
|
||||||
<input bitInput placeholder="Placeholder" disabled />
|
<input bitInput placeholder="Placeholder" disabled />
|
||||||
<button bitSuffix bitButton disabled>
|
<button bitSuffix bitButton bitIconButton="bwi-eye" disabled></button>
|
||||||
<i aria-hidden="true" class="bwi bwi-lg bwi-eye"></i>
|
<button bitSuffix bitButton bitIconButton="bwi-clone"></button>
|
||||||
</button>
|
|
||||||
<button bitSuffix bitButton>
|
|
||||||
<i aria-hidden="true" class="bwi bwi-lg bwi-clone"></i>
|
|
||||||
</button>
|
|
||||||
</bit-form-field>
|
</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({
|
@Component({
|
||||||
selector: "button[bitIconButton]",
|
selector: "button[bitIconButton]:not(button[bitButton])",
|
||||||
templateUrl: "icon-button.component.html",
|
templateUrl: "icon-button.component.html",
|
||||||
providers: [{ provide: ButtonLikeAbstraction, useExisting: BitIconButtonComponent }],
|
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 { NgControl, Validators } from "@angular/forms";
|
||||||
|
|
||||||
import { BitFormFieldControl } from "../form-field/form-field-control";
|
import { BitFormFieldControl } from "../form-field/form-field-control";
|
||||||
@ -41,14 +41,14 @@ export class BitInputDirective implements BitFormFieldControl {
|
|||||||
|
|
||||||
@HostBinding("attr.aria-describedby") ariaDescribedBy: string;
|
@HostBinding("attr.aria-describedby") ariaDescribedBy: string;
|
||||||
|
|
||||||
get labelForId(): string {
|
|
||||||
return this.id;
|
|
||||||
}
|
|
||||||
|
|
||||||
@HostBinding("attr.aria-invalid") get ariaInvalid() {
|
@HostBinding("attr.aria-invalid") get ariaInvalid() {
|
||||||
return this.hasError ? true : undefined;
|
return this.hasError ? true : undefined;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@HostBinding("attr.type") @Input() type?: "text" | "password";
|
||||||
|
|
||||||
|
@HostBinding("attr.spellcheck") @Input() spellcheck?: boolean;
|
||||||
|
|
||||||
@HostBinding()
|
@HostBinding()
|
||||||
@Input()
|
@Input()
|
||||||
get required() {
|
get required() {
|
||||||
@ -62,6 +62,10 @@ export class BitInputDirective implements BitFormFieldControl {
|
|||||||
@Input() hasPrefix = false;
|
@Input() hasPrefix = false;
|
||||||
@Input() hasSuffix = false;
|
@Input() hasSuffix = false;
|
||||||
|
|
||||||
|
get labelForId(): string {
|
||||||
|
return this.id;
|
||||||
|
}
|
||||||
|
|
||||||
get hasError() {
|
get hasError() {
|
||||||
return this.ngControl?.status === "INVALID" && this.ngControl?.touched;
|
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];
|
const key = Object.keys(this.ngControl.errors)[0];
|
||||||
return [key, this.ngControl.errors[key]];
|
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