src/js/media-grid.js
//
// HLF Media Grid Extension
// ========================
// [Styles](../css/media-grid.html) | [Tests](../../tests/js/media-grid.html)
//
// The `MediaGrid` extension, inspired by the Cargo Voyager design template, can
// expand an item inline without affecting the position of its siblings. The
// extension tries to add the minimal amount of DOM elements and styles. So the
// layout rules are mostly defined in the styles, and initial html for items is
// required (see the tests for an example). The extension also handles additional
// effects like focusing on the expanded item and dimming its siblings.
//
(function(root, attach) {
if (typeof define === 'function' && define.amd) {
define(['hlf/core'], attach);
} else if (typeof exports === 'object') {
module.exports = attach(require('hlf/core'));
} else {
attach(HLF);
}
})(this, function(HLF) {
'use strict';
//
// MediaGrid
// ---------
//
// - __autoReady__ is `false` by default, as recommended. Turning it on means
// the `ready` event gets triggered immediately, synchronously, and is only
// recommended if your grid doesn't have images and such that require a
// wait before being fully loaded and sized. Otherwise, you can manually
// __load__ and use __createPreviewImagesPromise__ to help determine when
// to do so.
//
// - __resizeDelay__ is the millis to wait for window resizing to stop before
// doing a re-layout. `100` is the default to balance responsiveness and
// performance.
//
// - __undimDelay__ is the millis to wait before removing the dim effect when
// focus is toggled off on an expanded item.
//
// - Note: the majority of presentation state logic is in the extension
// stylesheet. We update the presentation state by using __className__.
//
// To summarize the implementation, on a DOM that's already created, the
// extension, given the `element`, will select the `itemElements` and
// `sampleItemElement`, as well as parse the `expandDuration`. The extension
// will wait for content to load before doing an initial layout via
// `_updateMetrics` and `_layoutItems`. `eventListeners` are added
// automatically with the `_onMouseLeave` and `_onItemExpand` handlers that
// `toggleItemFocus`. `_toggleItemEventListeners` runs for each item to add
// `_onItemClick`, and `_onItemMouseEnter` and `_onItemMouseLeave` that
// respectively `toggleItemExpansion` and `toggleExpandedItemFocus`. The
// `_onWindowResize` handler is automatically set up to `_reLayoutItems`. And
// the `_itemsObserver` is manually set up with the `_onItemsMutation`
// handler that `_toggleItemEventListeners` and also `_reLayoutItems`. Once
// `ready`, the respective namespaced event is dispatched from and class is
// added to the `element`.
//
class MediaGrid {
static get defaults() {
return {
autoReady: false,
resizeDelay: 100,
undimDelay: 1000,
};
}
static toPrefix(context) {
switch (context) {
case 'event': return 'hlfmg';
case 'data': return 'hlf-mg';
case 'class': return 'mg';
case 'var': return 'mg';
default: return 'hlf-mg';
}
}
constructor(element, options) {
this.eventListeners = { mouseleave: this._onMouseLeave };
this.eventListeners[this.eventName('expand')] = this._onItemExpand;
}
init() {
if (!this.itemElements) {
this._selectItemElements();
}
this.itemElements.forEach(this._toggleItemEventListeners.bind(this, true));
this.sampleItemElement = this.itemElements[0];
this.expandDuration = this.cssDuration('transitionDuration', this.sampleItemElement);
this.expandedItemElement = null;
this._itemsObserver = new MutationObserver(this._onItemsMutation);
this._itemsObserver.connect = () => {
this._itemsObserver.observe(this.element, { childList: true });
};
this.metrics = {};
if (this.autoReady) {
this.load();
}
}
deinit() {
this.itemElements.forEach(this._toggleItemEventListeners.bind(this, false));
this._itemsObserver.disconnect();
}
createPreviewImagesPromise() {
const selector = `.${this.className('preview')} img`;
const imageElements = [...this.element.querySelectorAll(selector)];
let teardownTasks = [];
return Promise.all(imageElements.map((element) => new Promise((resolve, reject) => {
element.addEventListener('load', resolve);
element.addEventListener('error', reject);
teardownTasks.push(() => {
element.removeEventListener('load', resolve);
element.removeEventListener('error', reject);
});
}))).then(() => {
teardownTasks.forEach(task => task());
}, () => {
teardownTasks.forEach(task => task());
});
}
load() {
this._updateMetrics({ hard: true });
this._layoutItems();
this._itemsObserver.connect();
this.element.classList.add(this.className('ready'));
this.dispatchCustomEvent('ready');
}
//
// `toggleItemExpansion` basically toggles the `-expanded` class on the
// given `itemElement` to `expanded` and triggers the `expand` event. To
// allow styling or scripting during the transition, it adds the
// `-transitioning`, `-contracting`, and `-expanding` classes and removes
// them afterwards per `expandDuration`.
//
// `toggleExpandedItemFocus` wraps `toggleItemFocus` to factor in
// `undimDelay` when toggling off `focus`. Focusing dims without delay.
//
// `toggleItemFocus` basically toggles the `-focused` class on the given
// `itemElement` to `focused` and the `-dimmed` class on the root element
// after any given `delay`.
//
// `_getMetricSamples` returns cloned `itemElement` and
// `expandedItemElement` mainly for calculating initial metrics. For them
// to have the right sizes, they're attached to an invisible container
// appended to the root element.
//
// `_layoutItems` occurs once `metrics` is updated. With the latest
// `wrapWidth` and `wrapHeight` metrics, the root element is resized. Each
// element in `itemElements` gets its position style set to `absolute` non-
// destructively; this method assumes the original is `float`, and so
// iterates in reverse.
//
// `_reLayoutItems` wraps `_layoutItems` to be its idempotent version by
// first resetting each item's to its `original-position`.
//
// `_toggleNeighborItemsRecessed` toggles the `-recessed` class on items
// per the occlusion-causing expansion of the item at `index`.
//
// `_updateMetrics` builds the `metrics` around item and wrap as well as
// row and column sizes. It does so by measuring sample elements and their
// margins, as well as sizing the wrap (root element) to fit its items. As
// such, this method isn't idempotent and expects to be followed by a call
// to `_layoutItems`.
//
toggleItemExpansion(itemElement, expanded, completion) {
if (typeof expanded === 'undefined') {
expanded = !(
itemElement.classList.contains(this.className('expanded')) ||
itemElement.classList.contains(this.className('expanding'))
);
}
let index = this.itemElements.indexOf(itemElement);
if (expanded) {
if (this.expandedItemElement) {
this.toggleItemExpansion(this.expandedItemElement, false);
}
if (this._isRightEdgeItem(index)) {
this._adjustItemToRightEdge(itemElement);
}
if (this._isBottomEdgeItem(index)) {
this._adjustItemToBottomEdge(itemElement);
}
}
this._toggleNeighborItemsRecessed(index, expanded);
itemElement.classList.remove(
this.className('expanding'), this.className('contracting')
);
let classNames = [
this.className('transitioning'),
this.className(expanded ? 'expanding' : 'contracting')
];
itemElement.classList.add(...classNames);
this.setElementTimeout(itemElement, 'expand-timeout', this.expandDuration, () => {
itemElement.classList.remove(...classNames);
itemElement.classList.toggle(this.className('expanded'), expanded);
if (completion) {
completion();
}
});
this.expandedItemElement = expanded ? itemElement : null;
itemElement.dispatchEvent(this.createCustomEvent('expand', { expanded }));
}
toggleExpandedItemFocus(itemElement, focused) {
if (!itemElement.classList.contains(this.className('expanded'))) { return; }
let delay = focused ? 0 : this.undimDelay;
this.toggleItemFocus(itemElement, focused, delay);
}
toggleItemFocus(itemElement, focused, delay = 0) {
if (focused) {
this.itemElements.forEach((itemElement) => {
itemElement.classList.remove(this.className('focused'));
});
}
itemElement.classList.toggle(this.className('focused'), focused);
this.setTimeout('_dimTimeout', delay, () => {
this.element.classList.toggle(this.className('dimmed'), focused);
});
}
_onItemClick(event) {
const actionElementTags = ['a', 'audio', 'button', 'input', 'video'];
if (actionElementTags.indexOf(event.target.tagName.toLowerCase()) !== -1) { return; }
this.toggleItemExpansion(event.currentTarget);
}
_onItemExpand(event) {
const { target } = event;
if (!this._isItemElement(target)) { return; }
const { expanded } = event.detail;
this.toggleItemFocus(target, expanded, this.expandDuration);
}
_onItemMouseEnter(event) {
this.toggleExpandedItemFocus(event.currentTarget, true);
}
_onItemMouseLeave(event) {
this.toggleExpandedItemFocus(event.currentTarget, false);
}
_onItemsMutation(mutations) {
let addedItemElements = mutations
.filter(m => !!m.addedNodes.length)
.reduce((allElements, m) => {
let elements = [...m.addedNodes].filter(this._isItemElement);
return allElements.concat(elements);
}, []);
addedItemElements.forEach(this._toggleItemEventListeners.bind(this, true));
this._itemsObserver.disconnect();
this._reLayoutItems(() => {
addedItemElements[0].scrollIntoView();
});
this._itemsObserver.connect();
}
_onMouseLeave(_) {
if (!this.expandedItemElement) { return; }
this.toggleItemFocus(this.expandedItemElement, false);
}
_onWindowResize(_) {
this._reLayoutItems();
}
_isItemElement(node) {
return (node instanceof HTMLElement &&
node.classList.contains(this.className('item')));
}
_selectItemElements() {
this.itemElements = [...this.element.querySelectorAll(
`.${this.className('item')}:not(.${this.className('sample')})`
)];
}
_toggleItemEventListeners(on, itemElement) {
this.toggleEventListeners(on, {
'click': this._onItemClick,
'mouseenter': this._onItemMouseEnter,
'mouseleave': this._onItemMouseLeave,
}, itemElement);
}
_adjustItemToBottomEdge(itemElement) {
let { style } = itemElement;
style.top = 'auto';
style.bottom = '0px';
}
_adjustItemToRightEdge(itemElement) {
let { style } = itemElement;
style.left = 'auto';
style.right = '0px';
}
_getMetricSamples() {
let containerElement = this.selectByClass('samples');
if (containerElement) {
containerElement.parentNode.removeChild(containerElement);
}
let itemElement = this.sampleItemElement.cloneNode(true);
itemElement.classList.add(this.className('sample'));
let expandedItemElement = this.sampleItemElement.cloneNode(true);
expandedItemElement.classList.add(
this.className('expanded'), this.className('sample')
);
containerElement = document.createElement('div');
containerElement.classList.add(this.className('samples'));
let { style } = containerElement;
style.left = style.right = style.top = '0px';
style.position = 'absolute';
style.visibility = 'hidden';
style.zIndex = 0;
containerElement.appendChild(itemElement);
containerElement.appendChild(expandedItemElement);
this.element.appendChild(containerElement);
return { itemElement, expandedItemElement };
}
_isBottomEdgeItem(i) {
const { rowSize } = this.metrics;
let lastRowSize = (this.itemElements.length % rowSize) || rowSize;
let untilLastRow = this.itemElements.length - lastRowSize;
return (i + 1) > untilLastRow;
}
_isRightEdgeItem(i) {
return ((i + 1) % this.metrics.rowSize) === 0;
}
_layoutItems() {
[...this.itemElements].reverse().forEach((itemElement) => {
if (!itemElement.hasAttribute(this.attrName('original-position'))) {
itemElement.setAttribute(this.attrName('original-position'),
getComputedStyle(itemElement).position);
}
let { offsetLeft, offsetTop, style } = itemElement;
style.position = 'absolute';
style.left = `${offsetLeft}px`;
style.top = `${offsetTop}px`;
});
let { style } = this.element;
style.width = `${this.metrics.wrapWidth}px`;
style.height = `${this.metrics.wrapHeight}px`;
}
_reLayoutItems(completion) {
if (this.expandedItemElement) {
this.toggleItemExpansion(this.expandedItemElement, false, () => {
this._reLayoutItems(completion);
});
return;
}
this._selectItemElements();
this._updateMetrics();
this.itemElements.forEach((itemElement) => {
let { style } = itemElement;
style.bottom = style.left = style.right = style.top = 'auto';
style.position = itemElement.getAttribute(this.attrName('original-position'));
itemElement.classList.remove(this.className('raw'));
});
this._layoutItems();
if (completion) {
completion();
}
}
_toggleNeighborItemsRecessed(index, recessed) {
const { expandedScale, rowSize } = this.metrics;
let dx = this._isRightEdgeItem(index) ? -1 : 1;
let dy = this._isBottomEdgeItem(index) ? -1 : 1;
let level = 1;
let neighbors = [];
while (level < expandedScale) {
neighbors.push(
this.itemElements[index + level * dx],
this.itemElements[index + level * dy * rowSize],
this.itemElements[index + level * (dy * rowSize + dx)]
);
level += 1;
}
neighbors.filter(n => !!n).forEach((itemElement) => {
itemElement.classList.toggle(this.className('recessed'));
});
}
_updateMetrics({ hard } = { hard: false }) {
if (hard) {
const { itemElement, expandedItemElement } = this._getMetricSamples();
this.metrics = {
itemWidth: itemElement.offsetWidth,
itemHeight: itemElement.offsetHeight,
expandedWidth: expandedItemElement.offsetWidth,
expandedHeight: expandedItemElement.offsetHeight,
expandedScale: parseInt(this.cssVariable('item-expanded-scale')),
};
}
let gutter = Math.round(parseFloat(
getComputedStyle(this.sampleItemElement).marginRight
));
let fullWidth = this.metrics.itemWidth + gutter;
let fullHeight = this.metrics.itemHeight + gutter;
let { style } = this.element;
style.height = style.width = 'auto';
let rowSize = parseInt(((this.element.offsetWidth + gutter) / fullWidth));
let colSize = Math.ceil(this.itemElements.length / rowSize);
Object.assign(this.metrics, { gutter, rowSize, colSize }, {
wrapWidth: fullWidth * rowSize,
wrapHeight: fullHeight * colSize,
});
}
}
MediaGrid.debug = false;
HLF.buildExtension(MediaGrid, {
autoBind: true,
autoListen: true,
compactOptions: true,
mixinNames: ['css', 'selection'],
});
Object.assign(HLF, { MediaGrid });
return MediaGrid;
});