Skip to content

Commit

Permalink
feat(accessibility): add password visibility toggle, use dedicated pa…
Browse files Browse the repository at this point in the history
…ssword component and update error indicators (#1694)

* Add functionality to toggle password visibility for password fields.
* Replace TextInputFieldComponent with PasswordFieldComponent to provide password-specific functionality.
* Update error field indicator from "x" to "!" for improved clarity and accessibility.

BREAKING CHANGES: The TextInputFieldComponent is no longer used for password fields. Instead, the PasswordFieldComponent is now used, which includes functionality to toggle password visibility. Password validators are applied to each instance as needed.
  • Loading branch information
andreassteinmann authored and LucasHengelhaupt committed Nov 28, 2024
1 parent 3e49e6c commit 43d6d27
Show file tree
Hide file tree
Showing 19 changed files with 272 additions and 29 deletions.
3 changes: 3 additions & 0 deletions src/app/core/directives.module.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import { NgModule } from '@angular/core';

import { BrowserLazyViewDirective } from './directives/browser-lazy-view.directive';
import { ClickOutsideDirective } from './directives/click-outside.directive';
import { FocusOutsideDirective } from './directives/focus-outside.directive';
import { IdentityProviderCapabilityDirective } from './directives/identity-provider-capability.directive';
import { IntersectionObserverDirective } from './directives/intersection-observer.directive';
import { LazyLoadingContentDirective } from './directives/lazy-loading-content.directive';
Expand All @@ -14,6 +15,7 @@ import { ServerHtmlDirective } from './directives/server-html.directive';
declarations: [
BrowserLazyViewDirective,
ClickOutsideDirective,
FocusOutsideDirective,
IdentityProviderCapabilityDirective,
IntersectionObserverDirective,
LazyLoadingContentDirective,
Expand All @@ -25,6 +27,7 @@ import { ServerHtmlDirective } from './directives/server-html.directive';
exports: [
BrowserLazyViewDirective,
ClickOutsideDirective,
FocusOutsideDirective,
IdentityProviderCapabilityDirective,
IntersectionObserverDirective,
LazyLoadingContentDirective,
Expand Down
32 changes: 32 additions & 0 deletions src/app/core/directives/focus-outside.directive.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
import { Directive, ElementRef, EventEmitter, HostListener, Output } from '@angular/core';

/**
* This directive can be set on a html tag to check focus outside of the tag.
* It only works on desktop devices, NOT on touch devices.
*/
@Directive({
selector: '[ishFocusOutside]',
})
export class FocusOutsideDirective {
private isTouchDevice: boolean;

constructor(private elementRef: ElementRef) {
this.isTouchDevice = 'ontouchstart' in window || navigator.maxTouchPoints > 0;
}

/**
* Event to tell the listener, when focus moves outside the target element
*/
@Output() isFocusedOutside = new EventEmitter<boolean>();

/**
* Method to check if focus is outside of the targetElement. Emits true when focus moves outside.
*/
@HostListener('document:focusin', ['$event.target'])
onFocusIn(targetElement: ElementRef): void {
const focusedInside = this.elementRef.nativeElement.contains(targetElement);
if (!focusedInside && !this.isTouchDevice) {
this.isFocusedOutside.emit(true);
}
}
}
6 changes: 6 additions & 0 deletions src/app/core/icon.module.ts
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,9 @@ import {
faCog,
faCogs,
faEnvelope,
faExclamationTriangle,
faEye,
faEyeSlash,
faFastForward,
faFax,
faGear,
Expand Down Expand Up @@ -90,6 +93,9 @@ export class IconModule {
faGlobeAmericas,
faHeart,
faHome,
faExclamationTriangle,
faEye,
faEyeSlash,
faInbox,
faInfoCircle,
faList,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ import { FormlyFieldConfig } from '@ngx-formly/core';

import { markAsDirtyRecursive } from 'ish-shared/forms/utils/form-utils';
import { FormsService } from 'ish-shared/forms/utils/forms.service';
import { SpecialValidators } from 'ish-shared/forms/validators/special-validators';
import { SpecialValidators, formlyValidation } from 'ish-shared/forms/validators/special-validators';

import { PunchoutType, PunchoutUser } from '../../models/punchout-user/punchout-user.model';

Expand Down Expand Up @@ -90,19 +90,24 @@ export class PunchoutUserFormComponent implements OnInit {
},
hideRequiredMarker: true,
},
validators: {
password: formlyValidation('password', SpecialValidators.password),
},
},
{
key: 'passwordConfirmation',
type: 'ish-text-input-field',
type: 'ish-password-field',
props: {
type: 'password',
required: this.punchoutUser ? false : true,
label: this.punchoutUser
? 'account.punchout.password.new.confirmation.label'
: 'account.punchout.password.confirmation.label',
attributes: { autocomplete: 'new-password' },
hideRequiredMarker: true,
},
validators: {
password: formlyValidation('password', SpecialValidators.password),
},
validation: {
messages: {
required: 'account.punchout.password.confirmation.error.required',
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,7 @@ import { FormlyFieldConfig } from '@ngx-formly/core';
import { HttpError } from 'ish-core/models/http-error/http-error.model';
import { markAsDirtyRecursive } from 'ish-shared/forms/utils/form-utils';
import { FormsService } from 'ish-shared/forms/utils/forms.service';
import { SpecialValidators } from 'ish-shared/forms/validators/special-validators';
import { SpecialValidators, formlyValidation } from 'ish-shared/forms/validators/special-validators';

/**
* The Account Profile Password Page Component displays a form for changing the user's password
Expand Down Expand Up @@ -43,9 +43,8 @@ export class AccountProfilePasswordComponent implements OnInit, OnChanges {
fieldGroup: [
{
key: 'currentPassword',
type: 'ish-text-input-field',
type: 'ish-password-field',
props: {
type: 'password',
required: true,
hideRequiredMarker: true,
label: 'account.password.label',
Expand All @@ -70,16 +69,21 @@ export class AccountProfilePasswordComponent implements OnInit, OnChanges {
},
attributes: { autocomplete: 'new-password' },
},
validators: {
password: formlyValidation('password', SpecialValidators.password),
},
},
{
key: 'passwordConfirmation',
type: 'ish-text-input-field',
type: 'ish-password-field',
props: {
type: 'password',
required: true,
hideRequiredMarker: true,
label: 'account.update_password.newpassword_confirmation.label',
},
validators: {
password: formlyValidation('password', SpecialValidators.password),
},
validation: {
messages: {
required: 'account.register.password_confirmation.error.default',
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -34,7 +34,7 @@ describe('Update Password Form Component', () => {
it('should render forgot password form step 2 for password reminder', () => {
fixture.detectChanges();

expect(element.innerHTML.match(/ish-password-field/g)).toHaveLength(1);
expect(element.innerHTML.match(/ish-password-field/g)).toHaveLength(2);
expect(element.innerHTML).toContain('password');
expect(element.innerHTML).toContain('passwordConfirmation');

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ import { FormlyFieldConfig } from '@ngx-formly/core';

import { markAsDirtyRecursive } from 'ish-shared/forms/utils/form-utils';
import { FormsService } from 'ish-shared/forms/utils/forms.service';
import { SpecialValidators } from 'ish-shared/forms/validators/special-validators';
import { SpecialValidators, formlyValidation } from 'ish-shared/forms/validators/special-validators';

/**
* The Update Password Form Component displays a Forgot Password Update Password form and triggers the submit.
Expand Down Expand Up @@ -49,6 +49,9 @@ export class UpdatePasswordFormComponent implements OnInit {
args: { 0: '7' },
},
},
validators: {
password: formlyValidation('password', SpecialValidators.password),
},
validation: {
messages: {
minLength: 'account.update_password.new_password.error.length',
Expand All @@ -57,13 +60,15 @@ export class UpdatePasswordFormComponent implements OnInit {
},
{
key: 'passwordConfirmation',
type: 'ish-text-input-field',
type: 'ish-password-field',
props: {
type: 'password',
required: true,
hideRequiredMarker: true,
label: 'account.register.password_confirmation.label',
},
validators: {
password: formlyValidation('password', SpecialValidators.password),
},
validation: {
messages: {
required: 'account.register.password_confirmation.error.default',
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,7 @@ import { Customer, CustomerRegistrationType } from 'ish-core/models/customer/cus
import { User } from 'ish-core/models/user/user.model';
import { ConfirmLeaveModalComponent } from 'ish-shared/components/registration/confirm-leave-modal/confirm-leave-modal.component';
import { FieldLibrary } from 'ish-shared/formly/field-library/field-library';
import { SpecialValidators } from 'ish-shared/forms/validators/special-validators';
import { SpecialValidators, formlyValidation } from 'ish-shared/forms/validators/special-validators';

export interface RegistrationConfigType {
businessCustomer?: boolean;
Expand Down Expand Up @@ -285,16 +285,21 @@ export class RegistrationFormConfigurationService {
},
attributes: { autocomplete: 'new-password' },
},
validators: {
password: formlyValidation('password', SpecialValidators.password),
},
},
{
key: 'passwordConfirmation',
type: 'ish-text-input-field',
type: 'ish-password-field',
props: {
type: 'password',
required: true,
label: 'account.register.password_confirmation.label',
attributes: { autocomplete: 'new-password' },
},
validators: {
password: formlyValidation('password', SpecialValidators.password),
},
validation: {
messages: {
required: 'account.register.password_confirmation.error.default',
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -73,9 +73,8 @@ export class LoginFormComponent implements OnInit {
},
{
key: 'password',
type: 'ish-text-input-field',
type: 'ish-password-field',
props: {
type: 'password',
label: 'account.login.password.label',
labelClass: this.labelClass || 'col-md-3',
fieldClass: this.inputClass || 'col-md-6',
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -2,8 +2,8 @@
<fa-icon
*ngIf="showError; else noError"
class="form-control-feedback"
[icon]="['fas', 'times']"
data-testing-id="times-icon"
[icon]="['fas', 'exclamation-triangle']"
data-testing-id="exclamation-triangle-icon"
/>
<ng-template #noError>
<span class="has-success">
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
<div class="password-field-wrapper" ishFocusOutside (isFocusedOutside)="onFocusOutside($event)">
<input
[type]="showPassword ? 'text' : 'password'"
[formControl]="formControl"
[formlyAttributes]="field"
class="form-control"
[ngClass]="props.inputClass"
[attr.data-testing-id]="field.key"
[attr.aria-label]="props.ariaLabel | translate"
[attr.aria-required]="props.required ? 'true' : undefined"
[attr.aria-invalid]="showError ? 'true' : undefined"
[attr.aria-describedby]="ariaDescribedByIds"
(input)="onInput()"
/>

<button
[attr.aria-label]="
showPassword ? ('form.password.hide.button.label' | translate) : ('form.password.show.button.label' | translate)
"
class="btn btn-link text-decoration-none"
[ngClass]="{ visible: !isButtonDisabled }"
type="button"
[disabled]="isButtonDisabled"
(click)="togglePasswordVisibility()"
>
<fa-icon *ngIf="showPassword" [icon]="['fas', 'eye-slash']" />
<fa-icon *ngIf="!showPassword" [icon]="['fas', 'eye']" />
</button>
</div>
Original file line number Diff line number Diff line change
@@ -0,0 +1,70 @@
import { ComponentFixture, TestBed } from '@angular/core/testing';
import { FormGroup, ReactiveFormsModule } from '@angular/forms';
import { FaIconComponent } from '@fortawesome/angular-fontawesome';
import { FormlyFieldConfig, FormlyModule } from '@ngx-formly/core';
import { TranslateModule } from '@ngx-translate/core';
import { MockComponent } from 'ng-mocks';

import { FormlyTestingComponentsModule } from 'ish-shared/formly/dev/testing/formly-testing-components.module';
import { FormlyTestingContainerComponent } from 'ish-shared/formly/dev/testing/formly-testing-container/formly-testing-container.component';

import { PasswordFieldComponent } from './password-field.component';

describe('Password Field Component', () => {
let component: FormlyTestingContainerComponent;
let fixture: ComponentFixture<FormlyTestingContainerComponent>;
let element: HTMLElement;

beforeEach(async () => {
await TestBed.configureTestingModule({
imports: [
FormlyModule.forRoot({
types: [
{
name: 'ish-password-field',
component: PasswordFieldComponent,
},
],
}),
FormlyTestingComponentsModule,
ReactiveFormsModule,
TranslateModule.forRoot(),
],
declarations: [MockComponent(FaIconComponent), PasswordFieldComponent],
}).compileComponents();
});

beforeEach(() => {
const testComponentInputs = {
fields: [
{
key: 'password',
type: 'ish-password-field',
props: {
required: true,
},
} as FormlyFieldConfig,
],
form: new FormGroup({}),
model: {
displayValue: 'testValue',
},
};
fixture = TestBed.createComponent(FormlyTestingContainerComponent);
component = fixture.componentInstance;
element = fixture.nativeElement;

component.testComponentInputs = testComponentInputs;
});

it('should be created', () => {
expect(component).toBeTruthy();
expect(element).toBeTruthy();
expect(() => fixture.detectChanges()).not.toThrow();
});

it('should be rendered after creation', () => {
fixture.detectChanges();
expect(element.querySelector('ish-password-field')).toBeTruthy();
});
});
Loading

0 comments on commit 43d6d27

Please sign in to comment.