coral-spectrum/coral-component-masonry/src/scripts/MasonryColumnLayout.js
/**
* Copyright 2019 Adobe. All rights reserved.
* This file is licensed to you under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License. You may obtain a copy
* of the License at http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software distributed under
* the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR REPRESENTATIONS
* OF ANY KIND, either express or implied. See the License for the specific language
* governing permissions and limitations under the License.
*/
import MasonryLayout from './MasonryLayout';
import {setTransition, setTransform, csspx, getPositiveNumberProperty} from './MasonryLayoutUtil';
import {Keys} from '../../../coral-utils';
/**
Base class for column-based masonry layouts.
@class Coral.Masonry.ColumnLayout
@classdesc A Masonry Column layout
@extends {MasonryLayout}
*/
class MasonryColumnLayout extends MasonryLayout {
/**
Takes a {Masonry} instance as argument.
@param {Masonry} masonry
*/
constructor(masonry) {
super(masonry);
this._columns = [];
const up = this._moveFocusVertically.bind(this, true);
const down = this._moveFocusVertically.bind(this, false);
const left = this._moveFocusHorizontally.bind(this, true);
const right = this._moveFocusHorizontally.bind(this, false);
const home = this._moveFocusHomeEnd.bind(this, true);
const end = this._moveFocusHomeEnd.bind(this, false);
const keys = this._keys = new Keys(masonry, {
context: this
});
keys.on('up', up).on('k', up);
keys.on('down', down).on('j', down);
keys.on('left', left).on('h', left);
keys.on('right', right).on('l', right);
keys.on('home', home);
keys.on('end', end);
}
/**
Hook to remove layout specific style and data from the item.
@param item
@private
*/
// eslint-disable-next-line no-unused-vars
_resetItem(item) {
// To override
}
/**
Initialize layout variables.
@private
*/
_init(items) {
const firstItem = items[0];
const masonry = this._masonry;
this._columnWidth = getPositiveNumberProperty(masonry, 'columnWidth', 'columnwidth', 200);
this._zeroOffsetLeft = -csspx(firstItem, 'marginLeft');
// with padding
this._masonryInnerWidth = masonry.clientWidth;
const spacing = this._masonry.spacing;
if (typeof spacing === 'number') {
this._horSpacing = spacing;
this._verSpacing = spacing;
this._offsetLeft = spacing + this._zeroOffsetLeft;
this._offsetTop = spacing - csspx(firstItem, 'marginTop');
this._verPadding = 2 * spacing;
this._masonryAvailableWidth = masonry.clientWidth - spacing;
} else {
this._horSpacing = csspx(firstItem, 'marginLeft') + csspx(firstItem, 'marginRight');
this._verSpacing = csspx(firstItem, 'marginTop') + csspx(firstItem, 'marginBottom');
this._offsetLeft = csspx(masonry, 'paddingLeft');
this._offsetTop = csspx(masonry, 'paddingTop');
this._verPadding = this._offsetTop + this._verSpacing + csspx(masonry, 'paddingBottom');
this._masonryAvailableWidth = masonry.clientWidth - this._offsetLeft - csspx(masonry, 'paddingRight');
}
// Initialize column objects
const columnCount = Math.max(1, Math.floor(this._masonryAvailableWidth / (this._columnWidth + this._horSpacing)));
this._columns.length = columnCount;
for (let ci = 0 ; ci < columnCount ; ci++) {
this._columns[ci] = {
height: this._offsetTop,
items: []
};
}
// Prepare layout data
for (let ii = 0 ; ii < items.length ; ii++) {
const item = items[ii];
let layoutData = item._layoutData;
if (!layoutData) {
item._layoutData = layoutData = {};
}
// Read colspan
layoutData.colspan = Math.min(getPositiveNumberProperty(item, 'colspan', 'colspan', 1), this._columns.length);
}
}
/**
Updates the width of all items.
@param items
@private
*/
_writeStyles(items) {
for (let i = 0 ; i < items.length ; i++) {
const item = items[i];
const layoutData = item._layoutData;
// Update width
const itemWidth = Math.round(this._getItemWidth(layoutData.colspan));
if (layoutData.width !== itemWidth) {
item.style.width = `${itemWidth}px`;
layoutData.width = itemWidth;
}
this._writeItemStyle(item);
}
}
/**
@param colspan column span of the item
@return the width of the item for the given colspan
@private
*/
// eslint-disable-next-line no-unused-vars
_getItemWidth(colspan) {
// To override
}
/**
Hook to execute layout specific item preparation.
@param item
@private
*/
// eslint-disable-next-line no-unused-vars
_writeItemStyle(item) {
// To override
}
/**
Reads the dimension of all items.
@param items
@private
*/
_readStyles(items) {
// Record size of items in a separate loop to avoid unneccessary reflows
for (let i = 0 ; i < items.length ; i++) {
const item = items[i];
const layoutData = item._layoutData;
layoutData.height = Math.round(item.getBoundingClientRect().height);
layoutData.ignored = layoutData.detached || !item.offsetParent;
}
}
/**
Update the position of all items.
@param items
@private
*/
_positionItems(items) {
let j;
for (let i = 0 ; i < items.length ; i++) {
const item = items[i];
const layoutData = item._layoutData;
// Skip ignored items
if (layoutData.ignored) {
continue;
}
// Search for column with the least height
const maxLength = this._columns.length - (layoutData.colspan - 1);
let minColumnIndex = -1;
let minColumnHeight;
for (j = 0 ; j < maxLength ; j++) {
// can be negative if set spacing < item css margin
let columnHeight = this._offsetTop;
for (let y = 0 ; y < layoutData.colspan ; y++) {
columnHeight = Math.max(columnHeight, this._columns[j + y].height);
}
if (minColumnIndex === -1 || columnHeight < minColumnHeight) {
minColumnIndex = j;
minColumnHeight = columnHeight;
}
}
const top = minColumnHeight;
const left = Math.round(this._getItemLeft(minColumnIndex));
// Check if position has changed
if (layoutData.left !== left || layoutData.top !== top) {
layoutData.columnIndex = minColumnIndex;
layoutData.itemIndex = this._columns[minColumnIndex].items.length;
layoutData.left = left;
layoutData.top = top;
setTransform(item, `translate(${left}px, ${top}px)`);
}
// Remember new column height to position all other items
const newColumnHeight = top + layoutData.height + this._verSpacing;
for (j = 0 ; j < layoutData.colspan ; j++) {
const column = this._columns[minColumnIndex + j];
column.height = newColumnHeight;
column.items.push(item);
}
}
}
/**
@param columnIndex
@return the left position for the given column index
@private
*/
// eslint-disable-next-line no-unused-vars
_getItemLeft(columnIndex) {
// To override
}
/**
@returns {number} the height of the content (independent of the current gird container height)
@private
*/
_getContentHeight() {
return this._columns.reduce((height, column) => Math.max(height, column.height), 0) - this._offsetTop;
}
/**
Hook which is called after the positioning is done.
@param contentHeight
@private
*/
// eslint-disable-next-line no-unused-vars
_postLayout(contentHeight) {
// To override
}
/**
Moves the focus vertically.
@private
*/
_moveFocusVertically(up, event) {
const currentLayoutData = event.target._layoutData;
if (!currentLayoutData) {
return;
}
// Choose item above or below
const nextItemIndex = currentLayoutData.itemIndex + (up ? -1 : 1);
let nextItem = this._columns[currentLayoutData.columnIndex].items[nextItemIndex];
if (nextItem) {
nextItem.focus();
// prevent scrolling at the same time
event.preventDefault();
} else {
// in case there is no item in the same column, we should move to first item in next column for down
// and last item of previous column for up key
let columnIndex = currentLayoutData.columnIndex;
if (up) {
if (columnIndex > 0) {
// move to last item of previous column
let prevColumn = this._columns[columnIndex - 1];
if (prevColumn) {
nextItem = prevColumn.items[prevColumn.items.length - 1]; // last item of previous column
}
}
} else {
// down key is pressed, go to first item of next column if exists
let columnCount = this._columns.length;
let nextColumnIndex = columnIndex + currentLayoutData.colspan;
if (nextColumnIndex < columnCount) {
nextItem = this._columns[nextColumnIndex].items[0]; // first item of next column
}
}
if (nextItem) {
nextItem.focus();
event.preventDefault(); // prevent scrolling at the same time
}
}
}
/**
Moves the focus horizontally.
@private
*/
_moveFocusHorizontally(left, event) {
const currentLayoutData = event.target._layoutData;
if (!currentLayoutData) {
return;
}
let nextItem;
let items = this._masonry.items.getAll();
let collectionItemIndex = items.indexOf(event.target);
if (left) {
if (collectionItemIndex > 0) {
nextItem = items[collectionItemIndex - 1];
}
} else if (collectionItemIndex < items.length - 1) {
nextItem = items[collectionItemIndex + 1];
}
if (nextItem) {
nextItem.focus();
event.preventDefault(); // prevent scrolling at the same time
}
}
/**
Moves the focus to first or last item based on the visual order.
@private
*/
_moveFocusHomeEnd(home, event) {
const currentLayoutData = event.target._layoutData;
if (!currentLayoutData) {
return;
}
let nextItem;
const columns = this._columns;
// when home is pressed, we take the first item of the first column
if (home) {
nextItem = columns[0] && columns[0].items[0];
} else {
// when end is pressed, we take the last item of the last column; since some columns are empty, we need to
// iterate backwards to find the first column that has items
for (let i = columns.length - 1 ; i > -1 ; i--) {
// since we found a column with items, we take the last item as the next one
if (columns[i].items.length > 0) {
nextItem = columns[i].items[columns[i].items.length - 1];
break;
}
}
}
if (nextItem) {
nextItem.focus();
// we prevent the scrolling
event.preventDefault();
}
}
/** @inheritdoc */
layout(secondTry) {
const masonry = this._masonry;
const items = masonry.items.getAll();
if (items.length > 0) {
// For best possible performance none of these function calls must both read and write attributes in a loop to
// avoid unnecessary reflows.
this._init(items);
this._writeStyles(items);
this._readStyles(items);
this._positionItems(items);
} else {
this._columns.length = 0;
}
// Update the height of the masonry (otherwise it has a height of 0px due to the absolutely positioned items)
const contentHeight = this._getContentHeight();
masonry.style.height = `${contentHeight - this._verSpacing + this._verPadding}px`;
// Check if the masonry has changed its width due to the changed height (can happen because of appearing/disappearing scrollbars)
if (!secondTry && this._masonryInnerWidth !== masonry.clientWidth) {
this.layout(true);
} else {
// Post layout hook for sub classes
this._postLayout(contentHeight);
}
}
/** @inheritdoc */
destroy() {
this._keys.destroy();
const items = this._masonry.items.getAll();
for (let i = 0 ; i < items.length ; i++) {
const item = items[i];
item._layoutData = undefined;
setTransform(item, '');
this._resetItem(item);
}
}
/** @inheritdoc */
detach(item) {
item._layoutData.detached = true;
}
/** @inheritdoc */
reattach(item) {
const layoutData = item._layoutData;
layoutData.detached = false;
const rect = item.getBoundingClientRect();
// Disable transition while repositioning
setTransition(item, 'none');
item.style.left = '';
item.style.top = '';
setTransform(item, '');
const nullRect = item.getBoundingClientRect();
layoutData.left = rect.left - nullRect.left;
layoutData.top = rect.top - nullRect.top;
setTransform(item, `translate(${layoutData.left}px, ${layoutData.top}px)`);
// Enforce position
item.getBoundingClientRect();
// Enable transition again
setTransition(item, '');
}
/** @inheritdoc */
itemAt(x, y) {
// TODO it would be more efficient to pick first the right column
const items = this._masonry.items.getAll();
for (let i = 0 ; i < items.length ; i++) {
const item = items[i];
const layoutData = item._layoutData;
if (layoutData && !layoutData.ignored && (
layoutData.left <= x && layoutData.left + layoutData.width >= x &&
layoutData.top <= y && layoutData.top + layoutData.height >= y)) {
return item;
}
}
return null;
}
}
export default MasonryColumnLayout;