diff --git a/lib/modal-box/index.d.ts b/lib/modal-box/index.d.ts index 4985539cc3..d85f2ac750 100644 --- a/lib/modal-box/index.d.ts +++ b/lib/modal-box/index.d.ts @@ -80,7 +80,10 @@ declare namespace Coveo.ModalBox { * Specify the content that you wish to put inside the modal box */ body?: HTMLElement; - sizeMod?: 'big'; + /** + * The size for the modal box + */ + sizeMod?: 'small' | 'normal' | 'big'; } /** * Open a modal box with the given content diff --git a/src/ui/Quickview/Quickview.ts b/src/ui/Quickview/Quickview.ts index 6bd121cc60..dd763150ff 100644 --- a/src/ui/Quickview/Quickview.ts +++ b/src/ui/Quickview/Quickview.ts @@ -24,9 +24,7 @@ import { TemplateComponentOptions } from '../Base/TemplateComponentOptions'; import { Template } from '../Templates/Template'; import { DefaultQuickviewTemplate } from './DefaultQuickviewTemplate'; import { QuickviewDocument } from './QuickviewDocument'; -import { KeyboardUtils } from '../../Core'; -import { KEYBOARD } from '../../utils/KeyboardUtils'; -import { FocusTrap } from '../FocusTrap/FocusTrap'; +import { AccessibleModal } from '../../utils/AccessibleModal'; /** * The allowed [`Quickview`]{@link Quickview} [`tooltipPlacement`]{@link Quickview.options.tooltipPlacement} option values. The `-start` and `-end` variations indicate relative alignement. Horizontally (`top`, `bottom`), `-start` means _left_ and `-end` means _right_. Vertically (`left`, `right`), `-start` means _top_ and `-end` means _bottom_. No variation means _center_. @@ -254,12 +252,10 @@ export class Quickview extends Component { public static resultCurrentlyBeingRendered: IQueryResult = null; - private modalbox: Coveo.ModalBox.ModalBox; + private modalbox: AccessibleModal; private lastFocusedElement: HTMLElement; - private focusTrap: FocusTrap; - /** * Creates a new `Quickview` component. * @param element The HTMLElement on which to instantiate the component. @@ -274,7 +270,7 @@ export class Quickview extends Component { public options?: IQuickviewOptions, public bindings?: IResultsComponentBindings, public result?: IQueryResult, - private ModalBox: Coveo.ModalBox.ModalBox = ModalBoxModule + ModalBox: Coveo.ModalBox.ModalBox = ModalBoxModule ) { super(element, Quickview.ID, bindings); this.options = ComponentOptions.initComponentOptions(element, Quickview, options); @@ -296,6 +292,8 @@ export class Quickview extends Component { this.open(); }); } + + this.modalbox = new AccessibleModal('coveo-quick-view', element.ownerDocument.body as HTMLBodyElement, ModalBox); } private buildContent() { @@ -361,7 +359,7 @@ export class Quickview extends Component { * Opens the `Quickview` modal box. */ public open() { - if (Utils.isNullOrUndefined(this.modalbox)) { + if (!this.modalbox.isOpen) { // To prevent the keyboard from opening on mobile if the search bar has focus Quickview.resultCurrentlyBeingRendered = this.result; // activeElement does not exist in LockerService @@ -387,11 +385,8 @@ export class Quickview extends Component { * Closes the `Quickview` modal box. */ public close() { - if (this.modalbox != null) { + if (this.modalbox.isOpen) { this.modalbox.close(); - this.modalbox = null; - this.focusTrap.disable(); - this.focusTrap = null; if (this.lastFocusedElement && this.lastFocusedElement.parentElement) { this.lastFocusedElement.focus(); } @@ -438,7 +433,7 @@ export class Quickview extends Component { } private animateAndOpen() { - const quickviewDocument = $$(this.modalbox.modalBox).find('.' + Component.computeCssClassName(QuickviewDocument)); + const quickviewDocument = $$(this.modalbox.element).find('.' + Component.computeCssClassName(QuickviewDocument)); if (quickviewDocument) { Initialization.dispatchNamedMethodCallOrComponentCreation('open', quickviewDocument, null); } @@ -458,34 +453,14 @@ export class Quickview extends Component { this.bindings ).el; - this.modalbox = this.ModalBox.open(computedModalBoxContent.el, { - title, - className: 'coveo-quick-view', - validation: () => { - this.closeQuickview(); - return true; - }, - body: this.element.ownerDocument.body, - sizeMod: 'big' + this.modalbox.open(title, computedModalBoxContent.el, () => { + this.closeQuickview(); + return true; }); - this.makeModalboxAccessible(this.modalbox.modalBox); return computedModalBoxContent; }); } - private makeModalboxAccessible(modal: HTMLElement) { - modal.setAttribute('aria-modal', 'true'); - this.makeModalCloseButtonAccessible(modal.querySelector('.coveo-small-close')); - this.focusTrap = new FocusTrap(modal); - } - - private makeModalCloseButtonAccessible(closeButton: HTMLElement) { - closeButton.setAttribute('aria-label', l('Close')); - closeButton.tabIndex = 0; - closeButton.focus(); - $$(closeButton).on('keyup', KeyboardUtils.keypressAction(KEYBOARD.ENTER, () => closeButton.click())); - } - private prepareOpenQuickviewObject() { const loadingAnimation = this.options.loadingAnimation; return { diff --git a/src/ui/YouTube/YouTubeThumbnail.ts b/src/ui/YouTube/YouTubeThumbnail.ts index bca42a2462..038958b64b 100644 --- a/src/ui/YouTube/YouTubeThumbnail.ts +++ b/src/ui/YouTube/YouTubeThumbnail.ts @@ -12,6 +12,7 @@ import { Initialization } from '../Base/Initialization'; import { get } from '../Base/RegisteredNamedMethods'; import { IResultsComponentBindings } from '../Base/ResultsComponentBindings'; import { ResultLink } from '../ResultLink/ResultLink'; +import { AccessibleModal } from '../../utils/AccessibleModal'; export interface IYouTubeThumbnailOptions { width: string; @@ -71,14 +72,14 @@ export class YouTubeThumbnail extends Component { public resultLink: Dom; - private modalbox: Coveo.ModalBox.ModalBox; + private modalbox: AccessibleModal; constructor( public element: HTMLElement, public options?: IYouTubeThumbnailOptions, public bindings?: IResultsComponentBindings, public result?: IQueryResult, - public ModalBox = ModalBoxModule + ModalBox = ModalBoxModule ) { super(element, YouTubeThumbnail.ID, bindings); this.options = ComponentOptions.initComponentOptions(element, YouTubeThumbnail, options); @@ -123,6 +124,10 @@ export class YouTubeThumbnail extends Component { Initialization.automaticallyCreateComponentsInsideResult(element, result, { ResultLink: this.options.embed ? { onClick: () => this.openYoutubeIframe() } : null }); + + this.modalbox = new AccessibleModal('coveo-youtube-player', element.ownerDocument.body as HTMLBodyElement, ModalBox, { + overlayClose: true + }); } /** @@ -148,14 +153,11 @@ export class YouTubeThumbnail extends Component { div.append(iframe.el); - this.modalbox = this.ModalBox.open(div.el, { - overlayClose: true, - title: DomUtils.getQuickviewHeader(this.result, { showDate: true, title: this.result.title }, this.bindings).el, - className: 'coveo-youtube-player', - validation: () => true, - body: this.element.ownerDocument.body, - sizeMod: 'big' - }); + this.modalbox.open( + DomUtils.getQuickviewHeader(this.result, { showDate: true, title: this.result.title }, this.bindings).el, + div.el, + () => true + ); $$($$(this.modalbox.wrapper).find('.coveo-quickview-close-button')).on('click', () => { this.modalbox.close(); diff --git a/src/utils/AccessibleModal.ts b/src/utils/AccessibleModal.ts new file mode 100644 index 0000000000..4f050286e8 --- /dev/null +++ b/src/utils/AccessibleModal.ts @@ -0,0 +1,91 @@ +import { ModalBox as ModalBoxModule } from '../ExternalModulesShim'; +import { FocusTrap } from '../ui/FocusTrap/FocusTrap'; +import { l } from '../strings/Strings'; +import { $$ } from './Dom'; +import { KeyboardUtils, KEYBOARD } from './KeyboardUtils'; + +export interface IAccessibleModalOptions { + overlayClose?: boolean; + sizeMod: 'small' | 'normal' | 'big'; +} + +export class AccessibleModal { + private focusTrap: FocusTrap; + private activeModal: Coveo.ModalBox.ModalBox; + private options: IAccessibleModalOptions; + + public get isOpen() { + return !!this.focusTrap; + } + + public get element() { + return this.activeModal && this.activeModal.modalBox; + } + + public get content() { + return this.activeModal && this.activeModal.content; + } + + public get wrapper() { + return this.activeModal && this.activeModal.wrapper; + } + + constructor( + private className: string, + private ownerBody: HTMLBodyElement, + private modalboxModule: Coveo.ModalBox.ModalBox = ModalBoxModule, + options: Partial = {} + ) { + this.options = { + ...{ + sizeMod: 'big' + }, + ...options + }; + } + + public open(title: HTMLElement, content: HTMLElement, validation: () => boolean) { + if (this.isOpen) { + return; + } + this.activeModal = this.modalboxModule.open(content, { + title, + className: this.className, + validation: () => { + this.onModalClose(); + return validation(); + }, + body: this.ownerBody, + sizeMod: this.options.sizeMod, + overlayClose: this.options.overlayClose + }); + this.focusTrap = new FocusTrap(this.element); + this.makeAccessible(); + } + + public close() { + if (!this.isOpen) { + return; + } + this.activeModal.close(); + this.activeModal = null; + } + + private makeAccessible() { + this.element.setAttribute('aria-modal', 'true'); + this.makeCloseButtonAccessible(); + } + + private makeCloseButtonAccessible() { + const closeButton: HTMLElement = this.element.querySelector('.coveo-small-close'); + closeButton.setAttribute('aria-label', l('Close')); + closeButton.tabIndex = 0; + closeButton.focus(); + $$(closeButton).on('keyup', KeyboardUtils.keypressAction(KEYBOARD.ENTER, () => closeButton.click())); + } + + private onModalClose() { + this.focusTrap.disable(); + this.focusTrap = null; + } +} diff --git a/unitTests/Simulate.ts b/unitTests/Simulate.ts index 4895cf2195..d9c695f3eb 100644 --- a/unitTests/Simulate.ts +++ b/unitTests/Simulate.ts @@ -6,7 +6,7 @@ import { IQueryCorrection } from '../src/rest/QueryCorrection'; import { IGroupByResult } from '../src/rest/GroupByResult'; import { IMockEnvironment } from './MockEnvironment'; import { FakeResults } from './Fake'; -import { $$ } from '../src/utils/Dom'; +import { $$, Dom } from '../src/utils/Dom'; import { QueryEvents } from '../src/events/QueryEvents'; import { INewQueryEventArgs, @@ -209,25 +209,34 @@ export class Simulate { static modalBoxModule(): ModalBox { let content: HTMLElement; + let closeButton: Dom; const container = $$( 'div', { className: 'coveo-wrapper coveo-modal-container' }, (content = $$( 'div', { className: 'coveo-modal-content' }, - $$('header', { className: 'coveo-modal-header' }, $$('div', { className: 'coveo-quickview-close-button coveo-small-close' })).el + $$( + 'header', + { className: 'coveo-modal-header' }, + (closeButton = $$('div', { className: 'coveo-quickview-close-button coveo-small-close' })) + ).el ).el) ).el; + spyOn(closeButton.el, 'focus'); const backdrop = $$('div', { className: 'coveo-modal-backdrop' }).el; let modalBox = {}; - modalBox.open = jasmine.createSpy('open'); - modalBox.close = jasmine.createSpy('close'); - modalBox.open.and.returnValue({ - modalBox: container, - wrapper: content, - overlay: backdrop, - content, - close: modalBox.close + let currentValidation: () => boolean = null; + modalBox.close = jasmine.createSpy('close').and.callFake(() => currentValidation && currentValidation()); + modalBox.open = jasmine.createSpy('open').and.callFake((_, { validation }) => { + currentValidation = validation; + return { + modalBox: container, + wrapper: content, + overlay: backdrop, + content, + close: modalBox.close + }; }); return modalBox; } diff --git a/unitTests/Test.ts b/unitTests/Test.ts index 3773fc131f..a059b4496d 100644 --- a/unitTests/Test.ts +++ b/unitTests/Test.ts @@ -812,3 +812,6 @@ CommerceQueryTest(); import { FocusTrapTest } from './ui/FocusTrapTest'; FocusTrapTest(); + +import { AccessibleModalTest } from './utils/AccessibleModalTest'; +AccessibleModalTest(); diff --git a/unitTests/ui/YouTubeThumbnailTest.ts b/unitTests/ui/YouTubeThumbnailTest.ts index 41f8bb3ed9..e9cb27638b 100644 --- a/unitTests/ui/YouTubeThumbnailTest.ts +++ b/unitTests/ui/YouTubeThumbnailTest.ts @@ -91,7 +91,7 @@ export function YouTubeThumbnailTest() { { embed: false }, result ); - test.cmp.ModalBox = modalBox; + test.cmp['modalbox']['modalboxModule'] = modalBox; test.cmp.openResultLink(); expect(modalBox.open).not.toHaveBeenCalled(); }); @@ -102,10 +102,21 @@ export function YouTubeThumbnailTest() { { embed: true }, result ); - test.cmp.ModalBox = modalBox; + test.cmp['modalbox']['modalboxModule'] = modalBox; test.cmp.openResultLink(); expect(modalBox.open).toHaveBeenCalled(); }); + + it('should open an accessible modal', () => { + test = Mock.optionsResultComponentSetup( + YouTubeThumbnail, + { embed: true }, + result + ); + test.cmp['modalbox']['modalboxModule'] = modalBox; + test.cmp.openResultLink(); + expect(test.cmp['modalbox'].isOpen).toEqual(true); + }); }); it('should call whatever method is associated on the result link when we try to open it', done => { diff --git a/unitTests/utils/AccessibleModalTest.ts b/unitTests/utils/AccessibleModalTest.ts new file mode 100644 index 0000000000..1de6df1669 --- /dev/null +++ b/unitTests/utils/AccessibleModalTest.ts @@ -0,0 +1,140 @@ +import { AccessibleModal } from '../../src/utils/AccessibleModal'; +import { $$ } from '../../src/utils/Dom'; +import { Simulate } from '../Simulate'; +import { FocusTrap } from '../../src/ui/FocusTrap/FocusTrap'; +import { KEYBOARD } from '../../src/utils/KeyboardUtils'; + +export function AccessibleModalTest() { + describe('AccessibleModal', () => { + let bodyElement: HTMLElement; + let titleElement: HTMLElement; + let contentElement: HTMLElement; + let modalBoxMock: Coveo.ModalBox.ModalBox; + let accessibleModal: AccessibleModal; + let validationSpy: jasmine.Spy; + const modalClass = 'notice-me'; + + function createBody() { + return (bodyElement = $$('div').el); + } + + function createAccessibleModal() { + return (accessibleModal = new AccessibleModal( + modalClass, + createBody() as HTMLBodyElement, + (modalBoxMock = Simulate.modalBoxModule()) + )); + } + + function createTitle() { + return (titleElement = $$('div').el); + } + + function createContent() { + return (contentElement = $$('div').el); + } + + function createValidationSpy() { + (validationSpy = jasmine.createSpy('validation')).and.returnValue(true); + return validationSpy as () => boolean; + } + + beforeEach(() => { + createAccessibleModal(); + }); + + describe('when calling open', () => { + let focusTrap: FocusTrap; + let container: HTMLElement; + let closeButton: HTMLElement; + let closeButtonClickSpy: jasmine.Spy; + + beforeEach(() => { + accessibleModal.open(createTitle(), createContent(), createValidationSpy()); + focusTrap = accessibleModal['focusTrap']; + container = accessibleModal.element; + closeButton = container.querySelector('.coveo-small-close'); + closeButtonClickSpy = spyOn(closeButton, 'click'); + }); + + it('has an element', () => { + expect(accessibleModal.element).toBeTruthy(); + }); + + it('has a wrapper', () => { + expect(accessibleModal.wrapper).toBeTruthy(); + }); + + it('has content', () => { + expect(accessibleModal.content).toBeTruthy(); + }); + + it("calls ModalBox's open", () => { + expect(modalBoxMock.open).toHaveBeenCalledTimes(1); + }); + + it("doesn't call the validation function", () => { + expect(validationSpy).not.toHaveBeenCalled(); + }); + + it('creates a focus trap', () => { + expect(focusTrap).not.toBeNull(); + }); + + it("doesn't disable its focus trap", () => { + expect(focusTrap['enabled']).toEqual(true); + }); + + it('sets aria-modal to true on the modal element', () => { + expect(container.getAttribute('aria-modal')).toEqual('true'); + }); + + it('gives the close button a label', () => { + expect(closeButton.getAttribute('aria-label')).toBeTruthy(); + }); + + it('gives the close button a tabindex', () => { + expect(closeButton.tabIndex).toEqual(0); + }); + + it('focuses on the close button', () => { + expect(closeButton.focus).toHaveBeenCalledTimes(1); + }); + + it('clicks on the close button when enter is pressed', () => { + Simulate.keyUp(closeButton, KEYBOARD.ENTER); + expect(closeButtonClickSpy).toHaveBeenCalledTimes(1); + }); + + describe('then calling close', () => { + beforeEach(() => { + accessibleModal.close(); + }); + + it("doesn't have an element", () => { + expect(accessibleModal.element).toBeNull(); + }); + + it("doesn't have a wrapper", () => { + expect(accessibleModal.wrapper).toBeNull(); + }); + + it("doesn't have content", () => { + expect(accessibleModal.content).toBeNull(); + }); + + it("calls ModalBox's close", () => { + expect(modalBoxMock.close).toHaveBeenCalledTimes(1); + }); + + it('calls the validation function', () => { + expect(validationSpy).toHaveBeenCalledTimes(1); + }); + + it('disables the focus trap', () => { + expect(focusTrap['enabled']).toEqual(false); + }); + }); + }); + }); +}