UNPKG

@adobe/coral-spectrum

Version:

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

596 lines (490 loc) 18.3 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 {SelectableCollection} from '../../../coral-collection'; import {transform, commons, i18n} from '../../../coral-utils'; import {Decorator} from '../../../coral-decorator'; const CLASSNAME = '_coral-Table-row'; /** @class Coral.Table.Row @classdesc A Table row component @htmltag coral-table-row @htmlbasetag tr @extends {HTMLTableRowElement} @extends {BaseComponent} */ const TableRow = Decorator(class extends BaseComponent(HTMLTableRowElement) { /** @ignore */ constructor() { super(); // Templates this._elements = {}; accessibilityState.call(this._elements, {commons}); // Required for coral-table-row:change event this._oldSelection = []; // Events this._delegateEvents({ // Private 'coral-table-cell:_beforeselectedchanged': '_onBeforeCellSelectionChanged', 'coral-table-cell:_selectedchanged': '_onCellSelectionChanged' }); // Initialize content MO this._observer = new MutationObserver(this._handleMutations.bind(this)); this._observer.observe(this, { childList: true }); } /** Whether the table row is locked. @type {Boolean} @default false @htmlattribute locked @htmlattributereflected */ get locked() { return this._locked || false; } set locked(value) { this._locked = transform.booleanAttr(value); this._reflectAttribute('locked', this._locked); this.trigger('coral-table-row:_lockedchanged'); } /** Whether the table row is selected. @type {Boolean} @default false @htmlattribute selected @htmlattributereflected */ get selected() { return this._selected || false; } set selected(value) { // Prevent selection if disabled if (this.hasAttribute('coral-table-rowselect') && this.hasAttribute('disabled') || this.querySelector('[coral-table-rowselect][disabled]')) { return; } this.trigger('coral-table-row:_beforeselectedchanged'); this._selected = transform.booleanAttr(value); this._reflectAttribute('selected', this._selected); this.trigger('coral-table-row:_selectedchanged'); this._syncSelectHandle(); this._syncAriaLabelledby(); this._syncAriaSelectedState(); } /** Whether the items are selectable. @type {Boolean} @default false @htmlattribute selectable @htmlattributereflected */ get selectable() { return this._selectable || false; } set selectable(value) { this._selectable = transform.booleanAttr(value); this._reflectAttribute('selectable', this._selectable); this.items.getAll().forEach((cell) => { cell[this._selectable ? 'setAttribute' : 'removeAttribute']('_selectable', ''); }); } /** Whether multiple items can be selected. @type {Boolean} @default false @htmlattribute multiple @htmlattributereflected */ get multiple() { return this._multiple || false; } set multiple(value) { this._multiple = transform.booleanAttr(value); this._reflectAttribute('multiple', this._multiple); this.trigger('coral-table-row:_multiplechanged'); } /** Returns an Array containing the selected items. @type {Array.<HTMLElement>} @readonly */ get selectedItems() { return this.items._getAllSelected(); } /** Returns the first selected item of the row. The value <code>null</code> is returned if no element is selected. @type {HTMLElement} @readonly */ get selectedItem() { return this.items._getFirstSelected(); } /** The Collection Interface that allows interacting with the items that the component contains. @type {SelectableCollection} @readonly */ get items() { // Construct the collection on first request if (!this._items) { this._items = new SelectableCollection({ host: this, itemBaseTagName: 'td', itemTagName: 'coral-table-cell' }); } return this._items; } /** * The row header element for the row. * @type {HTMLElement} * @readonly */ get rowHeader () { return this.items.getAll().filter(cell => { return (cell.getAttribute('role') === 'rowheader' || (cell.tagName === 'TH' && cell.getAttribute('scope') === 'row')); })[0]; } _triggerChangeEvent() { const selectedItems = this.selectedItems; this.trigger('coral-table-row:_change', { oldSelection: this._oldSelection, selection: selectedItems }); this._oldSelection = selectedItems; } /** @private */ _onCellSelectionChanged(event) { event.stopImmediatePropagation(); this._triggerChangeEvent(); } /** @private */ _onBeforeCellSelectionChanged(event) { event.stopImmediatePropagation(); // In single selection, if the added item is selected, the rest should be deselected const selectedItem = this.selectedItem; if (!this.multiple && selectedItem && !event.target.selected) { selectedItem.set('selected', false, true); } } /** @private */ _syncAriaSelectedState() { this.classList.toggle('is-selected', this.selected); const selectHandle = this.querySelector('[coral-table-rowselect]'); // @a11y Only update aria-selected if the table row can be selected if (!(this.hasAttribute('coral-table-rowselect') || selectHandle)) { this.removeAttribute('aria-selected'); return; } const rowOrderHandle = this.querySelector('[coral-table-roworder]'); const rowLockHandle = this.querySelector('[coral-table-rowlock]'); const rowRemoveHandle = this.querySelector('[coral-row-remove]'); const accessibilityState = this._elements.accessibilityState; const resetAccessibilityState = () => { // @a11y remove aria-live this.removeAttribute('aria-live'); this.removeAttribute('aria-atomic'); this.removeAttribute('aria-relevant'); // @a11y Unhide the selectHandle, so that it will be resume being announced by assistive // technology if (selectHandle && selectHandle.tagName === 'CORAL-CHECKBOX') { selectHandle.removeAttribute('aria-hidden'); } // @a11y Unhide the coral-table-roworder handle, so that it will be resume being announced by // assistive technology if (rowOrderHandle) { rowOrderHandle.removeAttribute('aria-hidden'); } // @a11y Unhide the coral-table-rowlock handle, so that it will be resume being announced by // assistive technology if (rowLockHandle) { rowLockHandle.removeAttribute('aria-hidden'); } // @a11y Unhide the coral-row-remove handle, so that it will be resume being announced by // assistive technology if (rowRemoveHandle) { rowRemoveHandle.removeAttribute('aria-hidden'); } if (accessibilityState) { // @a11y Hide the _accessibilityState from assistive technology, so that it can not be read // using a screen reader separately from the row it helps label accessibilityState.setAttribute('aria-hidden', 'true'); // @a11y If the item is not selected, remove ', unchecked' to decrease verbosity. if (!this.selected) { accessibilityState.innerHTML = ''; } } }; // @a11y set aria-selected this.setAttribute('aria-selected', this.selected); if (this._ariaLiveOnTimeout || this._ariaLiveOffTimeout) { clearTimeout(this._ariaLiveOnTimeout); clearTimeout(this._ariaLiveOffTimeout); } // @ally If _accessibilityState has been added to a cell within the row, if (accessibilityState) { resetAccessibilityState(); this._ariaLiveOnTimeout = setTimeout(() => { // @a11y and the row or one of its descendants has focus, if (this === document.activeElement || this.contains(document.activeElement)) { // @a11y Hide the "Select" checkbox so that it does not get announced with the state change. if (selectHandle && selectHandle.tagName === 'CORAL-CHECKBOX') { selectHandle.setAttribute('aria-hidden', 'true'); } // @a11y Hide the coral-table-roworder handle so that it does not get announced with the // state change. if (rowOrderHandle) { rowOrderHandle.setAttribute('aria-hidden', 'true'); } // @a11y Hide the coral-table-rowlock handle so that it does not get announced with the state // change. if (rowLockHandle) { rowLockHandle.setAttribute('aria-hidden', 'true'); } // @a11y Hide the coral-row-remove handle so that it does not get announced with the state // change. if (rowRemoveHandle) { rowRemoveHandle.setAttribute('aria-hidden', 'true'); } // @a11y The ChromeVox screenreader, used on Chromebook, announces the state change and // should not need aria-live, otherwise it double-voices the row. if (!window.cvox) { // @a11y Unhide the _accessibilityState so that it will get announced with the state change. accessibilityState.removeAttribute('aria-hidden'); // @ally use aria-live to announce the state change this.setAttribute('aria-live', 'assertive'); // @ally use aria-atomic="true" to announce the entire row this.setAttribute('aria-atomic', 'true'); } this._ariaLiveOnTimeout = setTimeout(() => { // @ally Set the _accessibilityState text to read either ", checked" or ", unchecked", // which should trigger a live region announcement. accessibilityState.innerHTML = i18n.get(this.selected ? ', checked' : ', unchecked'); // @ally wait 250ms for row to announce this._ariaLiveOffTimeout = setTimeout(resetAccessibilityState, 250); }, 20); } }, 20); if (!(this === document.activeElement || this.contains(document.activeElement))) { accessibilityState.innerHTML = i18n.get(this.selected ? ', checked' : ''); } } } /** @private */ _syncAriaLabelledby() { // @a11y if the row is not selectable, remove accessibilityState if (!(this.hasAttribute('coral-table-rowselect') || this.querySelector('[coral-table-rowselect]'))) { if (this._elements.accessibilityState.parentNode) { this.removeAttribute('aria-labelledby'); this._elements.accessibilityState = this._elements.accessibilityState.parentNode.removeChild(this._elements.accessibilityState); } return; } // @a11y get a list of ids for cells const cells = this.items.getAll().filter(cell => { // @a11y exclude cells for coral-table-roworder, coral-table-rowlock or coral-row-remove return ( cell.id && !( cell.hasAttribute('coral-table-roworder') || cell.querySelector('[coral-table-roworder]') || cell.hasAttribute('coral-table-rowlock') || cell.querySelector('[coral-table-rowlock]') || cell.hasAttribute('coral-row-remove') || cell.querySelector('[coral-table-remove]') ) ); }); const rowHeaders = cells.filter(cell => { return (cell.getAttribute('role') === 'rowheader' || (cell.tagName === 'TH' && cell.getAttribute('scope') === 'row')); }); let cellForAccessibilityState; const ids = cells.map(cell => { const handle = cell.querySelector('[coral-table-rowselect]'); if (handle) { cellForAccessibilityState = cell; // @a11y otherwise, if the selectHandle is a coral-checkbox, if (handle && handle.tagName === 'CORAL-CHECKBOX' && handle._elements) { // @a11y if the row is selected, don't add the coral-table-rowselect to accessibility name if (this.selected) { return; } // otherwise, include the checkbox input labelled "Select" in the accessibility name return handle._elements.input && handle._elements.input.id; } } // @a11y include row headers, or if no row header is defined, // all other cells in the row, in the accessibility name if (rowHeaders.length === 0 || rowHeaders.indexOf(cell) !== -1) { return cell.id; } }); // @a11y If an _accessibilityState has not been defined within one of the cells, add to the last // cell if (!cellForAccessibilityState && cells.length) { cellForAccessibilityState = cells[cells.length - 1]; } if (cellForAccessibilityState) { cellForAccessibilityState.appendChild(this._elements.accessibilityState); } // @a11y Once defined, if (this._elements.accessibilityState.parentNode) { // @a11y add the _accessibilityState ", checked" or ", unchecked" as the last item in the // accessibility name ids.push(this._elements.accessibilityState.id); } // @a11y Update the aria-labelledby attribute for the row. this.setAttribute('aria-labelledby', ids.join(' ')); } /** @private */ _syncSelectHandle() { // Check/uncheck the select handle const selectHandle = this.querySelector('[coral-table-rowselect]'); if (selectHandle) { if (typeof selectHandle.indeterminate !== 'undefined') { selectHandle.indeterminate = false; } selectHandle[this.selected ? 'setAttribute' : 'removeAttribute']('checked', ''); // @a11y If the handle is a checkbox but lacks a label, label it with "Select". if (selectHandle.tagName === 'CORAL-CHECKBOX') { if (!selectHandle.labelled) { selectHandle.labelled = i18n.get('Select'); } // @a11y provide a more explicit label for the checkbox than just "Select" if (this.hasAttribute('aria-labelledby')) { // Wait for the next frame to ensure the selectHandle has initialized _elements object. window.requestAnimationFrame(() => { let ids = this.getAttribute('aria-labelledby') .split(/\s+/g) .filter(id => selectHandle._elements.id !== id && this._elements.accessibilityState.id !== id) .join(' '); selectHandle.labelledBy = selectHandle._elements.id + ' ' + ids; }); } } } } /** @private */ _toggleSelectable(selectable) { if (selectable) { this._setHandle('coral-table-rowselect'); } else { // Clear selection but leave the handle if any this.set('selected', false, true); } // Sync the aria-labelledby attribute to include the _accessibilityState this._syncAriaLabelledby(); } /** @private */ _toggleOrderable(orderable) { if (orderable) { this._setHandle('coral-table-roworder', 0); } // Remove DragAction instance else if (this.dragAction) { this.dragAction.destroy(); } } /** @private */ _toggleLockable(lockable) { if (lockable) { this._setHandle('coral-table-rowlock'); } } _setHandleAndSync(handle) { // Specify handle directly on the row if none found if (!this.querySelector(`[${handle}]`)) { this.setAttribute(handle, ''); } this._syncSelectHandle(); this._syncAriaLabelledby(); this._syncAriaSelectedState(); } /** @private */ _setHandle(handle, timeout) { if(typeof timeout === "number") { setTimeout(() => { this._setHandleAndSync(handle); }, timeout); } else { requestAnimationFrame(() => { this._setHandleAndSync(handle); }); } } /** @private */ _handleMutations(mutations) { mutations.forEach((mutation) => { // Sync added nodes this.trigger('coral-table-row:_contentchanged', { addedNodes: mutation.addedNodes, removedNodes: mutation.removedNodes }); this._syncAriaLabelledby(); }); } /** @ignore */ static get observedAttributes() { return super.observedAttributes.concat(['locked', 'selected', 'multiple', 'selectable', '_selectable', '_orderable', '_lockable']); } /** @ignore */ attributeChangedCallback(name, oldValue, value) { if (name === '_selectable') { this._toggleSelectable(value !== null); } else if (name === '_orderable') { this._toggleOrderable(value !== null); } else if (name === '_lockable') { this._toggleLockable(value !== null); } else { super.attributeChangedCallback(name, oldValue, value); } } /** @ignore */ render() { super.render(); this.classList.add(CLASSNAME); this._syncAriaLabelledby(); } /** Triggered before {@link TableRow#selected} is changed. @typedef {CustomEvent} coral-table-row:_beforeselectedchanged @private */ /** Triggered when {@link TableRow#selected} changed. @typedef {CustomEvent} coral-table-row:_selectedchanged @private */ /** Triggered when {@link TableRow#locked} changed. @typedef {CustomEvent} coral-table-row:_lockedchanged @private */ /** Triggered when {@link TableRow#multiple} changed. @typedef {CustomEvent} coral-table-row:_multiplechanged @private */ /** Triggered when the {@link TableRow} selection changed. @typedef {CustomEvent} coral-table-row:_change @property {Array.<TableCell>} detail.oldSelection The old item selection. When {@link TableRow#multiple}, it includes an Array. @property {Array.<TableCell>} event.detail.selection The item selection. When {@link TableRow#multiple}, it includes an Array. @private */ }); export default TableRow;