@nuxeo/nuxeo-ui-elements
Version:
Nuxeo UI Web Components.
497 lines (444 loc) • 16.3 kB
JavaScript
/**
@license
(C) Copyright Nuxeo Corp. (http://nuxeo.com/)
Licensed 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 CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
*/
import { html } from '@polymer/polymer/lib/utils/html-tag.js';
import { dom } from '@polymer/polymer/lib/legacy/polymer.dom.js';
import { timeOut } from '@polymer/polymer/lib/utils/async.js';
import { mixinBehaviors } from '@polymer/polymer/lib/legacy/class.js';
import { Templatizer } from '@polymer/polymer/lib/legacy/templatizer-behavior.js';
import '@polymer/iron-list/iron-list.js';
import { IronResizableBehavior } from '@polymer/iron-resizable-behavior/iron-resizable-behavior.js';
import '@polymer/iron-scroll-threshold/iron-scroll-threshold.js';
import '@nuxeo/nuxeo-elements/nuxeo-element.js';
import '@polymer/paper-icon-button/paper-icon-button.js';
import '@polymer/polymer/lib/elements/array-selector.js';
import '@polymer/polymer/lib/elements/dom-if.js';
import '@polymer/polymer/lib/elements/dom-repeat.js';
import { Debouncer } from '@polymer/polymer/lib/utils/debounce.js';
import { afterNextRender } from '@polymer/polymer/lib/utils/render-status.js';
import { PageProviderDisplayBehavior } from '../nuxeo-page-provider-display-behavior.js';
import { RoutingBehavior } from '../nuxeo-routing-behavior.js';
{
/**
* An element for displaying page provider results in a justified grid with infinite scrolling.
*
* @memberof Nuxeo
* @appliesMixin Polymer.IronResizableBehavior
* @appliesMixin Polymer.Templatizer
* @appliesMixin Nuxeo.PageProviderDisplayBehavior
* @appliesMixin Nuxeo.RoutingBehavior
* @demo demo/nuxeo-justified-grid/index.html
*/
class JustifiedGrid extends mixinBehaviors(
[IronResizableBehavior, Templatizer, PageProviderDisplayBehavior, RoutingBehavior],
Nuxeo.Element,
) {
static get template() {
return html`
<style>
:host {
display: block;
}
#container {
position: relative;
height: 100%;
width: 100%;
}
#list {
@apply --layout-fit;
}
#list::after {
content: '';
flex-grow: 999999999;
}
#list .row {
display: flex;
flex-direction: row;
}
#list .item {
position: relative;
box-shadow: 0 3px 5px rgba(0, 0, 0, 0.04);
border: 2px solid transparent;
cursor: pointer;
outline: none;
}
#list .item[selected],
#list .item:hover,
#list .item:focus {
border: 2px solid var(--nuxeo-grid-selected, transparent);
background-color: var(--nuxeo-grid-selected, transparent);
color: white;
}
#list .item paper-icon-button {
position: absolute;
left: 10px;
top: 10px;
background-color: rgba(255, 255, 255, 0.95);
border: 2px solid var(--nuxeo-grid-selected);
border-radius: 50%;
width: 32px;
height: 32px;
padding: 2px;
color: var(--nuxeo-grid-selected);
display: none;
}
#list .item[selected] paper-icon-button {
border: 2px solid var(--nuxeo-grid-selected);
background-color: var(--nuxeo-grid-selected);
color: white;
display: block;
}
#list .item:hover paper-icon-button,
#list .item paper-icon-button[selection-mode] {
display: block;
}
[hidden] {
display: none ;
}
.emptyResult {
opacity: 0.5;
display: block;
font-weight: 300;
padding: 1.5em 0.7em;
text-align: center;
font-size: 1.1rem;
}
</style>
<dom-if if="[[_isEmpty]]">
<template>
<div class="emptyResult">[[_computedEmptyLabel]]</div>
</template>
</dom-if>
<div id="container">
<iron-list id="list" items="[[rows]]" as="row" on-iron-resize="_resize">
<template>
<div class="row">
<dom-repeat items="[[row]]">
<template>
<div
class="item"
tabindex="0"
on-click="_click"
selected$="[[_isSelected(item, selectedItems.*)]]"
style$="height: [[item._view.height]]px; width: [[item._view.width]]px;"
>
<div id="item-[[item._view.index]]"></div>
[[_itemChanged(item, item._view.width, item._view.height)]]
<paper-icon-button
noink
icon="icons:check"
selection-mode$="[[selectionMode]]"
hidden$="[[!selectionEnabled]]"
on-click="_check"
aria-label$="[[i18n('command.select')]]"
>
</paper-icon-button>
</div>
</template>
</dom-repeat>
</div>
</template>
</iron-list>
<iron-scroll-threshold id="scrollThreshold" scroll-target="list" on-lower-threshold="_scrollChanged">
</iron-scroll-threshold>
<array-selector id="selector" items="{{items}}" selected="{{selectedItems}}" multi></array-selector>
</div>
`;
}
static get is() {
return 'nuxeo-justified-grid';
}
static get properties() {
return {
/**
* Height of each result line in pixels.
*/
rowHeight: {
type: Number,
value: 196,
},
/**
* Page number the search result starts from.
*/
page: {
type: Number,
value: 1,
},
/**
* Number of results per page. The maximum number recommended is 200 if results display thumbnails.
*/
pageSize: {
type: Number,
value: 50,
},
rows: {
type: Array,
value: [],
},
_isFetching: {
type: Boolean,
value: false,
},
_templateElement: {
type: Object,
},
};
}
static get observers() {
return ['_selectedItemsChanged(selectedItems.splices)'];
}
ready() {
super.ready();
const template = dom(this).querySelector('template');
if (template) {
this.templatize(template);
this._templateElement = this.stamp().root.firstElementChild;
}
}
disconnectedCallback() {
super.disconnectedCallback();
this._templateElement = null;
}
_itemChanged(item) {
if (this._templateElement && item && item._view) {
afterNextRender(this, () => {
const el = this.$$(`#item-${item._view.index}`);
if (el) {
if (el.childNodes.length > 0) {
el.childNodes[0].set('document', item);
} else {
const clone = this._templateElement.cloneNode(true);
clone.set('document', item);
el.appendChild(clone);
}
}
});
}
}
// overridden from Nuxeo.PageProviderDisplayBehavior
reset(size) {
this.set('items', []);
this.set('rows', []);
this.page = 1;
this.$.scrollThreshold.clearTriggers();
this._reset(size);
}
// overridden from Nuxeo.PageProviderDisplayBehavior
fetch() {
// consistent with other views, fetch always gets the first page (resets the page number)
return this._fetchNewPage(true);
}
_fetchNewPage(reset) {
if (this._isFetching || !this._hasPageProvider() || this.page > this.nxProvider.numberOfPages) {
if (!reset) {
this.$.scrollThreshold.clearTriggers();
}
this._isFetching = false;
return Promise.resolve();
}
this._isFetching = true;
this.page = reset ? 1 : this.page;
return this._fetchPage(this.page, this.pageSize).then((response) => {
this._addItems(response.entries);
if (!reset || this.page === 1) {
this.page += 1;
this.$.scrollThreshold.clearTriggers();
}
this._isFetching = false;
});
}
_scrollChanged() {
this._debouncer = Debouncer.debounce(
this._debouncer,
timeOut.after(this.scrollThrottle > 0 ? this.scrollThrottle : 1),
() => this._fetchNewPage(false),
);
// when scrolling there's no need to reset the page number
return this._fetchNewPage(false);
}
// overridden from Nuxeo.PageProviderDisplayBehavior
getSelectedItems() {
return this.$.selector.selected;
}
// overridden from Nuxeo.PageProviderDisplayBehavior
selectItem(item) {
if (this.selectionEnabled) {
this.$.selector.select(item);
this._updateFlags();
}
}
// overridden from Nuxeo.PageProviderDisplayBehavior
selectIndex(index) {
if (this.selectionEnabled) {
this.$.selector.selectIndex(index);
this._updateFlags();
}
}
// overridden from Nuxeo.PageProviderDisplayBehavior
selectItems(items) {
if (this.selectionEnabled && items && items.length > 0) {
items.forEach((item) => this.$.selector.select(item));
this._updateFlags();
}
}
// overridden from Nuxeo.PageProviderDisplayBehavior
deselectItem(item) {
if (this.selectionEnabled && !this.selectAllActive) {
this.$.selector.deselect(item);
this._updateFlags();
}
}
// overridden from Nuxeo.PageProviderDisplayBehavior
deselectIndex(index) {
if (this.selectionEnabled && !this.selectAllActive) {
this.$.selector.deselectIndex(index);
this._updateFlags();
}
}
// overridden from Nuxeo.PageProviderDisplayBehavior
clearSelection() {
this._isSelectAllActive = false;
this.$.selector.clearSelection();
this._updateFlags();
}
_check(e) {
if (this.selectionEnabled && !this.selectAllActive) {
this.selectionMode = true;
this._click(e);
} else {
// prevent bubbling of the click event (prevent the execution of _click() in a grid tile)
e.preventDefault();
e.stopPropagation();
}
}
_click(e) {
const { index } = e.model.item._view;
if (this.selectionEnabled && this.selectionMode && !this.selectAllActive) {
// since we are using Object.assign() when creating items for the grid, we cannot really use
// selector.selectItem()/deselectItem() because it relies on indexOf and since the e.model.item is not a
// reference of the original item, it doesn't work.
if (this._isIndexSelected(index)) {
this.deselectIndex(index);
} else {
this.selectIndex(index);
}
} else {
this.dispatchEvent(
new CustomEvent('navigate', {
composed: true,
bubbles: true,
detail: { doc: this.items[index], index },
}),
);
}
e.stopPropagation();
}
_selectedItemsChanged() {
this.selectionMode = this.selectedItems && this.selectedItems.length > 0;
}
_isSelected(item) {
return this._isIndexSelected(item._view.index);
}
_isIndexSelected(index) {
return this.selectedItems.indexOf(this.items[index]) > -1;
}
/**
* Adds a list of items to the grid. The `items` are computed and transformed into rows.
* This is the ideal way to add new items to the grid (e.g. when loading more items with infinite scroll behavior).
*/
_addItems(newItems) {
let items = newItems;
// recompute last row before adding new items
if (this.rows.length > 0) {
// get last row items
const lastRowItems = this.rows[this.rows.length - 1].map((item) => this.items[item._view.index]);
// append last row items to `newItems` param
items = lastRowItems.concat(items);
// drop last row
this.pop('rows');
}
this._computeRows(items).forEach((row) => this.push('rows', row));
this.$.scrollThreshold.clearTriggers();
}
/**
* Given a set of items, computes a list of rows. The goal is to compute each row by trying to add items until
* they fit. When a row doesn't have space to fit another item, we increase the height of the current row to take
* advantage of the remaining width space to avoid cropping images.
* Currently relies on `picture:info` to get the dimensions of a video/image. Fallbacks to square dimensions if the
* document doesn't have the `picture` schema.
*/
_computeRows(items) {
const gridWidth = this.$.list.offsetWidth;
const rows = [];
let currentRowWidth = 0;
let currentRow = [];
// filter out items that were not loaded yet
items
.filter((item) => Object.keys(item).length !== 0)
.forEach((item, idx) => {
const clone = Object.assign({}, item);
// fallback to square dimensions if the item doesn't have a size object in it's model
clone.size = clone.properties['picture:info'] || { width: 1, height: 1 };
clone.size.width = clone.size.width || 1;
clone.size.height = clone.size.height || 1;
clone._view = {};
clone._view.index = this.items.indexOf(item);
// compute item width to fit a row with `rowHeight`
// new width = original width * rowHeight / original height
clone._view.width = (clone.size.width * this.rowHeight) / clone.size.height;
clone._view.height = this.rowHeight;
// if item fits, add it to current row
if (currentRowWidth + clone._view.width <= gridWidth) {
currentRow.push(clone);
currentRowWidth += clone._view.width;
} else {
// current item doesn't fit in current row
// fit items do width and push current row to rows
rows.push(this._fitItemsToWidth(currentRow, currentRowWidth, gridWidth));
// append current item (that didn't fit in current row) to a new row
currentRow = [clone];
currentRowWidth = clone._view.width;
}
if (idx === items.length - 1) {
// fit items do width and push current row to rows
rows.push(this._fitItemsToWidth(currentRow, currentRowWidth, gridWidth));
}
});
return rows;
}
/**
* Computes the ideal height of current row in order to increase the width of items and take advantage of the
* remaining width space.
*/
_fitItemsToWidth(currentRow, currentRowWidth, gridWidth) {
const computedHeight = (gridWidth * this.rowHeight) / currentRowWidth;
currentRow.forEach((item) => {
item._view.height = computedHeight;
item._view.width = (item._view.width / currentRowWidth) * gridWidth;
});
return currentRow;
}
/**
* Recomputes rows when the browser window is resized.
*/
_resize() {
if (this.$.list.offsetWidth || this.$.list.offsetHeight) {
this._debouncer = Debouncer.debounce(this._debouncer, timeOut.after(150), () => {
this.rows = this._computeRows(this.items);
});
}
}
}
customElements.define(JustifiedGrid.is, JustifiedGrid);
Nuxeo.JustifiedGrid = JustifiedGrid;
}