Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -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';

Expand Down Expand Up @@ -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<boolean | undefined>(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: '<input cFormCheckInput aria-invalid="true" [valid]="valid()" />'
})
class TestAriaInvalidHostComponent {
readonly valid = signal<boolean | undefined>(undefined);
}

@Component({
imports: [FormCheckInputDirective],
template: '<input cFormCheckInput type="radio" [valid]="false" />'
})
class TestRadioHostComponent {}
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,8 @@ import {
Renderer2
} from '@angular/core';

import { bindAriaInvalid } from '../../shared/aria-invalid.utils';

@Directive({
selector: 'input[cFormCheckInput]',
host: {
Expand Down Expand Up @@ -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<boolean>();

readonly #ariaInvalidEffect = bindAriaInvalid(this.valid);

readonly hostClasses = computed(() => {
const valid = this.valid();
return {
Expand Down
Original file line number Diff line number Diff line change
@@ -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';

Expand Down Expand Up @@ -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');
Expand All @@ -66,3 +94,11 @@ describe('FormControlDirective', () => {
warn.mockRestore();
});
});

@Component({
imports: [FormControlDirective],
template: '<input cFormControl aria-invalid="true" [valid]="valid()" />'
})
class TestAriaInvalidHostComponent {
readonly valid = signal<boolean | undefined>(undefined);
}
Original file line number Diff line number Diff line change
@@ -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]',
Expand All @@ -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<boolean>();
Expand All @@ -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';
Expand Down
Original file line number Diff line number Diff line change
@@ -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';

Expand Down Expand Up @@ -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: '<select cSelect aria-invalid="true" [valid]="valid()"></select>'
})
class TestAriaInvalidHostComponent {
readonly valid = signal<boolean | undefined>(undefined);
}
Original file line number Diff line number Diff line change
@@ -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()' }
Expand All @@ -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<boolean>();

readonly #ariaInvalidEffect = bindAriaInvalid(this.valid);

readonly hostClasses = computed(() => {
const sizing = this.sizing();
const valid = this.valid();
Expand Down
130 changes: 130 additions & 0 deletions projects/coreui-angular/src/lib/shared/aria-invalid.utils.spec.ts
Original file line number Diff line number Diff line change
@@ -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<boolean>();
readonly type = input<string>('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: `
<input id="plain" cTestAriaInvalid [valid]="valid()" [type]="type()" />
<input id="page-true" cTestAriaInvalid aria-invalid="true" [valid]="valid()" />
<input id="page-false" cTestAriaInvalid aria-invalid="false" [valid]="valid()" />
<input id="bound-false-upper" cTestAriaInvalid [attr.aria-invalid]="pageFalseUpper()" [valid]="valid()" />
<input id="bound-empty" cTestAriaInvalid [attr.aria-invalid]="pageEmpty()" [valid]="valid()" />
<input id="page-grammar" cTestAriaInvalid aria-invalid="grammar" [valid]="valid()" />
<input id="page-bound" cTestAriaInvalid [attr.aria-invalid]="page()" />
<input id="rendered" cTestAriaInvalid cTestRendered [valid]="renderedValid()" />
`
})
class TestHostComponent {
readonly valid = signal<boolean | undefined>(undefined);
readonly type = signal('text');
readonly page = signal<string | null>('true');
readonly pageFalseUpper = signal('FALSE');
readonly pageEmpty = signal('');
readonly renderedValid = signal<boolean | undefined>(false);
}

describe('bindAriaInvalid', () => {
let fixture: ComponentFixture<TestHostComponent>;
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');
});
});
37 changes: 37 additions & 0 deletions projects/coreui-angular/src/lib/shared/aria-invalid.utils.ts
Original file line number Diff line number Diff line change
@@ -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<boolean | undefined>, type?: Signal<unknown>): 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;
}
});
};
Loading