Skip to content

Commit

Permalink
fix(components/forms): set radio group 'aria-owns' to satisfy accessi…
Browse files Browse the repository at this point in the history
…bility rules (#671)
  • Loading branch information
Blackbaud-SteveBrush authored Oct 21, 2022
1 parent e78f849 commit 32f1e1e
Show file tree
Hide file tree
Showing 10 changed files with 165 additions and 27 deletions.
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,7 @@
<legend id="radio-group-label">Reactive Radio button options:</legend>
<ul class="sky-list-unstyled">
<li *ngFor="let value of options">
<sky-radio [disabled]="value.disabled" [value]="value">
<sky-radio [disabled]="value.disabled" [id]="value.id" [value]="value">
<sky-radio-label> Option {{ value.name }} </sky-radio-label>
</sky-radio>
</li>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,7 @@ export class SkyRadioGroupReactiveFixtureComponent implements OnInit {

public initialValue: unknown = null;

public options = [
public options: { name: string; disabled: boolean; id?: string }[] = [
{ name: 'Lillith Corharvest', disabled: false },
{ name: 'Harima Kenji', disabled: false },
{ name: 'Harry Mckenzie', disabled: false },
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,8 @@ <h1 id="radio-group-label">Radio label</h1>
[label]="label1"
[value]="value1"
[(ngModel)]="selectedValue"
(checkedChange)="onCheckedChange($event)"
(disabledChange)="onDisabledChange($event)"
>
<sky-radio-label> My label </sky-radio-label>
</sky-radio>
Expand Down
Original file line number Diff line number Diff line change
@@ -1,11 +1,11 @@
import { AfterViewInit, Component, ViewChild } from '@angular/core';
import { Component, ViewChild } from '@angular/core';

import { SkyRadioComponent } from '../radio.component';

@Component({
templateUrl: './radio.component.fixture.html',
})
export class SkyRadioTestComponent implements AfterViewInit {
export class SkyRadioTestComponent {
public selectedValue = '1';
public disabled1 = false;

Expand All @@ -23,13 +23,15 @@ export class SkyRadioTestComponent implements AfterViewInit {
@ViewChild(SkyRadioComponent)
public checkboxComponent!: SkyRadioComponent;

public ngAfterViewInit() {
this.checkboxComponent.disabledChange.subscribe((value) => {
this.onDisabledChange(value);
});
public onCheckedChange(): void {
/* */
}

public onDisabledChange(value: boolean): void {}
public onClick(): void {
/* */
}

public onClick() {}
public onDisabledChange(): void {
/* */
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,51 @@
import { Injectable } from '@angular/core';

import { BehaviorSubject, Observable } from 'rxjs';

/**
* Tracks the element IDs for all radios within a radio group.
* @internal
*/
@Injectable()
export class SkyRadioGroupIdService {
public get radioIds(): Observable<string[]> {
return this.#radioIdsObs;
}

#radioIds: Map<string, string>;
#radioIds$: BehaviorSubject<string[]>;
#radioIdsObs: Observable<string[]>;

constructor() {
this.#radioIds = new Map();
this.#radioIds$ = new BehaviorSubject<string[]>([]);
this.#radioIdsObs = this.#radioIds$.asObservable();
}

/**
* Associates a radio input's ID with its parent radio group.
* @param {string} id A unique ID for the radio component.
* @param {string} inputElementId The ID applied to the radio input element.
*/
public register(id: string, inputElementId: string): void {
if (!this.#radioIds.has(id) || this.#radioIds.get(id) !== inputElementId) {
this.#radioIds.set(id, inputElementId);
this.#emitRadioIds();
}
}

/**
* Disassociates a radio input's ID with its parent radio group.
* @param {string} id The ID used to register the radio component.
*/
public unregister(id: string): void {
if (this.#radioIds.has(id)) {
this.#radioIds.delete(id);
this.#emitRadioIds();
}
}

#emitRadioIds(): void {
this.#radioIds$.next(Array.from(this.#radioIds.values()));
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@
role="radiogroup"
[attr.aria-label]="ariaLabel"
[attr.aria-labelledby]="ariaLabelledBy"
[attr.aria-owns]="ariaOwns"
[attr.aria-required]="required ? true : null"
[attr.required]="required ? '' : null"
>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import {
} from '@angular/core/testing';
import { By } from '@angular/platform-browser';
import { expect, expectAsync } from '@skyux-sdk/testing';
import { SkyIdService } from '@skyux/core';

import { SkyRadioFixturesModule } from './fixtures/radio-fixtures.module';
import { SkyRadioGroupBooleanTestComponent } from './fixtures/radio-group-boolean.component.fixture';
Expand Down Expand Up @@ -46,6 +47,11 @@ describe('Radio group component (reactive)', function () {
imports: [SkyRadioFixturesModule],
});

// Mock the ID service.
let uniqueId = 0;
const idSvc = TestBed.inject(SkyIdService);
spyOn(idSvc, 'generateId').and.callFake(() => `MOCK_ID_${++uniqueId}`);

fixture = TestBed.createComponent(SkyRadioGroupReactiveFixtureComponent);
componentInstance = fixture.componentInstance;
});
Expand Down Expand Up @@ -559,6 +565,38 @@ describe('Radio group component (reactive)', function () {
await fixture.whenStable();
await expectAsync(fixture.nativeElement).toBeAccessible();
});

it('should set aria-owns as a space-separated list of radio ids', () => {
fixture.detectChanges();

const radioGroupEl: HTMLDivElement =
fixture.nativeElement.querySelector('.sky-radio-group');

expect(radioGroupEl.getAttribute('aria-owns')).toEqual(
'sky-radio-MOCK_ID_1-input sky-radio-MOCK_ID_2-input sky-radio-MOCK_ID_3-input'
);
});

it('should update aria-owns if a child radio modifies its ID', () => {
fixture.detectChanges();

const radioGroupEl: HTMLDivElement =
fixture.nativeElement.querySelector('.sky-radio-group');

const originalAriaOwns = radioGroupEl.getAttribute('aria-owns');
expect(originalAriaOwns).toEqual(
'sky-radio-MOCK_ID_1-input sky-radio-MOCK_ID_2-input sky-radio-MOCK_ID_3-input'
);

// Change an existing ID to something else.
fixture.componentInstance.options[0].id = 'foobar';
fixture.detectChanges();

const newAriaOwns = radioGroupEl.getAttribute('aria-owns');
expect(newAriaOwns).toEqual(
'sky-radio-foobar-input sky-radio-MOCK_ID_2-input sky-radio-MOCK_ID_3-input'
);
});
});

describe('Radio group component (template-driven)', () => {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@ import { takeUntil } from 'rxjs/operators';

import { SkyFormsUtility } from '../shared/forms-utility';

import { SkyRadioGroupIdService } from './radio-group-id.service';
import { SkyRadioComponent } from './radio.component';
import { SkyRadioChange } from './types/radio-change';

Expand All @@ -31,6 +32,7 @@ let nextUniqueId = 0;
@Component({
selector: 'sky-radio-group',
templateUrl: './radio-group.component.html',
providers: [SkyRadioGroupIdService],
})
export class SkyRadioGroupComponent
implements AfterContentInit, AfterViewInit, OnDestroy
Expand Down Expand Up @@ -135,6 +137,15 @@ export class SkyRadioGroupComponent
return this.#_tabIndex;
}

/**
* Our radio components are usually implemented using an unordered list. This is an
* accessibility violation because the unordered list has an implicit role which
* interrupts the 'radiogroup' and 'radio' relationship. To correct this, we can set the
* radio group's 'aria-owns' attribute to a space-separated list of radio IDs.
* @see https://developer.mozilla.org/en-US/docs/Web/Accessibility/ARIA/Roles/radio_role
*/
public ariaOwns: string | undefined;

@ContentChildren(SkyRadioComponent, { descendants: true })
public radios: QueryList<SkyRadioComponent> | undefined;

Expand All @@ -151,18 +162,28 @@ export class SkyRadioGroupComponent
#_tabIndex: number | undefined;

#changeDetector: ChangeDetectorRef;
#radioGroupIdSvc: SkyRadioGroupIdService;
#ngControl: NgControl | undefined;

constructor(
changeDetector: ChangeDetectorRef,
radioGroupIdSvc: SkyRadioGroupIdService,
@Self() @Optional() ngControl: NgControl
) {
if (ngControl) {
ngControl.valueAccessor = this;
}
this.#changeDetector = changeDetector;
this.#radioGroupIdSvc = radioGroupIdSvc;
this.#ngControl = ngControl;
this.name = this.#defaultName;

this.#radioGroupIdSvc.radioIds
.pipe(takeUntil(this.#ngUnsubscribe))
.subscribe((ids) => {
this.ariaOwns = ids.join(' ') || undefined;
this.#changeDetector.markForCheck();
});
}

public ngAfterContentInit(): void {
Expand Down Expand Up @@ -248,8 +269,11 @@ export class SkyRadioGroupComponent
this.#onTouched = fn;
}

/* istanbul ignore next */
// eslint-disable-next-line @typescript-eslint/no-empty-function
#onChange: (value: any) => void = () => {};

/* istanbul ignore next */
// eslint-disable-next-line @typescript-eslint/no-empty-function
#onTouched: () => any = () => {};

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ import {
import { NgModel } from '@angular/forms';
import { By } from '@angular/platform-browser';
import { SkyAppTestUtility, expect, expectAsync } from '@skyux-sdk/testing';
import { SkyIdService } from '@skyux/core';

import { SkyRadioFixturesModule } from './fixtures/radio-fixtures.module';
import { SkyRadioOnPushTestComponent } from './fixtures/radio-on-push.component.fixture';
Expand All @@ -24,6 +25,11 @@ describe('Radio component', function () {
TestBed.configureTestingModule({
imports: [SkyRadioFixturesModule],
});

// Mock the ID service.
let uniqueId = 0;
const idSvc = TestBed.inject(SkyIdService);
spyOn(idSvc, 'generateId').and.callFake(() => `MOCK_ID_${++uniqueId}`);
});

afterEach(function () {
Expand Down Expand Up @@ -53,6 +59,19 @@ describe('Radio component', function () {
expect(onDisabledChangeSpy).toHaveBeenCalledTimes(1);
});

it('should emit when radio is checked', async () => {
const onCheckedChangeSpy = spyOn(testComponent, 'onCheckedChange');
const radios: NodeListOf<HTMLInputElement> =
fixture.nativeElement.querySelectorAll('input');

// Select the second radio.
radios.item(1).click();
fixture.detectChanges();
await fixture.whenStable();

expect(onCheckedChangeSpy).toHaveBeenCalledOnceWith(false);
});

it('should update the ngModel properly when radio button is changed', fakeAsync(function () {
const radioElement = fixture.debugElement.queryAll(
By.directive(SkyRadioComponent)
Expand Down Expand Up @@ -173,15 +192,9 @@ describe('Radio component', function () {
componentInstance.provideIds = false;
fixture.detectChanges();

expect(radios.item(0).id).toEqual(
jasmine.stringMatching(/sky-radio-sky-radio-[0-9]+-input/)
);
expect(radios.item(1).id).toEqual(
jasmine.stringMatching(/sky-radio-sky-radio-[0-9]+-input/)
);
expect(radios.item(2).id).toEqual(
jasmine.stringMatching(/sky-radio-sky-radio-[0-9]+-input/)
);
expect(radios.item(0).id).toEqual('sky-radio-MOCK_ID_1-input');
expect(radios.item(1).id).toEqual('sky-radio-MOCK_ID_2-input');
expect(radios.item(2).id).toEqual('sky-radio-MOCK_ID_3-input');
}));

it('should pass a label when specified', fakeAsync(function () {
Expand Down
23 changes: 15 additions & 8 deletions libs/components/forms/src/lib/modules/radio/radio.component.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,23 +4,21 @@ import {
Component,
Input,
OnDestroy,
Optional,
Output,
Provider,
forwardRef,
} from '@angular/core';
import { ControlValueAccessor, NG_VALUE_ACCESSOR } from '@angular/forms';
import { SkyIdService } from '@skyux/core';

import { BehaviorSubject, Observable, Subject } from 'rxjs';

import { SkyFormsUtility } from '../shared/forms-utility';

import { SkyRadioGroupIdService } from './radio-group-id.service';
import { SkyRadioChange } from './types/radio-change';

/**
* Auto-incrementing integer that generates unique IDs for radio components.
*/
let nextUniqueId = 0;

/**
* Provider Expression that allows sky-radio to register as a ControlValueAccessor.
* This allows it to support [(ngModel)].
Expand Down Expand Up @@ -93,7 +91,7 @@ export class SkyRadioComponent implements OnDestroy, ControlValueAccessor {

/**
* Specifies an ID for the radio button.
* @default a unique, auto-incrementing integer. For example: `sky-radio-1`
* If a value is not provided, an autogenerated ID is used.
*/
@Input()
public set id(value: string | undefined) {
Expand All @@ -102,6 +100,7 @@ export class SkyRadioComponent implements OnDestroy, ControlValueAccessor {
} else {
this.inputId = `sky-radio-${this.#defaultId}-input`;
}
this.#radioGroupIdSvc?.register(this.#defaultId, this.inputId);
}

/**
Expand Down Expand Up @@ -271,7 +270,7 @@ export class SkyRadioComponent implements OnDestroy, ControlValueAccessor {
#changeObs: Observable<SkyRadioChange>;
#checkedChange: BehaviorSubject<boolean>;
#checkedChangeObs: Observable<boolean>;
#defaultId = `sky-radio-${++nextUniqueId}`;
#defaultId: string;
#disabledChange: BehaviorSubject<boolean>;
#disabledChangeObs: Observable<boolean>;

Expand All @@ -284,20 +283,28 @@ export class SkyRadioComponent implements OnDestroy, ControlValueAccessor {
#_value: any;

#changeDetector: ChangeDetectorRef;
#radioGroupIdSvc: SkyRadioGroupIdService | undefined;

constructor(changeDetector: ChangeDetectorRef) {
constructor(
changeDetector: ChangeDetectorRef,
idService: SkyIdService,
@Optional() radioGroupIdService?: SkyRadioGroupIdService
) {
this.#changeDetector = changeDetector;
this.#radioGroupIdSvc = radioGroupIdService;
this.#change = new Subject<SkyRadioChange>();
this.#changeObs = this.#change.asObservable();
this.#checkedChange = new BehaviorSubject<boolean>(this.checked);
this.#checkedChangeObs = this.#checkedChange.asObservable();
this.#disabledChange = new BehaviorSubject<boolean>(this.disabled);
this.#disabledChangeObs = this.#disabledChange.asObservable();

this.#defaultId = idService.generateId();
this.id = this.#defaultId;
}

public ngOnDestroy(): void {
this.#radioGroupIdSvc?.unregister(this.#defaultId);
this.#removeUniqueSelectionListener();
this.#change.complete();
this.#checkedChange.complete();
Expand Down

0 comments on commit 32f1e1e

Please sign in to comment.