1
0
mirror of https://github.com/videojs/video.js.git synced 2025-02-12 12:16:27 +02:00

@misteroneill added a modal dialog. closes #2668

This commit is contained in:
Pat O'Neill 2015-10-28 13:28:15 -04:00 committed by Gary Katsevman
parent ab88bcdde3
commit 51f1863adc
20 changed files with 1430 additions and 68 deletions

View File

@ -8,6 +8,7 @@ CHANGELOG
* @forbesjo removed android/ios tests to increase build stability ([view](https://github.com/videojs/video.js/pull/2739))
* @nickygerritsen added canPlayType method to player ([view](https://github.com/videojs/video.js/pull/2709))
* @gkatsev fixes track tests and ignored empty properties in tracks converter ([view](https://github.com/videojs/video.js/pull/2744))
* @misteroneill added a modal dialog ([view](https://github.com/videojs/video.js/pull/2668))
--------------------

View File

@ -28,7 +28,7 @@
"object.assign": "^4.0.1",
"safe-json-parse": "^4.0.0",
"tsml": "1.0.1",
"videojs-font": "1.3.0",
"videojs-font": "1.4.0",
"videojs-ie8": "1.1.0",
"videojs-swf": "5.0.0-rc1",
"vtt.js": "git+https://github.com/gkatsev/vtt.js.git#vjs-v0.12.1",

View File

@ -1,3 +1,5 @@
@import "utilities/linear-gradient";
@mixin background-color-with-alpha($color, $alpha) {
background-color: $color;
background-color: rgba($color, $alpha);
@ -94,11 +96,15 @@
order: $value;
}
%icon-default {
%fill-parent {
position: absolute;
top: 0;
left: 0;
width: 100%;
height: 100%;
}
%icon-default {
@extend %fill-parent;
text-align: center;
}

View File

@ -0,0 +1,9 @@
.video-js .vjs-control.vjs-close-button {
@extend .vjs-icon-cancel;
cursor: pointer;
height: 3em;
position: absolute;
right: 0;
top: 0.5em;
z-index: 2;
}

View File

@ -1,48 +1,23 @@
.vjs-error-display {
display: none;
}
.vjs-error .vjs-error-display {
display: block;
position: absolute;
left: 0;
top: 0;
width: 100%;
height: 100%;
.vjs-error .vjs-error-display .vjs-modal-dialog-content {
font-size: 1.4em;
text-align: center;
}
.vjs-error .vjs-error-display:before {
color: #fff;
content: 'X';
font-family: $text-font-family;
font-size: 4em;
color: #fff;
/* In order to center the play icon vertically we need to set the line height
to the same as the button height */
line-height: 1;
text-shadow: 0.05em 0.05em 0.1em #000;
text-align: center /* Needed for IE8 */;
vertical-align: middle;
position: absolute;
left: 0;
top: 50%;
// In order to center the play icon vertically we need to set the line height
// to the same as the button height
line-height: 1;
margin-top: -0.5em;
position: absolute;
text-shadow: 0.05em 0.05em 0.1em #000;
text-align: center; // Needed for IE8
top: 50%;
vertical-align: middle;
width: 100%;
}
.vjs-error-display div {
position: absolute;
bottom: 1em;
right: 0;
left: 0;
font-size: 1.4em;
text-align: center;
padding: 3px;
@include background-color-with-alpha(#000, 0.5);
}
.vjs-error-display a,
.vjs-error-display a:visited {
color: #66A8CC;
}

View File

@ -125,6 +125,15 @@ body.vjs-full-window {
/* Hide disabled or unsupported controls. */
.vjs-hidden { display: none !important; }
// Visually hidden offscreen, but accessible to screen readers.
.video-js .vjs-offscreen {
height: 1px;
left: -9999px;
position: absolute;
top: 0;
width: 1px;
}
.vjs-lock-showing {
display: block !important;
opacity: 1;

View File

@ -0,0 +1,13 @@
.video-js .vjs-modal-dialog {
@extend %fill-parent;
@include linear-gradient(180deg, rgba(0, 0, 0, 0.8), rgba(255, 255, 255, 0));
}
.vjs-modal-dialog .vjs-modal-dialog-content {
@extend %fill-parent;
font-size: 1.2em; // 12px
line-height: 1.5; // 18px
padding: 30px;
z-index: 1;
}

View File

@ -0,0 +1,94 @@
// These functions and mixins taken from:
//
// "Building a linear-gradient Mixin in Sass" by Hugo Giraudel
// http://www.sitepoint.com/building-linear-gradient-mixin-sass/
// http://sassmeister.com/gist/b58f6e2cc3160007c880
//
/// Convert angle
/// @author Chris Eppstein
/// @param {Number} $value - Value to convert
/// @param {String} $unit - Unit to convert to
/// @return {Number} Converted angle
@function convert-angle($value, $unit) {
$convertable-units: deg grad turn rad;
$conversion-factors: 1 (10grad/9deg) (1turn/360deg) (3.1415926rad/180deg);
@if index($convertable-units, unit($value)) and index($convertable-units, $unit) {
@return $value
/ nth($conversion-factors, index($convertable-units, unit($value)))
* nth($conversion-factors, index($convertable-units, $unit));
}
@warn "Cannot convert `#{unit($value)}` to `#{$unit}`.";
}
/// Test if `$value` is an angle
/// @param {*} $value - Value to test
/// @return {Bool}
@function is-direction($value) {
$is-direction: index((
'to top',
'to top right',
'to right top',
'to right',
'to bottom right',
'to right bottom',
'to bottom',
'to bottom left',
'to left bottom',
'to left',
'to left top',
'to top left'
), $value);
$is-angle: type-of($value) == 'number' and index('deg' 'grad' 'turn' 'rad', unit($value));
@return $is-direction or $is-angle;
}
/// Convert a direction to legacy syntax
/// @param {Keyword | Angle} $value - Value to convert
/// @require {function} is-direction
/// @require {function} convert-angle
@function legacy-direction($value) {
@if is-direction($value) == false {
@warn "Cannot convert `#{$value}` to legacy syntax because it doesn't seem to be an angle or a direction";
}
$conversion-map: (
'to top' : 'bottom',
'to top right' : 'bottom left',
'to right top' : 'left bottom',
'to right' : 'left',
'to bottom right' : 'top left',
'to right bottom' : 'left top',
'to bottom' : 'top',
'to bottom left' : 'top right',
'to left bottom' : 'right top',
'to left' : 'right',
'to left top' : 'right bottom',
'to top left' : 'bottom right'
);
@if map-has-key($conversion-map, $value) {
@return map-get($conversion-map, $value);
}
@return 90deg - convert-angle($value, 'deg');
}
/// Mixin printing a linear-gradient
/// as well as a plain color fallback
/// and the `-webkit-` prefixed declaration
/// @access public
/// @param {String | List | Angle} $direction - Linear gradient direction
/// @param {Arglist} $color-stops - List of color-stops composing the gradient
@mixin linear-gradient($direction, $color-stops...) {
@if is-direction($direction) == false {
$color-stops: ($direction, $color-stops);
$direction: 180deg;
}
background: nth(nth($color-stops, 1), 1);
background: -webkit-linear-gradient(legacy-direction($direction), $color-stops);
background: linear-gradient($direction, $color-stops);
}

View File

@ -7,6 +7,7 @@
@import "components/layout";
@import "components/big-play";
@import "components/button";
@import "components/close-button";
@import "components/menu/menu";
@import "components/menu/menu-popup";
@ -35,3 +36,4 @@
@import "components/subtitles";
@import "components/adaptive";
@import "components/captions-settings";
@import "components/modal-dialog";

28
src/js/close-button.js Normal file
View File

@ -0,0 +1,28 @@
import Button from './button';
import Component from './component';
/**
* The `CloseButton` component is a button which fires a "close" event
* when it is activated.
*
* @extends Button
* @class CloseButton
*/
class CloseButton extends Button {
constructor(player, options) {
super(player, options);
this.controlText(options && options.controlText || this.localize('Close'));
}
buildCSSClass() {
return `vjs-close-button ${super.buildCSSClass()}`;
}
handleClick() {
this.trigger({type: 'close', bubbles: false});
}
}
Component.registerComponent('CloseButton', CloseButton);
export default CloseButton;

View File

@ -2,53 +2,58 @@
* @file error-display.js
*/
import Component from './component';
import * as Dom from './utils/dom.js';
import ModalDialog from './modal-dialog';
import * as Dom from './utils/dom';
import mergeOptions from './utils/merge-options';
/**
* Display that an error has occurred making the video unplayable
* Display that an error has occurred making the video unplayable.
*
* @param {Object} player Main Player
* @param {Object=} options Object of option names and values
* @extends Component
* @extends ModalDialog
* @class ErrorDisplay
*/
class ErrorDisplay extends Component {
class ErrorDisplay extends ModalDialog {
/**
* Constructor for error display modal.
*
* @param {Player} player
* @param {Object} [options]
*/
constructor(player, options) {
super(player, options);
this.update();
this.on(player, 'error', this.update);
this.on(player, 'error', this.open);
}
/**
* Create the component's DOM element
* Include the old class for backward-compatibility.
*
* @return {Element}
* @method createEl
* This can be removed in 6.0.
*
* @method buildCSSClass
* @deprecated
* @return {String}
*/
createEl() {
var el = super.createEl('div', {
className: 'vjs-error-display'
});
this.contentEl_ = Dom.createEl('div');
el.appendChild(this.contentEl_);
return el;
buildCSSClass() {
return `vjs-error-display ${super.buildCSSClass()}`;
}
/**
* Update the error message in localized language
* Generates the modal content based on the player error.
*
* @method update
* @return {String|Null}
*/
update() {
if (this.player().error()) {
this.contentEl_.innerHTML = this.localize(this.player().error().message);
}
content() {
let error = this.player().error();
return error ? this.localize(error.message) : '';
}
}
ErrorDisplay.prototype.options_ = mergeOptions(ModalDialog.prototype.options_, {
fillAlways: true,
uncloseable: true
});
Component.registerComponent('ErrorDisplay', ErrorDisplay);
export default ErrorDisplay;

372
src/js/modal-dialog.js Normal file
View File

@ -0,0 +1,372 @@
/**
* @file modal-dialog.js
*/
import document from 'global/document';
import * as Dom from './utils/dom';
import * as Fn from './utils/fn';
import log from './utils/log';
import Component from './component';
import CloseButton from './close-button';
const MODAL_CLASS_NAME = 'vjs-modal-dialog';
const ESC = 27;
/**
* The `ModalDialog` displays over the video and its controls, which blocks
* interaction with the player until it is closed.
*
* Modal dialogs include a "Close" button and will close when that button
* is activated - or when ESC is pressed anywhere.
*
* @extends Component
* @class ModalDialog
*/
class ModalDialog extends Component {
/**
* Constructor for modals.
*
* @param {Player} player
* @param {Object} [options]
* @param {Mixed} [options.content=undefined]
* Provide customized content for this modal.
*
* @param {String} [options.description]
* A text description for the modal, primarily for accessibility.
*
* @param {Boolean} [options.fillAlways=false]
* Normally, modals are automatically filled only the first time
* they open. This tells the modal to refresh its content
* every time it opens.
*
* @param {String} [options.label]
* A text label for the modal, primarily for accessibility.
*
* @param {Boolean} [options.temporary=true]
* If `true`, the modal can only be opened once; it will be
* disposed as soon as it's closed.
*
* @param {Boolean} [options.uncloseable=false]
* If `true`, the user will not be able to close the modal
* through the UI in the normal ways. Programmatic closing is
* still possible.
*
*/
constructor(player, options) {
super(player, options);
this.opened_ = this.hasBeenOpened_ = this.hasBeenFilled_ = false;
this.closeable(!this.options_.uncloseable);
this.content(this.options_.content);
// Make sure the contentEl is defined AFTER any children are initialized
// because we only want the contents of the modal in the contentEl
// (not the UI elements like the close button).
this.contentEl_ = Dom.createEl('div', {
className: `${MODAL_CLASS_NAME}-content`
}, {
role: 'document'
});
this.descEl_ = Dom.createEl('p', {
className: `${MODAL_CLASS_NAME}-description vjs-offscreen`,
id: this.el().getAttribute('aria-describedby')
});
Dom.textContent(this.descEl_, this.description());
this.el_.appendChild(this.descEl_);
this.el_.appendChild(this.contentEl_);
}
/**
* Create the modal's DOM element
*
* @method createEl
* @return {Element}
*/
createEl() {
return super.createEl('div', {
className: this.buildCSSClass(),
tabIndex: -1
}, {
'aria-describedby': `${this.id()}_description`,
'aria-hidden': 'true',
'aria-label': this.label(),
role: 'dialog'
});
}
/**
* Build the modal's CSS class.
*
* @method buildCSSClass
* @return {String}
*/
buildCSSClass() {
return `${MODAL_CLASS_NAME} vjs-hidden ${super.buildCSSClass()}`;
}
/**
* Handles key presses on the document, looking for ESC, which closes
* the modal.
*
* @method handleKeyPress
* @param {Event} e
*/
handleKeyPress(e) {
if (e.which === ESC && this.closeable()) {
this.close();
}
}
/**
* Returns the label string for this modal. Primarily used for accessibility.
*
* @return {String}
*/
label() {
return this.options_.label || this.localize('Modal Window');
}
/**
* Returns the description string for this modal. Primarily used for
* accessibility.
*
* @return {String}
*/
description() {
let desc = this.options_.description || this.localize('This is a modal window.');
// Append a universal closeability message if the modal is closeable.
if (this.closeable()) {
desc += ' ' + this.localize('This modal can be closed by pressing the Escape key or activating the close button.');
}
return desc;
}
/**
* Opens the modal.
*
* @method open
* @return {ModalDialog}
*/
open() {
if (!this.opened_) {
let player = this.player();
this.trigger('beforemodalopen');
this.opened_ = true;
// Fill content if the modal has never opened before and
// never been filled.
if (this.options_.fillAlways || !this.hasBeenOpened_ && !this.hasBeenFilled_) {
this.fill();
}
// If the player was playing, pause it and take note of its previously
// playing state.
this.wasPlaying_ = !player.paused();
if (this.wasPlaying_) {
player.pause();
}
if (this.closeable()) {
this.on(document, 'keydown', Fn.bind(this, this.handleKeyPress));
}
player.controls(false);
this.show();
this.el().setAttribute('aria-hidden', 'false');
this.trigger('modalopen');
this.hasBeenOpened_ = true;
}
return this;
}
/**
* Whether or not the modal is opened currently.
*
* @method opened
* @param {Boolean} [value]
* If given, it will open (`true`) or close (`false`) the modal.
*
* @return {Boolean}
*/
opened(value) {
if (typeof value === 'boolean') {
this[value ? 'open' : 'close']();
}
return this.opened_;
}
/**
* Closes the modal.
*
* @method close
* @return {ModalDialog}
*/
close() {
if (this.opened_) {
let player = this.player();
this.trigger('beforemodalclose');
this.opened_ = false;
if (this.wasPlaying_) {
player.play();
}
if (this.closeable()) {
this.off(document, 'keydown', Fn.bind(this, this.handleKeyPress));
}
player.controls(true);
this.hide();
this.el().setAttribute('aria-hidden', 'true');
this.trigger('modalclose');
if (this.options_.temporary) {
this.dispose();
}
}
return this;
}
/**
* Whether or not the modal is closeable via the UI.
*
* @method closeable
* @param {Boolean} [value]
* If given as a Boolean, it will set the `closeable` option.
*
* @return {Boolean}
*/
closeable(value) {
if (typeof value === 'boolean') {
let closeable = this.closeable_ = !!value;
let close = this.getChild('closeButton');
// If this is being made closeable and has no close button, add one.
if (closeable && !close) {
// The close button should be a child of the modal - not its
// content element, so temporarily change the content element.
let temp = this.contentEl_;
this.contentEl_ = this.el_;
close = this.addChild('closeButton');
this.contentEl_ = temp;
this.on(close, 'close', this.close);
}
// If this is being made uncloseable and has a close button, remove it.
if (!closeable && close) {
this.off(close, 'close', this.close);
this.removeChild(close);
close.dispose();
}
}
return this.closeable_;
}
/**
* Fill the modal's content element with the modal's "content" option.
*
* The content element will be emptied before this change takes place.
*
* @method fill
* @return {ModalDialog}
*/
fill() {
return this.fillWith(this.content());
}
/**
* Fill the modal's content element with arbitrary content.
*
* The content element will be emptied before this change takes place.
*
* @method fillWith
* @param {Mixed} [content]
* The same rules apply to this as apply to the `content` option.
*
* @return {ModalDialog}
*/
fillWith(content) {
let contentEl = this.contentEl();
let parentEl = contentEl.parentNode;
let nextSiblingEl = contentEl.nextSibling;
this.trigger('beforemodalfill');
this.hasBeenFilled_ = true;
// Detach the content element from the DOM before performing
// manipulation to avoid modifying the live DOM multiple times.
parentEl.removeChild(contentEl);
this.empty();
Dom.insertContent(contentEl, content);
this.trigger('modalfill');
// Re-inject the re-filled content element.
if (nextSiblingEl) {
parentEl.insertBefore(contentEl, nextSiblingEl);
} else {
parentEl.appendChild(contentEl);
}
return this;
}
/**
* Empties the content element.
*
* This happens automatically anytime the modal is filled.
*
* @method empty
* @return {ModalDialog}
*/
empty() {
this.trigger('beforemodalempty');
Dom.emptyEl(this.contentEl());
this.trigger('modalempty');
return this;
}
/**
* Gets or sets the modal content, which gets normalized before being
* rendered into the DOM.
*
* This does not update the DOM or fill the modal, but it is called during
* that process.
*
* @method content
* @param {Mixed} [value]
* If defined, sets the internal content value to be used on the
* next call(s) to `fill`. This value is normalized before being
* inserted. To "clear" the internal content value, pass `null`.
*
* @return {Mixed}
*/
content(value) {
if (typeof value !== 'undefined') {
this.content_ = value;
}
return this.content_;
}
}
/*
* Modal dialog default options.
*
* @type {Object}
* @private
*/
ModalDialog.prototype.options_ = {
temporary: true
};
Component.registerComponent('ModalDialog', ModalDialog);
export default ModalDialog;

View File

@ -32,6 +32,7 @@ import BigPlayButton from './big-play-button.js';
import ControlBar from './control-bar/control-bar.js';
import ErrorDisplay from './error-display.js';
import TextTrackSettings from './tracks/text-track-settings.js';
import ModalDialog from './modal-dialog';
// Require html5 tech, at least for disposing the original video tag
import Html5 from './tech/html5.js';
@ -2513,6 +2514,38 @@ class Player extends Component {
return options;
}
/**
* Creates a simple modal dialog (an instance of the `ModalDialog`
* component) that immediately overlays the player with arbitrary
* content and removes itself when closed.
*
* @param {String|Function|Element|Array|Null} content
* Same as `ModalDialog#content`'s param of the same name.
*
* The most straight-forward usage is to provide a string or DOM
* element.
*
* @param {Object} [options]
* Extra options which will be passed on to the `ModalDialog`.
*
* @return {ModalDialog}
*/
createModal(content, options) {
let player = this;
options = options || {};
options.content = content || '';
let modal = new ModalDialog(player, options);
player.addChild(modal);
modal.on('dispose', function() {
player.removeChild(modal);
});
return modal.open();
}
/**
* Gets tag settings
*

View File

@ -58,6 +58,22 @@ export function createEl(tagName='div', properties={}, attributes={}){
return el;
}
/**
* Injects text into an element, replacing any existing contents entirely.
*
* @param {Element} el
* @param {String} text
* @return {Element}
* @function textContent
*/
export function textContent(el, text) {
if (typeof el.textContent === 'undefined') {
el.innerText = text;
} else {
el.textContent = text;
}
}
/**
* Insert an element as the first child node of another
*
@ -369,3 +385,112 @@ export function getPointerPosition(el, event) {
return position;
}
/**
* Determines, via duck typing, whether or not a value is a DOM element.
*
* @param {Mixed} value
* @return {Boolean}
*/
export function isEl(value) {
return !!value && typeof value === 'object' && value.nodeType === 1;
}
/**
* Determines, via duck typing, whether or not a value is a text node.
*
* @param {Mixed} value
* @return {Boolean}
*/
export function isTextNode(value) {
return !!value && typeof value === 'object' && value.nodeType === 3;
}
/**
* Empties the contents of an element.
*
* @function emptyEl
* @param {Element} el
* @return {Element}
*/
export function emptyEl(el) {
while (el.firstChild) {
el.removeChild(el.firstChild);
}
return el;
}
/**
* Normalizes content for eventual insertion into the DOM.
*
* This allows a wide range of content definition methods, but protects
* from falling into the trap of simply writing to `innerHTML`, which is
* an XSS concern.
*
* The content for an element can be passed in multiple types, whose
* behavior is as follows:
*
* - String: Normalized into a text node.
* - Node: An Element or TextNode is passed through.
* - Array: A one-dimensional array of strings, nodes, or functions (which
* return single strings or nodes).
* - Function: If the sole argument, is expected to produce a string, node,
* or array.
*
* @function normalizeContent
* @param {String|Element|Array|Function} content
* @return {Array}
*/
export function normalizeContent(content) {
// First, invoke content if it is a function. If it produces an array,
// that needs to happen before normalization.
if (typeof content === 'function') {
content = content();
}
// Next up, normalize to an array, so one or many items can be normalized,
// filtered, and returned.
return (Array.isArray(content) ? content : [content]).map(value => {
// First, invoke value if it is a function to produce a new value,
// which will be subsequently normalized to a Node of some kind.
if (typeof value === 'function') {
value = value();
}
if (isEl(value) || isTextNode(value)) {
return value;
}
if (typeof value === 'string' && /\S/.test(value)) {
return document.createTextNode(value);
}
}).filter(value => value);
}
/**
* Normalizes and appends content to an element.
*
* @function appendContent
* @param {Element} el
* @param {String|Element|Array|Function} content
* @return {Element}
*/
export function appendContent(el, content) {
normalizeContent(content).forEach(node => el.appendChild(node));
return el;
}
/**
* Normalizes and inserts content into an element; this is identical to
* `appendContent()`, except it empties the element first.
*
* @function insertContent
* @param {Element} el
* @param {String|Element|Array|Function} content
* @return {Element}
*/
export function insertContent(el, content) {
return appendContent(emptyEl(el), content);
}

View File

@ -10,7 +10,7 @@
<div id="qunit"></div>
<script src="../node_modules/qunitjs/qunit/qunit.js"></script>
<script src="../build/temp/ie8/videojs-ie8.min.js"></script>
<script src="../build/temp/ie8/videojs-ie8.js"></script>
<!-- Execute the bundled tests first -->
<script src="../build/temp/tests.js"></script>

View File

@ -0,0 +1,47 @@
import CloseButton from '../../src/js/close-button';
import TestHelpers from './test-helpers';
q.module('CloseButton', {
beforeEach: function() {
this.player = TestHelpers.makePlayer();
this.btn = new CloseButton(this.player);
},
afterEach: function() {
this.player.dispose();
this.btn.dispose();
}
});
q.test('should create the expected element', function(assert) {
let elAssertions = TestHelpers.assertEl(assert, this.btn.el(), {
tagName: 'button',
classes: [
'vjs-button',
'vjs-close-button',
'vjs-control'
]
});
assert.expect(elAssertions.count + 1);
elAssertions();
assert.strictEqual(this.btn.el().querySelector('.vjs-control-text').innerHTML, 'Close');
});
q.test('should allow setting the controlText_ property as an option', function(assert) {
var text = 'OK!';
var btn = new CloseButton(this.player, {controlText: text});
assert.expect(1);
assert.strictEqual(btn.controlText_, text, 'set the controlText_ property');
});
q.test('should trigger an event on activation', function(assert) {
var spy = sinon.spy();
this.btn.on('close', spy);
this.btn.trigger('click');
assert.expect(1);
assert.strictEqual(spy.callCount, 1, 'the "close" event was triggered');
});

View File

@ -0,0 +1,398 @@
import CloseButton from '../../src/js/close-button';
import ModalDialog from '../../src/js/modal-dialog';
import * as Dom from '../../src/js/utils/dom';
import * as Fn from '../../src/js/utils/fn';
import TestHelpers from './test-helpers';
var ESC = 27;
q.module('ModalDialog', {
beforeEach: function() {
this.player = TestHelpers.makePlayer();
this.modal = new ModalDialog(this.player, {temporary: false});
this.el = this.modal.el();
},
afterEach: function() {
this.player.dispose();
this.modal.dispose();
this.el = null;
}
});
q.test('should create the expected element', function(assert) {
let elAssertions = TestHelpers.assertEl(assert, this.el, {
tagName: 'div',
classes: [
'vjs-modal-dialog',
'vjs-hidden'
],
attrs: {
'aria-describedby': this.modal.descEl_.id,
'aria-hidden': 'true',
'aria-label': this.modal.label(),
'role': 'dialog'
},
props: {
tabIndex: -1
}
});
assert.expect(elAssertions.count);
elAssertions();
});
q.test('should create the expected description element', function(assert) {
let elAssertions = TestHelpers.assertEl(assert, this.modal.descEl_, {
tagName: 'p',
innerHTML: this.modal.description(),
classes: [
'vjs-modal-dialog-description',
'vjs-offscreen'
],
attrs: {
id: this.el.getAttribute('aria-describedby')
}
});
assert.expect(elAssertions.count);
elAssertions();
});
q.test('should create the expected contentEl', function(assert) {
let elAssertions = TestHelpers.assertEl(assert, this.modal.contentEl(), {
tagName: 'div',
classes: [
'vjs-modal-dialog-content'
],
props: {
parentNode: this.el
}
});
assert.expect(elAssertions.count);
elAssertions();
});
q.test('should create a close button by default', function(assert) {
var btn = this.modal.getChild('closeButton');
// We only check the aspects of the button that relate to the modal. Other
// aspects of the button (classes, etc) are tested in their appropriate test
// module.
assert.expect(2);
assert.ok(btn instanceof CloseButton, 'close button is a CloseButton');
assert.strictEqual(btn.el().parentNode, this.el, 'close button is a child of el');
});
q.test('returns `this` for expected methods', function(assert) {
var methods = ['close', 'empty', 'fill', 'fillWith', 'open'];
assert.expect(methods.length);
methods.forEach(function(method) {
assert.strictEqual(this[method](), this, '`' + method + '()` returns `this`');
}, this.modal);
});
q.test('open() triggers events', function(assert) {
var modal = this.modal;
var beforeModalOpenSpy = sinon.spy(function() {
assert.notOk(modal.opened(), 'modal is not opened before opening event');
});
var modalOpenSpy = sinon.spy(function() {
assert.ok(modal.opened(), 'modal is opened on opening event');
});
assert.expect(4);
modal.
on('beforemodalopen', beforeModalOpenSpy).
on('modalopen', modalOpenSpy).
open();
assert.strictEqual(beforeModalOpenSpy.callCount, 1, 'beforemodalopen spy was called');
assert.strictEqual(modalOpenSpy.callCount, 1, 'modalopen spy was called');
});
q.test('open() removes "vjs-hidden" class', function(assert) {
assert.expect(2);
assert.ok(this.modal.hasClass('vjs-hidden'), 'modal starts hidden');
this.modal.open();
assert.notOk(this.modal.hasClass('vjs-hidden'), 'modal is not hidden after opening');
});
q.test('open() cannot be called on an opened modal', function(assert) {
var spy = sinon.spy();
this.modal.on('modalopen', spy).open().open();
assert.expect(1);
assert.strictEqual(spy.callCount, 1, 'modal was only opened once');
});
q.test('close() triggers events', function(assert) {
var modal = this.modal;
var beforeModalCloseSpy = sinon.spy(function() {
assert.ok(modal.opened(), 'modal is not closed before closing event');
});
var modalCloseSpy = sinon.spy(function() {
assert.notOk(modal.opened(), 'modal is closed on closing event');
});
assert.expect(4);
modal.
on('beforemodalclose', beforeModalCloseSpy).
on('modalclose', modalCloseSpy).
open().
close();
assert.strictEqual(beforeModalCloseSpy.callCount, 1, 'beforemodalclose spy was called');
assert.strictEqual(modalCloseSpy.callCount, 1, 'modalclose spy was called');
});
q.test('close() adds the "vjs-hidden" class', function(assert) {
assert.expect(1);
this.modal.open().close();
assert.ok(this.modal.hasClass('vjs-hidden'), 'modal is hidden upon close');
});
q.test('pressing ESC triggers close(), but only when the modal is opened', function(assert) {
var spy = sinon.spy();
this.modal.on('modalclose', spy).handleKeyPress({which: ESC});
assert.expect(2);
assert.strictEqual(spy.callCount, 0, 'ESC did not close the closed modal');
this.modal.open().handleKeyPress({which: ESC});
assert.strictEqual(spy.callCount, 1, 'ESC closed the now-opened modal');
});
q.test('close() cannot be called on a closed modal', function(assert) {
var spy = sinon.spy();
this.modal.on('modalclose', spy);
this.modal.open().close().close();
assert.expect(1);
assert.strictEqual(spy.callCount, 1, 'modal was only closed once');
});
q.test('open() pauses playback, close() resumes', function(assert) {
var playSpy = sinon.spy();
var pauseSpy = sinon.spy();
// Quick and dirty; make it looks like the player is playing.
this.player.paused = function() {
return false;
};
this.player.play = function() {
playSpy();
};
this.player.pause = function() {
pauseSpy();
};
this.modal.open();
assert.expect(2);
assert.strictEqual(pauseSpy.callCount, 1, 'player is paused when the modal opens');
this.modal.close();
assert.strictEqual(playSpy.callCount, 1, 'player is resumed when the modal closes');
});
q.test('open() hides controls, close() shows controls', function(assert) {
this.modal.open();
assert.expect(2);
assert.notOk(this.player.controls_, 'controls are hidden');
this.modal.close();
assert.ok(this.player.controls_, 'controls are no longer hidden');
});
q.test('opened()', function(assert) {
var openSpy = sinon.spy();
var closeSpy = sinon.spy();
assert.expect(4);
assert.strictEqual(this.modal.opened(), false, 'the modal is closed');
this.modal.open();
assert.strictEqual(this.modal.opened(), true, 'the modal is open');
this.modal.
close().
on('modalopen', openSpy).
on('modalclose', closeSpy).
opened(true);
this.modal.opened(true);
this.modal.opened(false);
assert.strictEqual(openSpy.callCount, 1, 'modal was opened only once');
assert.strictEqual(closeSpy.callCount, 1, 'modal was closed only once');
});
q.test('content()', function(assert) {
var content;
assert.expect(3);
assert.strictEqual(typeof this.modal.content(), 'undefined', 'no content by default');
content = this.modal.content(Dom.createEl());
assert.ok(Dom.isEl(content), 'content was set from a single DOM element');
assert.strictEqual(this.modal.content(null), null, 'content was nullified');
});
q.test('fillWith()', function(assert) {
var contentEl = this.modal.contentEl();
var children = [Dom.createEl(), Dom.createEl(), Dom.createEl()];
var beforeFillSpy = sinon.spy();
var fillSpy = sinon.spy();
children.forEach(function(el) {
contentEl.appendChild(el);
});
this.modal.
on('beforemodalfill', beforeFillSpy).
on('modalfill', fillSpy).
fillWith(children);
assert.expect(5 + children.length);
assert.strictEqual(contentEl.children.length, children.length, 'has the right number of children');
children.forEach(function(el) {
assert.strictEqual(el.parentNode, contentEl, 'new child appended');
});
assert.strictEqual(beforeFillSpy.callCount, 1, 'the "beforemodalfill" callback was called');
assert.strictEqual(beforeFillSpy.getCall(0).thisValue, this.modal, 'the value of "this" is the modal');
assert.strictEqual(fillSpy.callCount, 1, 'the "modalfill" callback was called');
assert.strictEqual(fillSpy.getCall(0).thisValue, this.modal, 'the value of "this" is the modal');
});
q.test('empty()', function(assert) {
var beforeEmptySpy = sinon.spy();
var emptySpy = sinon.spy();
this.modal.
fillWith([Dom.createEl(), Dom.createEl()]).
on('beforemodalempty', beforeEmptySpy).
on('modalempty', emptySpy).
empty();
assert.expect(5);
assert.strictEqual(this.modal.contentEl().children.length, 0, 'removed all `contentEl()` children');
assert.strictEqual(beforeEmptySpy.callCount, 1, 'the "beforemodalempty" callback was called');
assert.strictEqual(beforeEmptySpy.getCall(0).thisValue, this.modal, 'the value of "this" is the modal');
assert.strictEqual(emptySpy.callCount, 1, 'the "modalempty" callback was called');
assert.strictEqual(emptySpy.getCall(0).thisValue, this.modal, 'the value of "this" is the modal');
});
q.test('closeable()', function(assert) {
let initialCloseButton = this.modal.getChild('closeButton');
assert.expect(8);
assert.strictEqual(this.modal.closeable(), true, 'the modal is closed');
this.modal.open().closeable(false);
assert.notOk(this.modal.getChild('closeButton'), 'the close button is no longer a child of the modal');
assert.notOk(initialCloseButton.el(), 'the initial close button was disposed');
this.modal.handleKeyPress({which: ESC});
assert.ok(this.modal.opened(), 'the modal was not closed by the ESC key');
this.modal.close();
assert.notOk(this.modal.opened(), 'the modal was closed programmatically');
this.modal.open().closeable(true);
assert.ok(this.modal.getChild('closeButton'), 'a new close button was created');
this.modal.getChild('closeButton').trigger('click');
assert.notOk(this.modal.opened(), 'the modal was closed by the new close button');
this.modal.open().handleKeyPress({which: ESC});
assert.notOk(this.modal.opened(), 'the modal was closed by the ESC key');
});
q.test('"content" option (fills on first open() invocation)', function(assert) {
var modal = new ModalDialog(this.player, {
content: Dom.createEl(),
temporary: false
});
var spy = sinon.spy();
modal.on('modalfill', spy);
modal.open().close().open();
assert.expect(3);
assert.strictEqual(modal.content(), modal.options_.content, 'has the expected content');
assert.strictEqual(spy.callCount, 1, 'auto-fills only once');
assert.strictEqual(modal.contentEl().firstChild, modal.options_.content, 'has the expected content in the DOM');
});
q.test('"temporary" option', function(assert) {
var temp = new ModalDialog(this.player, {temporary: true});
var tempSpy = sinon.spy();
var perm = new ModalDialog(this.player, {temporary: false});
var permSpy = sinon.spy();
temp.on('dispose', tempSpy);
perm.on('dispose', permSpy);
temp.open().close();
perm.open().close();
assert.expect(2);
assert.strictEqual(tempSpy.callCount, 1, 'temporary modals are disposed');
assert.strictEqual(permSpy.callCount, 0, 'permanent modals are not disposed');
});
q.test('"fillAlways" option', function(assert) {
var modal = new ModalDialog(this.player, {
content: 'foo',
fillAlways: true,
temporary: false
});
var spy = sinon.spy();
modal.on('modalfill', spy);
modal.open().close().open();
assert.expect(1);
assert.strictEqual(spy.callCount, 2, 'the modal was filled on each open call');
});
q.test('"label" option', function(assert) {
var label = 'foo';
var modal = new ModalDialog(this.player, {label: label});
assert.expect(1);
assert.strictEqual(modal.el().getAttribute('aria-label'), label, 'uses the label as the aria-label');
});
q.test('"uncloseable" option', function(assert) {
var modal = new ModalDialog(this.player, {
temporary: false,
uncloseable: true
});
var spy = sinon.spy();
modal.on('modalclose', spy);
assert.expect(3);
assert.strictEqual(modal.closeable(), false, 'the modal is uncloseable');
assert.notOk(modal.getChild('closeButton'), 'the close button is not present');
modal.open().handleKeyPress({which: ESC});
assert.strictEqual(spy.callCount, 0, 'ESC did not close the modal');
});

View File

@ -833,3 +833,29 @@ test('should return correct values for canPlayType', function(){
player.dispose();
});
test('createModal()', function() {
var player = TestHelpers.makePlayer();
var modal = player.createModal('foo');
var spy = sinon.spy();
modal.on('dispose', spy);
expect(5);
strictEqual(modal.el().parentNode, player.el(), 'the modal is injected into the player');
strictEqual(modal.content(), 'foo', 'content is set properly');
ok(modal.opened(), 'modal is opened by default');
modal.close();
ok(spy.called, 'modal was disposed when closed');
strictEqual(player.children().indexOf(modal), -1, 'modal was removed from player\'s children');
});
test('createModal() options object', function() {
var player = TestHelpers.makePlayer();
var modal = player.createModal('foo', {content: 'bar', label: 'boo'});
expect(2);
strictEqual(modal.content(), 'foo', 'content argument takes precedence');
strictEqual(modal.options_.label, 'boo', 'modal options are set properly');
modal.close();
});

View File

@ -1,3 +1,4 @@
import * as Dom from '../../src/js/utils/dom';
import Player from '../../src/js/player.js';
import TechFaker from './tech/tech-faker.js';
import window from 'global/window';
@ -40,6 +41,90 @@ var TestHelpers = {
return el.currentStyle[rule];
}
}
},
/**
* Runs a range of assertions on a DOM element.
*
* @param {QUnit.Assert} assert
* @param {Element} el
* @param {Object} spec
* An object from which assertions are generated.
*
* @param {Object} [spec.attrs]
* An object mapping attribute names (keys) to strict values.
*
* @param {Array} [spec.classes]
* An array of classes that are expected on the element.
*
* @param {String} [spec.innerHTML]
* A string of text/html that is expected as the content of element.
* Both values will be trimmed, but remains case-sensitive.
*
* @param {Object} [spec.props]
* An object mapping property names (keys) to strict values.
*
* @param {String} [spec.tagName]
* A string (case-insensitive) representing that element's tagName.
*
* @return {Function}
* Invoke the returned function to run the assertions. This
* function has a `count` property which can be used to
* reference how many assertions will be run (e.g. for use
* with `assert.expect()`).
*/
assertEl: function(assert, el, spec) {
let attrs = spec.attrs ? Object.keys(spec.attrs) : [];
let classes = spec.classes || [];
let innerHTML = spec.innerHTML ? spec.innerHTML.trim() : '';
let props = spec.props ? Object.keys(spec.props) : [];
let tagName = spec.tagName ? spec.tagName.toLowerCase() : '';
// Return value is a function, which runs through all the combined
// assertions. This is done so that the count can be attached dynamically
// and run whenever desired.
let run = () => {
if (tagName) {
let elTagName = el.tagName.toLowerCase();
let msg = `el should have been a <${tagName}> and was a <${elTagName}>`;
assert.strictEqual(elTagName, tagName, msg);
}
if (innerHTML) {
let elInnerHTML = el.innerHTML.trim();
let msg = `el should have expected HTML content`;
assert.strictEqual(elInnerHTML, innerHTML, msg);
}
attrs.forEach(a => {
let actual = el.getAttribute(a);
let expected = spec.attrs[a];
let msg = `el should have the "${a}" attribute with the value "${expected}" and it was "${actual}"`;
assert.strictEqual(actual, expected, msg);
});
classes.forEach(c => {
let msg = `el should have the "${c}" class in its className, which is "${el.className}"`;
assert.ok(Dom.hasElClass(el, c), msg);
});
props.forEach(p => {
let actual = el[p];
let expected = spec.props[p];
let msg = `el should have the "${p}" property with the value "${expected}" and it was "${actual}"`;
assert.strictEqual(actual, expected, msg);
});
};
// Include the number of assertions to run, so it can be used to set
// expectations (via `assert.expect()`).
run.count = Number(!!tagName) +
Number(!!innerHTML) +
classes.length +
attrs.length +
props.length;
return run;
}
};

View File

@ -160,3 +160,137 @@ test('Dom.findElPosition should find top and left position', function() {
position = Dom.findElPosition(d);
deepEqual(position, {left: 0, top: 0}, 'If there is no gBCR, we should get zeros');
});
test('Dom.isEl', function(assert) {
assert.expect(7);
assert.notOk(Dom.isEl(), 'undefined is not an element');
assert.notOk(Dom.isEl(true), 'booleans are not elements');
assert.notOk(Dom.isEl({}), 'objects are not elements');
assert.notOk(Dom.isEl([]), 'arrays are not elements');
assert.notOk(Dom.isEl('<h1></h1>'), 'strings are not elements');
assert.ok(Dom.isEl(document.createElement('div')), 'elements are elements');
assert.ok(Dom.isEl({nodeType: 1}), 'duck typing is imperfect');
});
test('Dom.isTextNode', function(assert) {
assert.expect(7);
assert.notOk(Dom.isTextNode(), 'undefined is not a text node');
assert.notOk(Dom.isTextNode(true), 'booleans are not text nodes');
assert.notOk(Dom.isTextNode({}), 'objects are not text nodes');
assert.notOk(Dom.isTextNode([]), 'arrays are not text nodes');
assert.notOk(Dom.isTextNode('hola mundo'), 'strings are not text nodes');
assert.ok(Dom.isTextNode(document.createTextNode('hello, world!')), 'text nodes are text nodes');
assert.ok(Dom.isTextNode({nodeType: 3}), 'duck typing is imperfect');
});
test('Dom.emptyEl', function(assert) {
let el = Dom.createEl();
el.appendChild(Dom.createEl('span'));
el.appendChild(Dom.createEl('span'));
el.appendChild(document.createTextNode('hola mundo'));
el.appendChild(Dom.createEl('span'));
Dom.emptyEl(el);
assert.expect(1);
assert.notOk(el.firstChild, 'the element was emptied');
});
test('Dom.normalizeContent: strings and elements/nodes', function(assert) {
assert.expect(8);
let str = Dom.normalizeContent('hello');
assert.strictEqual(str[0].data, 'hello', 'single string becomes a text node');
let elem = Dom.normalizeContent(Dom.createEl());
assert.ok(Dom.isEl(elem[0]), 'an element is passed through');
let node = Dom.normalizeContent(document.createTextNode('goodbye'));
assert.strictEqual(node[0].data, 'goodbye', 'a text node is passed through');
assert.strictEqual(Dom.normalizeContent(null).length, 0, 'falsy values are ignored');
assert.strictEqual(Dom.normalizeContent(false).length, 0, 'falsy values are ignored');
assert.strictEqual(Dom.normalizeContent().length, 0, 'falsy values are ignored');
assert.strictEqual(Dom.normalizeContent(123).length, 0, 'numbers are ignored');
assert.strictEqual(Dom.normalizeContent({}).length, 0, 'objects are ignored');
});
test('Dom.normalizeContent: functions returning strings and elements/nodes', function(assert) {
assert.expect(9);
let str = Dom.normalizeContent(() => 'hello');
assert.strictEqual(str[0].data, 'hello', 'a function can return a string, which becomes a text node');
let elem = Dom.normalizeContent(() => Dom.createEl());
assert.ok(Dom.isEl(elem[0]), 'a function can return an element');
let node = Dom.normalizeContent(() => document.createTextNode('goodbye'));
assert.strictEqual(node[0].data, 'goodbye', 'a function can return a text node');
assert.strictEqual(Dom.normalizeContent(() => null).length, 0, 'a function CANNOT return falsy values');
assert.strictEqual(Dom.normalizeContent(() => false).length, 0, 'a function CANNOT return falsy values');
assert.strictEqual(Dom.normalizeContent(() => undefined).length, 0, 'a function CANNOT return falsy values');
assert.strictEqual(Dom.normalizeContent(() => 123).length, 0, 'a function CANNOT return numbers');
assert.strictEqual(Dom.normalizeContent(() => {}).length, 0, 'a function CANNOT return objects');
assert.strictEqual(Dom.normalizeContent(() => (() => null)).length, 0, 'a function CANNOT return a function');
});
test('Dom.normalizeContent: arrays of strings and objects', function(assert) {
assert.expect(7);
let source = [
'hello',
{},
Dom.createEl(),
['oops'],
null,
document.createTextNode('goodbye'),
() => 'it works'
];
let result = Dom.normalizeContent(source);
assert.strictEqual(result[0].data, 'hello', 'an array can include a string normalized to a text node');
assert.ok(Dom.isEl(result[1]), 'an array can include an element');
assert.strictEqual(result[2].data, 'goodbye', 'an array can include a text node');
assert.strictEqual(result[3].data, 'it works', 'an array can include a function, which is invoked');
assert.strictEqual(result.indexOf(source[1]), -1, 'an array CANNOT include an object');
assert.strictEqual(result.indexOf(source[3]), -1, 'an array CANNOT include an array');
assert.strictEqual(result.indexOf(source[4]), -1, 'an array CANNOT include falsy values');
});
test('Dom.normalizeContent: functions returning arrays', function(assert) {
assert.expect(3);
let arr = [];
let result = Dom.normalizeContent(() => ['hello', Function.prototype, arr]);
assert.strictEqual(result[0].data, 'hello', 'a function can return an array');
assert.strictEqual(result.indexOf(Function.prototype), -1, 'a function can return an array, but it CANNOT include a function');
assert.strictEqual(result.indexOf(arr), -1, 'a function can return an array, but it CANNOT include an array');
});
test('Dom.insertContent', function(assert) {
let p = Dom.createEl('p');
let text = document.createTextNode('hello');
let el = Dom.insertContent(Dom.createEl(), [p, text]);
assert.expect(2);
assert.strictEqual(el.firstChild, p, 'the paragraph was inserted first');
assert.strictEqual(el.firstChild.nextSibling, text, 'the text node was inserted last');
});
test('Dom.appendContent', function(assert) {
let p1 = Dom.createEl('p');
let p2 = Dom.createEl('p');
let el = Dom.insertContent(Dom.createEl(), [p1]);
Dom.appendContent(el, p2);
assert.expect(2);
assert.strictEqual(el.firstChild, p1, 'the first paragraph is the first child');
assert.strictEqual(el.firstChild.nextSibling, p2, 'the second paragraph was appended');
});