Skip to content
New issue

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

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

Already on GitHub? Sign in to your account

JSUI-2821 Fixed Youtube modal's accessibility #1366

Merged
merged 7 commits into from
Jan 17, 2020
5 changes: 4 additions & 1 deletion lib/modal-box/index.d.ts
Original file line number Diff line number Diff line change
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
Original file line number Diff line number Diff line change
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
Original file line number Diff line number Diff line change
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
Original file line number Diff line number Diff line change
@@ -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);
btaillon-coveo marked this conversation as resolved.
Show resolved Hide resolved
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
Original file line number Diff line number Diff line change
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
Original file line number Diff line number Diff line change
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
Original file line number Diff line number Diff line change
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