Reference Source

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;
  }

  _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')) {
          let ids = this.getAttribute('aria-labelledby')
            .split(' ')
            .filter(id => selectHandle._elements.input.id !== id && this._elements.accessibilityState.id !== id)
            .join(' ');
          selectHandle.labelledBy = selectHandle._elements.input.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;