UNPKG

@adobe/coral-spectrum

Version:

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

1,413 lines (1,173 loc) 47.6 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 accessibilityState from '../templates/accessibilityState'; import {BaseComponent} from '../../../coral-base-component'; import ColumnViewCollection from './ColumnViewCollection'; import isInteractiveTarget from './isInteractiveTarget'; import selectionMode from './selectionMode'; import {transform, validate, commons, i18n} from '../../../coral-utils'; import {Decorator} from '../../../coral-decorator'; const CLASSNAME = '_coral-MillerColumns'; const scrollTo = (element, to, duration, scrollCallback) => { if (duration <= 0) { if (scrollCallback) { scrollCallback(); } return; } const difference = to - element.scrollLeft; const perTick = difference / duration * 10; window.setTimeout(() => { element.scrollLeft = element.scrollLeft + perTick; if (element.scrollLeft === to) { if (scrollCallback) { scrollCallback(); } } else { scrollTo(element, to, duration - 10, scrollCallback); } }, 10); }; /** @class Coral.ColumnView @classdesc A ColumnView component to display and allow users to browse and select items in a dynamic tree structure (e.g. a filesystem or multi-level navigation). @htmltag coral-columnview @extends {HTMLElement} @extends {BaseComponent} */ const ColumnView = Decorator(class extends BaseComponent(HTMLElement) { /** @ignore */ constructor() { super(); // Content zone this._elements = { accessibilityState: this.querySelector('span[handle="accessibilityState"]') }; if (!this._elements.accessibilityState) { // Templates accessibilityState.call(this._elements, {commons}); this._elements.accessibilityState.removeAttribute('aria-hidden'); this._elements.accessibilityState.hidden = true; } // Events this._delegateEvents({ // Prevents text selection while selecting multiple items 'global:keyup': '_onGlobalKeyUp', 'global:keydown': '_onGlobalKeyDown', 'key:up': '_onKeyUp', 'key:down': '_onKeyDown', 'key:right': '_onKeyRight', 'key:left': '_onKeyLeft', 'key:shift+up': '_onKeyShiftAndUp', 'key:shift+down': '_onKeyShiftAndDown', 'key:space': '_onKeySpace', 'key:control+a': '_onKeyCtrlA', 'key:command+a': '_onKeyCtrlA', 'key:control+shift+a': '_onKeyCtrlShiftA', 'key:command+shift+a': '_onKeyCtrlShiftA', 'key:esc': '_onKeyCtrlShiftA', 'capture:focus coral-columnview-item': '_onItemFocus', 'mousedown coral-columnview-item': '_onItemMouseDown', 'mouseup coral-columnview-item': '_onItemMouseUp', // column events 'coral-columnview-column:_loaditems': '_onColumnLoadItems', 'coral-columnview-column:_activeitemchanged': '_onColumnActiveItemChanged', 'coral-columnview-column:_selecteditemchanged': '_onColumnSelectedItemChanged' }); // Defaults this._oldActiveItem = null; this._oldSelection = []; // default value of inner flag to process events this._bulkSelectionChange = false; // initializes the mutation observer that used to detect when new items are added or removed this._observer = new MutationObserver(this._handleMutation.bind(this)); this._observer.observe(this, { // only watch the childList, items will tell us if selected/value/content changes childList: true }); // Init the collection mutation observer this.items._startHandlingItems(true); this.columns._startHandlingItems(true); } /** Collection that holds all the columns inside the ColumnView. @type {ColumnViewCollection} @readonly */ get columns() { // constructs the collection on first request if (!this._columns) { this._columns = new ColumnViewCollection({ host: this, itemTagName: 'coral-columnview-column', onlyHandleChildren: true }); } return this._columns; } /** Collection used to represent the coral-columnview-item across all columns. @type {ColumnViewCollection} @readonly @private */ get items() { // constructs the collection on first request if (!this._items) { this._items = new ColumnViewCollection({ host: this, itemTagName: 'coral-columnview-item' }); } return this._items; } /** Selection mode of the ColumnView. See {@link ColumnViewSelectionModeEnum}. @type {String} @default ColumnViewSelectionModeEnum.NONE @htmlattribute selectionmode @htmlattributereflected */ get selectionMode() { return this._selectionMode || selectionMode.NONE; } set selectionMode(value) { value = transform.string(value).toLowerCase(); value = validate.enumeration(selectionMode)(value) && value || selectionMode.NONE; this._reflectAttribute('selectionmode', value); if(validate.valueMustChange(this._selectionMode, value)) { this._selectionMode = value; // propagates the selection mode to the columns let columns = this.columns.getAll(); columns.forEach((item) => { item.setAttribute('_selectionmode', value); }); this.classList.remove(`${CLASSNAME}--selection`); if (value !== selectionMode.NONE) { this.classList.add(`${CLASSNAME}--selection`); } // @a11y this.setAttribute('aria-multiselectable', value === selectionMode.MULTIPLE); } } /** First selected item of the ColumnView. @type {HTMLElement} @readonly */ get selectedItem() { return this.selectionMode !== selectionMode.NONE ? this.items._getFirstSelected() : null; } /** Array containing the set selected items. The items will match only one column since selection across columns is not allowed. @type {Array.<HTMLElement>} @readonly */ get selectedItems() { return this.selectionMode !== selectionMode.NONE ? this.items._getAllSelected() : []; } /** Active Item that corresponds to the last item in the path. @type {HTMLElement} @readonly */ get activeItem() { return this.items._getAllActive().pop() || null; } /** @private */ _onColumnActiveItemChanged(event) { // this is a private event and should not leave the column view event.stopImmediatePropagation(); // ignores event handling due to bulk select operation if (this._bulkSelectionChange) { return; } const column = event.target; // clears the internal selection cursor this._handleKeyboardMultiselect(null); this._bulkSelectionChange = true; if (!event.detail.activeItem) { // all items to the right must be removed. we do this at the end to be able to extract the values before // removing everything this._afterItemSelectedInColumn(column); } else { // when there is an active item, selection must not exist this.items._deselectAllExcept(); // we need to deactivate every item to the right of the new active item to keep a correct DOM representation let nextColumn = column.nextElementSibling; while (nextColumn) { // We ignore preview columns if (nextColumn.tagName === 'CORAL-COLUMNVIEW-COLUMN' && nextColumn.items) { nextColumn.items._deactivateAll(); } nextColumn = nextColumn.nextElementSibling; } } this._bulkSelectionChange = false; // we trigger the appropiate events this._validateColumnViewChange(); } /** Requests external data to be loaded. @emits {coral-columnview:loaditems} @private */ _onColumnLoadItems(event) { // this is a private event and should not leave the column view event.stopImmediatePropagation(); this._updateAriaLevel(event.target); this._ensureTabbableItem(); // triggers an event to indicate more data could be loaded this.trigger('coral-columnview:loaditems', { column: event.target, start: event.detail.start, item: event.detail.item }); } /** Handle when first selectable item is added and make sure it is tabbable. @param {HTMLElement} [item] @private */ _onItemAdd() { window.requestAnimationFrame(() => this._ensureTabbableItem()); } /** Handle when item is removed, make sure that at least one element is tabbable, or if there are no items, and add listener to handle when item is added. @param {HTMLElement} [item] Item that was removed. @private */ _onItemRemoved() { window.requestAnimationFrame(() => this._ensureTabbableItem()); } /* @private */ _ensureTabbableItem() { this._vent.off('coral-collection:add', this._onItemAdd); this._vent.off('coral-collection:remove', this._onItemRemoved); // Ensures that item will receive focus if (!this.selectedItem && !this.activeItem) { const selectableItems = this.items._getSelectableItems(); // If there are no selectable items, stop listening for items being removed and start listening for the next item added. if (!selectableItems.length) { this._vent.off('coral-collection:remove', this._onItemRemoved); this._vent.on('coral-collection:add', this._onItemAdd); } else { // Otherwise, if there is a selectable item, make sure it has a tabIndex. selectableItems[0].tabIndex = 0; // Listen for item removal so that we can handle the edge case where all items have been removed. this._vent.on('coral-collection:remove', this._onItemRemoved); } } else if (this.selectedItem && this.selectedItem.tabIndex !== 0) { // If the selectedItem is not tabbable, make sure that it has tabIndex === 0 this.selectedItem.tabIndex = 0; } else if (this.activeItem && this.activeItem.tabIndex !== 0) { // If the activeItem is not tabbable, make sure that it has tabIndex === 0 this.activeItem.tabIndex = 0; } } /** @private */ _onColumnSelectedItemChanged(event) { // this is a private event and should not leave the column view event.stopImmediatePropagation(); // ignores event handling due to bulk select operation if (this._bulkSelectionChange || this.selectionMode === selectionMode.NONE) { return; } this._bulkSelectionChange = true; // we need to deselect any other selection that is not part of the same column this._oldSelection.forEach((el) => { if (event.detail.selection.indexOf(el) === -1) { el.removeAttribute('selected'); } }); this._bulkSelectionChange = false; // we trigger the appropiate events this._validateColumnViewChange(); } /** @private */ _onGlobalKeyUp(event) { // removes the class to stop selection if (event.keyCode === 16 && !isInteractiveTarget(event.target)) { this.classList.remove('is-unselectable'); } } /** @private */ _onGlobalKeyDown(event) { // adds a class that prevents the text selection, otherwise shift + click would select the text if (event.keyCode === 16 || !isInteractiveTarget(event.target)) { this.classList.add('is-unselectable'); } } /** @private */ _onKeyShiftAndUp(event) { const matchedTarget = this._getRealMatchedTarget(event); // don't select items when focus is within the preview if (matchedTarget.closest('coral-columnview-preview') || isInteractiveTarget(event.target)) { return; } event.preventDefault(); if (this.selectionMode === selectionMode.NONE) { this._onKeyUp(event); return; } // using _oldSelection since it should be equivalent to this.items._getSelectedItems() but faster const oldSelectedItems = this._oldSelection; this._isKeyBoardMultiselect = true; // first make sure to select the active item as we want to multiselect if (oldSelectedItems.length === 0) { const activeItem = this.activeItem; if (activeItem) { activeItem.setAttribute('selected', ''); } } // gets all the selected items of the active column. calling _getSelectedItems() will include the active item // if it was selected const selectedItems = this.items._getAllSelected(); // reference of the last selected item to know the direction of the selection while using the multiselection const lastSelected = this._lastSelected; let selectedItem; // when no previous selection is stored we need to initialize it with the current information if (!lastSelected) { selectedItem = selectedItems[0].previousElementSibling; // selects the item selectedItem.setAttribute('selected', ''); } else if (lastSelected.item) { selectedItem = lastSelected.item; // we have reached the upper selection limit if (selectedItem.matches(':first-child')) { this._isKeyBoardMultiselect = false; return; } if (!lastSelected.direction || lastSelected.direction === 'up') { selectedItem = selectedItem.previousElementSibling; selectedItem.setAttribute('selected', ''); } else { if (selectedItem !== lastSelected.firstSelectedItem) { selectedItem.removeAttribute('selected'); } else { // switches the direction if this was the last item selected lastSelected.direction = 'up'; } selectedItem = selectedItem.previousElementSibling; selectedItem.setAttribute('selected', ''); } } // stores the reference and direction to be able to perform the multiple selection correctly this._lastSelected = { item: selectedItem, direction: lastSelected && lastSelected.direction ? lastSelected.direction : 'up', firstSelectedItem: lastSelected && lastSelected.firstSelectedItem ? lastSelected.firstSelectedItem : selectedItem.nextElementSibling }; if (selectedItem && selectedItem !== document.activeElement) { selectedItem.focus(); } this._isKeyBoardMultiselect = false; } /** @private */ _onKeyShiftAndDown(event) { const matchedTarget = this._getRealMatchedTarget(event); // don't select items when focus is within the preview if (matchedTarget.closest('coral-columnview-preview') || isInteractiveTarget(event.target)) { return; } event.preventDefault(); if (this.selectionMode === selectionMode.NONE) { this._onKeyDown(event); return; } // using _oldSelection since it should be equivalent to this.items._getSelectedItems() but faster const oldSelectedItems = this._oldSelection; this._isKeyBoardMultiselect = true; // first make sure to select the active item as we want to multiselect if (oldSelectedItems.length === 0) { const activeItem = this.activeItem; if (activeItem) { activeItem.setAttribute('selected', ''); } } // gets all the selected items of the active column. calling _getSelectedItems() will include the active item // if it was selected const selectedItems = this.items._getAllSelected(); // reference of the last selected item to know the direction of the selection while using the multiselection const lastSelected = this._lastSelected; let selectedItem; // when no previous selection is stored we need to initialize it with the current information if (!lastSelected) { selectedItem = selectedItems[selectedItems.length - 1].nextElementSibling; // selects the item selectedItem.setAttribute('selected', ''); } else if (lastSelected.item) { selectedItem = lastSelected.item; // we have reached the lower selection limit if (selectedItem.matches(':last-child')) { this._isKeyBoardMultiselect = false; return; } if (!lastSelected.direction || lastSelected.direction === 'down') { selectedItem = selectedItem.nextElementSibling; selectedItem.setAttribute('selected', ''); } else { if (selectedItem !== lastSelected.firstSelectedItem) { selectedItem.removeAttribute('selected'); } else { // switches the direction if this was the last item selected lastSelected.direction = 'down'; } selectedItem = selectedItem.nextElementSibling; selectedItem.setAttribute('selected', ''); } } // stores the reference and direction to be able to perform the multiple selection correctly this._lastSelected = { item: selectedItem, direction: lastSelected && lastSelected.direction ? lastSelected.direction : 'down', firstSelectedItem: lastSelected && lastSelected.firstSelectedItem ? lastSelected.firstSelectedItem : selectedItem.previousElementSibling }; if (selectedItem && selectedItem !== document.activeElement) { selectedItem.focus(); } this._isKeyBoardMultiselect = false; } /** @private */ _onKeyUp(event) { const matchedTarget = this._getRealMatchedTarget(event); // don't navigate items when focus is within the preview if (matchedTarget.closest('coral-columnview-preview') || isInteractiveTarget(event.target)) { return; } event.preventDefault(); // selection will win over active buttons, because they are the right most item. using _oldSelection since it // should be equivalent to this.items._getSelectedItems() but faster const selectedItems = this._oldSelection; let item; if (selectedItems.length !== 0) { const selectedItem = matchedTarget; item = selectedItem.previousElementSibling; if (!item) { item = selectedItem; } } // when there is no active item to select, we get the last item of the column. this way users can interact with // the column view when there is nothing selected or activated else if (this._oldActiveItem === null) { item = this.items._getLastSelectable(); } else { item = this._oldActiveItem.previousElementSibling; } // we use click instead of selected to force the deselection of the other items if (item && item !== document.activeElement) { item.focus(); if (this.selectionMode === selectionMode.NONE || selectedItems.length === 0 || // For use case in cascading schema editor, // where the focused item is not in the same column as the selected items, // we should activate the item so that coral-columnview:activeitemchange gets called. item.parentElement !== selectedItems[0].parentElement) { item.click(); } } } /** @private */ _onKeyDown(event) { const matchedTarget = this._getRealMatchedTarget(event); // don't navigate items when focus is within the preview if (matchedTarget.closest('coral-columnview-preview') || isInteractiveTarget(event.target)) { return; } event.preventDefault(); // selection will win over active buttons, because they are the right most item. using _oldSelection since it // should be equivalent to this.items._getSelectedItems() but faster const selectedItems = this._oldSelection; let item; if (selectedItems.length !== 0) { const selectedItem = matchedTarget; item = selectedItem.nextElementSibling; // when if (!item) { item = matchedTarget; } } // when there is no active item to select, we get the first item of the column. this way users can interact with // the column view when there is nothing selected or activated else if (this._oldActiveItem === null) { item = this.items._getFirstSelectable(); } else { item = this._oldActiveItem.nextElementSibling; } // we use click instead of selected to force the deselection of the other items if (item && item !== document.activeElement) { item.focus(); if (this.selectionMode === selectionMode.NONE || selectedItems.length === 0 || // For use case in cascading schema editor, // where the focused item is not in the same column as the selected items, // we should activate the item so that coral-columnview:activeitemchange gets called. item.parentElement !== selectedItems[0].parentElement) { item.click(); } } } /** @private */ _onKeyRight(event) { const matchedTarget = this._getRealMatchedTarget(event); if (matchedTarget.variant !== ColumnView.Item.variant.DRILLDOWN) { return false; } if (isInteractiveTarget(event.target)) { return; } event.preventDefault(); // we can only navigate right when there is a column on the right side to navigate to let nextColumn; // using _oldSelection since it should be equivalent to this.items._getSelectedItems() but faster let selectedItems = this._oldSelection; // when there is an active item, we use the item containing the active item as reference if (matchedTarget) { nextColumn = matchedTarget.closest('coral-columnview-column').nextElementSibling; } // otherwise when there is selection, we use the item containing the selected items as reference else if (selectedItems.length !== 0) { nextColumn = selectedItems[0].closest('coral-columnview-column').nextElementSibling; } if (nextColumn && nextColumn.tagName === 'CORAL-COLUMNVIEW-COLUMN') { // we need to make sure the column is initialized commons.ready(nextColumn, () => this._focusAndActivateFirstSelectableItem(nextColumn)); } } /** @private */ _onKeyLeft(event) { if (isInteractiveTarget(event.target)) { return; } event.preventDefault(); // we can only navigate left when there is a column on the left side to navigate to let previousColumn; // using _oldSelection since it should be equivalent to this.items._getSelectedItems() but faster const selectedItems = this._oldSelection; // when there is selection, we use the previous column as a reference if (selectedItems.length !== 0) { previousColumn = selectedItems[0].closest('coral-columnview-column').previousElementSibling; } // otherwise we use the activeItems as a reference else if (this.activeItem) { const col = this.activeItem.closest('coral-columnview-column'); previousColumn = event.target.closest('coral-columnview-preview') ? col : col.previousElementSibling; } if (previousColumn && previousColumn.tagName === 'CORAL-COLUMNVIEW-COLUMN') { // we need to make sure the column is initialized const activeDescendant = previousColumn.activeItem || previousColumn.items._getFirstSelected() || previousColumn.items._getFirstSelectable(); if (activeDescendant && activeDescendant !== document.activeElement) { activeDescendant.focus(); if (this.selectionMode === selectionMode.NONE || selectedItems.length === 0) { activeDescendant.click(); } } } } /** @private */ _onKeySpace(event) { const matchedTarget = this._getRealMatchedTarget(event); // don't select item when focus is within the preview if (matchedTarget.closest('coral-columnview-preview') || isInteractiveTarget(event.target)) { return; } event.preventDefault(); // using _oldSelection since it should be equivalent to this.items._getSelectedItems() but faster const selectedItems = this._oldSelection; let activeDescendant; // when there is a selection, we need to activate the first item of the selection if (selectedItems.length !== 0) { activeDescendant = matchedTarget; if (activeDescendant.hasAttribute('selected', '')) { if (selectedItems.length === 1) { activeDescendant.setAttribute('active', ''); } else { activeDescendant.removeAttribute('selected', ''); } } else { activeDescendant.setAttribute('selected', ''); } } else { const activeItem = this.activeItem || matchedTarget; // toggles the selection between active and selected if (activeItem && this.selectionMode !== selectionMode.NONE) { // select the item activeItem.setAttribute('selected', ''); activeDescendant = activeItem; } } } /** @private */ _onKeyCtrlA(event) { const matchedTarget = this._getRealMatchedTarget(event); // don't select item when focus is within the preview if (matchedTarget.closest('coral-columnview-preview') || isInteractiveTarget(event.target)) { return; } event.preventDefault(); if (this.selectionMode === selectionMode.MULTIPLE) { const currentColumn = matchedTarget.closest('coral-columnview-column'); currentColumn.items._selectAll(); } else if (this.selectionMode === selectionMode.SINGLE) { if (!matchedTarget.hasAttribute('selected')) { matchedTarget.setAttribute('selected', ''); } } } /** @private */ _onKeyCtrlShiftA(event) { const matchedTarget = this._getRealMatchedTarget(event); // don't select item when focus is within the preview if (matchedTarget.closest('coral-columnview-preview') || isInteractiveTarget(event.target)) { return; } event.preventDefault(); if (this.selectionMode !== selectionMode.NONE) { const currentColumn = matchedTarget.closest('coral-columnview-column'); currentColumn.items._deselectAndDeactivateAllExcept(matchedTarget); if (!matchedTarget.hasAttribute('active')) { matchedTarget.setAttribute('active', ''); } } } /** @private */ _onItemFocus(event) { if (isInteractiveTarget(event.target)) { return; } const matchedTarget = this._getRealMatchedTarget(event); if (!this.activeItem && !this._oldSelection.length && !matchedTarget._flagMouseDown) { matchedTarget.setAttribute('active', ''); } this.items._getSelectableItems().forEach(item => { item.tabIndex = item === matchedTarget ? 0 : -1; }); if (matchedTarget.contains(document.activeElement)) { matchedTarget.focus(); } } /** @ignore */ _onItemMouseDown(event) { if (isInteractiveTarget(event.target)) { return; } var matchedTarget = this._getRealMatchedTarget(event); matchedTarget._flagMouseDown = true; } /** @ignore */ _onItemMouseUp(event) { if (isInteractiveTarget(event.target)) { return; } var matchedTarget = this._getRealMatchedTarget(event); delete matchedTarget._flagMouseDown; } /** @ignore */ _updateAriaLevel(column) { const colIndex = this.columns.getAll().indexOf(column); const level = colIndex + 1; if (column.items) { let items = column.items.getAll(); items.filter((item, index) => { item.setAttribute('aria-posinset', index + 1); item.setAttribute('aria-setsize', items.length); return !item.hasAttribute('aria-level'); }).forEach((item) => { item.setAttribute('aria-level', level); }); } // root column has role="presentation" if (colIndex === 0) { column.setAttribute('role', 'presentation'); // and should not be labeled. return; } // Make sure the column group has a label so that it can be navigated with VoiceOver if (!column.hasAttribute('aria-labelledby')) { if (!column.hasAttribute('aria-label')) { column.setAttribute('aria-label', (this.getAttribute('aria-label') || '…')); } } else if (column.getAttribute('aria-label') === (this.getAttribute('aria-label') || '…')) { column.removeAttribute('aria-label'); } } /** @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 */ _handleKeyboardMultiselect(newSelectedItem) { if (!this._isKeyBoardMultiselect) { this._lastSelected = undefined; // if there is a new selected item save this (but without direction info) if (newSelectedItem) { this._lastSelected = { item: newSelectedItem, direction: null, firstSelectedItem: newSelectedItem }; } } } /** Scrolls the given {@link Coral.ColumnView.Column} into view. @param {HTMLElement} column The column that needs to be scrolled into view. @param {Boolean} clearEmptyColumns Remove empty columns once animation is done. @param {Boolean} triggerEvent @private */ _scrollColumnIntoView(column, clearEmptyColumns, triggerEvent) { // @todo: improve animation effect when key is kept press let left = 0; let duration; // we return if the column is not inside the current column view if (!this.contains(column)) { return; } // make sure to clear columns next to this column if animation is done const completeCallback = () => { if (clearEmptyColumns) { this._removeEmptyColumnsWithSmoothTransition(triggerEvent); } }; // scroll right to the given column if (column.getBoundingClientRect().left + column.offsetWidth >= this.offsetWidth) { let next = column.nextElementSibling; while (next) { next.parentNode.removeChild(next); next = column.nextElementSibling; } left = this.scrollWidth - this.offsetWidth; duration = left - this.scrollLeft; scrollTo(this, left, duration, completeCallback); } else if (clearEmptyColumns) { this._removeEmptyColumnsWithSmoothTransition(triggerEvent); } } /** Handling of the column view after selecting an item. @param {HTMLElement} column @private */ _afterItemSelectedInColumn(column) { // @todo: emptying the columns allows them to be queried this._emptyColumnsNextToColumn(column); this._scrollColumnIntoView(column, true, true); } /** Empties all the columns to the right of the provided column. @param {HTMLElement} column @private */ _emptyColumnsNextToColumn(column) { if (column !== null) { let next = column.nextElementSibling; while (next && next.innerHTML.length) { next.innerHTML = ''; next = next.nextElementSibling; } } } /** Remove all empty columns with a smooth transition. Optionally the navigate event is triggered when all the extra columns are removed from the DOM. @param {Boolean} triggerEvent Whether the navigate event must be triggered. @private */ _removeEmptyColumnsWithSmoothTransition(triggerEvent) { // fade width of empty items to 0 before removing the columns (for better usability while navigating) const emptyColumns = Array.prototype.filter.call(this.querySelectorAll('coral-columnview-column, coral-columnview-preview'), el => !el.firstChild); if (emptyColumns.length) { emptyColumns.forEach((column, i) => { column.style.visibility = 'hidden'; column.classList.add('is-collapsing'); commons.transitionEnd(column, () => { column.remove(); if (i === emptyColumns.length - 1 && triggerEvent) { this._validateNavigation(this.columns.last()); } }); column.style.width = 0; }); } else if (triggerEvent) { this._validateNavigation(this.columns.last()); } } /** @private */ _triggerCollectionEvents(addedNodes, removedNodes) { let item; const addedNodesCount = addedNodes.length; for (let i = 0 ; i < addedNodesCount ; i++) { item = addedNodes[i]; if (this.activeItem) { // @a11y add aria-owns attribute to active item to express relationship of added column to the active item this.activeItem.setAttribute('aria-owns', item.id); // @a11y column or preview should be labelled by active item item.setAttribute('aria-labelledby', this.activeItem.content.id); // @a11y preview should provide description for active item if (item.tagName === 'CORAL-COLUMNVIEW-PREVIEW') { this.activeItem.setAttribute('aria-describedby', item.id); } } if (item.tagName === 'CORAL-COLUMNVIEW-COLUMN') { // we use the property since the item may not be ready item.setAttribute('_selectionmode', this.selectionMode); this.trigger('coral-collection:add', {item}); this._updateAriaLevel(item); } } // @todo: check if special handling is needed when selected column is removed const removedNodesCount = removedNodes.length; for (let j = 0 ; j < removedNodesCount ; j++) { item = removedNodes[j]; // @todo: should I handle it specially if it was selected? should a selection and active event be triggered? if (item.tagName === 'CORAL-COLUMNVIEW-COLUMN') { this.trigger('coral-collection:remove', {item}); } } } /** @private */ _setStateFromDOM() { // @todo: should I trigger change events? // initial state of the columnview this._oldActiveItem = this.activeItem; this._oldSelection = this.selectedItems; this._ensureTabbableItem(); if (this.columns) { var columns = this.columns.getAll(); var self = this; columns.forEach(function (column) { self._updateAriaLevel(column); }); } } /** @private */ _handleMutation(mutations) { const mutationsCount = mutations.length; for (let i = 0 ; i < mutationsCount ; i++) { const mutation = mutations[i]; // we handle the collection events this._triggerCollectionEvents(mutation.addedNodes, mutation.removedNodes); } // sets the internal state based on the existing columns this._setStateFromDOM(); } /** Determines if something of the internal state of the component has changed. Active item event is always triggered first and then the selection event. @private */ _validateColumnViewChange() { // we evaluate first the active event since we always need to trigger active first and then selection const activeItem = this.activeItem; const oldActiveItem = this._oldActiveItem; // same column events are only triggered if the active item changed, otherwise they are ignored if (activeItem !== oldActiveItem) { this.trigger('coral-columnview:activeitemchange', {activeItem, oldActiveItem}); // we cache the old active item to be able to report correct change events this._oldActiveItem = activeItem; } // when there is no selection we avoid triggering any change event but we do not stop items from having the // selected attribute if (this.selectionMode === selectionMode.NONE) { return; } const newSelection = this.selectedItems; const oldSelection = this._oldSelection || []; // use first newly selected item for new selection const newSelectedItems = newSelection.filter((item) => oldSelection.indexOf(item) === -1); this._handleKeyboardMultiselect(newSelectedItems.length > 0 ? newSelectedItems[0] : null); if (this._arraysAreDifferent(newSelection, oldSelection)) { this.trigger('coral-columnview:change', { selection: newSelection, oldSelection: oldSelection }); // changes the old selection array since we selected something new this._oldSelection = newSelection; // announce the selection state for the focused item this._announceActiveElementState(); } } /** Triggers the navigation event. Navigation would happen when a) a new column is added, and it is ready to be used or b) columns are removed and the active changed. In case the column is actually a preview column, the event will only be triggered when there is no selection (meanning a real navigation was performed). @param {HTMLElement} column Last column of the ColumnView. @emits {coral-columnview:navigate} @private */ _validateNavigation(column) { // we use _oldSelection because it is faster if (column.tagName === 'CORAL-COLUMNVIEW-PREVIEW' && this._oldSelection.length !== 0) { return; } this.trigger('coral-columnview:navigate', { activeItem: this.activeItem, column: column }); } /* @private */ _announceActiveElementState() { // @a11y Add live region element to ensure announcement of selected state const accessibilityState = this._elements.accessibilityState; // @a11y accessibility state string should announce in document lang, rather than item lang. accessibilityState.setAttribute('lang', i18n.locale); // @a11y append live region content element if (!this.contains(accessibilityState)) { this.appendChild(accessibilityState); } // utility method to clean up accessibility state function resetAccessibilityState() { accessibilityState.hidden = true; accessibilityState.setAttribute('aria-live', 'off'); accessibilityState.innerHTML = ''; } resetAccessibilityState(); if (this._addTimeout || this._removeTimeout) { clearTimeout(this._addTimeout); clearTimeout(this._removeTimeout); } // we use setTimeout instead of nextFrame to give screen reader // more time to respond to live region update in order to announce // complete text content when the state changes. this._addTimeout = window.setTimeout(() => { const activeElement = document.activeElement.closest('coral-columnview-item') || document.activeElement; if (!this.contains(activeElement) || activeElement.tagName !== 'CORAL-COLUMNVIEW-ITEM') { return; } const span = document.createElement('span'); const contentSpan = document.createElement('span'); const lang = !activeElement.hasAttribute('lang') && activeElement.closest('[lang]') ? activeElement.closest('[lang]').getAttribute('lang') : activeElement.getAttribute('lang'); if (lang && lang !== i18n.locale) { contentSpan.setAttribute('lang', lang); } contentSpan.innerText = activeElement._elements.content.innerText; span.appendChild(contentSpan); span.appendChild( document.createTextNode( i18n.get(activeElement.selected ? ', checked' : ', unchecked') ) ); accessibilityState.hidden = false; accessibilityState.setAttribute('aria-live', 'assertive'); accessibilityState.appendChild(span); // give screen reader 2 secs before clearing the live region, to provide enough time for announcement this._removeTimeout = window.setTimeout(() => { resetAccessibilityState(); if(accessibilityState.parentNode) { this._elements.accessibilityState = accessibilityState.parentNode.removeChild(accessibilityState); } }, 2000); }, 20); } /** * Helper function to extract the correct matchedTarget from the event. * * Tests can interact with ColumnView directly where the key events are triggered on * the ColumnView itself. In that case the event.matchedTarget point to the ColumnView * instead of the ColumnViewItem, in other word the active or selected element. * * @private **/ _getRealMatchedTarget(event) { if (event.matchedTarget.nodeName !== 'CORAL-COLUMNVIEW') { return event.matchedTarget; } if (event.matchedTarget.contains(document.activeElement) && document.activeElement.nodeName === 'CORAL-COLUMNVIEW-ITEM') { return document.activeElement; } if (event.matchedTarget.selectedItem) { return event.matchedTarget.selectedItem; } if (event.matchedTarget.activeItem) { return event.matchedTarget.activeItem; } } _focusAndActivateFirstSelectableItem(column) { let item; let selectedItems = this.selectedItems; if (column.items) { item = column.items._getFirstSelectable(); } else if (column.tagName === 'CORAL-COLUMNVIEW-PREVIEW') { item = selectedItems[0] || this.activeItem; } if (item && item !== document.activeElement) { item.focus(); if (this.selectionMode === selectionMode.NONE || this._oldSelection.length === 0 || selectedItems.length === 0 || // For use case in cascading schema editor, // where the focused item is not in the same column as the selected items, // we should activate the item so that coral-columnview:activeitemchange gets called. item.parentElement !== selectedItems[0].parentElement) { item.click(); } } } /** @ignore */ focus() { // selected items go first because there is no active item in a column with selection const item = this.selectedItems[0] || this.activeItem; if (item && item !== document.activeElement) { item.focus(); } } /** Sets the next column given a reference column. This will handle cleaning the DOM and removing any columns as required. @param {HTMLElement} newColumn The new column to add to the column view. It will be placed next to the <code>referenceColumn</code> if provided. @param {HTMLElement} referenceColumn The column that will be used as a reference to place the new column. This column needs to be already inside the DOM. @param {Boolean} [scrollToColumn = true] Whether the columnview show scroll to have the <code>newColumn</code> visible. @emits {coral-columnview:navigate} */ setNextColumn(newColumn, referenceColumn, scrollToColumn) { scrollToColumn = typeof scrollToColumn === 'undefined' || scrollToColumn; const column = referenceColumn || null; let columnReplacedContainedFocus = false; // handles the case where the first column needs to be added if (column === null || !this.contains(column)) { this.appendChild(newColumn); } else { const nextColumn = column.nextElementSibling; if (nextColumn) { columnReplacedContainedFocus = nextColumn.contains(document.activeElement); this._emptyColumnsNextToColumn(column); const before = nextColumn.nextElementSibling; this.removeChild(nextColumn); this.insertBefore(newColumn, before); } else { this.appendChild(newColumn); } } // if we want to scroll to it, we need for it to be ready due to measurements commons.ready(newColumn, () => { if (scrollToColumn) { // event is not triggered because it is handled separately this._scrollColumnIntoView(newColumn, true, false); } // we notify that the columnview navigated and it is ready to be used this._validateNavigation(newColumn); // if the column the newColumn replaces contained focus, restore focus to an item in the newColumn if (columnReplacedContainedFocus && !newColumn.contains(document.activeElement)) { this._focusAndActivateFirstSelectableItem(newColumn); } }); } /** Returns {@link ColumnView} selection options. @return {ColumnViewSelectionModeEnum} */ static get selectionMode() { return selectionMode; } 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 this.setAttribute('role', 'tree'); // @a11y: the columnview needs to be focusable to handle a11y properly this.tabIndex = -1; // @a11y: the columnview should be labelled so that its entire content // is not read as its accessibility name if (!this.hasAttribute('aria-label') && !this.hasAttribute('aria-labelledby')) { this.setAttribute('aria-label', i18n.get('Column View')); } // Default reflect attributes if (!this._selectionMode) { this._selectionMode = selectionMode.NONE; } // no need to wait for the mutation observers this._setStateFromDOM(); } /** Triggered when additional items can be loaded into the {@link ColumnView}. This will happen when the current column can still hold more items, when the user scrolls down the current column or when a new column needs to be loaded. If <code>preventDefault()</code> is called, then a loading indicator will be shown. {@link ColumnViewColumn#loading} should be set to false to indicate that the data has been successfully loaded. @typedef {CustomEvent} coral-columnview:loaditems @property {ColumnViewColumn} detail.column The column that is requesting more items. While doing pagination, it will become the target of the loaded items. @property {Number} detail.start Indicates the current amount of items in the <code>column</code> to do pagination. If <code>item</code> is available, start will be 0 to denote that the column should be loaded from the start. @property {ColumnViewItem} detail.item The item that initialized the load. If item is provided, it means that a new column needs to be added after the load is performed. In this scenario, <code>column</code> will be refer to the column that holds the item. */ /** Triggered when the selection inside the {@link ColumnViewColumn} changes. In case both the selection and the active item change, the <code>coral-columnview:activeitemchange</code> will be triggered first. @typedef {CustomEvent} coral-columnview:change @property {ColumnViewColumn} detail.column The column whose selection changed. @property {ColumnViewItem|Array.<ColumnViewItem>} detail.selection The new selection of the Column. @property {ColumnViewItem|Array.<ColumnViewItem>} detail.oldSelection The old selection of the Column. */ /** Triggered when the active item of the {@link ColumnViewColumn} changes. @typedef {CustomEvent} coral-columnview:activeitemchange @property {ColumnViewColumn} detail.column The column whose active item has changed. @property {ColumnViewItem} detail.activeItem The currently active item of the column. @property {ColumnViewItem} detail.oldActiveItem The item of the column that was active before. */ /** Triggered when the {@link ColumnView} navigation is complete and the new columns are ready. @typedef {CustomEvent} coral-columnview:navigate @property {ColumnViewColumn} detail.column The last Column of the ColumnView that is used to determine the path. If the navigate was triggered because a new {@link ColumnViewColumn} was added, then it will match that column. In case the path was reduced, the column will match the last column. @property {ColumnViewItem} detail.activeItem The currently active item of the ColumnView. */ }); export default ColumnView;