Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

fix(components/forms): character counter reads to screen readers at unobtrusive intervals #1947

Merged
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,38 +1,48 @@
<div style="width: 50%">
<form novalidate [formGroup]="formGroup">
<sky-input-box stacked="true">
<label class="sky-control-label" [for]="descriptionInput.id">
Transaction description
</label>
<sky-page layout="blocks">
<sky-page-content>
<div style="width: 50%">
<h3>Standard</h3>
<form novalidate [formGroup]="formGroup">
<sky-input-box stacked="true">
<label class="sky-control-label" [for]="descriptionInput.id">
Transaction description
</label>

<sky-character-counter-indicator
data-sky-id="description-indicator"
#descriptionIndicator
/>
<sky-character-counter-indicator
data-sky-id="description-indicator"
#descriptionIndicator
/>

<input
class="sky-form-control description-input"
formControlName="description"
skyCharacterCounter
skyId
type="text"
[attr.aria-describedby]="characterCountError.id"
[skyCharacterCounterIndicator]="descriptionIndicator"
[skyCharacterCounterLimit]="maxDescriptionCharacterCount"
#descriptionInput="skyId"
/>
<input
class="sky-form-control description-input"
formControlName="description"
skyCharacterCounter
skyId
type="text"
[attr.aria-describedby]="characterCountError.id"
[skyCharacterCounterIndicator]="descriptionIndicator"
[skyCharacterCounterLimit]="maxDescriptionCharacterCount"
#descriptionInput="skyId"
/>

<span class="sky-error-indicator" skyId #characterCountError="skyId">
<sky-status-indicator
*ngIf="description.errors?.['skyCharacterCounter']"
data-sky-id="description-status-indicator-over-limit"
descriptionType="error"
indicatorType="danger"
>
Limit Transaction description to
{{ maxDescriptionCharacterCount }} characters.
</sky-status-indicator>
</span>
</sky-input-box>
</form>
</div>
<span class="sky-error-indicator" skyId #characterCountError="skyId">
<sky-status-indicator
*ngIf="description.errors?.['skyCharacterCounter']"
data-sky-id="description-status-indicator-over-limit"
descriptionType="error"
indicatorType="danger"
>
Limit Transaction description to
{{ maxDescriptionCharacterCount }} characters.
</sky-status-indicator>
</span>
</sky-input-box>

<h3>Easy mode</h3>
<sky-input-box stacked="true" labelText="Name" characterLimit="99">
<input formControlName="name" type="text" />
</sky-input-box>
</form>
</div>
</sky-page-content>
</sky-page>
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,8 @@ import { FormBuilder, FormControl, FormGroup } from '@angular/forms';
export class CharacterCounterComponent {
protected description: FormControl;
protected formGroup: FormGroup;
protected maxDescriptionCharacterCount = 50;
protected name: FormControl;
protected maxDescriptionCharacterCount = 99;

readonly #formBuilder = inject(FormBuilder);

Expand All @@ -17,8 +18,11 @@ export class CharacterCounterComponent {
'Boys and Girls Club of South Carolina donation',
);

this.name = this.#formBuilder.control('Robert Hernandez');

this.formGroup = this.#formBuilder.group({
description: this.description,
name: this.name,
});
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import { ReactiveFormsModule } from '@angular/forms';
import { SkyIdModule } from '@skyux/core';
import { SkyCharacterCounterModule, SkyInputBoxModule } from '@skyux/forms';
import { SkyStatusIndicatorModule } from '@skyux/indicators';
import { SkyPageModule } from '@skyux/pages';

