Skip to content

Commit

Permalink
JSUI-2821 Fixed Youtube modal's accessibility (#1366)
Browse files Browse the repository at this point in the history
Created the `AccessibleModal` component and adapted `Quickview` and `YoutubeThumbnail` to use it.

https://coveord.atlassian.net/browse/JSUI-2821
  • Loading branch information
btaillon-coveo committed Jan 17, 2020
1 parent de86bff commit 9a5daa1
Show file tree
Hide file tree
Showing 8 changed files with 293 additions and 59 deletions.
5 changes: 4 additions & 1 deletion lib/modal-box/index.d.ts
Expand Up @@ -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
Expand Down
47 changes: 11 additions & 36 deletions src/ui/Quickview/Quickview.ts
Expand Up @@ -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_.
Expand Down Expand Up @@ -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.
Expand All @@ -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);
Expand All @@ -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() {
Expand Down Expand Up @@ -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
Expand All @@ -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();
}
Expand Down Expand Up @@ -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);
}
Expand All @@ -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 {
Expand Down
22 changes: 12 additions & 10 deletions src/ui/YouTube/YouTubeThumbnail.ts
Expand Up @@ -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;
Expand Down Expand Up @@ -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);
Expand Down Expand Up @@ -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
});
}

/**
Expand All @@ -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();
Expand Down
91 changes: 91 additions & 0 deletions 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<IAccessibleModalOptions> = {}
) {
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;
}
}
29 changes: 19 additions & 10 deletions unitTests/Simulate.ts
Expand Up @@ -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,
Expand Down Expand Up @@ -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 = <any>{};
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;
}
Expand Down
3 changes: 3 additions & 0 deletions unitTests/Test.ts
Expand Up @@ -812,3 +812,6 @@ CommerceQueryTest();

import { FocusTrapTest } from './ui/FocusTrapTest';
FocusTrapTest();

import { AccessibleModalTest } from './utils/AccessibleModalTest';
AccessibleModalTest();
15 changes: 13 additions & 2 deletions unitTests/ui/YouTubeThumbnailTest.ts
Expand Up @@ -91,7 +91,7 @@ export function YouTubeThumbnailTest() {
<IYouTubeThumbnailOptions>{ embed: false },
result
);
test.cmp.ModalBox = modalBox;
test.cmp['modalbox']['modalboxModule'] = modalBox;
test.cmp.openResultLink();
expect(modalBox.open).not.toHaveBeenCalled();
});
Expand All @@ -102,10 +102,21 @@ export function YouTubeThumbnailTest() {
<IYouTubeThumbnailOptions>{ 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, IYouTubeThumbnailOptions>(
YouTubeThumbnail,
<IYouTubeThumbnailOptions>{ 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 => {
Expand Down

0 comments on commit 9a5daa1

Please sign in to comment.