diff --git a/playwright/cps-accessibility.spec.ts b/playwright/cps-accessibility.spec.ts index a91e14a5..9909d645 100644 --- a/playwright/cps-accessibility.spec.ts +++ b/playwright/cps-accessibility.spec.ts @@ -106,7 +106,7 @@ const components: ComponentEntry[] = [ { route: '/file-upload', name: 'File upload', selector: 'cps-file-upload' }, // { route: '/icon', name: 'Icon', selector: 'cps-icon' }, { route: '/info-circle', name: 'Info circle', selector: 'cps-info-circle' }, - // { route: '/input', name: 'Input', selector: 'cps-input' }, + { route: '/input', name: 'Input', selector: '.example-content cps-input' }, // { route: '/loader', name: 'Loader', selector: 'cps-loader' }, { route: '/menu', diff --git a/projects/composition/src/app/api-data/cps-datepicker.json b/projects/composition/src/app/api-data/cps-datepicker.json index 96902e76..62e0c1d8 100644 --- a/projects/composition/src/app/api-data/cps-datepicker.json +++ b/projects/composition/src/app/api-data/cps-datepicker.json @@ -13,6 +13,14 @@ "default": "", "description": "Label of the datepicker element." }, + { + "name": "ariaLabel", + "optional": false, + "readonly": false, + "type": "string", + "default": "", + "description": "Aria label for the datepicker component, used for accessibility, it takes precedence over label." + }, { "name": "disabled", "optional": false, diff --git a/projects/composition/src/app/api-data/cps-input.json b/projects/composition/src/app/api-data/cps-input.json index e6b579b3..71b3220b 100644 --- a/projects/composition/src/app/api-data/cps-input.json +++ b/projects/composition/src/app/api-data/cps-input.json @@ -13,6 +13,14 @@ "default": "", "description": "Label of the input element." }, + { + "name": "ariaLabel", + "optional": false, + "readonly": false, + "type": "string", + "default": "", + "description": "Aria label for the input component, used for accessibility, it takes precedence over label." + }, { "name": "hint", "optional": false, @@ -98,9 +106,17 @@ "optional": false, "readonly": false, "type": "iconSizeType", - "default": "18px", + "default": "1.125rem", "description": "Size of icon before input value." }, + { + "name": "prefixIconAriaLabel", + "optional": false, + "readonly": false, + "type": "string", + "default": "", + "description": "Aria label for the clickable prefix icon, required when prefixIconClickable is true." + }, { "name": "prefixText", "optional": false, diff --git a/projects/composition/src/app/components/navigation-sidebar/navigation-sidebar.component.html b/projects/composition/src/app/components/navigation-sidebar/navigation-sidebar.component.html index 04a53cb2..4fc848e4 100644 --- a/projects/composition/src/app/components/navigation-sidebar/navigation-sidebar.component.html +++ b/projects/composition/src/app/components/navigation-sidebar/navigation-sidebar.component.html @@ -13,6 +13,7 @@

Styles

Components

> {{ syncVal }}
+
@if (label) {
- + @if (infoTooltip) { + [class.underlined]="appearance === 'underlined'" + [class.keyboard-focused]="isKeyboardFocused"> + @if (prefixIcon || prefixText) { +
+ @if (prefixIcon) { + + + + + } + + @if (prefixText) { + + {{ prefixText }} + + } +
+ } + @if (!valueToDisplay) { } @if (valueToDisplay) { + [style.width]="cvtWidth" /> } -
- @if (prefixIcon) { - - - - - } - - @if (prefixText) { - - {{ prefixText }} - - } -
- @if (!disabled && !readonly) {
@if (clearable) { - + (mousedown)="$event.preventDefault()" + (click)="onClear()" + (keydown.enter)="onClear()" + (keydown.space)="$event.preventDefault(); onClear()"> + } @if (type === 'password') { - + [class.password-show-btn-active]="currentType === 'text'" + role="button" + tabindex="0" + [attr.aria-label]=" + currentType === 'password' ? 'Show password' : 'Hide password' + " + [attr.aria-pressed]="currentType !== 'password'" + (mousedown)="$event.preventDefault()" + (click)="togglePassword()" + (keydown.enter)="togglePassword()" + (keydown.space)="$event.preventDefault(); togglePassword()"> + }
@@ -121,7 +151,7 @@
} @if (error && !hideDetails) { -
+ } diff --git a/projects/cps-ui-kit/src/lib/components/cps-input/cps-input.component.scss b/projects/cps-ui-kit/src/lib/components/cps-input/cps-input.component.scss index 17316443..b820a212 100644 --- a/projects/cps-ui-kit/src/lib/components/cps-input/cps-input.component.scss +++ b/projects/cps-ui-kit/src/lib/components/cps-input/cps-input.component.scss @@ -1,3 +1,5 @@ +@use '../../../../styles/mixins' as *; + $color-calm: var(--cps-color-calm); $input-hint-color: var(--cps-color-text-mild); $input-label-disabled-color: var(--cps-color-text-mild); @@ -28,17 +30,48 @@ $hover-transition-duration: 0.2s; .cps-input-wrap { position: relative; overflow: hidden; + display: flex; + align-items: stretch; + min-height: 2.375rem; + border: 0.0625rem solid $input-border-color; + border-radius: 0.25rem; + background: var(--cps-input-background); + transition-duration: $hover-transition-duration; &:hover { - input:enabled:not(:read-only) { - border: 1px solid $color-calm; + &:has(input:enabled:not(:read-only)) { + border-color: $color-calm; + } + .cps-input-action-btns { + .clear-btn { + cps-icon { + opacity: 0.5; + } + } + } + } + &:focus-within:not(:has(input:read-only)), + &.keyboard-focused:has(input:read-only) { + border-color: $color-calm; + } + &.keyboard-focused { + @include focus-ring(0.0625rem, 0, 0.25rem); + &::before, + &::after { + pointer-events: none; } } + &.keyboard-focused.underlined, + &.keyboard-focused.borderless { + @include focus-ring(0, -0.0625rem, 0.25rem); + } + + &:has(input:disabled:not([readonly])) { + background-color: $input-background-disabled; + } &-error { - input { - border-color: $color-error !important; - &:not(:focus) { - background: $error-background !important; - } + border-color: $color-error !important; + &:not(:focus-within) { + background: $error-background !important; } .cps-input-prefix-icon { color: $color-error !important; @@ -46,24 +79,20 @@ $hover-transition-duration: 0.2s; } input { - min-height: 38px; font-family: inherit; font-size: 1rem; color: $input-text-color; - background: var(--cps-input-background); + background: transparent; padding: 0.375rem 0.75rem; line-height: 1.5; - border: 1px solid $input-border-color; - transition-duration: $hover-transition-duration; + border: none; appearance: none; - border-radius: var(--cps-border-radius-small); - width: 100%; + border-radius: 0; + flex: 1; + min-width: 0; &:focus { outline: 0; } - &:focus:not(:read-only) { - border-color: $color-calm; - } &:read-only { cursor: default; } @@ -73,90 +102,114 @@ $hover-transition-duration: 0.2s; } &:disabled:not([readonly]) { color: $input-text-disabled-color; - background-color: $input-background-disabled; + background-color: transparent; pointer-events: none; } } - input:focus:not(:read-only) + .cps-input-prefix > .cps-input-prefix-icon, - input:hover:not(:read-only) + .cps-input-prefix > .cps-input-prefix-icon { + &:has(.cps-input-prefix) input { + padding-left: 0; + } + + &:has(input:focus:not(:read-only)) .cps-input-prefix-icon, + &:has(input:hover:not(:read-only)) .cps-input-prefix-icon { color: $color-calm; } - input:disabled + .cps-input-prefix > .cps-input-prefix-icon { + &:has(input:disabled) .cps-input-prefix-icon { color: $input-prefix-icon-color; } - input:focus + .cps-input-prefix + .cps-input-action-btns > .clear-btn { + input:focus ~ .cps-input-action-btns > .clear-btn { cps-icon { opacity: 0.5; } - } - - &:hover { - .cps-input-action-btns { - .clear-btn { - cps-icon { - opacity: 0.5; - } - } + &:hover cps-icon { + opacity: 1; } } .cps-input-action-btns { display: flex; position: absolute; - top: 50%; - right: 0.75rem; - margin-top: -0.5rem; + top: 0; + bottom: 0; + right: 0.5rem; + align-items: center; .clear-btn { display: flex; cursor: pointer; color: var(--cps-state-error); + padding: 0.25rem; cps-icon { opacity: 0; transition-duration: $hover-transition-duration; - &:hover { - opacity: 1 !important; + } + &:hover cps-icon { + opacity: 1; + } + &:focus { + outline: none; + } + &:focus-visible { + @include focus-ring(-0.125rem, -0.25rem, 50%); + cps-icon { + opacity: 1; } } } .password-show-btn { - margin-left: 0.5rem; + margin-top: 0.125rem; + margin-left: 0.0625rem; cursor: pointer; color: $input-pass-show-btn-color; + display: flex; + padding: 0.1875rem; &-active { color: $color-calm; } cps-icon { transition-duration: $hover-transition-duration; - &:hover { - color: $color-calm; - } + } + &:hover cps-icon { + color: $color-calm; + } + &:focus { + outline: none; + } + &:focus-visible { + @include focus-ring(-0.125rem, -0.25rem, 0.375rem); } } } - input:not(:read-only) + .cps-input-prefix:hover > .cps-input-prefix-icon { + &:has(input:not(:read-only)) + .cps-input-prefix:hover + > .cps-input-prefix-icon { color: $color-calm; } .cps-input-prefix { display: flex; - position: absolute; - height: 100%; - top: 50%; - left: 0.8rem; - transform: translate(0, -50%); + flex-shrink: 0; + padding-left: 0.5rem; &-icon { display: flex; - flex-direction: column; - justify-content: center; + align-self: center; + padding: 0.1875rem; + margin-left: 0.0625rem; + margin-right: 0.3125rem; transition-duration: $hover-transition-duration; - margin-right: 0.5rem; color: $input-prefix-icon-color; + &:focus { + outline: none; + color: $color-calm; + } + &:focus-visible { + @include focus-ring(0, -0.0625rem, 0.375rem); + } } &-text { display: flex; @@ -165,27 +218,26 @@ $hover-transition-duration: 0.2s; color: $input-prefix-text-color; cursor: default; line-height: 1.2; + padding-left: 0.25rem; + padding-right: 0.5rem; } } .cps-input-progress-bar { position: absolute; - bottom: 1px; - padding: 0 1px; + bottom: 0; display: block; } &.borderless, &.underlined { + border: none !important; + border-radius: 0; input { line-height: 1; - border: none !important; - border-radius: 0; } } &.underlined { - input { - border-bottom: 1px solid $input-border-color !important; - } + border-bottom: 0.0625rem solid $input-border-color !important; } } @@ -200,12 +252,12 @@ $hover-transition-duration: 0.2s; } .password.clearable > input { - padding-right: 3.8rem; + padding-right: 3.75rem; } .password > input, .clearable > input { - padding-right: 2.2rem; + padding-right: 2.5rem; } .cps-input-hint { @@ -233,7 +285,7 @@ $hover-transition-duration: 0.2s; font-weight: 600; .cps-input-label-info-circle { - margin-left: 8px; + margin-left: 0.5rem; } &-disabled { @@ -241,6 +293,7 @@ $hover-transition-duration: 0.2s; } } ::placeholder { + user-select: none; font-family: inherit; color: $input-placeholder-color; font-style: italic; diff --git a/projects/cps-ui-kit/src/lib/components/cps-input/cps-input.component.spec.ts b/projects/cps-ui-kit/src/lib/components/cps-input/cps-input.component.spec.ts index 292d3273..c0f437a9 100644 --- a/projects/cps-ui-kit/src/lib/components/cps-input/cps-input.component.spec.ts +++ b/projects/cps-ui-kit/src/lib/components/cps-input/cps-input.component.spec.ts @@ -1,4 +1,3 @@ -import { ElementRef } from '@angular/core'; import { ComponentFixture, TestBed } from '@angular/core/testing'; import { ReactiveFormsModule } from '@angular/forms'; import { NoopAnimationsModule } from '@angular/platform-browser/animations'; @@ -15,6 +14,7 @@ describe('CpsInputComponent', () => { fixture = TestBed.createComponent(CpsInputComponent); component = fixture.componentInstance; + fixture.componentRef.setInput('ariaLabel', 'Test input'); fixture.detectChanges(); }); @@ -380,13 +380,13 @@ describe('CpsInputComponent', () => { expect(focusSpy).toHaveBeenCalled(); }); - it('should blur input on enter key', () => { + it('should not blur input on enter key', () => { const input = fixture.nativeElement.querySelector('input'); const blurSpy = jest.spyOn(input, 'blur'); component.onInputEnterKeyDown(); - expect(blurSpy).toHaveBeenCalled(); + expect(blurSpy).not.toHaveBeenCalled(); }); it('should mark control as touched on focus', () => { @@ -434,20 +434,6 @@ describe('CpsInputComponent', () => { expect(unsubscribeSpy).toHaveBeenCalled(); }); - - it('should calculate prefix width after view init', () => { - fixture.componentRef.setInput('prefixText', 'USD'); - fixture.componentRef.setInput('prefixIcon', 'search'); - - // Mock prefixTextSpan - component.prefixTextSpan = { - nativeElement: { offsetWidth: 50 } - } as ElementRef; - - component.ngAfterViewInit(); - - expect(component.prefixWidth).toBeTruthy(); - }); }); describe('Edge Cases', () => { @@ -513,4 +499,152 @@ describe('CpsInputComponent', () => { expect(component.value).toBe('text'); }); }); + + describe('Accessibility', () => { + it('should associate label with input via for/id', () => { + fixture.componentRef.setInput('label', 'Username'); + fixture.detectChanges(); + + const label = fixture.nativeElement.querySelector('label'); + const input = fixture.nativeElement.querySelector('input'); + expect(label.getAttribute('for')).toBeTruthy(); + expect(label.getAttribute('for')).toBe(input.getAttribute('id')); + }); + + it('should set unique inputId', () => { + expect(component.inputId).toMatch(/^cps-input-/); + }); + + it('should set aria-invalid on input when error is set', () => { + fixture.componentRef.setInput('error', 'Field is required'); + fixture.detectChanges(); + + const input = fixture.nativeElement.querySelector('input'); + expect(input.getAttribute('aria-invalid')).toBe('true'); + }); + + it('should not set aria-invalid when there is no error', () => { + fixture.componentRef.setInput('error', ''); + fixture.detectChanges(); + + const input = fixture.nativeElement.querySelector('input'); + expect(input.getAttribute('aria-invalid')).toBeNull(); + }); + + it('should render error div with role="alert"', () => { + fixture.componentRef.setInput('error', 'Something went wrong'); + fixture.detectChanges(); + + const errorDiv = fixture.nativeElement.querySelector('.cps-input-error'); + expect(errorDiv).toBeTruthy(); + expect(errorDiv.getAttribute('role')).toBe('alert'); + }); + + describe('Clear button accessibility', () => { + beforeEach(() => { + fixture.componentRef.setInput('clearable', true); + component.writeValue('some value'); + fixture.detectChanges(); + }); + + it('should have role="button" on clear button', () => { + const clearBtn = fixture.nativeElement.querySelector('.clear-btn'); + expect(clearBtn.getAttribute('role')).toBe('button'); + }); + + it('should have tabindex="0" on clear button', () => { + const clearBtn = fixture.nativeElement.querySelector('.clear-btn'); + expect(clearBtn.getAttribute('tabindex')).toBe('0'); + }); + + it('should have aria-label="Clear" on clear button', () => { + const clearBtn = fixture.nativeElement.querySelector('.clear-btn'); + expect(clearBtn.getAttribute('aria-label')).toBe('Clear'); + }); + + it('should clear value on Enter keydown on clear button', () => { + const clearBtn = fixture.nativeElement.querySelector('.clear-btn'); + clearBtn.dispatchEvent( + new KeyboardEvent('keydown', { key: 'Enter', bubbles: true }) + ); + expect(component.value).toBe(''); + }); + + it('should clear value on Space keydown on clear button', () => { + const clearBtn = fixture.nativeElement.querySelector('.clear-btn'); + clearBtn.dispatchEvent( + new KeyboardEvent('keydown', { key: ' ', bubbles: true }) + ); + expect(component.value).toBe(''); + }); + }); + + describe('Password toggle button accessibility', () => { + beforeEach(() => { + component.type = 'password'; + component.ngOnInit(); + fixture.detectChanges(); + }); + + it('should have role="button" on password toggle', () => { + const toggleBtn = + fixture.nativeElement.querySelector('.password-show-btn'); + expect(toggleBtn.getAttribute('role')).toBe('button'); + }); + + it('should have tabindex="0" on password toggle', () => { + const toggleBtn = + fixture.nativeElement.querySelector('.password-show-btn'); + expect(toggleBtn.getAttribute('tabindex')).toBe('0'); + }); + + it('should have aria-label "Show password" when password is hidden', () => { + const toggleBtn = + fixture.nativeElement.querySelector('.password-show-btn'); + expect(toggleBtn.getAttribute('aria-label')).toBe('Show password'); + }); + + it('should have aria-label "Hide password" after toggling', () => { + component.togglePassword(); + fixture.detectChanges(); + + const toggleBtn = + fixture.nativeElement.querySelector('.password-show-btn'); + expect(toggleBtn.getAttribute('aria-label')).toBe('Hide password'); + }); + + it('should have aria-pressed="false" when password is hidden', () => { + const toggleBtn = + fixture.nativeElement.querySelector('.password-show-btn'); + expect(toggleBtn.getAttribute('aria-pressed')).toBe('false'); + }); + + it('should have aria-pressed="true" after toggling', () => { + component.togglePassword(); + fixture.detectChanges(); + + const toggleBtn = + fixture.nativeElement.querySelector('.password-show-btn'); + expect(toggleBtn.getAttribute('aria-pressed')).toBe('true'); + }); + + it('should toggle on Enter keydown', () => { + const toggleBtn = + fixture.nativeElement.querySelector('.password-show-btn'); + toggleBtn.dispatchEvent( + new KeyboardEvent('keydown', { key: 'Enter', bubbles: true }) + ); + expect(component.currentType).toBe('text'); + }); + + it('should toggle on Space keydown', () => { + const toggleBtn = + fixture.nativeElement.querySelector('.password-show-btn'); + toggleBtn.dispatchEvent( + new KeyboardEvent('keydown', { key: ' ', bubbles: true }) + ); + expect(component.currentType).toBe('text'); + }); + }); + }); }); diff --git a/projects/cps-ui-kit/src/lib/components/cps-input/cps-input.component.ts b/projects/cps-ui-kit/src/lib/components/cps-input/cps-input.component.ts index 77f521e0..bf3571bf 100644 --- a/projects/cps-ui-kit/src/lib/components/cps-input/cps-input.component.ts +++ b/projects/cps-ui-kit/src/lib/components/cps-input/cps-input.component.ts @@ -1,19 +1,18 @@ import { CommonModule } from '@angular/common'; import { - AfterViewInit, - ChangeDetectorRef, Component, ElementRef, EventEmitter, Input, + OnChanges, OnDestroy, OnInit, Optional, Output, Self, - ViewChild + SimpleChanges } from '@angular/core'; -import { ControlValueAccessor, NgControl } from '@angular/forms'; +import { ControlValueAccessor, NgControl, Validators } from '@angular/forms'; import { Subscription } from 'rxjs'; import { CpsTooltipPosition } from '../../directives/cps-tooltip/cps-tooltip.directive'; import { convertSize } from '../../utils/internal/size-utils'; @@ -24,6 +23,10 @@ import { } from '../cps-icon/cps-icon.component'; import { CpsInfoCircleComponent } from '../cps-info-circle/cps-info-circle.component'; import { CpsProgressLinearComponent } from '../cps-progress-linear/cps-progress-linear.component'; +import { + generateUniqueId, + getComputedLabel +} from '../../utils/internal/accessibility-utils'; /** * CpsInputAppearanceType is used to define the border of the input field. @@ -47,7 +50,7 @@ export type CpsInputAppearanceType = 'outlined' | 'underlined' | 'borderless'; styleUrls: ['./cps-input.component.scss'] }) export class CpsInputComponent - implements ControlValueAccessor, OnInit, AfterViewInit, OnDestroy + implements ControlValueAccessor, OnInit, OnChanges, OnDestroy { /** * Label of the input element. @@ -55,6 +58,12 @@ export class CpsInputComponent */ @Input() label = ''; + /** + * Aria label for the input component, used for accessibility, it takes precedence over label. + * @group Props + */ + @Input() ariaLabel = ''; + /** * Bottom hint text for the input field. * @group Props @@ -119,7 +128,13 @@ export class CpsInputComponent * Size of icon before input value. * @group Props */ - @Input() prefixIconSize: iconSizeType = '18px'; + @Input() prefixIconSize: iconSizeType = '1.125rem'; + + /** + * Aria label for the clickable prefix icon, required when prefixIconClickable is true. + * @group Props + */ + @Input() prefixIconAriaLabel = ''; /** * Text before input value. @@ -244,19 +259,18 @@ export class CpsInputComponent */ @Output() enterClicked = new EventEmitter(); - @ViewChild('prefixTextSpan') prefixTextSpan: ElementRef | undefined; - currentType = ''; - prefixWidth = ''; cvtWidth = ''; + isKeyboardFocused = false; + readonly inputId = generateUniqueId('cps-input'); + private _mouseActivated = false; private _statusChangesSubscription?: Subscription; private _value = ''; constructor( @Self() @Optional() private _control: NgControl, - public elementRef: ElementRef, - private cdRef: ChangeDetectorRef + public elementRef: ElementRef ) { if (this._control) { this._control.valueAccessor = this; @@ -274,16 +288,20 @@ export class CpsInputComponent ); } - ngAfterViewInit() { - let w = 0; - if (this.prefixText) { - w = this.prefixTextSpan?.nativeElement?.offsetWidth + 22; + ngOnChanges(changes: SimpleChanges) { + if (changes.width) { + this.cvtWidth = convertSize(this.width); + } + if (!this.label?.trim() && !this.ariaLabel?.trim()) { + console.error( + 'CpsInputComponent: unlabeled input component must have an ariaLabel for accessibility.' + ); } - if (this.prefixIcon) { - w += 38 - (this.prefixText ? 14 : 0); + if (this.prefixIconClickable && !this.prefixIconAriaLabel?.trim()) { + console.error( + 'CpsInputComponent: prefixIconClickable requires a prefixIconAriaLabel for accessibility.' + ); } - this.prefixWidth = w > 0 ? `${w}px` : ''; - this.cdRef.detectChanges(); } ngOnDestroy() { @@ -342,7 +360,7 @@ export class CpsInputComponent onTouched = () => {}; onInputEnterKeyDown() { - this.elementRef?.nativeElement?.querySelector('input')?.blur(); + this._checkErrors(); this.enterClicked.emit(); } @@ -369,9 +387,18 @@ export class CpsInputComponent this.valueChanged.emit(value); } + get computedLabel(): string | null { + return getComputedLabel({ + label: this.ariaLabel || this.label, + error: this.error, + hideDetails: this.hideDetails + }); + } + onClear() { this.clear(); this.cleared.emit(); + this.focus(); } clear() { @@ -385,21 +412,32 @@ export class CpsInputComponent // eslint-disable-next-line @typescript-eslint/no-empty-function setDisabledState(_disabled: boolean) {} + get isRequired(): boolean { + return this._control?.control?.hasValidator(Validators.required) ?? false; + } + onClickPrefixIcon() { if (!this.prefixIconClickable || this.readonly || this.disabled) return; this.prefixIconClicked.emit(); } onBlur() { + this.isKeyboardFocused = false; this._checkErrors(); this.blurred.emit(); } onFocus() { + this.isKeyboardFocused = !this._mouseActivated; + this._mouseActivated = false; this._control?.control?.markAsTouched(); this.focused.emit(); } + onInputMousedown() { + this._mouseActivated = true; + } + focus() { this.elementRef?.nativeElement?.querySelector('input')?.focus(); } diff --git a/projects/cps-ui-kit/src/lib/components/cps-scheduler/cps-scheduler.component.html b/projects/cps-ui-kit/src/lib/components/cps-scheduler/cps-scheduler.component.html index c65adac0..f76e3910 100644 --- a/projects/cps-ui-kit/src/lib/components/cps-scheduler/cps-scheduler.component.html +++ b/projects/cps-ui-kit/src/lib/components/cps-scheduler/cps-scheduler.component.html @@ -532,7 +532,7 @@