From 705ba85c13706628affef843a824c3e94336518a Mon Sep 17 00:00:00 2001 From: LarisaStar <61147963+Larisa-Staroverova@users.noreply.github.com> Date: Thu, 19 Oct 2023 09:58:36 +0200 Subject: [PATCH] A11Y: Quote modal needs enhancements for screen readers (#17959) Closes CXSPA-4923 --- .../assets/translations/en/quote.i18n.ts | 36 +++- .../quote-actions-by-role.component.spec.ts | 17 ++ .../quote-actions-by-role.component.ts | 3 + ...uote-actions-confirm-dialog.component.html | 7 +- ...e-actions-confirm-dialog.component.spec.ts | 155 +++++++++++++++++- .../quote-actions-confirm-dialog.component.ts | 46 +++++- .../quote-actions-confirm-dialog.model.ts | 3 + .../common-quote-test-utils.service.ts | 73 +++++++++ 8 files changed, 333 insertions(+), 7 deletions(-) diff --git a/feature-libs/quote/assets/translations/en/quote.i18n.ts b/feature-libs/quote/assets/translations/en/quote.i18n.ts index 1d98ab46cbd..86b0e174919 100644 --- a/feature-libs/quote/assets/translations/en/quote.i18n.ts +++ b/feature-libs/quote/assets/translations/en/quote.i18n.ts @@ -29,29 +29,44 @@ export const quote = { option: { yes: 'Yes', no: 'No' }, buyer: { submit: { + a11y: { + close: 'Close submit quote modal', + }, title: 'Submit Quote Request {{ code }}?', confirmNote: 'Are you sure you want to submit this quote request?', successMessage: 'Quote request submitted successfully', }, cancel: { + a11y: { + close: 'Close cancel quote modal', + }, title: 'Cancel Quote Request {{ code }}?', - confirmNote: 'Are you sure you want to cancel this quote request', + confirmNote: 'Are you sure you want to cancel this quote request?', successMessage: 'Quote request cancelled', }, }, buyer_offer: { edit: { + a11y: { + close: 'Close edit quote modal', + }, title: 'Confirm Edit Quote {{ code }}?', confirmNote: 'Are you sure you want to edit this approved quote?', warningNote: 'This Quote has been Approved. Editing this Quote will prevent Checkout until new edits are approved.', }, cancel: { + a11y: { + close: 'Close cancel quote modal', + }, title: 'Cancel Quote {{ code }}?', confirmNote: 'Are you sure you want to cancel this quote?', successMessage: 'Quote cancelled', }, checkout: { + a11y: { + close: 'Close checkout quote modal', + }, title: 'Checkout Quote {{ code }}?', confirmNote: 'Are you sure you want to accept and checkout this quote?', @@ -59,12 +74,18 @@ export const quote = { }, expired: { edit: { + a11y: { + close: 'Close edit quote modal', + }, title: 'Confirm Edit Quote {{ code }}?', confirmNote: 'Are you sure you want to edit this expired quote?', warningNote: 'This Quote is expired. Editing this quote will prevent checkout until new edits are approved.', }, requote: { + a11y: { + close: 'Close requote modal', + }, title: 'Recreate Quote Request {{ code }}?', confirmNote: 'Are you sure you want to recreate this quote request?', @@ -74,6 +95,9 @@ export const quote = { }, seller: { submit: { + a11y: { + close: 'Close submit quote modal', + }, title: 'Submit Quote {{ code }}?', confirmNote: 'Are you sure you want to submit this quote?', warningNote: @@ -83,11 +107,17 @@ export const quote = { }, approver: { approve: { + a11y: { + close: 'Close approve quote modal', + }, title: 'Approve Quote {{ code }}?', confirmNote: 'Are you sure you want to approve this quote?', successMessage: 'Quote approved successfully', }, reject: { + a11y: { + close: 'Close reject quote modal', + }, title: 'Reject Quote {{ code }}?', confirmNote: 'Are you sure you want to reject this quote?', successMessage: 'Quote rejected', @@ -95,6 +125,9 @@ export const quote = { }, all: { edit: { + a11y: { + close: 'Close edit quote modal', + }, title: 'Edit Quote {{ code }}?', confirmNote: 'Are you sure you want to edit this quote?', warningNote: @@ -180,5 +213,6 @@ export const quote = { CANCELLED: 'Cancelled', EXPIRED: 'Expired', }, + a11y: {}, }, }; diff --git a/feature-libs/quote/components/actions/by-role/quote-actions-by-role.component.spec.ts b/feature-libs/quote/components/actions/by-role/quote-actions-by-role.component.spec.ts index 38073f0cb83..2e71ee34dc3 100644 --- a/feature-libs/quote/components/actions/by-role/quote-actions-by-role.component.spec.ts +++ b/feature-libs/quote/components/actions/by-role/quote-actions-by-role.component.spec.ts @@ -92,10 +92,12 @@ const mockQuoteDetails$ = new BehaviorSubject(mockQuote); const currentCart: Partial = {}; let dialogClose$: BehaviorSubject; + class MockLaunchDialogService implements Partial { closeDialog(reason: any): void { dialogClose$.next(reason); } + openDialog( _caller: LAUNCH_CALLER, _openElement?: ElementRef, @@ -104,6 +106,7 @@ class MockLaunchDialogService implements Partial { ) { return of(); } + get dialogClose() { return dialogClose$.asObservable(); } @@ -113,12 +116,14 @@ class MockCommerceQuotesFacade implements Partial { getQuoteDetails(): Observable { return mockQuoteDetails$.asObservable(); } + performQuoteAction( _quote: Quote, _quoteAction: QuoteActionType ): Observable { return EMPTY; } + requote = createSpy(); } @@ -226,6 +231,9 @@ describe('QuoteActionsByRoleComponent', () => { title: 'quote.actions.confirmDialog.buyer.submit.title', confirmNote: 'quote.actions.confirmDialog.buyer.submit.confirmNote', successMessage: 'quote.actions.confirmDialog.buyer.submit.successMessage', + a11y: { + close: 'quote.actions.confirmDialog.buyer.submit.a11y.close', + }, }; mockQuoteDetails$.next(quoteForSubmitAction); fixture.detectChanges(); @@ -259,6 +267,9 @@ describe('QuoteActionsByRoleComponent', () => { confirmNote: 'quote.actions.confirmDialog.buyer_offer.edit.confirmNote', warningNote: 'quote.actions.confirmDialog.buyer_offer.edit.warningNote', validity: 'quote.actions.confirmDialog.validity', + a11y: { + close: 'quote.actions.confirmDialog.buyer_offer.edit.a11y.close', + }, }; mockQuoteDetails$.next(quoteInBuyerOfferState); fixture.detectChanges(); @@ -308,6 +319,9 @@ describe('QuoteActionsByRoleComponent', () => { title: 'quote.actions.confirmDialog.expired.requote.title', confirmNote: 'quote.actions.confirmDialog.expired.requote.confirmNote', warningNote: 'quote.actions.confirmDialog.expired.requote.warningNote', + a11y: { + close: 'quote.actions.confirmDialog.expired.requote.a11y.close', + }, }; mockQuoteDetails$.next(expiredQuote); fixture.detectChanges(); @@ -573,6 +587,9 @@ describe('QuoteActionsByRoleComponent', () => { title: 'title', confirmNote: 'confirmNote', successMessage: 'successMessage', + a11y: { + close: 'A11y text for close modal', + }, }; }); diff --git a/feature-libs/quote/components/actions/by-role/quote-actions-by-role.component.ts b/feature-libs/quote/components/actions/by-role/quote-actions-by-role.component.ts index c9f89a6db55..cf0953dd39d 100644 --- a/feature-libs/quote/components/actions/by-role/quote-actions-by-role.component.ts +++ b/feature-libs/quote/components/actions/by-role/quote-actions-by-role.component.ts @@ -186,6 +186,9 @@ export class QuoteActionsByRoleComponent implements OnInit, OnDestroy { quote: quote, title: dialogConfig.i18nKeyPrefix + '.title', confirmNote: dialogConfig.i18nKeyPrefix + '.confirmNote', + a11y: { + close: dialogConfig.i18nKeyPrefix + '.a11y.close', + }, }; if (dialogConfig.showWarningNote) { confirmationContext.warningNote = diff --git a/feature-libs/quote/components/actions/confirm-dialog/quote-actions-confirm-dialog.component.html b/feature-libs/quote/components/actions/confirm-dialog/quote-actions-confirm-dialog.component.html index 411671c7076..2bec4a82375 100644 --- a/feature-libs/quote/components/actions/confirm-dialog/quote-actions-confirm-dialog.component.html +++ b/feature-libs/quote/components/actions/confirm-dialog/quote-actions-confirm-dialog.component.html @@ -19,7 +19,7 @@ + + +
+ {{ getA11yModalText(confirmationContext) }} +
diff --git a/feature-libs/quote/components/actions/confirm-dialog/quote-actions-confirm-dialog.component.spec.ts b/feature-libs/quote/components/actions/confirm-dialog/quote-actions-confirm-dialog.component.spec.ts index 958e856bf62..279e95013d2 100644 --- a/feature-libs/quote/components/actions/confirm-dialog/quote-actions-confirm-dialog.component.spec.ts +++ b/feature-libs/quote/components/actions/confirm-dialog/quote-actions-confirm-dialog.component.spec.ts @@ -1,13 +1,17 @@ import { Component, Directive, Input } from '@angular/core'; import { ComponentFixture, TestBed, waitForAsync } from '@angular/core/testing'; -import { I18nTestingModule } from '@spartacus/core'; +import { + CxDatePipe, + I18nTestingModule, + LanguageService, +} from '@spartacus/core'; import { Quote, QuoteState } from '@spartacus/quote/root'; import { FocusConfig, ICON_TYPE, LaunchDialogService, } from '@spartacus/storefront'; -import { BehaviorSubject } from 'rxjs'; +import { BehaviorSubject, Observable, of } from 'rxjs'; import { CommonQuoteTestUtilsService } from '../../testing/common-quote-test-utils.service'; import { QuoteActionsConfirmDialogComponent } from './quote-actions-confirm-dialog.component'; import { ConfirmationContext } from './quote-actions-confirm-dialog.model'; @@ -20,7 +24,7 @@ const quote: Quote = { allowedActions: [], totalPrice: {}, description: 'Quote description', - expirationTime: new Date('2023-05-26'), + expirationTime: new Date('2017-01-11T10:14:39+0000'), isEditable: true, }; @@ -30,6 +34,9 @@ const confirmationContext: ConfirmationContext = { confirmNote: 'confirmActionDialog.buyer_offer.edit.confirmNote', warningNote: 'confirmActionDialog.buyer_offer.edit.warningNote', validity: 'confirmActionDialog.validity', + a11y: { + close: 'confirmActionDialog.buyer_offer.edit.a11y.close', + }, }; @Component({ @@ -47,11 +54,18 @@ export class MockKeyboadFocusDirective { @Input('cxFocus') config: FocusConfig = {}; } +class MockLanguageService { + getActive(): Observable { + return of('en-US'); + } +} + describe('QuoteActionsConfirmDialogComponent', () => { let component: QuoteActionsConfirmDialogComponent; let fixture: ComponentFixture; let htmlElem: HTMLElement; let mockLaunchDialogService: LaunchDialogService; + let datePipe: CxDatePipe; let dialogDataSender: BehaviorSubject<{ confirmationContext: ConfirmationContext; @@ -59,23 +73,26 @@ describe('QuoteActionsConfirmDialogComponent', () => { class MockLaunchDialogService { closeDialog(): void {} + data$ = dialogDataSender; } beforeEach( waitForAsync(() => { TestBed.configureTestingModule({ + imports: [I18nTestingModule], declarations: [ QuoteActionsConfirmDialogComponent, MockKeyboadFocusDirective, MockCxIconComponent, ], - imports: [I18nTestingModule], providers: [ + CxDatePipe, { provide: LaunchDialogService, useClass: MockLaunchDialogService, }, + { provide: LanguageService, useClass: MockLanguageService }, ], }).compileComponents(); }) @@ -89,6 +106,7 @@ describe('QuoteActionsConfirmDialogComponent', () => { htmlElem = fixture.nativeElement; component = fixture.componentInstance; mockLaunchDialogService = TestBed.inject(LaunchDialogService); + datePipe = TestBed.inject(CxDatePipe); spyOn(mockLaunchDialogService, 'closeDialog'); component.ngOnInit(); fixture.detectChanges(); @@ -160,4 +178,133 @@ describe('QuoteActionsConfirmDialogComponent', () => { modal.dispatchEvent(new Event('esc')); expect(mockLaunchDialogService.closeDialog).toHaveBeenCalled(); }); + + describe('isNotEmpty', () => { + it('should return "false" because string is null', () => { + expect(component['isNotEmpty'](null)).toBe(false); + }); + + it('should return "false" because string is undefined', () => { + expect(component['isNotEmpty'](undefined)).toBe(false); + }); + + it('should return "false" because string is an empty string', () => { + expect(component['isNotEmpty']('')).toBe(false); + }); + + it('should return "false" because string contains blank characters', () => { + expect(component['isNotEmpty'](' ')).toBe(false); + }); + + it('should return "true" because string contains something', () => { + expect(component['isNotEmpty']('test')).toBe(true); + }); + }); + + describe('getA11yModalText', () => { + it('should return only a confirmation note', () => { + const context = structuredClone(confirmationContext); + context.warningNote = null; + context.validity = null; + context.quote.expirationTime = null; + + expect(component.getA11yModalText(context)).toEqual( + confirmationContext.confirmNote + ); + }); + + it('should return a warning note with a confirmation note', () => { + const context = structuredClone(confirmationContext); + context.validity = null; + context.quote.expirationTime = null; + const a11yModalText = + confirmationContext.warningNote + confirmationContext.confirmNote; + + expect(component.getA11yModalText(context)).toEqual(a11yModalText); + }); + + it('should return a validity with date and a confirmation note', () => { + const context = structuredClone(confirmationContext); + context.warningNote = null; + const expirationTime = datePipe.transform( + confirmationContext.quote.expirationTime + ); + const a11yModalText = + confirmationContext.validity + + expirationTime + + confirmationContext.confirmNote; + + expect(component.getA11yModalText(context)).toEqual(a11yModalText); + }); + + it('should return a complete a11y relevant information', () => { + const context = structuredClone(confirmationContext); + const expirationTime = datePipe.transform( + confirmationContext.quote.expirationTime + ); + const a11yModalText = + confirmationContext.warningNote + + confirmationContext.validity + + expirationTime + + confirmationContext.confirmNote; + + expect(component.getA11yModalText(context)).toEqual(a11yModalText); + }); + }); + + describe('Accessibility', () => { + it("should contain action button element with class name 'close' and 'aria-label' attribute that indicates the text for close button", () => { + CommonQuoteTestUtilsService.expectElementContainsA11y( + expect, + htmlElem, + 'button', + 'close', + 0, + 'aria-label', + 'confirmActionDialog.buyer_offer.edit.a11y.close' + ); + }); + + it("should contain div element with class name 'cx-visually-hidden' and 'aria-live' attribute that indicates that the appeared modal requires the user's attention", () => { + CommonQuoteTestUtilsService.expectElementContainsA11y( + expect, + htmlElem, + 'div', + 'cx-visually-hidden', + 0, + 'aria-live', + 'polite' + ); + }); + + it("should contain action div element with class name 'cx-visually-hidden' and 'aria-atomic' attribute that indicates whether a screen reader will present all changed region", () => { + CommonQuoteTestUtilsService.expectElementContainsA11y( + expect, + htmlElem, + 'div', + 'cx-visually-hidden', + 0, + 'aria-atomic', + 'true' + ); + }); + + it('should contain a explanatory text that is seen only for a screen reader and explains that the conflicts must be resolved to continue', () => { + const expirationTime = datePipe.transform( + confirmationContext.quote.expirationTime + ); + const a11yModalText = + confirmationContext.warningNote + + confirmationContext.validity + + expirationTime + + confirmationContext.confirmNote; + + CommonQuoteTestUtilsService.expectElementToContainText( + expect, + htmlElem, + 'div.cx-visually-hidden', + a11yModalText + ); + }); + }); }); diff --git a/feature-libs/quote/components/actions/confirm-dialog/quote-actions-confirm-dialog.component.ts b/feature-libs/quote/components/actions/confirm-dialog/quote-actions-confirm-dialog.component.ts index 4c0b9d25ab1..dd6199250bf 100644 --- a/feature-libs/quote/components/actions/confirm-dialog/quote-actions-confirm-dialog.component.ts +++ b/feature-libs/quote/components/actions/confirm-dialog/quote-actions-confirm-dialog.component.ts @@ -10,6 +10,7 @@ import { inject, OnInit, } from '@angular/core'; +import { CxDatePipe, TranslationService } from '@spartacus/core'; import { QuoteCoreConfig } from '@spartacus/quote/core'; import { FocusConfig, @@ -17,17 +18,20 @@ import { LaunchDialogService, } from '@spartacus/storefront'; import { Observable } from 'rxjs'; -import { filter, map } from 'rxjs/operators'; +import { filter, map, take } from 'rxjs/operators'; import { ConfirmationContext } from './quote-actions-confirm-dialog.model'; @Component({ selector: 'cx-quote-actions-confirm-dialog', templateUrl: './quote-actions-confirm-dialog.component.html', changeDetection: ChangeDetectionStrategy.OnPush, + providers: [CxDatePipe], }) export class QuoteActionsConfirmDialogComponent implements OnInit { protected launchDialogService = inject(LaunchDialogService); protected quoteCoreConfig = inject(QuoteCoreConfig); + protected translationService = inject(TranslationService); + protected cxDatePipe = inject(CxDatePipe); iconTypes = ICON_TYPE; @@ -50,4 +54,44 @@ export class QuoteActionsConfirmDialogComponent implements OnInit { dismissModal(reason?: any): void { this.launchDialogService.closeDialog(reason); } + + protected isNotEmpty(value: string): boolean { + return value && value.trim()?.length !== 0 ? true : false; + } + + /** + * Retrieves an accessibility text for the confirmation modal. + * + * @param {ConfirmationContext} context - confirmation context + */ + getA11yModalText(context: ConfirmationContext): string { + let translatedText = ''; + if (context.warningNote && this.isNotEmpty(context.warningNote)) { + this.translationService + .translate(context.warningNote) + .pipe(take(1)) + .subscribe((text) => (translatedText += text)); + } + + if ( + context.validity && + this.isNotEmpty(context.validity) && + context.quote.expirationTime + ) { + this.translationService + .translate(context.validity) + .pipe(take(1)) + .subscribe((text) => (translatedText += text)); + + const date = new Date(context.quote.expirationTime); + translatedText += this.cxDatePipe.transform(date); + } + + this.translationService + .translate(context.confirmNote) + .pipe(take(1)) + .subscribe((text) => (translatedText += text)); + + return translatedText; + } } diff --git a/feature-libs/quote/components/actions/confirm-dialog/quote-actions-confirm-dialog.model.ts b/feature-libs/quote/components/actions/confirm-dialog/quote-actions-confirm-dialog.model.ts index d57b4e6e5c5..fac3d6e0578 100644 --- a/feature-libs/quote/components/actions/confirm-dialog/quote-actions-confirm-dialog.model.ts +++ b/feature-libs/quote/components/actions/confirm-dialog/quote-actions-confirm-dialog.model.ts @@ -13,4 +13,7 @@ export interface ConfirmationContext { warningNote?: string; validity?: string; successMessage?: string; + a11y: { + close: string; + }; } diff --git a/feature-libs/quote/components/testing/common-quote-test-utils.service.ts b/feature-libs/quote/components/testing/common-quote-test-utils.service.ts index 5bea991a8e8..efb8d9936c9 100644 --- a/feature-libs/quote/components/testing/common-quote-test-utils.service.ts +++ b/feature-libs/quote/components/testing/common-quote-test-utils.service.ts @@ -192,4 +192,77 @@ export class CommonQuoteTestUtilsService { caret.click(); } } + + protected static collectElementsWithClassName( + elements: Element[], + tagClass: string, + foundElements: Element[] + ) { + elements.forEach((element) => { + const classList = element.classList; + if (classList.length >= 1) { + classList.forEach((elementClass) => { + if (elementClass === tagClass) { + foundElements.push(element); + } + }); + } + }); + } + + protected static getElement( + htmlElements: HTMLElement, + tag: string, + tagClass?: string, + tagIndex?: number + ): Element | undefined { + const foundElements: Element[] = []; + const elements = Array.from(htmlElements.getElementsByTagName(tag)); + if (!tagClass) { + return !tagIndex ? elements[0] : elements[tagIndex]; + } else { + CommonQuoteTestUtilsService.collectElementsWithClassName( + elements, + tagClass, + foundElements + ); + return tagIndex ? foundElements[tagIndex] : foundElements[0]; + } + } + + /** + * Helper function for proving whether the element contains corresponding accessibility attribute with expected content. + * + * @param expect - Expectation for a spec + * @param htmlElement - whole HTML element + * @param tag - certain HTML element + * @param tagClass - Class of the HTML element + * @param tagIndex - Index of HTML element + * @param a11yAttr - A11y attribute + * @param a11yAttrContent - Content of a11y attribute + */ + static expectElementContainsA11y( + expect: any, + htmlElement: HTMLElement, + tag: string, + tagClass?: string, + tagIndex?: number, + a11yAttr?: string, + a11yAttrContent?: string + ) { + const item = CommonQuoteTestUtilsService.getElement( + htmlElement, + tag, + tagClass, + tagIndex + ); + + const attributes = item?.attributes; + if (a11yAttr) { + expect(attributes?.hasOwnProperty(a11yAttr)).toBe(true); + if (a11yAttrContent) { + expect(item?.getAttribute(a11yAttr)).toEqual(a11yAttrContent); + } + } + } }