diff --git a/projects/coreui-angular/src/lib/form/form-check/form-check-input.directive.spec.ts b/projects/coreui-angular/src/lib/form/form-check/form-check-input.directive.spec.ts index c26d1f1c..fc0d8e83 100644 --- a/projects/coreui-angular/src/lib/form/form-check/form-check-input.directive.spec.ts +++ b/projects/coreui-angular/src/lib/form/form-check/form-check-input.directive.spec.ts @@ -1,4 +1,4 @@ -import { inputBinding, signal, WritableSignal } from '@angular/core'; +import { Component, inputBinding, signal, WritableSignal } from '@angular/core'; import { DirectiveFixture, TestBed } from '@angular/core/testing'; import { FormCheckInputDirective } from './form-check-input.directive'; @@ -31,4 +31,48 @@ describe('FormCheckInputDirective', () => { expect(input.checked).toBe(false); expect(input.indeterminate).toBe(true); }); + + it.each(['checkbox', 'radio'] as const)('should set aria-invalid on a %s while invalid', async (type) => { + const valid = signal(false); + const check = TestBed.createDirective(FormCheckInputDirective, { + bindings: [inputBinding('type', signal(type)), inputBinding('valid', valid)] + }); + await check.whenStable(); + expect(check.nativeElement.getAttribute('aria-invalid')).toBe('true'); + valid.set(true); + await check.whenStable(); + expect(check.nativeElement.getAttribute('aria-invalid')).toBeNull(); + }); + + it('should set aria-invalid on a radio with a static type', async () => { + const host = TestBed.createComponent(TestRadioHostComponent); + await host.whenStable(); + expect(host.nativeElement.querySelector('input').getAttribute('aria-invalid')).toBe('true'); + }); + + it('should keep aria-invalid set in the template', async () => { + const host = TestBed.createComponent(TestAriaInvalidHostComponent); + await host.whenStable(); + const check: HTMLInputElement = host.nativeElement.querySelector('input'); + host.componentInstance.valid.set(false); + await host.whenStable(); + expect(check.getAttribute('aria-invalid')).toBe('true'); + host.componentInstance.valid.set(true); + await host.whenStable(); + expect(check.getAttribute('aria-invalid')).toBe('true'); + }); }); + +@Component({ + imports: [FormCheckInputDirective], + template: '' +}) +class TestAriaInvalidHostComponent { + readonly valid = signal(undefined); +} + +@Component({ + imports: [FormCheckInputDirective], + template: '' +}) +class TestRadioHostComponent {} diff --git a/projects/coreui-angular/src/lib/form/form-check/form-check-input.directive.ts b/projects/coreui-angular/src/lib/form/form-check/form-check-input.directive.ts index f32a5efd..f70204a4 100644 --- a/projects/coreui-angular/src/lib/form/form-check/form-check-input.directive.ts +++ b/projects/coreui-angular/src/lib/form/form-check/form-check-input.directive.ts @@ -10,6 +10,8 @@ import { Renderer2 } from '@angular/core'; +import { bindAriaInvalid } from '../../shared/aria-invalid.utils'; + @Directive({ selector: 'input[cFormCheckInput]', host: { @@ -52,11 +54,14 @@ export class FormCheckInputDirective { } /** - * Set component validation state to valid. + * Validation state: `true` valid, `false` invalid, which also sets `aria-invalid="true"`, `undefined` none. + * Do not bind `aria-invalid` on the same element as well. * @default undefined */ readonly valid = input(); + readonly #ariaInvalidEffect = bindAriaInvalid(this.valid); + readonly hostClasses = computed(() => { const valid = this.valid(); return { diff --git a/projects/coreui-angular/src/lib/form/form-control/form-control.directive.spec.ts b/projects/coreui-angular/src/lib/form/form-control/form-control.directive.spec.ts index b417154d..38d0b106 100644 --- a/projects/coreui-angular/src/lib/form/form-control/form-control.directive.spec.ts +++ b/projects/coreui-angular/src/lib/form/form-control/form-control.directive.spec.ts @@ -1,4 +1,4 @@ -import { inputBinding, signal, WritableSignal } from '@angular/core'; +import { Component, inputBinding, signal, WritableSignal } from '@angular/core'; import { DirectiveFixture, TestBed } from '@angular/core/testing'; import { FormControlDirective } from './form-control.directive'; @@ -46,6 +46,34 @@ describe('FormControlDirective', () => { expect(fixture.nativeElement.classList.contains('form-control-plaintext')).toBe(true); }); + it('should set aria-invalid while invalid', async () => { + expect(fixture.nativeElement.getAttribute('aria-invalid')).toBeNull(); + valid.set(false); + await fixture.whenStable(); + expect(fixture.nativeElement.getAttribute('aria-invalid')).toBe('true'); + type.set('submit'); + await fixture.whenStable(); + expect(fixture.nativeElement.getAttribute('aria-invalid')).toBeNull(); + type.set('text'); + await fixture.whenStable(); + expect(fixture.nativeElement.getAttribute('aria-invalid')).toBe('true'); + valid.set(true); + await fixture.whenStable(); + expect(fixture.nativeElement.getAttribute('aria-invalid')).toBeNull(); + }); + + it('should keep aria-invalid set in the template', async () => { + const host = TestBed.createComponent(TestAriaInvalidHostComponent); + await host.whenStable(); + const input: HTMLInputElement = host.nativeElement.querySelector('input'); + host.componentInstance.valid.set(false); + await host.whenStable(); + expect(input.getAttribute('aria-invalid')).toBe('true'); + host.componentInstance.valid.set(true); + await host.whenStable(); + expect(input.getAttribute('aria-invalid')).toBe('true'); + }); + it('should render range and color types', async () => { type.set('range'); sizing.set('sm'); @@ -66,3 +94,11 @@ describe('FormControlDirective', () => { warn.mockRestore(); }); }); + +@Component({ + imports: [FormControlDirective], + template: '' +}) +class TestAriaInvalidHostComponent { + readonly valid = signal(undefined); +} diff --git a/projects/coreui-angular/src/lib/form/form-control/form-control.directive.ts b/projects/coreui-angular/src/lib/form/form-control/form-control.directive.ts index 3573bfb7..b5eda25b 100644 --- a/projects/coreui-angular/src/lib/form/form-control/form-control.directive.ts +++ b/projects/coreui-angular/src/lib/form/form-control/form-control.directive.ts @@ -1,6 +1,7 @@ import { booleanAttribute, computed, Directive, ElementRef, inject, input, OnInit } from '@angular/core'; import { InputType } from '../../coreui.types'; +import { bindAriaInvalid } from '../../shared/aria-invalid.utils'; @Directive({ selector: 'input[cFormControl], textarea[cFormControl]', @@ -19,7 +20,9 @@ export class FormControlDirective implements OnInit { readonly sizing = input<'' | 'sm' | 'lg'>(); /** - * Set component validation state to valid. + * Validation state: `true` valid, `false` invalid, which also sets `aria-invalid="true"` + * (not on button-like, `color` and `hidden` inputs, where `aria-invalid` is left to the page), `undefined` none. + * Do not bind `aria-invalid` on the same element as well. * @default undefined */ readonly valid = input(); @@ -35,6 +38,8 @@ export class FormControlDirective implements OnInit { */ readonly plaintext = input(false, { transform: booleanAttribute }); + readonly #ariaInvalidEffect = bindAriaInvalid(this.valid, this.type); + readonly hostClasses = computed(() => { const type = this.type(); const isRange = type === 'range'; diff --git a/projects/coreui-angular/src/lib/form/form-select/form-select.directive.spec.ts b/projects/coreui-angular/src/lib/form/form-select/form-select.directive.spec.ts index 0de414cb..5cf34389 100644 --- a/projects/coreui-angular/src/lib/form/form-select/form-select.directive.spec.ts +++ b/projects/coreui-angular/src/lib/form/form-select/form-select.directive.spec.ts @@ -1,4 +1,4 @@ -import { inputBinding, signal, WritableSignal } from '@angular/core'; +import { Component, inputBinding, signal, WritableSignal } from '@angular/core'; import { DirectiveFixture, TestBed } from '@angular/core/testing'; import { FormSelectDirective } from './form-select.directive'; @@ -48,4 +48,33 @@ describe('FormSelectDirective', () => { expect(classList.contains('is-invalid')).toBe(false); expect(classList.contains('is-valid')).toBe(false); }); + + it('should set aria-invalid while invalid', async () => { + valid.set(false); + await fixture.whenStable(); + expect(fixture.nativeElement.getAttribute('aria-invalid')).toBe('true'); + valid.set(true); + await fixture.whenStable(); + expect(fixture.nativeElement.getAttribute('aria-invalid')).toBeNull(); + }); + + it('should keep aria-invalid set in the template', async () => { + const host = TestBed.createComponent(TestAriaInvalidHostComponent); + await host.whenStable(); + const select: HTMLSelectElement = host.nativeElement.querySelector('select'); + host.componentInstance.valid.set(false); + await host.whenStable(); + expect(select.getAttribute('aria-invalid')).toBe('true'); + host.componentInstance.valid.set(true); + await host.whenStable(); + expect(select.getAttribute('aria-invalid')).toBe('true'); + }); }); + +@Component({ + imports: [FormSelectDirective], + template: '' +}) +class TestAriaInvalidHostComponent { + readonly valid = signal(undefined); +} diff --git a/projects/coreui-angular/src/lib/form/form-select/form-select.directive.ts b/projects/coreui-angular/src/lib/form/form-select/form-select.directive.ts index 293e4550..3b3069ee 100644 --- a/projects/coreui-angular/src/lib/form/form-select/form-select.directive.ts +++ b/projects/coreui-angular/src/lib/form/form-select/form-select.directive.ts @@ -1,5 +1,7 @@ import { computed, Directive, input } from '@angular/core'; +import { bindAriaInvalid } from '../../shared/aria-invalid.utils'; + @Directive({ selector: 'select[cSelect]', host: { class: 'form-select', '[class]': 'hostClasses()' } @@ -12,11 +14,14 @@ export class FormSelectDirective { readonly sizing = input<'' | 'sm' | 'lg'>(); /** - * Set component validation state to valid. + * Validation state: `true` valid, `false` invalid, which also sets `aria-invalid="true"`, `undefined` none. + * Do not bind `aria-invalid` on the same element as well. * @default undefined */ readonly valid = input(); + readonly #ariaInvalidEffect = bindAriaInvalid(this.valid); + readonly hostClasses = computed(() => { const sizing = this.sizing(); const valid = this.valid(); diff --git a/projects/coreui-angular/src/lib/shared/aria-invalid.utils.spec.ts b/projects/coreui-angular/src/lib/shared/aria-invalid.utils.spec.ts new file mode 100644 index 00000000..8562a6be --- /dev/null +++ b/projects/coreui-angular/src/lib/shared/aria-invalid.utils.spec.ts @@ -0,0 +1,130 @@ +import { Component, Directive, ElementRef, inject, input, signal } from '@angular/core'; +import { ComponentFixture, TestBed } from '@angular/core/testing'; +import { bindAriaInvalid } from './aria-invalid.utils'; + +@Directive({ selector: 'input[cTestAriaInvalid]' }) +class TestAriaInvalidDirective { + readonly valid = input(); + readonly type = input('text'); + readonly #ariaInvalidEffect = bindAriaInvalid(this.valid, this.type); +} + +@Directive({ selector: 'input[cTestRendered]' }) +class TestRenderedDirective { + constructor() { + inject(ElementRef).nativeElement.setAttribute('aria-invalid', 'true'); + } +} + +@Component({ + imports: [TestRenderedDirective, TestAriaInvalidDirective], + template: ` + + + + + + + + + ` +}) +class TestHostComponent { + readonly valid = signal(undefined); + readonly type = signal('text'); + readonly page = signal('true'); + readonly pageFalseUpper = signal('FALSE'); + readonly pageEmpty = signal(''); + readonly renderedValid = signal(false); +} + +describe('bindAriaInvalid', () => { + let fixture: ComponentFixture; + let host: TestHostComponent; + const ariaInvalid = (id: string) => fixture.nativeElement.querySelector(`#${id}`).getAttribute('aria-invalid'); + + beforeEach(async () => { + fixture = TestBed.createComponent(TestHostComponent); + host = fixture.componentInstance; + await fixture.whenStable(); + }); + + it('should set aria-invalid only while valid is false', async () => { + expect(ariaInvalid('plain')).toBeNull(); + host.valid.set(false); + await fixture.whenStable(); + expect(ariaInvalid('plain')).toBe('true'); + host.valid.set(true); + await fixture.whenStable(); + expect(ariaInvalid('plain')).toBeNull(); + host.valid.set(false); + await fixture.whenStable(); + host.valid.set(undefined); + await fixture.whenStable(); + expect(ariaInvalid('plain')).toBeNull(); + }); + + it('should keep aria-invalid set by the page', async () => { + expect(ariaInvalid('page-true')).toBe('true'); + expect(ariaInvalid('page-grammar')).toBe('grammar'); + expect(ariaInvalid('page-bound')).toBe('true'); + host.valid.set(false); + await fixture.whenStable(); + expect(ariaInvalid('page-true')).toBe('true'); + expect(ariaInvalid('page-grammar')).toBe('grammar'); + host.valid.set(true); + await fixture.whenStable(); + expect(ariaInvalid('page-true')).toBe('true'); + expect(ariaInvalid('page-grammar')).toBe('grammar'); + host.valid.set(false); + await fixture.whenStable(); + host.valid.set(undefined); + await fixture.whenStable(); + expect(ariaInvalid('page-true')).toBe('true'); + expect(ariaInvalid('page-grammar')).toBe('grammar'); + }); + + it('should remove aria-invalid found on the host while invalid, as after hydration', async () => { + expect(ariaInvalid('rendered')).toBe('true'); + host.renderedValid.set(undefined); + await fixture.whenStable(); + expect(ariaInvalid('rendered')).toBeNull(); + }); + + it('should replace aria-invalid="false" set by the page while invalid and bring it back after', async () => { + host.valid.set(false); + await fixture.whenStable(); + expect(ariaInvalid('page-false')).toBe('true'); + expect(ariaInvalid('bound-false-upper')).toBe('true'); + expect(ariaInvalid('bound-empty')).toBe('true'); + host.valid.set(true); + await fixture.whenStable(); + expect(ariaInvalid('page-false')).toBe('false'); + expect(ariaInvalid('bound-false-upper')).toBeNull(); + expect(ariaInvalid('bound-empty')).toBeNull(); + host.valid.set(false); + await fixture.whenStable(); + host.valid.set(undefined); + await fixture.whenStable(); + expect(ariaInvalid('page-false')).toBe('false'); + }); + + it.each(['button', 'color', 'hidden', 'image', 'reset', 'submit', 'Submit'])( + 'should skip an input of type %s', + async (type) => { + host.valid.set(false); + await fixture.whenStable(); + expect(ariaInvalid('plain')).toBe('true'); + host.type.set(type); + await fixture.whenStable(); + expect(ariaInvalid('plain')).toBeNull(); + } + ); + + it.each(['email', 'file', 'range'])('should set aria-invalid on an input of type %s', async (type) => { + host.type.set(type); + host.valid.set(false); + await fixture.whenStable(); + expect(ariaInvalid('plain')).toBe('true'); + }); +}); diff --git a/projects/coreui-angular/src/lib/shared/aria-invalid.utils.ts b/projects/coreui-angular/src/lib/shared/aria-invalid.utils.ts new file mode 100644 index 00000000..ce98a4a6 --- /dev/null +++ b/projects/coreui-angular/src/lib/shared/aria-invalid.utils.ts @@ -0,0 +1,37 @@ +import { effect, EffectRef, ElementRef, HostAttributeToken, inject, Renderer2, Signal } from '@angular/core'; + +const TYPES_WITHOUT_ARIA_INVALID = ['button', 'color', 'hidden', 'image', 'reset', 'submit']; + +/** + * Sets `aria-invalid="true"` on the host form control while `valid()` is `false`. Call it in the injection context of the directive. + * Keeps a value already on the host unless it is empty or `"false"` in any case, and skips inputs whose type takes no + * `aria-invalid` (button-like, `color`, `hidden`), leaving the attribute to the page there. Once the control is no longer invalid, + * it writes back the static `aria-invalid` of the template, or removes the attribute when the template has none, so a value it + * found while invalid, rendered on the server or bound by the page, is removed as well. + * @param valid validation state: `false` invalid, `true` or `undefined` not invalid + * @param type input type of the host, read on every change + * @returns the effect that keeps the attribute in sync + */ +export const bindAriaInvalid = (valid: Signal, type?: Signal): EffectRef => { + const host: HTMLElement = inject(ElementRef).nativeElement; + const renderer = inject(Renderer2); + const templateValue = inject(new HostAttributeToken('aria-invalid'), { optional: true }); + let invalidated = false; + return effect(() => { + const invalid = valid() === false && !TYPES_WITHOUT_ARIA_INVALID.includes(String(type?.() ?? '').toLowerCase()); + if (invalid) { + const current = host.getAttribute('aria-invalid'); + if (!current || current.toLowerCase() === 'false') { + renderer.setAttribute(host, 'aria-invalid', 'true'); + } + invalidated = true; + } else if (invalidated) { + if (templateValue === null) { + renderer.removeAttribute(host, 'aria-invalid'); + } else { + renderer.setAttribute(host, 'aria-invalid', templateValue); + } + invalidated = false; + } + }); +};