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 = Array.from(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 = Array.from(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() {
Array.from(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,
});
}
}
HLF.buildExtension(MediaGrid, {
autoBind: true,
autoListen: true,
compactOptions: true,
mixinNames: ['css', 'selection'],
});
Object.assign(HLF, { MediaGrid });
return MediaGrid;
});
HLF Media Grid Extension
Styles | Tests
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.