Skip to content

Commit

Permalink
Abstract content normalization to utils/dom.
Browse files Browse the repository at this point in the history
Also adds a number of methods to the DOM utility and lots of tests for
those. Simplifies the modal dialog component correspondingly.
  • Loading branch information
misteroneill committed Oct 27, 2015
1 parent 06e942f commit a7def7c
Show file tree
Hide file tree
Showing 4 changed files with 258 additions and 153 deletions.
108 changes: 21 additions & 87 deletions src/js/modal-dialog.js
Original file line number Diff line number Diff line change
Expand Up @@ -13,17 +13,6 @@ import CloseButton from './close-button';
const MODAL_CLASS_NAME = 'vjs-modal-dialog';
const ESC = 27;

/**
* Whether or not a value is a non-empty string.
*
* @function nonEmptyString
* @param {Mixed} value
* @return {Boolean}
*/
function nonEmptyString(value) {
return typeof value === 'string' && /\S/.test(value);
}

/**
* The `ModalDialog` displays over the video and its controls, which blocks
* interaction with the player until it is closed.
Expand Down Expand Up @@ -293,10 +282,8 @@ class ModalDialog extends Component {
* The content element will be emptied before this change takes place.
*
* @method fillWith
* @param {String|Function|Element|Array} [content]
* The content with which to fill the modal. This must be either
* a DOM element, an array of DOM elements, a string, or a
* function which returns one of these.
* @param {Mixed} [content]
* The same rules apply to this as apply to the `content` option.
*
* @return {ModalDialog}
*/
Expand All @@ -305,35 +292,21 @@ class ModalDialog extends Component {
let parentEl = contentEl.parentNode;
let nextSiblingEl = contentEl.nextSibling;

content = this.normalizeContent_(content);

if (content && content.length) {
this.trigger('beforemodalfill');
this.hasBeenFilled_ = true;
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();
// 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');

// Strings are written into the DOM directly, arrays should by filtered
// down to DOM elements only and appended in order.
if (typeof content === 'string') {
contentEl.innerHTML = content;
} else {
content.forEach(el => contentEl.appendChild(el));
}

this.trigger('modalfill');

// Re-inject the re-filled content element.
if (nextSiblingEl) {
parentEl.insertBefore(contentEl, nextSiblingEl);
} else {
parentEl.appendChild(contentEl);
}
// Re-inject the re-filled content element.
if (nextSiblingEl) {
parentEl.insertBefore(contentEl, nextSiblingEl);
} else {
log.warn('no content defined for modal');
parentEl.appendChild(contentEl);
}

return this;
Expand All @@ -348,12 +321,8 @@ class ModalDialog extends Component {
* @return {ModalDialog}
*/
empty() {
let contentEl = this.contentEl();
let count = contentEl.children.length;
this.trigger('beforemodalempty');
while (count--) {
contentEl.removeChild(contentEl.children[0]);
}
Dom.emptyEl(this.contentEl());
this.trigger('modalempty');
return this;
}
Expand All @@ -366,54 +335,19 @@ class ModalDialog extends Component {
* that process.
*
* @method content
* @param {String|Function|Element|Array|Null} [value]
* If given, sets the internal content value to be used on the next
* call to `fill`. This value is passed through normalizeContent_
* before being rendered into the DOM.
* @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 {String|Function|Element|Array|Null}
* @return {Mixed}
*/
content(value) {
if (
value === null ||
typeof value === 'string' ||
typeof value === 'function' ||
Array.isArray(value) ||
Dom.isEl(value)
) {
if (typeof value !== 'undefined') {
this.content_ = value;
}
return this.content_;
}

/**
* Normalizes contents for insertion into a content element.
*
* @method normalizeContent_
* @private
* @param {String|Function|Element|Array} content
* @return {Array|String|Null}
* An array of one or more DOM element(s). A non-empty string. Null.
*/
normalizeContent_(content) {

// Short-cut out if it's clearly invalid.
if (!content) {
return null;
}

if (typeof content === 'function') {
content = content.call(this, this.contentEl());
}

if (nonEmptyString(content)) {
return content;
}

// DOM element and array handling.
content = (Array.isArray(content) ? content : [content]).filter(Dom.isEl);
return content.length ? content : null;
}
}

/*
Expand Down
103 changes: 102 additions & 1 deletion src/js/utils/dom.js
Original file line number Diff line number Diff line change
Expand Up @@ -386,7 +386,6 @@ export function getPointerPosition(el, event) {
return position;
}


/**
* Determines, via duck typing, whether or not a value is a DOM element.
*
Expand All @@ -396,3 +395,105 @@ export function getPointerPosition(el, event) {
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 inserts content into an element.
*
* @function insertContent
* @param {Element} el
* @param {String|Element|Array|Function} content
* @param {Boolean} [append=false]
* @return {Element}
*/
export function insertContent(el, content, append=false) {
if (!append) {
emptyEl(el);
}
normalizeContent(content).forEach(node => el.appendChild(node));
return el;
}

/**
* Normalizes and inserts content into an element.
*
* @function insertContent
* @param {Element} el
* @param {String|Element|Array|Function} content
* @return {Element}
*/
export function appendContent(el, content) {
return insertContent(el, content, true);
}
66 changes: 1 addition & 65 deletions test/unit/modal-dialog.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -241,79 +241,15 @@ q.test('opened()', function(assert) {
q.test('content()', function(assert) {
var content;

assert.expect(4);
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(123), content, 'content was NOT changed by invalid input');
assert.strictEqual(this.modal.content(null), null, 'content was nullified');
});

q.test('normalizeContent_() arrays, elements, and non-empty strings', function(assert) {
var asElement = this.modal.normalizeContent_(Dom.createEl());

var asString = this.modal.normalizeContent_('hello');

var asArray = this.modal.normalizeContent_([
Dom.createEl(), {}, Dom.createEl('span'), []
]);

var asInvalid = this.modal.normalizeContent_(true);

var asEmptyString = this.modal.normalizeContent_(' ');

assert.expect(5);
assert.strictEqual(asElement.length, 1, 'single elements are accepted');
assert.strictEqual(asString.length, 5, 'non-empty strings are accepted');
assert.strictEqual(asArray.length, 2, 'invalid values filtered out of array');
assert.strictEqual(asInvalid, null, 'single invalid values are rejected');
assert.strictEqual(asEmptyString, null, 'empty strings are rejected');
});

q.test('normalizeContent_() callbacks', function(assert) {
var asElementFn = this.modal.normalizeContent_(function() {
return Dom.createEl();
});

var asStringFn = this.modal.normalizeContent_(function() {
return 'hello';
});

var asArrayFn = this.modal.normalizeContent_(function() {
return [null, '123', Dom.createEl()];
});

var asInvalidFn = this.modal.normalizeContent_(function() {
return 123;
});

var asEmptyStringFn = this.modal.normalizeContent_(function() {
return '\t\r\n';
});

assert.expect(5);
assert.strictEqual(asElementFn.length, 1, 'single elements are accepted when returned by a function');
assert.strictEqual(asStringFn.length, 5, 'non-empty strings are passed through directly');
assert.strictEqual(asArrayFn.length, 1, 'invalid values filtered out of array when returned by a function');
assert.strictEqual(asInvalidFn, null, 'single invalid values are rejected when returned by a function');
assert.strictEqual(asEmptyStringFn, null, 'empty strings are rejected when returned by a function');
});

q.test('normalizeContent_() callback invocations', function(assert) {
var callbackSpy = sinon.spy();
var spyCall;

this.modal.normalizeContent_(callbackSpy);
spyCall = callbackSpy.getCall(0);

assert.expect(3);
assert.strictEqual(callbackSpy.callCount, 1, 'the test callback was called');
assert.strictEqual(spyCall.thisValue, this.modal, 'the value of "this" in the callback is the modal');
assert.ok(spyCall.calledWithExactly(this.modal.contentEl()), 'the contentEl is passed to the callback');
});

q.test('fillWith()', function(assert) {
var contentEl = this.modal.contentEl();
var children = [Dom.createEl(), Dom.createEl(), Dom.createEl()];
Expand Down
Loading

0 comments on commit a7def7c

Please sign in to comment.