Neovici/cosmoz-data-nav

View on GitHub
cosmoz-data-nav.js

Summary

Maintainability
B
5 hrs
Test Coverage
A
92%
/* eslint-disable max-lines */
import { render } from 'lit-html';
 
import { PolymerElement } from '@polymer/polymer/polymer-element';
import { html } from '@polymer/polymer/lib/utils/html-tag';
import { templatize } from '@polymer/polymer/lib/utils/templatize';
 
import { FlattenedNodesObserver } from '@polymer/polymer/lib/utils/flattened-nodes-observer';
 
import { Debouncer } from '@polymer/polymer/lib/utils/debounce';
import { animationFrame } from '@polymer/polymer/lib/utils/async';
import { flush } from '@polymer/polymer/lib/utils/flush';
 
import { hauntedPolymer } from '@neovici/cosmoz-utils';
 
import { useDataNav } from './lib/use-data-nav.js';
 
const _async = window.requestIdleCallback || window.requestAnimationFrame || window.setTimeout,
_hasDeadline = 'IdleDeadline' in window,
_asyncPeriod = (cb, timeout = 1500) => {
_async(() => cb(), _hasDeadline && { timeout });
},
 
hashUrl = () => new URL(location.hash.replace(/^#!?/iu, '').replace('%23', '#'), location.origin),
getHashParam = param => new URLSearchParams(hashUrl().hash.replace('#', '')).get(param),
setHashParam = (param, value) => {
const
url = hashUrl(),
searchParams = new URLSearchParams(url.hash.replace('#', ''));
 
searchParams.set(param, value);
 
const newUrl = '#!' + Object.assign(url, { hash: searchParams }).href.replace(location.origin, '');
 
history.replaceState(null, '', newUrl);
};
 
`CosmozDataNav` has 37 functions (exceeds 20 allowed). Consider refactoring.
class CosmozDataNav extends hauntedPolymer('haunted', useDataNav)(PolymerElement) {
static get template() { // eslint-disable-line max-lines-per-function
return html`
<style>
:host {
position: relative;
}
 
#items {
overflow-x: hidden;
}
 
#items,
#items > ::slotted(.animatable) {
position: absolute;
top: 0;
right: 0;
bottom: 0;
left: 0;
}
:host([animating]) #items > ::slotted(.animatable){
transition: transform 0.25s cubic-bezier(0.4, 0, 0.2, 1) 0s;
backface-visibility: hidden;
}
 
:host([animating][reverse]) #items > ::slotted(.in),
:host([animating]) #items > ::slotted(.out){
transform: translateX(-100%);
}
 
:host([animating][reverse]) #items > ::slotted(.out),
:host([animating]) #items > ::slotted(.in){
transform: translateX(100%);
}
 
:host([has-items][animating]) #items > ::slotted(:not(.selected):not(.out)),
:host([has-items]:not([animating])) #items > ::slotted(:not(.selected)),
:host(:not([has-items])) #items > ::slotted(:not(:first-of-type)){
display: none;
}
</style>
<div id="items">
<slot name="items"></slot>
</div>
<div id="templates">
<slot id="templatesSlot"></slot>
</div>
`;
}
 
static get properties() { // eslint-disable-line max-lines-per-function
return {
/**
* The array of buffer elements.
*/
_elements: {
type: Array,
value() {
return [];
}
},
 
/**
* The name of the variable to add to the binding scope for the array
* element associated with a template instance.
*/
as: {
type: String,
value: 'item'
},
 
/**
* The name of the variable to add to the binding scope with the index
* for the item.
*/
indexAs: {
type: String,
value: 'index'
},
 
/**
* An array containing items from which a selection can be made.
*/
items: {
type: Array,
value() {
return [];
},
notify: true,
observer: '_itemsChanged'
},
 
/**
* The length of items array.
*/
queueLength: {
type: Number,
notify: true,
readOnly: true
},
 
hasItems: {
type: Boolean,
readOnly: true,
reflectToAttribute: true
},
 
elementsBuffer: {
type: Number,
value: 3
},
 
/**
* Number of items after the currently selected one to preload.
*/
preload: {
type: Number,
value: 1
},
 
renderItem: {
type: Function
},
 
/**
* The currently selected index.
*/
selected: {
type: Number,
value: 0,
notify: true,
observer: '_updateSelected'
},
 
/**
* The index of the next element.
*/
selectedNext: {
type: Number,
notify: true,
value: 1,
readOnly: true
},
 
/**
* The currently selected element (holder)
*/
selectedElement: {
type: Object,
notify: true,
readOnly: true
},
 
/**
* The currently selected element (instance)
*/
selectedInstance: {
type: Object,
notify: true,
readOnly: true
},
 
/**
* The currently selected item, or `null` if no item is selected.
*/
selectedItem: {
type: Object,
notify: true,
readOnly: true,
computed: '_getItem(selected, items.*)'
},
 
/**
* True if cosmoz-data-nav should try to maintain selection when
* `items` change.
*/
maintainSelection: {
type: Boolean,
value: false
},
 
/**
* The attribute that elements which control the `selected` of this element
* should have. The value of the attribute can be `-1` or `+1`.
*/
selectAttribute: {
type: String,
value: 'cosmoz-data-nav-select'
},
 
/**
* True if the element is currently animating.
*/
animating: {
type: Boolean,
value: false,
reflectToAttribute: true
},
 
/**
* True if selecting a element with a index smaller than the current one.
*/
reverse: {
type: Boolean,
value: false,
reflectToAttribute: true
},
 
/**
* Function used to determine if a item is incomplete and needs to be preloaded.
* The default values is a function that requires item to be a `Object`.
*/
isIncompleteFn: {
type: Function,
value() {
return item => item == null || typeof item !== 'object';
}
},
 
/**
* The hash parameter to use for selecting an item.
*/
hashParam: {
type: String
},
 
/**
*
*/
idPath: {
type: String,
value: 'id'
},
 
/**
* True if element should render items even if it is not visible.
*/
hiddenRendering: {
type: Boolean,
value: false
},
 
/**
* Whether we should request data for all preloaded items at once or one at a time
*/
parallelDataRequests: {
type: Boolean,
value: false
}
};
}
 
static get observers() {
return [
'renderIncomplete(selected, haunted)'
];
}
 
renderIncomplete(index, haunted) {
if (haunted == null) {
return;
}
 
const position = index < this.items.length ? index : index - 1,
element = this._getElement(position),
item = this.items[position];
 
if (element == null || !this.isIncompleteFn(item)) {
return;
}
render(haunted.incompleteTemplates[position], element.__incomplete);
}
 
constructor() {
super();
this._previouslySelectedItem = null;
this._preloadIdx = 0;
this._boundOnTemplatesChange = this._onTemplatesChange.bind(this);
}
 
connectedCallback() {
super.connectedCallback();
this._templatesObserver = new FlattenedNodesObserver(
this.$.templatesSlot,
this._boundOnTemplatesChange
);
this.addEventListener('tap', this._onTap);
this.addEventListener('transitionend', this._onTransitionEnd);
}
 
disconnectedCallback() {
super.disconnectedCallback();
if (this._templatesObserver) {
this._templatesObserver.disconnect();
this._templatesObserver = null;
}
 
if (this._selectDebouncer != null) {
this._selectDebouncer.cancel();
}
 
this._previouslySelectedItem = null;
this._indexRenderQueue = [];
this.removeEventListener('tap', this._onTap);
this.removeEventListener('transitionend', this._onTransitionEnd);
 
this.splice('_elements', 0, this._elements.length, this._createElement())
.forEach(element => {
if (this.renderItem) {
element.removeChild(element.__instance);
} else {
this._removeInstance(element.__instance);
}
element.removeChild(element.__incomplete);
element.__instance = element.__incomplete = null;
});
}
 
_onTemplatesChange(change) {
if (!this._elementTemplate && !this.renderItem) {
const templates = change.addedNodes.filter(n => n.nodeType === Node.ELEMENT_NODE && n.tagName === 'TEMPLATE'),
elementTemplate = templates[0];
 
if (!elementTemplate) {
// eslint-disable-next-line no-console
console.warn('cosmoz-data-nav requires a template');
return;
}
this._templatize(elementTemplate);
}
 
const elements = this._elements,
length = elements.length;
 
this.splice('_elements', -1, 0, ...Array(this.elementsBuffer - length)
.fill().map(this._createElement, this));
 
elements.forEach(el => this.appendChild(el));
}
 
_templatize(elementTemplate) {
this._elementTemplate = elementTemplate;
 
const baseProps = {
prevDisabled: true,
nextDisabled: true,
[this.indexAs]: true
};
this._elementCtor = templatize(this._elementTemplate, this, {
instanceProps: Object.assign({ [this.as]: true }, baseProps),
parentModel: true,
forwardHostProp: this._forwardHostProp,
notifyInstanceProp: this._notifyInstanceProp
});
}
 
get _allElementInstances() {
return this._elements
.map(e => e.__instance)
.filter(i => i != null);
}
 
_forwardHostProp(prop, value) {
const instances = this._allElementInstances;
if (!instances || !instances.length) {
return;
}
instances.forEach(inst => inst.forwardHostProp(prop, value));
}
 
_notifyInstanceProp(inst, prop, value) {
const index = inst.index,
item = this.items[index];
if (prop !== this.as || value === item || this._allElementInstances.indexOf(inst) < 0) {
return;
}
this.haunted.cache.dropItem(item);
this.set(['items', index], value);
}
 
_createElement() {
const element = document.createElement('div'),
incDiv = document.createElement('div');
element.appendChild(incDiv);
element.__incomplete = incDiv;
if (this.renderItem) {
const instDiv = document.createElement('div');
element.appendChild(instDiv);
element.__instance = instDiv;
}
element.setAttribute('slot', 'items');
element.classList.add('animatable');
return element;
}
 
/**
* Selects an item by index.
*
* @param {Number} index The index
* @return {void}
*/
select(index) {
const length = this.items && this.items.length;
if (!length || index < 0 || index >= length) {
return;
}
this.reverse = index < this.selected;
this.selected = index;
}
 
/**
* Replace an id in the `items` element list with the full data of the item.
*
* @param {type} id The id currently stored in the `items` array
* @param {Object} item The full data of object
* @return {void}
*/
setItemById(id, item) {
const items = this.items,
matches = items.filter(item => this._getItemId(item) === id);
 
if (matches.length === 0) {
// eslint-disable-next-line no-console
console.warn('List item replacement failed, no matching idPath', this.idPath, 'with id', id, 'in the item list', items, 'to replace with item', item);
return;
} else if (matches.length > 1) {
// eslint-disable-next-line no-console
console.warn('Multiple replaceable items matches idPath', this.idPath, 'with id', id, 'in the item list', items, 'to replace with item', item);
}
 
this.haunted.cache.set(id, item);
matches.forEach(match => this.set(['items', items.indexOf(match)], { ...item }));
 
this._preload();
 
if (this.animating || this.selected == null) {
return;
}
 
this._updateSelected();
}
 
/**
* Observes full changes to `items` properties
* and replaces cached items with full data if available.
*
* @param {type} items description
* @return {type} description
*/
_itemsChanged(items) { // eslint-disable-line max-statements
const length = items && items.length;
 
// update read-only properties
this._setQueueLength(length >> 0); // eslint-disable-line no-bitwise
this._setHasItems(!!length);
 
// replace incomplete items with cached item data
if (length) {
items.forEach((item, index) => {
if (this.isIncompleteFn(item)) {
const cachedItem = this.haunted?.cache?.get(item);
if (cachedItem) {
this.set(['items', index], cachedItem);
}
}
});
}
 
// synchronize `selected` with hash params
if (this._updateSelectedFromHash()) {
return;
}
 
// reset queue to 0 or maintain selection
let index = 0;
if (items.length > 0 && this.maintainSelection && this._previouslySelectedItem != null) {
// search for previously selected item by reference
index = items.indexOf(this._previouslySelectedItem);
 
// if not found, search by id
if (index < 0) {
const prevId = this._getItemId(this._previouslySelectedItem);
index = items.findIndex(item => this._getItemId(item) === prevId);
}
 
// if still not found, remain on the selected index
if (index < 0) {
index = this.selected < items.length
? this.selected
: items.length - 1;
}
this._realignElements(index);
}
// update selected or force re-render if selected did not change
if (this.selected === index) {
return this._updateSelected();
}
this.selected = index;
return index;
}
 
_realignElements(index) { // eslint-disable-line max-statements
const elements = this._elements,
element = this._getElement(index),
item = this.items[index];
if (this.isIncompleteFn(item) || element.item === item) {
return;
}
const renderedElement = this._elements.find(el => !this.isIncompleteFn(el.item) && this._getItemId(el.item) === this._getItemId(item));
if (!renderedElement) {
return;
}
const elementIndex = elements.indexOf(element),
renderedIndex = elements.indexOf(renderedElement);
if (elementIndex === renderedIndex) {
return;
}
 
if (!this.renderItem) {
// update instance's data-nav related props
const instance = renderedElement.__instance;
Object.entries(this._getBaseProps(index))
.forEach(([key, value]) => instance._setPendingProperty(key, value));
instance._flushProperties();
}
this._elements.splice(renderedIndex, 1);
this.splice('_elements', elementIndex, 0, renderedElement);
}
 
/**
* Observes changed to `selected` property and
* updates related properties and the `selected` page.
*
* @param {Number} selected The selected property
* @param {Number} previous The previous value of selected property
* @return {void}
*/
_updateSelected(selected = this.selected, previous) { // eslint-disable-line max-statements
if (this.items.length === 0) {
return;
}
const position = selected < this.items.length ? selected : selected - 1;
this._setSelectedNext((position || 0) + 1);
this._preload(position);
this._previouslySelectedItem = this.items[position];
 
const element = this._getElement(position);
 
if (!element) {
return;
}
 
this._setSelectedElement(element);
this._setSelectedInstance(this._getInstance(element));
 
this._updateHashForSelected(position);
 
const classes = element.classList,
animating = this.animating && previous != null && previous !== position;
 
if (!animating) {
this._elements.forEach(el => el.classList.remove('selected'));
}
 
classes.toggle('in', animating);
classes.add('selected');
 
if (!animating) {
if (this.isConnected) {
this._synchronize();
}
return;
}
 
const prev = animating && this._getElement(previous);
 
requestAnimationFrame(() => {
if (prev && element.offsetWidth) {
prev.classList.add('out');
prev.classList.remove('selected');
}
classes.remove('in');
}, 8);
}
 
/**
* Handles `transitionend` event and cleans up animation classe and properties
*
* @param {TransitionEvent} e The event
* @return {void}
*/
_onTransitionEnd(e) {
const elements = this._elements;
 
if (!this.animating || elements.indexOf(e.target) < 0) {
return;
}
 
this.animating = false;
elements.forEach(el => el.classList.remove('in', 'out'));
this._synchronize();
}
 
/**
* Preloads items that are not loaded depending on the currently
* selected item and the `preload` property.
*
* @fires need-data
* @param {Number} index The index to preload from
* @return {void}
*/
_preload(index = this._preloadIdx) {
const items = this.items;
 
if (!Array.isArray(items) || items.length === 0) {
return;
}
 
const item = items[index];
 
if (this.isIncompleteFn(item)) {
this.dispatchEvent(new CustomEvent('need-data', {
bubbles: true,
composed: true,
detail: {
id: item,
render: true
}
}));
if (!this.parallelDataRequests) {
return;
}
}
 
if (index >= Math.min(this.selected + this.preload, items.length - 1)) {
return;
}
 
this._preloadIdx = index + 1;
this._preload();
}
 
_getBaseProps(index) {
return {
prevDisabled: index < 1,
nextDisabled: index + 1 >= this.items.length,
[this.indexAs]: Math.max(Math.min(index, this.items.length - 1), 0)
};
}
 
_getElement(index, _elements = this._elements) {
const elements = _elements && _elements.base || _elements,
bufferLength = this.elementsBuffer || elements.length,
elementIndex = index % bufferLength;
 
return elements[elementIndex];
}
 
_getInstance(selectedElement) {
if (selectedElement == null) {
return;
}
// return reference to the rendered template instance or the incomplete template if missing
return selectedElement.children[1] || selectedElement.__incomplete;
}
 
_getItem(index, items = this.items) {
const arr = items.base ? items.base : items;
return arr[index];
}
 
_resetElement(index) { // eslint-disable-line max-statements
const element = this._getElement(index);
if (!element) {
return;
}
 
const item = this.items[index],
baseProps = this._getBaseProps(index),
instance = element.__instance;
 
if (!this.renderItem && instance) {
Object.assign(instance, baseProps);
}
 
if (!this.isIncompleteFn(item) && element.item === item) {
return;
}
 
if (element._reset) {
return;
}
element._reset = true;
element.__incomplete.style.display = 'block';
 
if (!instance) {
return;
}
 
this._toggleInstance(instance, false);
}
 
_removeInstance(instance) {
if (!instance) {
return;
}
instance.children.forEach(child => child.parentNode.removeChild(child));
}
 
/**
* Syncronizes the `items` data with the created template instances
* depending on the currently selected item.
* @return {type} description
*/
_synchronize() {
const selected = this.selected,
buffer = this.elementsBuffer,
offset = buffer / 2 >> 0, // eslint-disable-line no-bitwise
max = Math.max,
min = Math.min,
length = this.items.length,
 
start = min(max(selected - offset, 0), length ? length - buffer : 0),
end = max(min(selected + offset, length ? length - 1 : 0), buffer - 1),
indexes = Array(end + 1)
.fill()
.map((u, i) => i)
.slice(start >= 0 ? start : 0);
 
// Reset items
indexes.forEach(i => this._resetElement(i));
this._indexRenderQueue = indexes;
_asyncPeriod(this._renderQueue.bind(this));
 
}
 
/**
* Handle `tap` event and finds the closest item to the rootTarget that has a `selectAttribute` attribute.
* If the attribute is `next` or `previous` the `selectNext` or `selectPrevious` action is called.
*
* @param {Event} event The tap event
* @return {void}
*/
_onTap(event) {
if (this.animating) {
return;
}
const path = event.composedPath(),
attr = this.selectAttribute,
selectEl = path.find(e => e && e.hasAttribute && e.hasAttribute(attr));
 
if (!selectEl) {
return;
}
const inBetween = path.slice(path.indexOf(selectEl)),
ancestorNav = inBetween.find(e => e && e.tagName === this.tagName);
 
if (ancestorNav !== this) {
return;
}
 
const select = parseInt(selectEl.getAttribute(attr), 10);
 
if (isNaN(select)) {
return;
}
this._selectDebouncer = Debouncer.debounce(this._selectDebouncer,
animationFrame,
() => {
this.animating = true;
this.select(this.selected + select);
}
);
}
 
/**
* True if the current element is visible.
*/
get _isVisible() {
return Boolean(this.offsetWidth || this.offsetHeight);
}
 
/**
* Select item by id.
*
* @deprecated
* @param {String|Number} id The item's id
* @return {void}
*/
selectById(id) {
for (let index = 0; index < this.items.length; index++) {
const item = this.items[index];
if (typeof item === 'object' && item.id === id || item === id) {
this.selected = index;
return;
}
}
}
 
_forwardItem(element, item, idx) {
this._removeInstance(element.__instance);
flush();
 
const props = Object.assign({ [this.as]: item }, this._getBaseProps(idx)),
instance = new this._elementCtor(props);
 
element.__instance = instance;
element.appendChild(instance.root);
}
 
_renderQueue() {
if (!this.isConnected) {
return;
}
const queue = this._indexRenderQueue;
 
if (!Array.isArray(queue) || queue.length < 1) {
// no tasks in queue
return;
}
 
if (this.animating) {
// will be re-run on transition end
return;
}
 
if (this.hiddenRendering || this._isVisible) {
 
this._renderRan = this._renderAbort = false;
 
this._indexRenderQueue = queue
.sort((a, b) => {
if (a === this.selected) {
return -1;
}
if (b === this.selected) {
return 1;
}
return 0;
})
.map(this._renderQueueProcess, this)
.filter(idx => idx != null);
 
if (this._renderAbort || this._indexRenderQueue.length === 0) {
return;
}
}
 
_asyncPeriod(this._renderQueue.bind(this));
}
 
_renderQueueProcess(idx) { // eslint-disable-line max-statements
const element = this._getElement(idx),
item = this.items[idx];
 
if (this.isIncompleteFn(item)) {
element.item = false;
// no data for item drop task from queue
return;
}
 
if (this._renderRan) {
// one render per run
// maintain task in queue
return idx;
}
element.__incomplete.style.display = 'none';
 
const isSelected = idx === this.selected,
needsRender = element.item !== item || (this.renderItem && element.ilen !== this.items?.length);
 
this._renderRan = needsRender;
 
if (needsRender) {
element.item = item;
element._reset = false;
if (this.renderItem) {
render(this.renderItem(item, idx, this.items), element.__instance);
this._toggleInstance(element.__instance, true);
element.ilen = this.items?.length
return;
}
this._forwardItem(element, item, idx);
if (isSelected) {
return idx;
}
} else if (isSelected) {
// make sure that the instance is visible (may be a re-aligned invisible instance)
this._toggleInstance(element.__instance, true);
this._renderRan = true;
this._setSelectedInstance(this._getInstance(element));
}
}
 
_toggleInstance(inst, show) {
if (this.renderItem) {
inst.style.display = show ? 'contents' : 'none';
return;
}
inst?._showHideChildren(!show);
}
 
_getItemId(item) {
return this.isIncompleteFn(item) ? item : this.get(this.idPath, item);
}
 
_updateHashForSelected(selected) {
const hashParam = this.hashParam,
idPath = this.idPath;
 
if (!hashParam || !idPath || !this.items.length) {
return;
}
 
const item = this.items[selected];
if (item == null) {
return;
}
const itemId = this._getItemId(item),
hashValue = getHashParam(hashParam);
 
if (itemId === hashValue) {
return;
}
 
setHashParam(hashParam, itemId);
}
 
_updateSelectedFromHash() {
const hashParam = this.hashParam,
idPath = this.idPath;
 
if (!(hashParam && idPath)) {
return;
}
 
if (this._readFromHashOnce && !this.maintainSelection) {
return;
}
 
const hashValue = getHashParam(hashParam);
 
if (!hashValue) {
this._readFromHashOnce = true;
return;
}
 
const selection = this.items.findIndex(i => this._getItemId(i) === hashValue);
 
if (selection < 0 || selection === this.selected) {
return;
}
this._readFromHashOnce = true;
this.selected = selection;
Avoid too many `return` statements within this function.
return true;
}
 
}
customElements.define('cosmoz-data-nav', CosmozDataNav);