UNPKG

@adobe/coral-spectrum

Version:

Coral Spectrum is a JavaScript library of Web Components following Spectrum design patterns.

463 lines (396 loc) 13.7 kB
/** * 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;