ExamplesPlaygroundReference Source

coral-spectrum/coral-component-taglist/src/scripts/TagList.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 {BaseComponent} from '../../../coral-base-component';
  14. import {BaseFormField} from '../../../coral-base-formfield';
  15. import Tag from './Tag';
  16. import {Collection} from '../../../coral-collection';
  17. import {transform, commons} from '../../../coral-utils';
  18. import {Decorator} from '../../../coral-decorator';
  19.  
  20. const CLASSNAME = '_coral-Tags';
  21. // Collection
  22. const ITEM_TAGNAME = 'coral-tag';
  23.  
  24. /**
  25. Extracts the value from the item in case no explicit value was provided.
  26. @param {HTMLElement} item
  27. the item whose value will be extracted.
  28. @returns {String} the value that will be submitted for this item.
  29. @private
  30. */
  31. const itemValueFromDOM = function (item) {
  32. const attr = item.getAttribute('value');
  33. // checking explicitly for null allows to differentiate between non set values and empty strings
  34. return attr !== null ? attr : item.textContent.replace(/\s{2,}/g, ' ').trim();
  35. };
  36.  
  37. /**
  38. @class Coral.TagList
  39. @classdesc A TagList component is a form field container to manipulate tags.
  40. @htmltag coral-taglist
  41. @extends {HTMLElement}
  42. @extends {BaseComponent}
  43. @extends {BaseFormField}
  44. */
  45. const TagList = Decorator(class extends BaseFormField(BaseComponent(HTMLElement)) {
  46. /** @ignore */
  47. constructor() {
  48. super();
  49.  
  50. // Attach events
  51. this._delegateEvents(commons.extend(this._events, {
  52. 'capture:focus coral-tag': '_onItemFocus',
  53. 'capture:blur coral-tag': '_onItemBlur',
  54. 'key:right coral-tag': '_onNextItemFocus',
  55. 'key:down coral-tag': '_onNextItemFocus',
  56. 'key:pagedown coral-tag': '_onNextItemFocus',
  57. 'key:left coral-tag': '_onPreviousItemFocus',
  58. 'key:up coral-tag': '_onPreviousItemFocus',
  59. 'key:pageup coral-tag': '_onPreviousItemFocus',
  60. 'key:home coral-tag': '_onFirstItemFocus',
  61. 'key:end coral-tag': '_onLastItemFocus',
  62.  
  63. // Accessibility
  64. 'capture:focus coral-tag:not(.is-disabled)': '_onItemFocusIn',
  65. 'capture:blur coral-tag:not(.is-disabled)': '_onItemFocusOut',
  66.  
  67. // Private
  68. 'coral-tag:_valuechanged': '_onTagValueChanged',
  69. 'coral-tag:_connected': '_onTagConnected'
  70. }));
  71.  
  72. // Pre-define labellable element
  73. this._labellableElement = this;
  74.  
  75. this._itemToFocusAfterDelete = null;
  76. }
  77.  
  78. /**
  79. Changing the values will redefine the component's items.
  80.  
  81. @type {Array.<String>}
  82. @emits {change}
  83. */
  84. get values() {
  85. return this.items.getAll().map((item) => item.value);
  86. }
  87.  
  88. set values(values) {
  89. if (Array.isArray(values)) {
  90. this.items.clear();
  91.  
  92. values.forEach((value) => {
  93. const item = new Tag().set({
  94. label: {
  95. innerHTML: value
  96. },
  97. value: value
  98. });
  99.  
  100. this._attachInputToItem(item);
  101.  
  102. this.items.add(item);
  103. });
  104. }
  105. }
  106.  
  107. /**
  108. The Collection Interface that allows interacting with the items that the component contains.
  109.  
  110. @type {Collection}
  111. @readonly
  112. */
  113. get items() {
  114. // just init on demand
  115. if (!this._items) {
  116. this._items = new Collection({
  117. host: this,
  118. itemTagName: ITEM_TAGNAME
  119. });
  120. }
  121. return this._items;
  122. }
  123.  
  124. /**
  125. Name used to submit the data in a form.
  126. @type {String}
  127. @default ""
  128. @htmlattribute name
  129. @htmlattributereflected
  130. */
  131. get name() {
  132. return this._name || '';
  133. }
  134.  
  135. set name(value) {
  136. this._name = transform.string(value);
  137. this._reflectAttribute('name', value);
  138.  
  139. this.items.getAll().forEach((item) => {
  140. if (item._input) {
  141. item._input.name = this._name;
  142. }
  143. });
  144. }
  145.  
  146. /**
  147. This field's current value.
  148. @type {String}
  149. @default ""
  150. @htmlattribute value
  151. */
  152. get value() {
  153. const all = this.items.getAll();
  154. return all.length ? all[0].value : '';
  155. }
  156.  
  157. set value(value) {
  158. this.items.clear();
  159.  
  160. if (value) {
  161. const item = new Tag().set({
  162. label: {
  163. innerHTML: value
  164. },
  165. value: value
  166. });
  167.  
  168. this._attachInputToItem(item);
  169.  
  170. this.items.add(item);
  171. }
  172. }
  173.  
  174. /**
  175. Whether this field is disabled or not.
  176. @type {Boolean}
  177. @default false
  178. @htmlattribute disabled
  179. @htmlattributereflected
  180. */
  181. get disabled() {
  182. return this._disabled || false;
  183. }
  184.  
  185. set disabled(value) {
  186. this._disabled = transform.booleanAttr(value);
  187. this._reflectAttribute('disabled', this._disabled);
  188.  
  189. this.items.getAll().forEach((item) => {
  190. item.classList.toggle('is-disabled', this._disabled);
  191. if (item._input) {
  192. item._input.disabled = this._disabled;
  193. }
  194. });
  195.  
  196. // a11y
  197. this[this._disabled ? 'setAttribute' : 'removeAttribute']('aria-disabled', this._disabled);
  198. }
  199.  
  200. // JSDoc inherited
  201. get invalid() {
  202. return super.invalid;
  203. }
  204.  
  205. set invalid(value) {
  206. super.invalid = value;
  207.  
  208. this.items.getAll().forEach((item) => {
  209. item.classList.toggle('is-invalid', this._invalid);
  210. });
  211. }
  212.  
  213. /**
  214. Whether this field is readOnly or not. Indicating that the user cannot modify the value of the control.
  215. @type {Boolean}
  216. @default false
  217. @htmlattribute readonly
  218. @htmlattributereflected
  219. */
  220. get readOnly() {
  221. return this._readOnly || false;
  222. }
  223.  
  224. set readOnly(value) {
  225. this._readOnly = transform.booleanAttr(value);
  226. this._reflectAttribute('readonly', this._readOnly);
  227.  
  228. this.items.getAll().forEach((item) => {
  229. item.closable = !this._readOnly;
  230. });
  231. }
  232.  
  233. /**
  234. Whether this field is required or not.
  235. @type {Boolean}
  236. @default false
  237. @htmlattribute required
  238. @htmlattributereflected
  239. */
  240. get required() {
  241. return this._required || false;
  242. }
  243.  
  244. set required(value) {
  245. this._required = transform.booleanAttr(value);
  246. this._reflectAttribute('required', this._required);
  247. }
  248.  
  249. /** @private */
  250. _attachInputToItem(item) {
  251. if (!item._input) {
  252. item._input = document.createElement('input');
  253. item._input.type = 'hidden';
  254. // We do this so it is recognized by Coral.Tag and handled if cloned
  255. item._input.setAttribute('handle', 'input');
  256. }
  257.  
  258. const input = item._input;
  259.  
  260. input.disabled = this.disabled;
  261. input.name = this.name;
  262. input.value = item.value;
  263.  
  264. if (!item.contains(input)) {
  265. item.appendChild(input);
  266. }
  267. }
  268.  
  269. /** @private */
  270. _prepareItem(attachedItem) {
  271. const items = this.items.getAll();
  272.  
  273. // Prevents to add duplicates based on the tag value
  274. const duplicate = items.some((tag) => {
  275. if (itemValueFromDOM(tag) === itemValueFromDOM(attachedItem) && tag !== attachedItem) {
  276. (items.indexOf(tag) < items.indexOf(attachedItem) ? attachedItem : tag).remove();
  277. return true;
  278. }
  279.  
  280. return false;
  281. });
  282.  
  283. if (duplicate) {
  284. return;
  285. }
  286.  
  287. // create corresponding input field
  288. this._attachInputToItem(attachedItem);
  289.  
  290. // Set tag defaults
  291. attachedItem.setAttribute('color', Tag.color.DEFAULT);
  292. attachedItem.setAttribute('size', Tag.size.SMALL);
  293.  
  294. // adds the role to support accessibility
  295. attachedItem.setAttribute('role', 'row');
  296.  
  297. // adds role to parent to support accessibility, if it doesn't already have it
  298. if (this.getAttribute('role') === null) {
  299. this.setAttribute('role', 'grid');
  300. }
  301.  
  302. if (!this.disabled) {
  303. attachedItem.setAttribute('tabindex', '-1');
  304. }
  305. attachedItem[this.readOnly ? 'removeAttribute' : 'setAttribute']('closable', '');
  306.  
  307. // add tabindex to first item if none existing
  308. if (!this.disabled && !this.querySelector(`${ITEM_TAGNAME}[tabindex="0"]`)) {
  309. const first = items[0];
  310. if (first) {
  311. first.setAttribute('tabindex', '0');
  312. }
  313. }
  314.  
  315. // Keep a reference on the host in case the tag gets removed
  316. attachedItem._host = this;
  317.  
  318. // triggers the Coral.Collection event
  319. this.trigger('coral-collection:add', {
  320. item: attachedItem
  321. });
  322. }
  323.  
  324. /** @private */
  325. _onItemDisconnected(detachedItem) {
  326. // Cleans the tag from TagList specific values
  327. detachedItem.removeAttribute('role');
  328.  
  329. // Removes role from taglist if it has no tag elements
  330. if (this.items.length <= 0) {
  331. this.removeAttribute('role');
  332. }
  333.  
  334. detachedItem.removeAttribute('tabindex');
  335. detachedItem._host = undefined;
  336.  
  337. const parentElement = this.parentElement;
  338. if (this.items.length === 0 && parentElement) {
  339. // If all tags are removed, call focus method on parent element
  340. if (typeof parentElement.focus === 'function') {
  341. parentElement.focus();
  342. }
  343.  
  344. const self = this;
  345.  
  346. commons.nextFrame(() => {
  347. // if the parentElement did not receive focus or move focus to some other element
  348. if (document.activeElement.tagName === 'BODY') {
  349. if (this.items.length > 0) {
  350. self.items.first().focus();
  351. } else {
  352. // make the TagList focusable
  353. self.tabIndex = -1;
  354. self.classList.add('u-coral-screenReaderOnly');
  355. self.style.outline = '0';
  356. self.innerHTML = ' ';
  357. const onBlurFocusManagement = function () {
  358. self.removeAttribute('tabindex');
  359. self.classList.remove('u-coral-screenReaderOnly');
  360. self.style.outline = '';
  361. self.innerHTML = '';
  362. self._vent.off('blur.focusManagement');
  363. };
  364. self._vent.on('blur.focusManagement', null, onBlurFocusManagement);
  365. if (!parentElement.contains(document.activeElement)) {
  366. self.focus();
  367. } else {
  368. onBlurFocusManagement();
  369. }
  370. }
  371. }
  372. });
  373. } else if (this._itemToFocusAfterDelete) {
  374. this._itemToFocusAfterDelete.focus();
  375. }
  376.  
  377. // triggers the Coral.Collection event
  378. this.trigger('coral-collection:remove', {
  379. item: detachedItem
  380. });
  381. }
  382.  
  383. /** @private */
  384. _onItemFocus(event) {
  385. if (!this.disabled) {
  386. this.setAttribute('aria-live', 'polite');
  387.  
  388. const tag = event.matchedTarget;
  389.  
  390. // add tabindex to first item and remove from previous focused item
  391. this.items.getAll().forEach((item) => {
  392. if (item !== tag) {
  393. item.setAttribute('tabindex', '-1');
  394. }
  395. });
  396. tag.setAttribute('tabindex', '0');
  397.  
  398. this._setItemToFocusOnDelete(tag);
  399. }
  400. }
  401.  
  402. /** @private */
  403. _onItemBlur(event) {
  404. if (!this.disabled) {
  405. this.setAttribute('aria-live', 'off');
  406.  
  407. const tag = event.matchedTarget;
  408.  
  409. this._setItemToFocusOnDelete(tag);
  410. }
  411. }
  412.  
  413. /** @private */
  414. _onSiblingItemFocus(event, sibling) {
  415. if (!this.disabled) {
  416. event.preventDefault();
  417.  
  418. let item = event.target[sibling];
  419. while (item) {
  420. if (item.tagName.toLowerCase() === ITEM_TAGNAME && !item.hidden) {
  421. item.focus();
  422. break;
  423. } else {
  424. item = item[sibling];
  425. }
  426. }
  427. }
  428. }
  429.  
  430. /** @private */
  431. _onNextItemFocus(event) {
  432. this._onSiblingItemFocus(event, 'nextElementSibling');
  433. }
  434.  
  435. /** @private */
  436. _onPreviousItemFocus(event) {
  437. this._onSiblingItemFocus(event, 'previousElementSibling');
  438. }
  439.  
  440. /** @private */
  441. _onFirstItemFocus(event) {
  442. event.preventDefault();
  443. const length = this.items.length;
  444. if (length > 0) {
  445. this.items.getAll()[0].focus();
  446. }
  447. }
  448.  
  449. /** @private */
  450. _onLastItemFocus(event) {
  451. event.preventDefault();
  452. const length = this.items.length;
  453. if (length > 0) {
  454. this.items.getAll()[length - 1].focus();
  455. }
  456. }
  457.  
  458. _onItemFocusIn(event) {
  459. event.matchedTarget.classList.add('focus-ring');
  460. }
  461.  
  462. _onItemFocusOut(event) {
  463. event.matchedTarget.classList.remove('focus-ring');
  464. }
  465.  
  466. /** @private */
  467. _onTagButtonClicked(item, event) {
  468. this.trigger('change');
  469.  
  470. this._trackEvent('remove', 'coral-tag', event, item);
  471. }
  472.  
  473. /** @private */
  474. _onTagValueChanged(event) {
  475. event.stopImmediatePropagation();
  476.  
  477. const tag = event.target;
  478. if (tag._input) {
  479. tag._input.value = tag.value;
  480. }
  481. }
  482.  
  483. /** @private */
  484. _setItemToFocusOnDelete(tag) {
  485. let itemToFocusAfterDelete = tag.nextElementSibling;
  486.  
  487. // Next item will be focusable if the focused tag is removed
  488. while (itemToFocusAfterDelete) {
  489. if (itemToFocusAfterDelete.tagName.toLowerCase() === ITEM_TAGNAME && !itemToFocusAfterDelete.hidden) {
  490. this._itemToFocusAfterDelete = itemToFocusAfterDelete;
  491. return;
  492. }
  493.  
  494. itemToFocusAfterDelete = itemToFocusAfterDelete.nextElementSibling;
  495. }
  496.  
  497. itemToFocusAfterDelete = tag.previousElementSibling;
  498. // Previous item will be focusable if the focused tag is removed
  499. while (itemToFocusAfterDelete) {
  500. if (itemToFocusAfterDelete.tagName.toLowerCase() === ITEM_TAGNAME && !itemToFocusAfterDelete.hidden) {
  501. this._itemToFocusAfterDelete = itemToFocusAfterDelete;
  502. break;
  503. } else {
  504. itemToFocusAfterDelete = itemToFocusAfterDelete.previousElementSibling;
  505. }
  506. }
  507.  
  508. window.requestAnimationFrame(() => {
  509. if (tag.parentElement !== null && !this.contains(document.activeElement)) {
  510. itemToFocusAfterDelete = undefined;
  511. }
  512. });
  513. }
  514.  
  515. /** @private */
  516. _onTagConnected(event) {
  517. event.stopImmediatePropagation();
  518.  
  519. const item = event.target;
  520. this._prepareItem(item);
  521. }
  522.  
  523. /**
  524. Inherited from {@link BaseFormField#reset}.
  525. */
  526. reset() {
  527. // reset the values to the initial values
  528. this.values = this._initialValues;
  529. }
  530.  
  531. /** @ignore */
  532. static get observedAttributes() {
  533. return super.observedAttributes.concat([
  534. 'closable',
  535. 'value',
  536. 'quiet',
  537. 'multiline',
  538. 'size',
  539. 'color'
  540. ]);
  541. }
  542.  
  543. /** @ignore */
  544. render() {
  545. super.render();
  546.  
  547. this.classList.add(CLASSNAME);
  548.  
  549. // adds the role to support accessibility
  550. if (this.items.length > 0) {
  551. this.setAttribute('role', 'grid');
  552. } else {
  553. this.removeAttribute('role');
  554. };
  555.  
  556. this.setAttribute('aria-live', 'off');
  557. this.setAttribute('aria-atomic', 'false');
  558. this.setAttribute('aria-relevant', 'additions');
  559.  
  560. // Since tagList can have multiple values, we have to store them all to be able to reset them
  561. if (this.hasAttribute('value')) {
  562. this._initialValues = [this.getAttribute('value')];
  563. } else {
  564. this._initialValues = this.items.getAll().map((item) => itemValueFromDOM(item));
  565. }
  566.  
  567. // Prepare items
  568. this.items.getAll().forEach((item) => {
  569. this._prepareItem(item);
  570. });
  571. }
  572. });
  573.  
  574. export default TagList;