coral-spectrum/coral-component-table/src/scripts/TableRow.js
- /**
- * 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;