import { CharacterCounterRoutingModule } from './character-counter-routing.module';
import { CharacterCounterComponent } from './character-counter.component';
Expand All @@ -16,6 +17,7 @@ import { CharacterCounterComponent } from './character-counter.component';
SkyCharacterCounterModule,
SkyIdModule,
SkyInputBoxModule,
SkyPageModule,
SkyStatusIndicatorModule,
CharacterCounterRoutingModule,
],
Expand Down
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
{
"skyux_character_count_message": {
"_description": "Screen reader only label for character count",
"message": "characters out of"
"message": "{0} characters out of {1}"
},
"skyux_character_count_over_limit": {
"_description": "Screen reader only label for character count over limit symbol",
Expand Down
1 change: 1 addition & 0 deletions libs/components/forms/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,7 @@ export { SKY_FORM_ERRORS_ENABLED } from './lib/modules/form-error/form-errors-en

export { SkyCharacterCounterIndicatorComponent as λ1 } from './lib/modules/character-counter/character-counter-indicator.component';
export { SkyCharacterCounterInputDirective as λ2 } from './lib/modules/character-counter/character-counter.directive';
export { SkyCharacterCounterScreenReaderPipe as λ23 } from './lib/modules/character-counter/character-counter-screen-reader.pipe';
export { SkyCheckboxLabelComponent as λ4 } from './lib/modules/checkbox/checkbox-label.component';
export { SkyCheckboxComponent as λ3 } from './lib/modules/checkbox/checkbox.component';
export { SkyFileAttachmentLabelComponent as λ6 } from './lib/modules/file-attachment/file-attachment-label.component';
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -5,11 +5,21 @@
{{ characterCount + '/' + characterCountLimit }}
</span>
<span aria-live="polite" class="sky-screen-reader-only">
{{ characterCount }} {{ 'skyux_character_count_message' | skyLibResources }}
{{ characterCountLimit }}.
{{
characterCount > characterCountLimit
? ('skyux_character_count_over_limit' | skyLibResources)
: ''
}}
<ng-container
*ngIf="characterCount > characterCountLimit; else screenReaderCountMessage"
>{{ 'skyux_character_count_over_limit' | skyLibResources }}</ng-container
>
<ng-template #screenReaderCountMessage>
<ng-container
*ngIf="
characterCount
| skyCharacterCounterScreenReader
: characterCountLimit as screenReaderCount
"
>{{
'skyux_character_count_message'
| skyLibResources : screenReaderCount : characterCountLimit
}}</ng-container
>
</ng-template>
</span>
Original file line number Diff line number Diff line change
@@ -0,0 +1,56 @@
import { Pipe, PipeTransform } from '@angular/core';

@Pipe({
name: 'skyCharacterCounterScreenReader',
standalone: true,
})
export class SkyCharacterCounterScreenReaderPipe implements PipeTransform {
#previousAnnouncementPoint: number | undefined;

public transform(
characterCount: number | undefined,
characterCountLimit: number | undefined,
): string {
/* Safety check */
/* istanbul ignore if */
if (characterCount === undefined || characterCountLimit === undefined) {
return '';
}

// We want to announce every 10 characters if we are within 50 of the limit or every 50 otherwise.
const modulus =
characterCountLimit - Math.floor(characterCount / 10) * 10 <= 50
? 10
: 50;

if (
characterCount === characterCountLimit ||
characterCount % modulus === 0 ||
this.#previousAnnouncementPoint === undefined
) {
this.#previousAnnouncementPoint = characterCount;
} else {
// We want the floor of the previous announcement and modulus in case the previous announcement wasn't an announcement point.
const previousAnnouncementQuotient = Math.floor(
this.#previousAnnouncementPoint / modulus,
);
// Lower limit of what announcement should have been made for the current count
const currentAnnouncementQuotient = Math.floor(characterCount / modulus);
// Next announcement that would be made if the current count increases
const currentAnnouncementNextAnnouncement = Math.ceil(
characterCount / modulus,
);

// Jump to the appropriate announcement point if the count jumps. For example, if going from 43 of 50 characters to 21 of 50 characters.
if (
currentAnnouncementQuotient !== previousAnnouncementQuotient &&
currentAnnouncementNextAnnouncement !== previousAnnouncementQuotient
) {
this.#previousAnnouncementPoint =
Math.floor(characterCount / modulus) * modulus;
}
}

return this.#previousAnnouncementPoint.toLocaleString();
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import {
fakeAsync,
tick,
} from '@angular/core/testing';
import { By } from '@angular/platform-browser';
import { expect, expectAsync } from '@skyux-sdk/testing';

import { SkyCharacterCounterIndicatorComponent } from './character-counter-indicator.component';
Expand All @@ -23,6 +24,34 @@ describe('Character Counter component', () => {
tick();
}

function getScreenReaderText(
fixture: ComponentFixture<
CharacterCountTestComponent | CharacterCountNoIndicatorTestComponent
>,
): string | null | undefined {
const screenReaderElement: HTMLElement | undefined =
fixture.debugElement.query(
By.css('.sky-screen-reader-only'),
).nativeElement;
return screenReaderElement?.textContent;
}

function validateScreenReaderTextForCount(
fixture: ComponentFixture<
CharacterCountTestComponent | CharacterCountNoIndicatorTestComponent
>,
characterCount: number,
expectedCountOrText: number | string,
characterCountLimit: number,
): void {
setInputValue(fixture, '1'.repeat(characterCount));
expect(getScreenReaderText(fixture)).toBe(
typeof expectedCountOrText === 'string'
? expectedCountOrText
: `${expectedCountOrText} characters out of ${characterCountLimit}`,
);
}

beforeEach(function () {
TestBed.configureTestingModule({
imports: [CharacterCountTestModule],
Expand Down Expand Up @@ -155,6 +184,94 @@ describe('Character Counter component', () => {
expect(characterCountLabelLastName.innerText.trim()).toBe('4/5');
});

it('should announce to screen readers every 10 characters when within 50 characters of the limit', fakeAsync(() => {
// Sets the screen reader to the initial state
expect(getScreenReaderText(fixture)).toBe('4 characters out of 5');
component.setCharacterCountLimit(49);
fixture.detectChanges();

// Sets currently typed characters do not change until a breakpoint
validateScreenReaderTextForCount(fixture, 9, 4, 49);

validateScreenReaderTextForCount(fixture, 10, 10, 49);

validateScreenReaderTextForCount(fixture, 0, 0, 49);
}));

it('should announce to screen readers every 50 characters when not within 50 characters of the limit', fakeAsync(() => {
// Sets the screen reader to the initial state
expect(getScreenReaderText(fixture)).toBe('4 characters out of 5');
component.setCharacterCountLimit(99);
fixture.detectChanges();

// Sets currently typed characters do not change until a breakpoint
validateScreenReaderTextForCount(fixture, 9, 4, 99);

validateScreenReaderTextForCount(fixture, 10, 4, 99);

// Should not update when 50 characters is hit on a non-multiple of 10
validateScreenReaderTextForCount(fixture, 49, 4, 99);

validateScreenReaderTextForCount(fixture, 50, 50, 99);

validateScreenReaderTextForCount(fixture, 60, 60, 99);

validateScreenReaderTextForCount(fixture, 0, 0, 99);
}));

it('should announce to screen readers when backspacing at breakpoints', fakeAsync(() => {
// Sets the screen reader to the initial state
expect(getScreenReaderText(fixture)).toBe('4 characters out of 5');
component.setCharacterCountLimit(99);
fixture.detectChanges();

validateScreenReaderTextForCount(fixture, 60, 60, 99);

validateScreenReaderTextForCount(fixture, 59, 60, 99);

validateScreenReaderTextForCount(fixture, 50, 50, 99);

validateScreenReaderTextForCount(fixture, 49, 50, 99);

validateScreenReaderTextForCount(fixture, 5, 50, 99);

validateScreenReaderTextForCount(fixture, 0, 0, 99);
}));

it('should announce to screen readers when jumping from the initial value to a value past an announcement point', fakeAsync(() => {
// Sets the screen reader to the initial state
expect(getScreenReaderText(fixture)).toBe('4 characters out of 5');
component.setCharacterCountLimit(99);
fixture.detectChanges();

validateScreenReaderTextForCount(fixture, 98, 90, 99);
}));

it('should announce to screen readers when reaching the limit', fakeAsync(() => {
// Sets the screen reader to the initial state
expect(getScreenReaderText(fixture)).toBe('4 characters out of 5');
component.setCharacterCountLimit(99);
fixture.detectChanges();

validateScreenReaderTextForCount(fixture, 90, 90, 99);

validateScreenReaderTextForCount(fixture, 99, 99, 99);
}));

it('should announce to screen readers when over the limit', fakeAsync(() => {
component.setCharacterCountLimit(99);
fixture.detectChanges();

validateScreenReaderTextForCount(fixture, 99, 99, 99);

validateScreenReaderTextForCount(
fixture,
100,
'You are over the character limit.',
99,
);
}));

it('should pass accessibility', async () => {
fixture.detectChanges();
await fixture.whenStable();
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import { FormsModule, ReactiveFormsModule } from '@angular/forms';
import { SkyFormsResourcesModule } from '../shared/sky-forms-resources.module';

import { SkyCharacterCounterIndicatorComponent } from './character-counter-indicator.component';
import { SkyCharacterCounterScreenReaderPipe } from './character-counter-screen-reader.pipe';
import { SkyCharacterCounterInputDirective } from './character-counter.directive';

@NgModule({
Expand All @@ -16,6 +17,7 @@ import { SkyCharacterCounterInputDirective } from './character-counter.directive
CommonModule,
FormsModule,
ReactiveFormsModule,
SkyCharacterCounterScreenReaderPipe,
SkyFormsResourcesModule,
],
exports: [
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@
aria-label="Input"
class="sky-form-control"
formControlName="firstName"
id="first-name-input"
type="text"
skyCharacterCounter
[skyCharacterCounterIndicator]="indicator"
Expand Down Expand Up @@ -44,6 +45,7 @@
aria-label="Input"
class="sky-form-control"
formControlName="lastName"
id="last-name-input"
type="text"
skyCharacterCounter
[skyCharacterCounterLimit]="maxCharacterCountLastName"
Expand Down
Loading
Loading