UNPKG

@adobe/coral-spectrum

Version:

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

588 lines (478 loc) 18.1 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 {BaseComponent} from '../../../coral-base-component'; import ColumnViewCollection from './ColumnViewCollection'; import isInteractiveTarget from './isInteractiveTarget'; import selectionMode from './selectionMode'; import {commons, transform, validate} from '../../../coral-utils'; import {Decorator} from '../../../coral-decorator'; const CLASSNAME = '_coral-MillerColumns-item'; // The number of milliseconds for which scroll events should be debounced. const SCROLL_DEBOUNCE = 100; // Height if every item to avoid using offsetHeight during calculations. const ITEM_HEIGHT = 40; // Flag to check if window load was called let WINDOW_LOAD = false; window.addEventListener('load', () => { WINDOW_LOAD = true; }); /** @class Coral.ColumnView.Column @classdesc A ColumnView Column component @htmltag coral-columnview-column @extends {HTMLElement} @extends {BaseComponent} */ const ColumnViewColumn = Decorator(class extends BaseComponent(HTMLElement) { /** @ignore */ constructor() { super(); // Events this._delegateEvents({ // we need to use capture as scroll events do not bubble 'capture:scroll coral-columnview-column-content': '_onContentScroll', 'click coral-columnview-column-content': '_onColumnContentClick', // item interaction 'click coral-columnview-item': '_onItemClick', 'click [coral-columnview-itemselect]': '_onItemSelectClick', // item events 'coral-columnview-item:_activechanged coral-columnview-item': '_onItemActiveChange', 'coral-columnview-item:_selectedchanged coral-columnview-item': '_onItemSelectedChange' }); // Content zone this._elements = { content: this.querySelector('coral-columnview-column-content') || document.createElement('coral-columnview-column-content') }; // default values this._bulkSelectionChange = false; this._oldSelection = []; this._oldActiveItem = null; // cache bound event handler functions this._onDebouncedScroll = this._onDebouncedScroll.bind(this); this._toggleItemSelection = this._toggleItemSelection.bind(this); // Init the collection mutation observer this.items._startHandlingItems(true); } /** The current active item. @type {HTMLElement} @readonly @default null */ get activeItem() { return this.items._getAllActive()[0] || null; } /** The content of the column. This container is where the items should be added and is responsible for handling the scrolling. @type {ColumnViewColumnContent} @contentzone */ get content() { return this._getContentZone(this._elements.content); } set content(value) { this._setContentZone('content', value, { handle: 'content', tagName: 'coral-columnview-column-content', insert: function (content) { content.classList.add('_coral-AssetList'); this.appendChild(content); } }); } /** The Collection Interface that allows interacting with the items that the component contains. @type {ColumnViewCollection} @readonly */ get items() { // we do lazy initialization of the collection if (!this._items) { this._items = new ColumnViewCollection({ host: this, container: this._elements.content, itemTagName: 'coral-columnview-item', onItemAdded: this._toggleItemSelection, onCollectionChange: this._handleMutation }); } return this._items; } /** Returns the first selected item in the ColumnView. The value <code>null</code> is returned if no element is selected. @type {?HTMLElement} @readonly */ get selectedItem() { return this._selectionMode !== selectionMode.NONE ? this.items._getFirstSelected() : null; } /** Returns an Array containing the set selected items inside this Column. @type {Array.<HTMLElement>} @readonly */ get selectedItems() { return this._selectionMode !== selectionMode.NONE ? this.items._getAllSelected() : []; } /** Private property that indicates the selection mode. If the <code>Coral.ColumnView.Column</code> is not inside a <code>Coral.ColumnView</code> this value will be <code>undefined</code>. See {@link ColumnViewSelectionModeEnum}. @type {String} @htmlattribute _selectionmode @htmlattributereflected @private */ get _selectionMode() { return this.__selectionMode; } set _selectionMode(value) { value = transform.string(value).toLowerCase(); value = validate.enumeration(selectionMode)(value) && value || null; this._reflectAttribute('_selectionmode', value); if(validate.valueMustChange(this.__selectionMode, value)) { this.__selectionMode = value; let items = this.items.getAll(); items.forEach(item => this._toggleItemSelection(item)); this._setStateFromDOM(); } } /** Returns an Array containing the last selected items inside this Column in selected order. @type {Array.<HTMLElement>} @private */ get _lastSelectedItems() { return this.__lastSelectedItems || this.selectedItems; } set _lastSelectedItems(value) { this.__lastSelectedItems = value; } /** @private */ _onItemClick(event) { if (isInteractiveTarget(event.target)) { return; } // since transform will kill the modification, we trigger the event manually if (event.matchedTarget.hasAttribute('active')) { // directly calls the event since setting the attribute will not trigger an event this._onItemActiveChange(event); } else { // sets the item as active. while handling mouse interaction, items are not toggled event.matchedTarget.active = true; } } _toggleItemSelection(item) { item[this._selectionMode !== selectionMode.NONE ? 'setAttribute' : 'removeAttribute']('_selectable', ''); } /** @private */ _onItemSelectClick(event) { if (this._selectionMode && this._selectionMode !== selectionMode.NONE) { // stops propagation so that active is not called as well event.stopPropagation(); const item = event.matchedTarget.parentElement; // toggles the selection of the item const isSelected = item.hasAttribute('selected'); // Handle multi-selection with shiftKey if (!isSelected && event.shiftKey && this._selectionMode === selectionMode.MULTIPLE) { const lastSelectedItem = this._lastSelectedItems[this._lastSelectedItems.length - 1]; if (lastSelectedItem) { const items = this.items.getAll(); const lastSelectedItemIndex = items.indexOf(lastSelectedItem); const selectedItemIndex = items.indexOf(item); // true : selection goes up, false : selection goes down const direction = selectedItemIndex < lastSelectedItemIndex; const selectionRange = []; let selectionIndex = lastSelectedItemIndex; // Retrieve all items in the range while (selectedItemIndex !== selectionIndex) { selectionIndex = direction ? selectionIndex - 1 : selectionIndex + 1; selectionRange.push(items[selectionIndex]); } // Select all items in the range silently selectionRange.forEach((rangeItem) => { // Except for item which is needed to trigger the selection change event if (rangeItem !== item) { rangeItem.set('selected', true, true); } }); } } item[isSelected ? 'removeAttribute' : 'setAttribute']('selected', ''); // if item was selected, make it active if (isSelected && !this._lastSelectedItems.length) { item.setAttribute('active', ''); } } } /** Handles the item activation, this causes the current item to get active and sets the next column to the item's src. @private */ _onItemActiveChange(event) { // we stop propagation since it is a private event event.stopImmediatePropagation(); // ignores event handling due to bulk select operation if (this._bulkSelectionChange) { return; } const item = event.matchedTarget; this._bulkSelectionChange = true; // clears the selection and keeps the item active. this force only 1 item to be active per column this.items._deselectAndDeactivateAllExcept(item); this._bulkSelectionChange = false; // we check if the selection requires an event to be triggered this._validateColumnChange(item); // loads data using the item as the activator and 0 as the start since it is a new column this._loadItems(0, item); } /** Handles selecting multiple items in the same column. Selection could result in none, a single or multiple selected items. @private */ _onItemSelectedChange(event) { // we stop propagation since it is a private event event.stopImmediatePropagation(); // item that was selected const item = event.target; const isSelected = item.selected; if (isSelected) { // Remember the last selected item this._lastSelectedItems.push(item); } else { const removedItemIndex = this._lastSelectedItems.indexOf(item); if (removedItemIndex !== -1) { this._lastSelectedItems = this._lastSelectedItems.splice(removedItemIndex, 1); } } // ignores event handling due to bulk select operation if (this._bulkSelectionChange) { return; } // when the item is selected, we need to enforce the selection mode if (isSelected) { this._bulkSelectionChange = true; // when there is selection, no item can be active this.items._deactivateAll(); // enforces the selection mode if (this._selectionMode === selectionMode.SINGLE) { this.items._deselectAllExcept(item); } this._bulkSelectionChange = false; } // we make sure the change event is triggered before the load event. this._validateColumnChange(); } /** @ignore */ _tryToLoadAdditionalItems() { // makes sure that not too many events are triggered (only one per frame) if (this._bulkCollectionChange) { return; } this._bulkCollectionChange = true; // we use setTimeout instead of nextFrame because macrotasks allow for more flexibility since they are less // aggressive in executing the code window.setTimeout(() => { // trigger 'coral-columnview:loaditems' asynchronously in order to be sure the application is done // adding/removing elements. Also make sure that only one event is triggered at once // bulkCollectionChange has to be reset before loading new items this._bulkCollectionChange = false; this._loadFittingAdditionalItems(); }, 0); } /** @private */ _onContentScroll() { window.clearTimeout(this._scrollTimeout); this._scrollTimeout = window.setTimeout(this._onDebouncedScroll, SCROLL_DEBOUNCE); } /** Handles the column click. When the column body is clicked, we need to deselect everything up to that column. @private */ _onColumnContentClick(event) { // we make sure the content was clicked directly and not an item if (event.target !== event.matchedTarget || isInteractiveTarget(event.target)) { return; } event.preventDefault(); // ignores event handling due to bulk select operation if (this._bulkSelectionChange) { return false; } this._bulkSelectionChange = true; // clears the current column this.items._deselectAndDeactivateAllExcept(); this._bulkSelectionChange = false; // we check if the selection requires an event to be triggered this._validateColumnChange(); } /** @private */ _onDebouncedScroll() { const threshold = 20; if (this.content.scrollTop + this.offsetHeight >= this.content.scrollHeight - threshold) { this._loadItems(this.items.length, undefined); } } /** @private */ _arraysAreDifferent(selection, oldSelection) { let diff = []; if (oldSelection.length === selection.length) { diff = oldSelection.filter((item) => selection.indexOf(item) === -1); } // since we guarantee that they are arrays, we can start by comparing their size return oldSelection.length !== selection.length || diff.length !== 0; } /** @private */ _validateColumnChange(item) { const newActiveItem = this.activeItem; const oldActiveItem = this._oldActiveItem || null; // we have to force the event in case the same active item was clicked again, but still try to avoid triggering as // less events as possible if (newActiveItem !== oldActiveItem || item === newActiveItem) { this.trigger('coral-columnview-column:_activeitemchanged', { activeItem: newActiveItem, oldActiveItem: oldActiveItem }); // we cache the active item for the next time this._oldActiveItem = newActiveItem; } const newSelection = this.selectedItems; const oldSelection = this._oldSelection; if (this._arraysAreDifferent(newSelection, oldSelection)) { this.trigger('coral-columnview-column:_selecteditemchanged', { selection: newSelection, oldSelection: oldSelection }); // changes the old selection array since we selected something new this._oldSelection = newSelection; } } /** Loads additional Items if the current items of the column to not exceed its height and a path this.next is given. @private */ _loadFittingAdditionalItems() { const itemsCount = this.items.length; // this value must match $columnview-item-height const itemsHeight = itemsCount * ITEM_HEIGHT; // we request more items if there is still space for them. in case the values are the same, we request more data // just to be sure, specially when the value is 0 if (itemsHeight <= this.offsetHeight) { this._loadItems(itemsCount, undefined); } } /** Loads additional items. If the given item is not <code>active</code>, no data will be requested. @param {Number} count Amount of items in the column. @param {?HTMLElement} item Item that triggered the load. @private */ _loadItems(count, item) { // if the given item is not active, it should not request data if (!item || item.hasAttribute('active')) { this.trigger('coral-columnview-column:_loaditems', { start: count, item: item }); } } /** Updates the active and selected options from the DOM. @ignore */ _setStateFromDOM() { // if the selection mode has not been set, we do no try to force selection if (this._selectionMode) { // single: only the last item is selected if (this._selectionMode === selectionMode.SINGLE) { this.items._deselectAllExceptLast(); } // none: deselects everything else if (this._selectionMode === selectionMode.NONE) { this.items._deselectAllExcept(); } // makes sure only one item is active this.items._deactivateAllExceptFirst(); } } /** @private */ _handleMutation() { this._setStateFromDOM(); // in case items were added removed and selection changed this._validateColumnChange(); // checks if more items can be added after the childlist change this._tryToLoadAdditionalItems(); } get _contentZones() { return {'coral-columnview-column-content': 'content'}; } static get _attributePropertyMap() { return commons.extend(super._attributePropertyMap, { _selectionmode: '_selectionMode' }); } /** @ignore */ static get observedAttributes() { return super.observedAttributes.concat([ '_selectionmode' ]); } /** @ignore */ render() { super.render(); this.classList.add(CLASSNAME); // @a11y if (!this.hasAttribute('role')) { this.setAttribute('role', 'group'); } this.id = this.id || commons.getUID(); // @todo: initial collection items needs to be triggered const content = this._elements.content; // when the content zone was not created, we need to make sure that everything is added inside it as a content. // this stops the content zone from being voracious if (!content.parentNode) { // move the contents of the item into the content zone while (this.firstChild) { content.appendChild(this.firstChild); } } // @a11y content.setAttribute('role', 'presentation'); // Call content zone insert this.content = content; // handles the initial selection this._setStateFromDOM(); // we keep a list of the last selection to determine if something changed. we need to do this after // validateSelection since it modifies the initial state based on the option this._oldSelection = this.selectedItems; this._oldActiveItem = this.activeItem; if (!WINDOW_LOAD) { // instead of being super aggressive on requesting data, we use window onload so it is scheduled after // all the code has been executed, this way events can be added before window.addEventListener('load', () => { this._loadFittingAdditionalItems(); }); } else { // macro-task is necessary for the same reasons as listed above window.setTimeout(() => { this._loadFittingAdditionalItems(); }); } } }); export default ColumnViewColumn;