+
{{ error }}
}
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 @@