From 002b94225a6abc9743203955a9b1629dd0a125ca Mon Sep 17 00:00:00 2001 From: Trevor Burch Date: Fri, 19 Jan 2024 14:39:19 -0500 Subject: [PATCH 1/7] fix(components/forms): character counter reads to screen readers at unobtrusive intervals --- .../character-counter.component.ts | 2 +- .../src/assets/locales/resources_en_US.json | 2 +- ...character-counter-indicator.component.html | 9 - .../character-counter-indicator.component.ts | 54 +++++- .../character-counter.component.spec.ts | 159 +++++++++++++++++- .../character-counter.directive.ts | 18 +- .../character-count.component.fixture.html | 2 + .../shared/sky-forms-resources.module.ts | 2 +- 8 files changed, 229 insertions(+), 19 deletions(-) diff --git a/apps/playground/src/app/components/forms/character-counter/character-counter.component.ts b/apps/playground/src/app/components/forms/character-counter/character-counter.component.ts index 4017babf57..5077b5a8b0 100644 --- a/apps/playground/src/app/components/forms/character-counter/character-counter.component.ts +++ b/apps/playground/src/app/components/forms/character-counter/character-counter.component.ts @@ -8,7 +8,7 @@ import { FormBuilder, FormControl, FormGroup } from '@angular/forms'; export class CharacterCounterComponent { protected description: FormControl; protected formGroup: FormGroup; - protected maxDescriptionCharacterCount = 50; + protected maxDescriptionCharacterCount = 99; readonly #formBuilder = inject(FormBuilder); diff --git a/libs/components/forms/src/assets/locales/resources_en_US.json b/libs/components/forms/src/assets/locales/resources_en_US.json index 0558fe7047..f581bd7466 100644 --- a/libs/components/forms/src/assets/locales/resources_en_US.json +++ b/libs/components/forms/src/assets/locales/resources_en_US.json @@ -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", diff --git a/libs/components/forms/src/lib/modules/character-counter/character-counter-indicator.component.html b/libs/components/forms/src/lib/modules/character-counter/character-counter-indicator.component.html index e0a90b0eaf..d81d2c0563 100644 --- a/libs/components/forms/src/lib/modules/character-counter/character-counter-indicator.component.html +++ b/libs/components/forms/src/lib/modules/character-counter/character-counter-indicator.component.html @@ -4,12 +4,3 @@ > {{ characterCount + '/' + characterCountLimit }} - - {{ characterCount }} {{ 'skyux_character_count_message' | skyLibResources }} - {{ characterCountLimit }}. - {{ - characterCount > characterCountLimit - ? ('skyux_character_count_over_limit' | skyLibResources) - : '' - }} - diff --git a/libs/components/forms/src/lib/modules/character-counter/character-counter-indicator.component.ts b/libs/components/forms/src/lib/modules/character-counter/character-counter-indicator.component.ts index 85e569e705..a819963b1d 100644 --- a/libs/components/forms/src/lib/modules/character-counter/character-counter-indicator.component.ts +++ b/libs/components/forms/src/lib/modules/character-counter/character-counter-indicator.component.ts @@ -3,7 +3,12 @@ import { ChangeDetectorRef, Component, Input, + inject, } from '@angular/core'; +import { SkyLiveAnnouncerService } from '@skyux/core'; +import { SkyLibResourcesService } from '@skyux/i18n'; + +import { take } from 'rxjs/operators'; @Component({ selector: 'sky-character-counter-indicator', @@ -15,11 +20,9 @@ export class SkyCharacterCounterIndicatorComponent { #_characterCountLimit = 0; #_characterCount = 0; - #changeDetector: ChangeDetectorRef; - - constructor(changeDetector: ChangeDetectorRef) { - this.#changeDetector = changeDetector; - } + #changeDetector = inject(ChangeDetectorRef); + #liveAnnouncerSvc = inject(SkyLiveAnnouncerService); + #resourceSvc = inject(SkyLibResourcesService); public get characterCount(): number { return this.#_characterCount; @@ -29,6 +32,8 @@ export class SkyCharacterCounterIndicatorComponent { public set characterCount(count: number) { this.#_characterCount = count; this.#changeDetector.markForCheck(); + + this.announceToScreenReader(); } public get characterCountLimit(): number { @@ -40,4 +45,43 @@ export class SkyCharacterCounterIndicatorComponent { this.#_characterCountLimit = limit; this.#changeDetector.markForCheck(); } + + /** @internal */ + public announceToScreenReader(alwaysAnnounce = false): void { + if (this.characterCount > this.characterCountLimit) { + this.#resourceSvc + .getString('skyux_character_count_over_limit') + .pipe(take(1)) + .subscribe((overLimitString) => { + this.#liveAnnouncerSvc.announce(overLimitString); + }); + } else { + // We want to announce every 10 characters if we are within 50 of the limit or every 50 otherwise. + const modulus = + this.characterCountLimit - this.characterCount <= 50 ? 10 : 50; + + // Announce if set to always announce. Otherwise, announce if at the limit or at one of the points described in the previous comment. + if ( + alwaysAnnounce || + this.characterCount === this.characterCountLimit || + this.characterCount % modulus === 0 + ) { + this.#resourceSvc + .getString( + 'skyux_character_count_message', + this.characterCount.toLocaleString(), + this.characterCountLimit.toLocaleString(), + ) + .pipe(take(1)) + .subscribe((characterCountMessage) => { + this.#liveAnnouncerSvc.announce(characterCountMessage); + }); + } + } + } + + /** @internal */ + public clearScreenReader(): void { + this.#liveAnnouncerSvc.clear(); + } } diff --git a/libs/components/forms/src/lib/modules/character-counter/character-counter.component.spec.ts b/libs/components/forms/src/lib/modules/character-counter/character-counter.component.spec.ts index 5c73484b1b..d3afce6ee3 100644 --- a/libs/components/forms/src/lib/modules/character-counter/character-counter.component.spec.ts +++ b/libs/components/forms/src/lib/modules/character-counter/character-counter.component.spec.ts @@ -4,7 +4,9 @@ import { fakeAsync, tick, } from '@angular/core/testing'; -import { expect, expectAsync } from '@skyux-sdk/testing'; +import { By } from '@angular/platform-browser'; +import { SkyAppTestUtility, expect, expectAsync } from '@skyux-sdk/testing'; +import { SkyLiveAnnouncerService } from '@skyux/core'; import { SkyCharacterCounterIndicatorComponent } from './character-counter-indicator.component'; import { CharacterCountNoIndicatorTestComponent } from './fixtures/character-count-no-indicator.component.fixture'; @@ -12,6 +14,33 @@ import { CharacterCountTestComponent } from './fixtures/character-count.componen import { CharacterCountTestModule } from './fixtures/character-count.module.fixture'; describe('Character Counter component', () => { + function focusFirstNameInput( + fixture: ComponentFixture< + CharacterCountTestComponent | CharacterCountNoIndicatorTestComponent + >, + ): void { + const inputElement = fixture.debugElement.query( + By.css('#first-name-input'), + ).nativeElement; + inputElement.focus(); + SkyAppTestUtility.fireDomEvent(inputElement, 'focus'); + fixture.detectChanges(); + tick(); + } + + function removeFirstNameFocus( + fixture: ComponentFixture< + CharacterCountTestComponent | CharacterCountNoIndicatorTestComponent + >, + ): void { + const inputElement = fixture.debugElement.query( + By.css('#first-name-input'), + ).nativeElement; + SkyAppTestUtility.fireDomEvent(inputElement, 'focusout'); + fixture.detectChanges(); + tick(); + } + function setInputValue( fixture: ComponentFixture< CharacterCountTestComponent | CharacterCountNoIndicatorTestComponent @@ -36,6 +65,8 @@ describe('Character Counter component', () => { let characterCountComponent: SkyCharacterCounterIndicatorComponent; let characterCountLabel: HTMLLabelElement; let characterCountLabelLastName: HTMLLabelElement; + let liveAnnouncerAnnounceSpy: jasmine.Spy; + let liveAnnouncerClearSpy: jasmine.Spy; beforeEach(() => { fixture = TestBed.createComponent(CharacterCountTestComponent); @@ -52,6 +83,10 @@ describe('Character Counter component', () => { characterCountLabelLastName = nativeElement.querySelector( '.input-count-example-wrapper-last-name .sky-character-count-label', ) as HTMLLabelElement; + + const liveAnnouncerSvc = TestBed.inject(SkyLiveAnnouncerService); + liveAnnouncerAnnounceSpy = spyOn(liveAnnouncerSvc, 'announce'); + liveAnnouncerClearSpy = spyOn(liveAnnouncerSvc, 'clear'); }); it('should set the count with the initial length', () => { @@ -155,6 +190,128 @@ 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(() => { + component.setCharacterCountLimit(49); + fixture.detectChanges(); + + setInputValue(fixture, '1'.repeat(9)); + expect(liveAnnouncerAnnounceSpy).not.toHaveBeenCalled(); + + setInputValue(fixture, '1'.repeat(10)); + expect(liveAnnouncerAnnounceSpy).toHaveBeenCalledWith( + '10 characters out of 49', + ); + + setInputValue(fixture, ''); + expect(liveAnnouncerAnnounceSpy).toHaveBeenCalledWith( + '0 characters out of 49', + ); + })); + + it('should announce to screen readers every 50 characters when not within 50 characters of the limit', fakeAsync(() => { + component.setCharacterCountLimit(99); + fixture.detectChanges(); + + setInputValue(fixture, '1'.repeat(9)); + expect(liveAnnouncerAnnounceSpy).not.toHaveBeenCalled(); + + setInputValue(fixture, '1'.repeat(10)); + expect(liveAnnouncerAnnounceSpy).not.toHaveBeenCalled(); + + setInputValue(fixture, '1'.repeat(50)); + expect(liveAnnouncerAnnounceSpy).toHaveBeenCalledWith( + '50 characters out of 99', + ); + + setInputValue(fixture, '1'.repeat(60)); + expect(liveAnnouncerAnnounceSpy).toHaveBeenCalledWith( + '60 characters out of 99', + ); + + setInputValue(fixture, ''); + expect(liveAnnouncerAnnounceSpy).toHaveBeenCalledWith( + '0 characters out of 99', + ); + })); + + it('should announce to screen readers when reaching the limit', fakeAsync(() => { + component.setCharacterCountLimit(99); + fixture.detectChanges(); + + setInputValue(fixture, '1'.repeat(98)); + expect(liveAnnouncerAnnounceSpy).not.toHaveBeenCalled(); + + setInputValue(fixture, '1'.repeat(90)); + expect(liveAnnouncerAnnounceSpy).toHaveBeenCalledWith( + '90 characters out of 99', + ); + + setInputValue(fixture, '1'.repeat(99)); + expect(liveAnnouncerAnnounceSpy).toHaveBeenCalledWith( + '99 characters out of 99', + ); + })); + + it('should announce to screen readers when over the limit', fakeAsync(() => { + component.setCharacterCountLimit(99); + fixture.detectChanges(); + + setInputValue(fixture, '1'.repeat(99)); + expect(liveAnnouncerAnnounceSpy).toHaveBeenCalledWith( + '99 characters out of 99', + ); + + setInputValue(fixture, '1'.repeat(100)); + expect(liveAnnouncerAnnounceSpy).toHaveBeenCalledWith( + 'You are over the character limit.', + ); + })); + + it('should announce to screen readers when the input is focused even when not at a standard announcement point', fakeAsync(() => { + component.setCharacterCountLimit(99); + fixture.detectChanges(); + + setInputValue(fixture, '1'.repeat(98)); + expect(liveAnnouncerAnnounceSpy).not.toHaveBeenCalled(); + + focusFirstNameInput(fixture); + expect(liveAnnouncerAnnounceSpy).toHaveBeenCalledWith( + '98 characters out of 99', + ); + expect(liveAnnouncerClearSpy).not.toHaveBeenCalled(); + })); + + it('should clear the screen reader announcement when removing focus from the input so that it can be put back when refocused', fakeAsync(() => { + component.setCharacterCountLimit(99); + fixture.detectChanges(); + + setInputValue(fixture, '1'.repeat(98)); + expect(liveAnnouncerAnnounceSpy).not.toHaveBeenCalled(); + + // Baseline that focusing works + focusFirstNameInput(fixture); + expect(liveAnnouncerAnnounceSpy).toHaveBeenCalledWith( + '98 characters out of 99', + ); + expect(liveAnnouncerClearSpy).not.toHaveBeenCalled(); + + liveAnnouncerAnnounceSpy.calls.reset(); + + // Clear screen reader when focus is removed (and ensure no new announcement is made) + removeFirstNameFocus(fixture); + expect(liveAnnouncerAnnounceSpy).not.toHaveBeenCalled(); + expect(liveAnnouncerClearSpy).toHaveBeenCalled(); + + liveAnnouncerClearSpy.calls.reset(); + + // Ensure a new announcement works if refocused. + focusFirstNameInput(fixture); + expect(liveAnnouncerAnnounceSpy).toHaveBeenCalledWith( + '98 characters out of 99', + ); + expect(liveAnnouncerClearSpy).not.toHaveBeenCalled(); + })); + it('should pass accessibility', async () => { fixture.detectChanges(); await fixture.whenStable(); diff --git a/libs/components/forms/src/lib/modules/character-counter/character-counter.directive.ts b/libs/components/forms/src/lib/modules/character-counter/character-counter.directive.ts index fc8b9be72a..ca95450d93 100644 --- a/libs/components/forms/src/lib/modules/character-counter/character-counter.directive.ts +++ b/libs/components/forms/src/lib/modules/character-counter/character-counter.directive.ts @@ -1,4 +1,4 @@ -import { Directive, Input } from '@angular/core'; +import { Directive, HostListener, Input } from '@angular/core'; import { AbstractControl, NG_VALIDATORS, @@ -54,6 +54,22 @@ export class SkyCharacterCounterInputDirective implements Validator { this.#updateIndicatorLimit(); } + /** + * Tells the character counter component to announce to screen readers when the input if focused - no matter the current state of the counter. + */ + @HostListener('focus') + public announceToScreenReaderOnFocus(): void { + this.skyCharacterCounterIndicator?.announceToScreenReader(true); + } + + /** + * Tells the character counter component to clear the screen reader element when losing focus. This ensures that the count will be read out again if refocused. + */ + @HostListener('focusout') + public clearScreenReader(): void { + this.skyCharacterCounterIndicator?.clearScreenReader(); + } + #_skyCharacterCounterIndicator: | SkyCharacterCounterIndicatorComponent | undefined; diff --git a/libs/components/forms/src/lib/modules/character-counter/fixtures/character-count.component.fixture.html b/libs/components/forms/src/lib/modules/character-counter/fixtures/character-count.component.fixture.html index 27596aaa97..b48fb8c5ed 100644 --- a/libs/components/forms/src/lib/modules/character-counter/fixtures/character-count.component.fixture.html +++ b/libs/components/forms/src/lib/modules/character-counter/fixtures/character-count.component.fixture.html @@ -17,6 +17,7 @@ aria-label="Input" class="sky-form-control" formControlName="firstName" + id="first-name-input" type="text" skyCharacterCounter [skyCharacterCounterIndicator]="indicator" @@ -44,6 +45,7 @@ aria-label="Input" class="sky-form-control" formControlName="lastName" + id="last-name-input" type="text" skyCharacterCounter [skyCharacterCounterLimit]="maxCharacterCountLastName" diff --git a/libs/components/forms/src/lib/modules/shared/sky-forms-resources.module.ts b/libs/components/forms/src/lib/modules/shared/sky-forms-resources.module.ts index 176f00ca26..3dd8c03b1b 100644 --- a/libs/components/forms/src/lib/modules/shared/sky-forms-resources.module.ts +++ b/libs/components/forms/src/lib/modules/shared/sky-forms-resources.module.ts @@ -19,7 +19,7 @@ import { const RESOURCES: { [locale: string]: SkyLibResources } = { 'EN-US': { - skyux_character_count_message: { message: 'characters out of' }, + skyux_character_count_message: { message: '{0} characters out of {1}' }, skyux_character_count_over_limit: { message: 'You are over the character limit.', }, From 2e1d42d19e1ecbc9cc8d0ae6b83c055c3c10874e Mon Sep 17 00:00:00 2001 From: Trevor Burch Date: Mon, 29 Jan 2024 16:12:09 -0500 Subject: [PATCH 2/7] Review changes --- libs/components/forms/src/index.ts | 1 + ...character-counter-indicator.component.html | 22 +++++++ .../character-counter-indicator.component.ts | 58 ++++--------------- .../character-counter-screen-reader.pipe.ts | 26 +++++++++ .../character-counter.directive.ts | 12 +--- .../character-counter.module.ts | 2 + 6 files changed, 65 insertions(+), 56 deletions(-) create mode 100644 libs/components/forms/src/lib/modules/character-counter/character-counter-screen-reader.pipe.ts diff --git a/libs/components/forms/src/index.ts b/libs/components/forms/src/index.ts index 5f047eecc3..5dc2cc2c6b 100644 --- a/libs/components/forms/src/index.ts +++ b/libs/components/forms/src/index.ts @@ -34,6 +34,7 @@ export { SkyToggleSwitchChange } from './lib/modules/toggle-switch/types/toggle- 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 λ22 } 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'; diff --git a/libs/components/forms/src/lib/modules/character-counter/character-counter-indicator.component.html b/libs/components/forms/src/lib/modules/character-counter/character-counter-indicator.component.html index d81d2c0563..1a9956048e 100644 --- a/libs/components/forms/src/lib/modules/character-counter/character-counter-indicator.component.html +++ b/libs/components/forms/src/lib/modules/character-counter/character-counter-indicator.component.html @@ -4,3 +4,25 @@ > {{ characterCount + '/' + characterCountLimit }} + + + {{ 'skyux_character_count_over_limit' | skyLibResources }} + + + + {{ + 'skyux_character_count_message' + | skyLibResources : screenReaderCount : characterCountLimit + }} + + + diff --git a/libs/components/forms/src/lib/modules/character-counter/character-counter-indicator.component.ts b/libs/components/forms/src/lib/modules/character-counter/character-counter-indicator.component.ts index a819963b1d..fd13c7d8d0 100644 --- a/libs/components/forms/src/lib/modules/character-counter/character-counter-indicator.component.ts +++ b/libs/components/forms/src/lib/modules/character-counter/character-counter-indicator.component.ts @@ -5,10 +5,6 @@ import { Input, inject, } from '@angular/core'; -import { SkyLiveAnnouncerService } from '@skyux/core'; -import { SkyLibResourcesService } from '@skyux/i18n'; - -import { take } from 'rxjs/operators'; @Component({ selector: 'sky-character-counter-indicator', @@ -17,12 +13,20 @@ import { take } from 'rxjs/operators'; changeDetection: ChangeDetectionStrategy.OnPush, }) export class SkyCharacterCounterIndicatorComponent { + #_alwaysAnnounce = false; #_characterCountLimit = 0; #_characterCount = 0; #changeDetector = inject(ChangeDetectorRef); - #liveAnnouncerSvc = inject(SkyLiveAnnouncerService); - #resourceSvc = inject(SkyLibResourcesService); + + public set alwaysAnnounce(value: boolean) { + this.#_alwaysAnnounce = value; + this.#changeDetector.markForCheck(); + } + + public get alwaysAnnounce(): boolean { + return this.#_alwaysAnnounce; + } public get characterCount(): number { return this.#_characterCount; @@ -31,9 +35,8 @@ export class SkyCharacterCounterIndicatorComponent { @Input() public set characterCount(count: number) { this.#_characterCount = count; + this.alwaysAnnounce = false; this.#changeDetector.markForCheck(); - - this.announceToScreenReader(); } public get characterCountLimit(): number { @@ -45,43 +48,4 @@ export class SkyCharacterCounterIndicatorComponent { this.#_characterCountLimit = limit; this.#changeDetector.markForCheck(); } - - /** @internal */ - public announceToScreenReader(alwaysAnnounce = false): void { - if (this.characterCount > this.characterCountLimit) { - this.#resourceSvc - .getString('skyux_character_count_over_limit') - .pipe(take(1)) - .subscribe((overLimitString) => { - this.#liveAnnouncerSvc.announce(overLimitString); - }); - } else { - // We want to announce every 10 characters if we are within 50 of the limit or every 50 otherwise. - const modulus = - this.characterCountLimit - this.characterCount <= 50 ? 10 : 50; - - // Announce if set to always announce. Otherwise, announce if at the limit or at one of the points described in the previous comment. - if ( - alwaysAnnounce || - this.characterCount === this.characterCountLimit || - this.characterCount % modulus === 0 - ) { - this.#resourceSvc - .getString( - 'skyux_character_count_message', - this.characterCount.toLocaleString(), - this.characterCountLimit.toLocaleString(), - ) - .pipe(take(1)) - .subscribe((characterCountMessage) => { - this.#liveAnnouncerSvc.announce(characterCountMessage); - }); - } - } - } - - /** @internal */ - public clearScreenReader(): void { - this.#liveAnnouncerSvc.clear(); - } } diff --git a/libs/components/forms/src/lib/modules/character-counter/character-counter-screen-reader.pipe.ts b/libs/components/forms/src/lib/modules/character-counter/character-counter-screen-reader.pipe.ts new file mode 100644 index 0000000000..888ec03014 --- /dev/null +++ b/libs/components/forms/src/lib/modules/character-counter/character-counter-screen-reader.pipe.ts @@ -0,0 +1,26 @@ +import { Pipe, PipeTransform } from '@angular/core'; + +@Pipe({ + name: 'skyCharacterCounterScreenReader', + standalone: true, +}) +export class SkyCharacterCounterScreenReaderPipe implements PipeTransform { + public transform( + characterCount: number, + characterCountLimit: number, + alwaysAnnounce = false, + ): string { + // We want to announce every 10 characters if we are within 50 of the limit or every 50 otherwise. + const modulus = characterCountLimit - characterCount <= 50 ? 10 : 50; + + if ( + alwaysAnnounce || + characterCount === characterCountLimit || + characterCount % modulus === 0 + ) { + return characterCount.toLocaleString(); + } else { + return ''; + } + } +} diff --git a/libs/components/forms/src/lib/modules/character-counter/character-counter.directive.ts b/libs/components/forms/src/lib/modules/character-counter/character-counter.directive.ts index ca95450d93..ee409a62e4 100644 --- a/libs/components/forms/src/lib/modules/character-counter/character-counter.directive.ts +++ b/libs/components/forms/src/lib/modules/character-counter/character-counter.directive.ts @@ -59,15 +59,9 @@ export class SkyCharacterCounterInputDirective implements Validator { */ @HostListener('focus') public announceToScreenReaderOnFocus(): void { - this.skyCharacterCounterIndicator?.announceToScreenReader(true); - } - - /** - * Tells the character counter component to clear the screen reader element when losing focus. This ensures that the count will be read out again if refocused. - */ - @HostListener('focusout') - public clearScreenReader(): void { - this.skyCharacterCounterIndicator?.clearScreenReader(); + if (this.skyCharacterCounterIndicator) { + this.skyCharacterCounterIndicator.alwaysAnnounce = true; + } } #_skyCharacterCounterIndicator: diff --git a/libs/components/forms/src/lib/modules/character-counter/character-counter.module.ts b/libs/components/forms/src/lib/modules/character-counter/character-counter.module.ts index 4261a16a3d..acaaabb8a4 100644 --- a/libs/components/forms/src/lib/modules/character-counter/character-counter.module.ts +++ b/libs/components/forms/src/lib/modules/character-counter/character-counter.module.ts @@ -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({ @@ -16,6 +17,7 @@ import { SkyCharacterCounterInputDirective } from './character-counter.directive CommonModule, FormsModule, ReactiveFormsModule, + SkyCharacterCounterScreenReaderPipe, SkyFormsResourcesModule, ], exports: [ From 4bbbb9afd78c8768d42c604306ad1cec5ac4fdd7 Mon Sep 17 00:00:00 2001 From: Trevor Burch Date: Tue, 30 Jan 2024 15:11:02 -0500 Subject: [PATCH 3/7] More tweaks --- ...character-counter-indicator.component.html | 12 +-- .../character-counter-indicator.component.ts | 12 +-- .../character-counter-screen-reader.pipe.ts | 29 ++++-- .../character-counter.component.spec.ts | 94 ++++++++----------- .../character-counter.directive.ts | 14 ++- 5 files changed, 81 insertions(+), 80 deletions(-) diff --git a/libs/components/forms/src/lib/modules/character-counter/character-counter-indicator.component.html b/libs/components/forms/src/lib/modules/character-counter/character-counter-indicator.component.html index 1a9956048e..511b1a0aa0 100644 --- a/libs/components/forms/src/lib/modules/character-counter/character-counter-indicator.component.html +++ b/libs/components/forms/src/lib/modules/character-counter/character-counter-indicator.component.html @@ -7,22 +7,20 @@ {{ 'skyux_character_count_over_limit' | skyLibResources }} - {{ 'skyux_character_count_over_limit' | skyLibResources }} - - {{ + >{{ 'skyux_character_count_message' | skyLibResources : screenReaderCount : characterCountLimit - }} - + }} diff --git a/libs/components/forms/src/lib/modules/character-counter/character-counter-indicator.component.ts b/libs/components/forms/src/lib/modules/character-counter/character-counter-indicator.component.ts index fd13c7d8d0..64cfaa7bdb 100644 --- a/libs/components/forms/src/lib/modules/character-counter/character-counter-indicator.component.ts +++ b/libs/components/forms/src/lib/modules/character-counter/character-counter-indicator.component.ts @@ -13,19 +13,19 @@ import { changeDetection: ChangeDetectionStrategy.OnPush, }) export class SkyCharacterCounterIndicatorComponent { - #_alwaysAnnounce = false; + #_hasFocus = false; #_characterCountLimit = 0; #_characterCount = 0; #changeDetector = inject(ChangeDetectorRef); - public set alwaysAnnounce(value: boolean) { - this.#_alwaysAnnounce = value; + public set hasFocus(value: boolean) { + this.#_hasFocus = value; this.#changeDetector.markForCheck(); } - public get alwaysAnnounce(): boolean { - return this.#_alwaysAnnounce; + public get hasFocus(): boolean { + return this.#_hasFocus; } public get characterCount(): number { @@ -35,7 +35,7 @@ export class SkyCharacterCounterIndicatorComponent { @Input() public set characterCount(count: number) { this.#_characterCount = count; - this.alwaysAnnounce = false; + this.hasFocus = false; this.#changeDetector.markForCheck(); } diff --git a/libs/components/forms/src/lib/modules/character-counter/character-counter-screen-reader.pipe.ts b/libs/components/forms/src/lib/modules/character-counter/character-counter-screen-reader.pipe.ts index 888ec03014..2ce4076781 100644 --- a/libs/components/forms/src/lib/modules/character-counter/character-counter-screen-reader.pipe.ts +++ b/libs/components/forms/src/lib/modules/character-counter/character-counter-screen-reader.pipe.ts @@ -5,22 +5,35 @@ import { Pipe, PipeTransform } from '@angular/core'; standalone: true, }) export class SkyCharacterCounterScreenReaderPipe implements PipeTransform { + #hasFocus = false; + public transform( - characterCount: number, - characterCountLimit: number, - alwaysAnnounce = false, + characterCount: number | undefined, + characterCountLimit: number | undefined, + hasFocus: boolean, ): 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 - characterCount <= 50 ? 10 : 50; + let returnString = ''; - if ( - alwaysAnnounce || + // We want to clear the screen reader when focus is lost - no matter the current count + if (!hasFocus && this.#hasFocus) { + returnString = ''; + } else if ( + (!this.#hasFocus && hasFocus) || characterCount === characterCountLimit || characterCount % modulus === 0 ) { - return characterCount.toLocaleString(); - } else { - return ''; + returnString = characterCount.toLocaleString(); } + + this.#hasFocus = hasFocus; + return returnString; } } diff --git a/libs/components/forms/src/lib/modules/character-counter/character-counter.component.spec.ts b/libs/components/forms/src/lib/modules/character-counter/character-counter.component.spec.ts index d3afce6ee3..db3ea2e7c8 100644 --- a/libs/components/forms/src/lib/modules/character-counter/character-counter.component.spec.ts +++ b/libs/components/forms/src/lib/modules/character-counter/character-counter.component.spec.ts @@ -6,7 +6,6 @@ import { } from '@angular/core/testing'; import { By } from '@angular/platform-browser'; import { SkyAppTestUtility, expect, expectAsync } from '@skyux-sdk/testing'; -import { SkyLiveAnnouncerService } from '@skyux/core'; import { SkyCharacterCounterIndicatorComponent } from './character-counter-indicator.component'; import { CharacterCountNoIndicatorTestComponent } from './fixtures/character-count-no-indicator.component.fixture'; @@ -23,7 +22,7 @@ describe('Character Counter component', () => { By.css('#first-name-input'), ).nativeElement; inputElement.focus(); - SkyAppTestUtility.fireDomEvent(inputElement, 'focus'); + SkyAppTestUtility.fireDomEvent(inputElement, 'focusin'); fixture.detectChanges(); tick(); } @@ -52,6 +51,18 @@ 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; + } + beforeEach(function () { TestBed.configureTestingModule({ imports: [CharacterCountTestModule], @@ -65,8 +76,6 @@ describe('Character Counter component', () => { let characterCountComponent: SkyCharacterCounterIndicatorComponent; let characterCountLabel: HTMLLabelElement; let characterCountLabelLastName: HTMLLabelElement; - let liveAnnouncerAnnounceSpy: jasmine.Spy; - let liveAnnouncerClearSpy: jasmine.Spy; beforeEach(() => { fixture = TestBed.createComponent(CharacterCountTestComponent); @@ -83,10 +92,6 @@ describe('Character Counter component', () => { characterCountLabelLastName = nativeElement.querySelector( '.input-count-example-wrapper-last-name .sky-character-count-label', ) as HTMLLabelElement; - - const liveAnnouncerSvc = TestBed.inject(SkyLiveAnnouncerService); - liveAnnouncerAnnounceSpy = spyOn(liveAnnouncerSvc, 'announce'); - liveAnnouncerClearSpy = spyOn(liveAnnouncerSvc, 'clear'); }); it('should set the count with the initial length', () => { @@ -195,17 +200,13 @@ describe('Character Counter component', () => { fixture.detectChanges(); setInputValue(fixture, '1'.repeat(9)); - expect(liveAnnouncerAnnounceSpy).not.toHaveBeenCalled(); + expect(getScreenReaderText(fixture)).toBe(''); setInputValue(fixture, '1'.repeat(10)); - expect(liveAnnouncerAnnounceSpy).toHaveBeenCalledWith( - '10 characters out of 49', - ); + expect(getScreenReaderText(fixture)).toBe('10 characters out of 49'); setInputValue(fixture, ''); - expect(liveAnnouncerAnnounceSpy).toHaveBeenCalledWith( - '0 characters out of 49', - ); + expect(getScreenReaderText(fixture)).toBe('0 characters out of 49'); })); it('should announce to screen readers every 50 characters when not within 50 characters of the limit', fakeAsync(() => { @@ -213,25 +214,19 @@ describe('Character Counter component', () => { fixture.detectChanges(); setInputValue(fixture, '1'.repeat(9)); - expect(liveAnnouncerAnnounceSpy).not.toHaveBeenCalled(); + expect(getScreenReaderText(fixture)).toBe(''); setInputValue(fixture, '1'.repeat(10)); - expect(liveAnnouncerAnnounceSpy).not.toHaveBeenCalled(); + expect(getScreenReaderText(fixture)).toBe(''); setInputValue(fixture, '1'.repeat(50)); - expect(liveAnnouncerAnnounceSpy).toHaveBeenCalledWith( - '50 characters out of 99', - ); + expect(getScreenReaderText(fixture)).toBe('50 characters out of 99'); setInputValue(fixture, '1'.repeat(60)); - expect(liveAnnouncerAnnounceSpy).toHaveBeenCalledWith( - '60 characters out of 99', - ); + expect(getScreenReaderText(fixture)).toBe('60 characters out of 99'); setInputValue(fixture, ''); - expect(liveAnnouncerAnnounceSpy).toHaveBeenCalledWith( - '0 characters out of 99', - ); + expect(getScreenReaderText(fixture)).toBe('0 characters out of 99'); })); it('should announce to screen readers when reaching the limit', fakeAsync(() => { @@ -239,17 +234,13 @@ describe('Character Counter component', () => { fixture.detectChanges(); setInputValue(fixture, '1'.repeat(98)); - expect(liveAnnouncerAnnounceSpy).not.toHaveBeenCalled(); + expect(getScreenReaderText(fixture)).toBe(''); setInputValue(fixture, '1'.repeat(90)); - expect(liveAnnouncerAnnounceSpy).toHaveBeenCalledWith( - '90 characters out of 99', - ); + expect(getScreenReaderText(fixture)).toBe('90 characters out of 99'); setInputValue(fixture, '1'.repeat(99)); - expect(liveAnnouncerAnnounceSpy).toHaveBeenCalledWith( - '99 characters out of 99', - ); + expect(getScreenReaderText(fixture)).toBe('99 characters out of 99'); })); it('should announce to screen readers when over the limit', fakeAsync(() => { @@ -257,12 +248,10 @@ describe('Character Counter component', () => { fixture.detectChanges(); setInputValue(fixture, '1'.repeat(99)); - expect(liveAnnouncerAnnounceSpy).toHaveBeenCalledWith( - '99 characters out of 99', - ); + expect(getScreenReaderText(fixture)).toBe('99 characters out of 99'); setInputValue(fixture, '1'.repeat(100)); - expect(liveAnnouncerAnnounceSpy).toHaveBeenCalledWith( + expect(getScreenReaderText(fixture)).toBe( 'You are over the character limit.', ); })); @@ -272,13 +261,10 @@ describe('Character Counter component', () => { fixture.detectChanges(); setInputValue(fixture, '1'.repeat(98)); - expect(liveAnnouncerAnnounceSpy).not.toHaveBeenCalled(); + expect(getScreenReaderText(fixture)).toBe(''); focusFirstNameInput(fixture); - expect(liveAnnouncerAnnounceSpy).toHaveBeenCalledWith( - '98 characters out of 99', - ); - expect(liveAnnouncerClearSpy).not.toHaveBeenCalled(); + expect(getScreenReaderText(fixture)).toBe('98 characters out of 99'); })); it('should clear the screen reader announcement when removing focus from the input so that it can be put back when refocused', fakeAsync(() => { @@ -286,30 +272,24 @@ describe('Character Counter component', () => { fixture.detectChanges(); setInputValue(fixture, '1'.repeat(98)); - expect(liveAnnouncerAnnounceSpy).not.toHaveBeenCalled(); + expect(getScreenReaderText(fixture)).toBe(''); // Baseline that focusing works focusFirstNameInput(fixture); - expect(liveAnnouncerAnnounceSpy).toHaveBeenCalledWith( - '98 characters out of 99', - ); - expect(liveAnnouncerClearSpy).not.toHaveBeenCalled(); - - liveAnnouncerAnnounceSpy.calls.reset(); + expect(getScreenReaderText(fixture)).toBe('98 characters out of 99'); // Clear screen reader when focus is removed (and ensure no new announcement is made) removeFirstNameFocus(fixture); - expect(liveAnnouncerAnnounceSpy).not.toHaveBeenCalled(); - expect(liveAnnouncerClearSpy).toHaveBeenCalled(); - - liveAnnouncerClearSpy.calls.reset(); + expect(getScreenReaderText(fixture)).toBe(''); // Ensure a new announcement works if refocused. + setInputValue(fixture, '1'.repeat(60)); focusFirstNameInput(fixture); - expect(liveAnnouncerAnnounceSpy).toHaveBeenCalledWith( - '98 characters out of 99', - ); - expect(liveAnnouncerClearSpy).not.toHaveBeenCalled(); + expect(getScreenReaderText(fixture)).toBe('60 characters out of 99'); + + // Clear screen reader when focus is removed (and ensure no new announcement is made) when the count is a standard announcement interval + removeFirstNameFocus(fixture); + expect(getScreenReaderText(fixture)).toBe(''); })); it('should pass accessibility', async () => { diff --git a/libs/components/forms/src/lib/modules/character-counter/character-counter.directive.ts b/libs/components/forms/src/lib/modules/character-counter/character-counter.directive.ts index ee409a62e4..dc99d92b20 100644 --- a/libs/components/forms/src/lib/modules/character-counter/character-counter.directive.ts +++ b/libs/components/forms/src/lib/modules/character-counter/character-counter.directive.ts @@ -57,10 +57,20 @@ export class SkyCharacterCounterInputDirective implements Validator { /** * Tells the character counter component to announce to screen readers when the input if focused - no matter the current state of the counter. */ - @HostListener('focus') + @HostListener('focusin') public announceToScreenReaderOnFocus(): void { if (this.skyCharacterCounterIndicator) { - this.skyCharacterCounterIndicator.alwaysAnnounce = true; + this.skyCharacterCounterIndicator.hasFocus = true; + } + } + + /** + * Tells the character counter component to clear the screen reader text when focus is lost. + */ + @HostListener('focusout') + public clearScreenReaderWithoutFocus(): void { + if (this.skyCharacterCounterIndicator) { + this.skyCharacterCounterIndicator.hasFocus = false; } } From 5f286ec2d87eb920213beaf9b29dc18d2eae01f3 Mon Sep 17 00:00:00 2001 From: Trevor Burch Date: Thu, 1 Feb 2024 14:31:29 -0500 Subject: [PATCH 4/7] Modify pipe --- ...character-counter-indicator.component.html | 5 +- .../character-counter-indicator.component.ts | 11 --- .../character-counter-screen-reader.pipe.ts | 26 +++--- .../character-counter.component.spec.ts | 90 +++++-------------- .../character-counter.directive.ts | 22 +---- 5 files changed, 38 insertions(+), 116 deletions(-) diff --git a/libs/components/forms/src/lib/modules/character-counter/character-counter-indicator.component.html b/libs/components/forms/src/lib/modules/character-counter/character-counter-indicator.component.html index 511b1a0aa0..f3ad8b382c 100644 --- a/libs/components/forms/src/lib/modules/character-counter/character-counter-indicator.component.html +++ b/libs/components/forms/src/lib/modules/character-counter/character-counter-indicator.component.html @@ -4,7 +4,7 @@ > {{ characterCount + '/' + characterCountLimit }} - + {{ 'skyux_character_count_over_limit' | skyLibResources }}{{ 'skyux_character_count_message' diff --git a/libs/components/forms/src/lib/modules/character-counter/character-counter-indicator.component.ts b/libs/components/forms/src/lib/modules/character-counter/character-counter-indicator.component.ts index 64cfaa7bdb..74c5c0cc36 100644 --- a/libs/components/forms/src/lib/modules/character-counter/character-counter-indicator.component.ts +++ b/libs/components/forms/src/lib/modules/character-counter/character-counter-indicator.component.ts @@ -13,21 +13,11 @@ import { changeDetection: ChangeDetectionStrategy.OnPush, }) export class SkyCharacterCounterIndicatorComponent { - #_hasFocus = false; #_characterCountLimit = 0; #_characterCount = 0; #changeDetector = inject(ChangeDetectorRef); - public set hasFocus(value: boolean) { - this.#_hasFocus = value; - this.#changeDetector.markForCheck(); - } - - public get hasFocus(): boolean { - return this.#_hasFocus; - } - public get characterCount(): number { return this.#_characterCount; } @@ -35,7 +25,6 @@ export class SkyCharacterCounterIndicatorComponent { @Input() public set characterCount(count: number) { this.#_characterCount = count; - this.hasFocus = false; this.#changeDetector.markForCheck(); } diff --git a/libs/components/forms/src/lib/modules/character-counter/character-counter-screen-reader.pipe.ts b/libs/components/forms/src/lib/modules/character-counter/character-counter-screen-reader.pipe.ts index 2ce4076781..9d98bb24b7 100644 --- a/libs/components/forms/src/lib/modules/character-counter/character-counter-screen-reader.pipe.ts +++ b/libs/components/forms/src/lib/modules/character-counter/character-counter-screen-reader.pipe.ts @@ -5,12 +5,11 @@ import { Pipe, PipeTransform } from '@angular/core'; standalone: true, }) export class SkyCharacterCounterScreenReaderPipe implements PipeTransform { - #hasFocus = false; + #previousAnnouncementPoint: number | undefined; public transform( characterCount: number | undefined, characterCountLimit: number | undefined, - hasFocus: boolean, ): string { /* Safety check */ /* istanbul ignore if */ @@ -20,20 +19,23 @@ export class SkyCharacterCounterScreenReaderPipe implements PipeTransform { // We want to announce every 10 characters if we are within 50 of the limit or every 50 otherwise. const modulus = characterCountLimit - characterCount <= 50 ? 10 : 50; - let returnString = ''; - // We want to clear the screen reader when focus is lost - no matter the current count - if (!hasFocus && this.#hasFocus) { - returnString = ''; - } else if ( - (!this.#hasFocus && hasFocus) || + if ( characterCount === characterCountLimit || characterCount % modulus === 0 ) { - returnString = characterCount.toLocaleString(); + this.#previousAnnouncementPoint = characterCount; + return characterCount.toLocaleString(); + } else if (this.#previousAnnouncementPoint === undefined) { + this.#previousAnnouncementPoint = characterCount; + return this.#previousAnnouncementPoint.toLocaleString(); + } else if ( + Math.floor(characterCount / modulus) === + Math.floor(this.#previousAnnouncementPoint / modulus) + ) { + return this.#previousAnnouncementPoint.toLocaleString(); + } else { + return (Math.floor(characterCount / modulus) * modulus).toLocaleString(); } - - this.#hasFocus = hasFocus; - return returnString; } } diff --git a/libs/components/forms/src/lib/modules/character-counter/character-counter.component.spec.ts b/libs/components/forms/src/lib/modules/character-counter/character-counter.component.spec.ts index db3ea2e7c8..7764705a2a 100644 --- a/libs/components/forms/src/lib/modules/character-counter/character-counter.component.spec.ts +++ b/libs/components/forms/src/lib/modules/character-counter/character-counter.component.spec.ts @@ -5,7 +5,7 @@ import { tick, } from '@angular/core/testing'; import { By } from '@angular/platform-browser'; -import { SkyAppTestUtility, expect, expectAsync } from '@skyux-sdk/testing'; +import { expect, expectAsync } from '@skyux-sdk/testing'; import { SkyCharacterCounterIndicatorComponent } from './character-counter-indicator.component'; import { CharacterCountNoIndicatorTestComponent } from './fixtures/character-count-no-indicator.component.fixture'; @@ -13,33 +13,6 @@ import { CharacterCountTestComponent } from './fixtures/character-count.componen import { CharacterCountTestModule } from './fixtures/character-count.module.fixture'; describe('Character Counter component', () => { - function focusFirstNameInput( - fixture: ComponentFixture< - CharacterCountTestComponent | CharacterCountNoIndicatorTestComponent - >, - ): void { - const inputElement = fixture.debugElement.query( - By.css('#first-name-input'), - ).nativeElement; - inputElement.focus(); - SkyAppTestUtility.fireDomEvent(inputElement, 'focusin'); - fixture.detectChanges(); - tick(); - } - - function removeFirstNameFocus( - fixture: ComponentFixture< - CharacterCountTestComponent | CharacterCountNoIndicatorTestComponent - >, - ): void { - const inputElement = fixture.debugElement.query( - By.css('#first-name-input'), - ).nativeElement; - SkyAppTestUtility.fireDomEvent(inputElement, 'focusout'); - fixture.detectChanges(); - tick(); - } - function setInputValue( fixture: ComponentFixture< CharacterCountTestComponent | CharacterCountNoIndicatorTestComponent @@ -196,11 +169,14 @@ describe('Character Counter component', () => { }); 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(); setInputValue(fixture, '1'.repeat(9)); - expect(getScreenReaderText(fixture)).toBe(''); + // Sets currently typed characters do not change until a breakpoint + expect(getScreenReaderText(fixture)).toBe('4 characters out of 49'); setInputValue(fixture, '1'.repeat(10)); expect(getScreenReaderText(fixture)).toBe('10 characters out of 49'); @@ -210,14 +186,17 @@ describe('Character Counter component', () => { })); 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(); setInputValue(fixture, '1'.repeat(9)); - expect(getScreenReaderText(fixture)).toBe(''); + // Sets currently typed characters do not change until a breakpoint + expect(getScreenReaderText(fixture)).toBe('4 characters out of 99'); setInputValue(fixture, '1'.repeat(10)); - expect(getScreenReaderText(fixture)).toBe(''); + expect(getScreenReaderText(fixture)).toBe('4 characters out of 99'); setInputValue(fixture, '1'.repeat(50)); expect(getScreenReaderText(fixture)).toBe('50 characters out of 99'); @@ -229,12 +208,21 @@ describe('Character Counter component', () => { expect(getScreenReaderText(fixture)).toBe('0 characters out of 99'); })); - it('should announce to screen readers when reaching the limit', fakeAsync(() => { + 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(); setInputValue(fixture, '1'.repeat(98)); - expect(getScreenReaderText(fixture)).toBe(''); + expect(getScreenReaderText(fixture)).toBe('90 characters out of 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(); setInputValue(fixture, '1'.repeat(90)); expect(getScreenReaderText(fixture)).toBe('90 characters out of 99'); @@ -256,42 +244,6 @@ describe('Character Counter component', () => { ); })); - it('should announce to screen readers when the input is focused even when not at a standard announcement point', fakeAsync(() => { - component.setCharacterCountLimit(99); - fixture.detectChanges(); - - setInputValue(fixture, '1'.repeat(98)); - expect(getScreenReaderText(fixture)).toBe(''); - - focusFirstNameInput(fixture); - expect(getScreenReaderText(fixture)).toBe('98 characters out of 99'); - })); - - it('should clear the screen reader announcement when removing focus from the input so that it can be put back when refocused', fakeAsync(() => { - component.setCharacterCountLimit(99); - fixture.detectChanges(); - - setInputValue(fixture, '1'.repeat(98)); - expect(getScreenReaderText(fixture)).toBe(''); - - // Baseline that focusing works - focusFirstNameInput(fixture); - expect(getScreenReaderText(fixture)).toBe('98 characters out of 99'); - - // Clear screen reader when focus is removed (and ensure no new announcement is made) - removeFirstNameFocus(fixture); - expect(getScreenReaderText(fixture)).toBe(''); - - // Ensure a new announcement works if refocused. - setInputValue(fixture, '1'.repeat(60)); - focusFirstNameInput(fixture); - expect(getScreenReaderText(fixture)).toBe('60 characters out of 99'); - - // Clear screen reader when focus is removed (and ensure no new announcement is made) when the count is a standard announcement interval - removeFirstNameFocus(fixture); - expect(getScreenReaderText(fixture)).toBe(''); - })); - it('should pass accessibility', async () => { fixture.detectChanges(); await fixture.whenStable(); diff --git a/libs/components/forms/src/lib/modules/character-counter/character-counter.directive.ts b/libs/components/forms/src/lib/modules/character-counter/character-counter.directive.ts index dc99d92b20..fc8b9be72a 100644 --- a/libs/components/forms/src/lib/modules/character-counter/character-counter.directive.ts +++ b/libs/components/forms/src/lib/modules/character-counter/character-counter.directive.ts @@ -1,4 +1,4 @@ -import { Directive, HostListener, Input } from '@angular/core'; +import { Directive, Input } from '@angular/core'; import { AbstractControl, NG_VALIDATORS, @@ -54,26 +54,6 @@ export class SkyCharacterCounterInputDirective implements Validator { this.#updateIndicatorLimit(); } - /** - * Tells the character counter component to announce to screen readers when the input if focused - no matter the current state of the counter. - */ - @HostListener('focusin') - public announceToScreenReaderOnFocus(): void { - if (this.skyCharacterCounterIndicator) { - this.skyCharacterCounterIndicator.hasFocus = true; - } - } - - /** - * Tells the character counter component to clear the screen reader text when focus is lost. - */ - @HostListener('focusout') - public clearScreenReaderWithoutFocus(): void { - if (this.skyCharacterCounterIndicator) { - this.skyCharacterCounterIndicator.hasFocus = false; - } - } - #_skyCharacterCounterIndicator: | SkyCharacterCounterIndicatorComponent | undefined; From 6e57f9bfab13ff7aa66276ae928b1db4df4fc894 Mon Sep 17 00:00:00 2001 From: Trevor Burch Date: Thu, 1 Feb 2024 14:43:18 -0500 Subject: [PATCH 5/7] Small tweaks --- .../character-counter-indicator.component.html | 2 +- .../character-counter-indicator.component.ts | 7 +++++-- 2 files changed, 6 insertions(+), 3 deletions(-) diff --git a/libs/components/forms/src/lib/modules/character-counter/character-counter-indicator.component.html b/libs/components/forms/src/lib/modules/character-counter/character-counter-indicator.component.html index f3ad8b382c..2a4e8b3ad6 100644 --- a/libs/components/forms/src/lib/modules/character-counter/character-counter-indicator.component.html +++ b/libs/components/forms/src/lib/modules/character-counter/character-counter-indicator.component.html @@ -4,7 +4,7 @@ > {{ characterCount + '/' + characterCountLimit }} - + {{ 'skyux_character_count_over_limit' | skyLibResources }} Date: Fri, 2 Feb 2024 14:14:25 -0500 Subject: [PATCH 6/7] Review changes --- .../character-counter.component.html | 80 +++++++++++-------- .../character-counter.component.ts | 4 + .../character-counter.module.ts | 2 + .../character-counter-screen-reader.pipe.ts | 13 ++- .../character-counter.component.spec.ts | 29 +++++++ .../input-box/input-box.component.html | 8 ++ .../input-box/input-box.component.spec.ts | 45 +++++++++++ .../modules/input-box/input-box.component.ts | 7 ++ .../lib/modules/input-box/input-box.module.ts | 2 + 9 files changed, 152 insertions(+), 38 deletions(-) diff --git a/apps/playground/src/app/components/forms/character-counter/character-counter.component.html b/apps/playground/src/app/components/forms/character-counter/character-counter.component.html index 6364d33cd8..67aca52ebf 100644 --- a/apps/playground/src/app/components/forms/character-counter/character-counter.component.html +++ b/apps/playground/src/app/components/forms/character-counter/character-counter.component.html @@ -1,38 +1,48 @@ -
-
- - + + +
+

Standard

+ + + - + - + - - - Limit Transaction description to - {{ maxDescriptionCharacterCount }} characters. - - - - -
+ + + Limit Transaction description to + {{ maxDescriptionCharacterCount }} characters. + + +
+ +

Easy mode

+ + + + +
+ + diff --git a/apps/playground/src/app/components/forms/character-counter/character-counter.component.ts b/apps/playground/src/app/components/forms/character-counter/character-counter.component.ts index 5077b5a8b0..c70bd999d8 100644 --- a/apps/playground/src/app/components/forms/character-counter/character-counter.component.ts +++ b/apps/playground/src/app/components/forms/character-counter/character-counter.component.ts @@ -8,6 +8,7 @@ import { FormBuilder, FormControl, FormGroup } from '@angular/forms'; export class CharacterCounterComponent { protected description: FormControl; protected formGroup: FormGroup; + protected name: FormControl; protected maxDescriptionCharacterCount = 99; readonly #formBuilder = inject(FormBuilder); @@ -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, }); } } diff --git a/apps/playground/src/app/components/forms/character-counter/character-counter.module.ts b/apps/playground/src/app/components/forms/character-counter/character-counter.module.ts index 77c31014ee..f7f41b5b98 100644 --- a/apps/playground/src/app/components/forms/character-counter/character-counter.module.ts +++ b/apps/playground/src/app/components/forms/character-counter/character-counter.module.ts @@ -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'; @@ -16,6 +17,7 @@ import { CharacterCounterComponent } from './character-counter.component'; SkyCharacterCounterModule, SkyIdModule, SkyInputBoxModule, + SkyPageModule, SkyStatusIndicatorModule, CharacterCounterRoutingModule, ], diff --git a/libs/components/forms/src/lib/modules/character-counter/character-counter-screen-reader.pipe.ts b/libs/components/forms/src/lib/modules/character-counter/character-counter-screen-reader.pipe.ts index 9d98bb24b7..0aa3764d8c 100644 --- a/libs/components/forms/src/lib/modules/character-counter/character-counter-screen-reader.pipe.ts +++ b/libs/components/forms/src/lib/modules/character-counter/character-counter-screen-reader.pipe.ts @@ -18,7 +18,10 @@ export class SkyCharacterCounterScreenReaderPipe implements PipeTransform { } // We want to announce every 10 characters if we are within 50 of the limit or every 50 otherwise. - const modulus = characterCountLimit - characterCount <= 50 ? 10 : 50; + const modulus = + characterCountLimit - Math.floor(characterCount / 10) * 10 <= 50 + ? 10 + : 50; if ( characterCount === characterCountLimit || @@ -31,11 +34,15 @@ export class SkyCharacterCounterScreenReaderPipe implements PipeTransform { return this.#previousAnnouncementPoint.toLocaleString(); } else if ( Math.floor(characterCount / modulus) === - Math.floor(this.#previousAnnouncementPoint / modulus) + Math.floor(this.#previousAnnouncementPoint / modulus) || + Math.ceil(characterCount / modulus) === + Math.floor(this.#previousAnnouncementPoint / modulus) ) { return this.#previousAnnouncementPoint.toLocaleString(); } else { - return (Math.floor(characterCount / modulus) * modulus).toLocaleString(); + this.#previousAnnouncementPoint = + Math.floor(characterCount / modulus) * modulus; + return this.#previousAnnouncementPoint.toLocaleString(); } } } diff --git a/libs/components/forms/src/lib/modules/character-counter/character-counter.component.spec.ts b/libs/components/forms/src/lib/modules/character-counter/character-counter.component.spec.ts index 7764705a2a..1a3355c029 100644 --- a/libs/components/forms/src/lib/modules/character-counter/character-counter.component.spec.ts +++ b/libs/components/forms/src/lib/modules/character-counter/character-counter.component.spec.ts @@ -198,6 +198,10 @@ describe('Character Counter component', () => { setInputValue(fixture, '1'.repeat(10)); expect(getScreenReaderText(fixture)).toBe('4 characters out of 99'); + // Should not update when 50 characters is hit on a non-multiple of 10 + setInputValue(fixture, '1'.repeat(49)); + expect(getScreenReaderText(fixture)).toBe('4 characters out of 99'); + setInputValue(fixture, '1'.repeat(50)); expect(getScreenReaderText(fixture)).toBe('50 characters out of 99'); @@ -208,6 +212,31 @@ describe('Character Counter component', () => { expect(getScreenReaderText(fixture)).toBe('0 characters out of 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(); + + setInputValue(fixture, '1'.repeat(60)); + expect(getScreenReaderText(fixture)).toBe('60 characters out of 99'); + + setInputValue(fixture, '1'.repeat(59)); + expect(getScreenReaderText(fixture)).toBe('60 characters out of 99'); + + setInputValue(fixture, '1'.repeat(50)); + expect(getScreenReaderText(fixture)).toBe('50 characters out of 99'); + + setInputValue(fixture, '1'.repeat(49)); + expect(getScreenReaderText(fixture)).toBe('50 characters out of 99'); + + setInputValue(fixture, '1'.repeat(5)); + expect(getScreenReaderText(fixture)).toBe('50 characters out of 99'); + + setInputValue(fixture, ''); + expect(getScreenReaderText(fixture)).toBe('0 characters out of 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'); diff --git a/libs/components/forms/src/lib/modules/input-box/input-box.component.html b/libs/components/forms/src/lib/modules/input-box/input-box.component.html index 477d94e91c..8a381250ee 100644 --- a/libs/components/forms/src/lib/modules/input-box/input-box.component.html +++ b/libs/components/forms/src/lib/modules/input-box/input-box.component.html @@ -79,6 +79,14 @@