ExamplesPlaygroundReference Source

coral-spectrum/coral-component-table/src/scripts/TableRow.js

  1. /**
  2. * Copyright 2019 Adobe. All rights reserved.
  3. * This file is licensed to you under the Apache License, Version 2.0 (the "License");
  4. * you may not use this file except in compliance with the License. You may obtain a copy
  5. * of the License at http://www.apache.org/licenses/LICENSE-2.0
  6. *
  7. * Unless required by applicable law or agreed to in writing, software distributed under
  8. * the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR REPRESENTATIONS
  9. * OF ANY KIND, either express or implied. See the License for the specific language
  10. * governing permissions and limitations under the License.
  11. */
  12.  
  13. import accessibilityState from '../templates/accessibilityState';
  14. import {BaseComponent} from '../../../coral-base-component';
  15. import {SelectableCollection} from '../../../coral-collection';
  16. import {transform, commons, i18n} from '../../../coral-utils';
  17. import {Decorator} from '../../../coral-decorator';
  18.  
  19. const CLASSNAME = '_coral-Table-row';
  20.  
  21. /**
  22. @class Coral.Table.Row
  23. @classdesc A Table row component
  24. @htmltag coral-table-row
  25. @htmlbasetag tr
  26. @extends {HTMLTableRowElement}
  27. @extends {BaseComponent}
  28. */
  29. const TableRow = Decorator(class extends BaseComponent(HTMLTableRowElement) {
  30. /** @ignore */
  31. constructor() {
  32. super();
  33.  
  34. // Templates
  35. this._elements = {};
  36. accessibilityState.call(this._elements, {commons});
  37.  
  38. // Required for coral-table-row:change event
  39. this._oldSelection = [];
  40.  
  41. // Events
  42. this._delegateEvents({
  43. // Private
  44. 'coral-table-cell:_beforeselectedchanged': '_onBeforeCellSelectionChanged',
  45. 'coral-table-cell:_selectedchanged': '_onCellSelectionChanged'
  46. });
  47.  
  48. // Initialize content MO
  49. this._observer = new MutationObserver(this._handleMutations.bind(this));
  50. this._observer.observe(this, {
  51. childList: true
  52. });
  53. }
  54.  
  55. /**
  56. Whether the table row is locked.
  57.  
  58. @type {Boolean}
  59. @default false
  60. @htmlattribute locked
  61. @htmlattributereflected
  62. */
  63. get locked() {
  64. return this._locked || false;
  65. }
  66.  
  67. set locked(value) {
  68. this._locked = transform.booleanAttr(value);
  69. this._reflectAttribute('locked', this._locked);
  70.  
  71. this.trigger('coral-table-row:_lockedchanged');
  72. }
  73.  
  74. /**
  75. Whether the table row is selected.
  76.  
  77. @type {Boolean}
  78. @default false
  79. @htmlattribute selected
  80. @htmlattributereflected
  81. */
  82. get selected() {
  83. return this._selected || false;
  84. }
  85.  
  86. set selected(value) {
  87. // Prevent selection if disabled
  88. if (this.hasAttribute('coral-table-rowselect') && this.hasAttribute('disabled') ||
  89. this.querySelector('[coral-table-rowselect][disabled]')) {
  90. return;
  91. }
  92.  
  93. this.trigger('coral-table-row:_beforeselectedchanged');
  94.  
  95. this._selected = transform.booleanAttr(value);
  96. this._reflectAttribute('selected', this._selected);
  97.  
  98. this.trigger('coral-table-row:_selectedchanged');
  99. this._syncSelectHandle();
  100. this._syncAriaLabelledby();
  101. this._syncAriaSelectedState();
  102. }
  103.  
  104. /**
  105. Whether the items are selectable.
  106.  
  107. @type {Boolean}
  108. @default false
  109. @htmlattribute selectable
  110. @htmlattributereflected
  111. */
  112. get selectable() {
  113. return this._selectable || false;
  114. }
  115.  
  116. set selectable(value) {
  117. this._selectable = transform.booleanAttr(value);
  118. this._reflectAttribute('selectable', this._selectable);
  119.  
  120. this.items.getAll().forEach((cell) => {
  121. cell[this._selectable ? 'setAttribute' : 'removeAttribute']('_selectable', '');
  122. });
  123. }
  124.  
  125. /**
  126. Whether multiple items can be selected.
  127.  
  128. @type {Boolean}
  129. @default false
  130. @htmlattribute multiple
  131. @htmlattributereflected
  132. */
  133. get multiple() {
  134. return this._multiple || false;
  135. }
  136.  
  137. set multiple(value) {
  138. this._multiple = transform.booleanAttr(value);
  139. this._reflectAttribute('multiple', this._multiple);
  140.  
  141. this.trigger('coral-table-row:_multiplechanged');
  142. }
  143.  
  144. /**
  145. Returns an Array containing the selected items.
  146.  
  147. @type {Array.<HTMLElement>}
  148. @readonly
  149. */
  150. get selectedItems() {
  151. return this.items._getAllSelected();
  152. }
  153.  
  154. /**
  155. Returns the first selected item of the row. The value <code>null</code> is returned if no element is
  156. selected.
  157.  
  158. @type {HTMLElement}
  159. @readonly
  160. */
  161. get selectedItem() {
  162. return this.items._getFirstSelected();
  163. }
  164.  
  165. /**
  166. The Collection Interface that allows interacting with the items that the component contains.
  167.  
  168. @type {SelectableCollection}
  169. @readonly
  170. */
  171. get items() {
  172. // Construct the collection on first request
  173. if (!this._items) {
  174. this._items = new SelectableCollection({
  175. host: this,
  176. itemBaseTagName: 'td',
  177. itemTagName: 'coral-table-cell'
  178. });
  179. }
  180.  
  181. return this._items;
  182. }
  183.  
  184. /**
  185. * The row header element for the row.
  186. * @type {HTMLElement}
  187. * @readonly
  188. */
  189. get rowHeader () {
  190. return this.items.getAll().filter(cell => {
  191. return (cell.getAttribute('role') === 'rowheader' ||
  192. (cell.tagName === 'TH' && cell.getAttribute('scope') === 'row'));
  193. })[0];
  194. }
  195.  
  196. _triggerChangeEvent() {
  197. const selectedItems = this.selectedItems;
  198. this.trigger('coral-table-row:_change', {
  199. oldSelection: this._oldSelection,
  200. selection: selectedItems
  201. });
  202. this._oldSelection = selectedItems;
  203. }
  204.  
  205. /** @private */
  206. _onCellSelectionChanged(event) {
  207. event.stopImmediatePropagation();
  208.  
  209. this._triggerChangeEvent();
  210. }
  211.  
  212. /** @private */
  213. _onBeforeCellSelectionChanged(event) {
  214. event.stopImmediatePropagation();
  215.  
  216. // In single selection, if the added item is selected, the rest should be deselected
  217. const selectedItem = this.selectedItem;
  218. if (!this.multiple && selectedItem && !event.target.selected) {
  219. selectedItem.set('selected', false, true);
  220. }
  221. }
  222.  
  223. /** @private */
  224. _syncAriaSelectedState() {
  225. this.classList.toggle('is-selected', this.selected);
  226. const selectHandle = this.querySelector('[coral-table-rowselect]');
  227.  
  228. // @a11y Only update aria-selected if the table row can be selected
  229. if (!(this.hasAttribute('coral-table-rowselect') || selectHandle)) {
  230. this.removeAttribute('aria-selected');
  231. return;
  232. }
  233.  
  234. const rowOrderHandle = this.querySelector('[coral-table-roworder]');
  235. const rowLockHandle = this.querySelector('[coral-table-rowlock]');
  236. const rowRemoveHandle = this.querySelector('[coral-row-remove]');
  237. const accessibilityState = this._elements.accessibilityState;
  238.  
  239. const resetAccessibilityState = () => {
  240. // @a11y remove aria-live
  241. this.removeAttribute('aria-live');
  242. this.removeAttribute('aria-atomic');
  243. this.removeAttribute('aria-relevant');
  244.  
  245. // @a11y Unhide the selectHandle, so that it will be resume being announced by assistive
  246. // technology
  247. if (selectHandle && selectHandle.tagName === 'CORAL-CHECKBOX') {
  248. selectHandle.removeAttribute('aria-hidden');
  249. }
  250.  
  251. // @a11y Unhide the coral-table-roworder handle, so that it will be resume being announced by
  252. // assistive technology
  253. if (rowOrderHandle) {
  254. rowOrderHandle.removeAttribute('aria-hidden');
  255. }
  256.  
  257. // @a11y Unhide the coral-table-rowlock handle, so that it will be resume being announced by
  258. // assistive technology
  259. if (rowLockHandle) {
  260. rowLockHandle.removeAttribute('aria-hidden');
  261. }
  262.  
  263. // @a11y Unhide the coral-row-remove handle, so that it will be resume being announced by
  264. // assistive technology
  265. if (rowRemoveHandle) {
  266. rowRemoveHandle.removeAttribute('aria-hidden');
  267. }
  268.  
  269. if (accessibilityState) {
  270. // @a11y Hide the _accessibilityState from assistive technology, so that it can not be read
  271. // using a screen reader separately from the row it helps label
  272. accessibilityState.setAttribute('aria-hidden', 'true');
  273.  
  274. // @a11y If the item is not selected, remove ', unchecked' to decrease verbosity.
  275. if (!this.selected) {
  276. accessibilityState.innerHTML = '';
  277. }
  278. }
  279. };
  280.  
  281. // @a11y set aria-selected
  282. this.setAttribute('aria-selected', this.selected);
  283.  
  284. if (this._ariaLiveOnTimeout || this._ariaLiveOffTimeout) {
  285. clearTimeout(this._ariaLiveOnTimeout);
  286. clearTimeout(this._ariaLiveOffTimeout);
  287. }
  288.  
  289. // @ally If _accessibilityState has been added to a cell within the row,
  290. if (accessibilityState) {
  291. resetAccessibilityState();
  292. this._ariaLiveOnTimeout = setTimeout(() => {
  293.  
  294. // @a11y and the row or one of its descendants has focus,
  295. if (this === document.activeElement || this.contains(document.activeElement)) {
  296.  
  297. // @a11y Hide the "Select" checkbox so that it does not get announced with the state change.
  298. if (selectHandle && selectHandle.tagName === 'CORAL-CHECKBOX') {
  299. selectHandle.setAttribute('aria-hidden', 'true');
  300. }
  301.  
  302. // @a11y Hide the coral-table-roworder handle so that it does not get announced with the
  303. // state change.
  304. if (rowOrderHandle) {
  305. rowOrderHandle.setAttribute('aria-hidden', 'true');
  306. }
  307.  
  308. // @a11y Hide the coral-table-rowlock handle so that it does not get announced with the state
  309. // change.
  310. if (rowLockHandle) {
  311. rowLockHandle.setAttribute('aria-hidden', 'true');
  312. }
  313.  
  314. // @a11y Hide the coral-row-remove handle so that it does not get announced with the state
  315. // change.
  316. if (rowRemoveHandle) {
  317. rowRemoveHandle.setAttribute('aria-hidden', 'true');
  318. }
  319.  
  320. // @a11y The ChromeVox screenreader, used on Chromebook, announces the state change and
  321. // should not need aria-live, otherwise it double-voices the row.
  322. if (!window.cvox) {
  323. // @a11y Unhide the _accessibilityState so that it will get announced with the state change.
  324. accessibilityState.removeAttribute('aria-hidden');
  325.  
  326. // @ally use aria-live to announce the state change
  327. this.setAttribute('aria-live', 'assertive');
  328.  
  329. // @ally use aria-atomic="true" to announce the entire row
  330. this.setAttribute('aria-atomic', 'true');
  331. }
  332.  
  333. this._ariaLiveOnTimeout = setTimeout(() => {
  334. // @ally Set the _accessibilityState text to read either ", checked" or ", unchecked",
  335. // which should trigger a live region announcement.
  336. accessibilityState.innerHTML = i18n.get(this.selected ? ', checked' : ', unchecked');
  337.  
  338. // @ally wait 250ms for row to announce
  339. this._ariaLiveOffTimeout = setTimeout(resetAccessibilityState, 250);
  340. }, 20);
  341. }
  342. }, 20);
  343.  
  344. if (!(this === document.activeElement || this.contains(document.activeElement))) {
  345. accessibilityState.innerHTML = i18n.get(this.selected ? ', checked' : '');
  346. }
  347. }
  348. }
  349.  
  350. /** @private */
  351. _syncAriaLabelledby() {
  352. // @a11y if the row is not selectable, remove accessibilityState
  353. if (!(this.hasAttribute('coral-table-rowselect') || this.querySelector('[coral-table-rowselect]'))) {
  354. if (this._elements.accessibilityState.parentNode) {
  355. this.removeAttribute('aria-labelledby');
  356. this._elements.accessibilityState = this._elements.accessibilityState.parentNode.removeChild(this._elements.accessibilityState);
  357. }
  358. return;
  359. }
  360.  
  361. // @a11y get a list of ids for cells
  362. const cells = this.items.getAll().filter(cell => {
  363. // @a11y exclude cells for coral-table-roworder, coral-table-rowlock or coral-row-remove
  364. return (
  365. cell.id &&
  366. !(
  367. cell.hasAttribute('coral-table-roworder') || cell.querySelector('[coral-table-roworder]') ||
  368. cell.hasAttribute('coral-table-rowlock') || cell.querySelector('[coral-table-rowlock]') ||
  369. cell.hasAttribute('coral-row-remove') || cell.querySelector('[coral-table-remove]')
  370. )
  371. );
  372. });
  373.  
  374. const rowHeaders = cells.filter(cell => {
  375. return (cell.getAttribute('role') === 'rowheader' ||
  376. (cell.tagName === 'TH' && cell.getAttribute('scope') === 'row'));
  377. });
  378.  
  379. let cellForAccessibilityState;
  380. const ids = cells.map(cell => {
  381. const handle = cell.querySelector('[coral-table-rowselect]');
  382. if (handle) {
  383.  
  384. cellForAccessibilityState = cell;
  385.  
  386. // @a11y otherwise, if the selectHandle is a coral-checkbox,
  387. if (handle && handle.tagName === 'CORAL-CHECKBOX' && handle._elements) {
  388. // @a11y if the row is selected, don't add the coral-table-rowselect to accessibility name
  389. if (this.selected) {
  390. return;
  391. }
  392. // otherwise, include the checkbox input labelled "Select" in the accessibility name
  393. return handle._elements.input && handle._elements.input.id;
  394. }
  395. }
  396.  
  397. // @a11y include row headers, or if no row header is defined,
  398. // all other cells in the row, in the accessibility name
  399. if (rowHeaders.length === 0 || rowHeaders.indexOf(cell) !== -1) {
  400. return cell.id;
  401. }
  402. });
  403.  
  404. // @a11y If an _accessibilityState has not been defined within one of the cells, add to the last
  405. // cell
  406. if (!cellForAccessibilityState && cells.length) {
  407. cellForAccessibilityState = cells[cells.length - 1];
  408. }
  409.  
  410. if (cellForAccessibilityState) {
  411. cellForAccessibilityState.appendChild(this._elements.accessibilityState);
  412. }
  413.  
  414. // @a11y Once defined,
  415. if (this._elements.accessibilityState.parentNode) {
  416. // @a11y add the _accessibilityState ", checked" or ", unchecked" as the last item in the
  417. // accessibility name
  418. ids.push(this._elements.accessibilityState.id);
  419. }
  420.  
  421. // @a11y Update the aria-labelledby attribute for the row.
  422. this.setAttribute('aria-labelledby', ids.join(' '));
  423. }
  424.  
  425. /** @private */
  426. _syncSelectHandle() {
  427. // Check/uncheck the select handle
  428. const selectHandle = this.querySelector('[coral-table-rowselect]');
  429. if (selectHandle) {
  430. if (typeof selectHandle.indeterminate !== 'undefined') {
  431. selectHandle.indeterminate = false;
  432. }
  433.  
  434. selectHandle[this.selected ? 'setAttribute' : 'removeAttribute']('checked', '');
  435.  
  436. // @a11y If the handle is a checkbox but lacks a label, label it with "Select".
  437. if (selectHandle.tagName === 'CORAL-CHECKBOX') {
  438. if (!selectHandle.labelled) {
  439. selectHandle.labelled = i18n.get('Select');
  440. }
  441.  
  442. // @a11y provide a more explicit label for the checkbox than just "Select"
  443. if (this.hasAttribute('aria-labelledby')) {
  444. // Wait for the next frame to ensure the selectHandle has initialized _elements object.
  445. window.requestAnimationFrame(() => {
  446. let ids = this.getAttribute('aria-labelledby')
  447. .split(/\s+/g)
  448. .filter(id => selectHandle._elements.id !== id && this._elements.accessibilityState.id !== id)
  449. .join(' ');
  450. selectHandle.labelledBy = selectHandle._elements.id + ' ' + ids;
  451. });
  452. }
  453. }
  454. }
  455. }
  456.  
  457. /** @private */
  458. _toggleSelectable(selectable) {
  459. if (selectable) {
  460. this._setHandle('coral-table-rowselect');
  461. } else {
  462. // Clear selection but leave the handle if any
  463. this.set('selected', false, true);
  464. }
  465.  
  466. // Sync the aria-labelledby attribute to include the _accessibilityState
  467. this._syncAriaLabelledby();
  468. }
  469.  
  470. /** @private */
  471. _toggleOrderable(orderable) {
  472. if (orderable) {
  473. this._setHandle('coral-table-roworder', 0);
  474. }
  475. // Remove DragAction instance
  476. else if (this.dragAction) {
  477. this.dragAction.destroy();
  478. }
  479. }
  480.  
  481. /** @private */
  482. _toggleLockable(lockable) {
  483. if (lockable) {
  484. this._setHandle('coral-table-rowlock');
  485. }
  486. }
  487.  
  488. _setHandleAndSync(handle) {
  489. // Specify handle directly on the row if none found
  490. if (!this.querySelector(`[${handle}]`)) {
  491. this.setAttribute(handle, '');
  492. }
  493. this._syncSelectHandle();
  494. this._syncAriaLabelledby();
  495. this._syncAriaSelectedState();
  496. }
  497.  
  498. /** @private */
  499. _setHandle(handle, timeout) {
  500. if(typeof timeout === "number") {
  501. setTimeout(() => {
  502. this._setHandleAndSync(handle);
  503. }, timeout);
  504. } else {
  505. requestAnimationFrame(() => {
  506. this._setHandleAndSync(handle);
  507. });
  508. }
  509. }
  510.  
  511. /** @private */
  512. _handleMutations(mutations) {
  513. mutations.forEach((mutation) => {
  514. // Sync added nodes
  515. this.trigger('coral-table-row:_contentchanged', {
  516. addedNodes: mutation.addedNodes,
  517. removedNodes: mutation.removedNodes
  518. });
  519. this._syncAriaLabelledby();
  520. });
  521. }
  522.  
  523. /** @ignore */
  524. static get observedAttributes() {
  525. return super.observedAttributes.concat(['locked', 'selected', 'multiple', 'selectable', '_selectable', '_orderable', '_lockable']);
  526. }
  527.  
  528. /** @ignore */
  529. attributeChangedCallback(name, oldValue, value) {
  530. if (name === '_selectable') {
  531. this._toggleSelectable(value !== null);
  532. } else if (name === '_orderable') {
  533. this._toggleOrderable(value !== null);
  534. } else if (name === '_lockable') {
  535. this._toggleLockable(value !== null);
  536. } else {
  537. super.attributeChangedCallback(name, oldValue, value);
  538. }
  539. }
  540.  
  541. /** @ignore */
  542. render() {
  543. super.render();
  544.  
  545. this.classList.add(CLASSNAME);
  546. this._syncAriaLabelledby();
  547. }
  548.  
  549. /**
  550. Triggered before {@link TableRow#selected} is changed.
  551.  
  552. @typedef {CustomEvent} coral-table-row:_beforeselectedchanged
  553.  
  554. @private
  555. */
  556.  
  557. /**
  558. Triggered when {@link TableRow#selected} changed.
  559.  
  560. @typedef {CustomEvent} coral-table-row:_selectedchanged
  561.  
  562. @private
  563. */
  564.  
  565. /**
  566. Triggered when {@link TableRow#locked} changed.
  567.  
  568. @typedef {CustomEvent} coral-table-row:_lockedchanged
  569.  
  570. @private
  571. */
  572.  
  573. /**
  574. Triggered when {@link TableRow#multiple} changed.
  575.  
  576. @typedef {CustomEvent} coral-table-row:_multiplechanged
  577.  
  578. @private
  579. */
  580.  
  581. /**
  582. Triggered when the {@link TableRow} selection changed.
  583.  
  584. @typedef {CustomEvent} coral-table-row:_change
  585.  
  586. @property {Array.<TableCell>} detail.oldSelection
  587. The old item selection. When {@link TableRow#multiple}, it includes an Array.
  588. @property {Array.<TableCell>} event.detail.selection
  589. The item selection. When {@link TableRow#multiple}, it includes an Array.
  590.  
  591. @private
  592. */
  593. });
  594.  
  595. export default TableRow;