coral-spectrum/coral-component-taglist/src/scripts/TagList.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 {BaseComponent} from '../../../coral-base-component';
- import {BaseFormField} from '../../../coral-base-formfield';
- import Tag from './Tag';
- import {Collection} from '../../../coral-collection';
- import {transform, commons} from '../../../coral-utils';
- import {Decorator} from '../../../coral-decorator';
-
- const CLASSNAME = '_coral-Tags';
- // Collection
- const ITEM_TAGNAME = 'coral-tag';
-
- /**
- Extracts the value from the item in case no explicit value was provided.
- @param {HTMLElement} item
- the item whose value will be extracted.
- @returns {String} the value that will be submitted for this item.
- @private
- */
- const itemValueFromDOM = function (item) {
- const attr = item.getAttribute('value');
- // checking explicitly for null allows to differentiate between non set values and empty strings
- return attr !== null ? attr : item.textContent.replace(/\s{2,}/g, ' ').trim();
- };
-
- /**
- @class Coral.TagList
- @classdesc A TagList component is a form field container to manipulate tags.
- @htmltag coral-taglist
- @extends {HTMLElement}
- @extends {BaseComponent}
- @extends {BaseFormField}
- */
- const TagList = Decorator(class extends BaseFormField(BaseComponent(HTMLElement)) {
- /** @ignore */
- constructor() {
- super();
-
- // Attach events
- this._delegateEvents(commons.extend(this._events, {
- 'capture:focus coral-tag': '_onItemFocus',
- 'capture:blur coral-tag': '_onItemBlur',
- 'key:right coral-tag': '_onNextItemFocus',
- 'key:down coral-tag': '_onNextItemFocus',
- 'key:pagedown coral-tag': '_onNextItemFocus',
- 'key:left coral-tag': '_onPreviousItemFocus',
- 'key:up coral-tag': '_onPreviousItemFocus',
- 'key:pageup coral-tag': '_onPreviousItemFocus',
- 'key:home coral-tag': '_onFirstItemFocus',
- 'key:end coral-tag': '_onLastItemFocus',
-
- // Accessibility
- 'capture:focus coral-tag:not(.is-disabled)': '_onItemFocusIn',
- 'capture:blur coral-tag:not(.is-disabled)': '_onItemFocusOut',
-
- // Private
- 'coral-tag:_valuechanged': '_onTagValueChanged',
- 'coral-tag:_connected': '_onTagConnected'
- }));
-
- // Pre-define labellable element
- this._labellableElement = this;
-
- this._itemToFocusAfterDelete = null;
- }
-
- /**
- Changing the values will redefine the component's items.
-
- @type {Array.<String>}
- @emits {change}
- */
- get values() {
- return this.items.getAll().map((item) => item.value);
- }
-
- set values(values) {
- if (Array.isArray(values)) {
- this.items.clear();
-
- values.forEach((value) => {
- const item = new Tag().set({
- label: {
- innerHTML: value
- },
- value: value
- });
-
- this._attachInputToItem(item);
-
- this.items.add(item);
- });
- }
- }
-
- /**
- The Collection Interface that allows interacting with the items that the component contains.
-
- @type {Collection}
- @readonly
- */
- get items() {
- // just init on demand
- if (!this._items) {
- this._items = new Collection({
- host: this,
- itemTagName: ITEM_TAGNAME
- });
- }
- return this._items;
- }
-
- /**
- Name used to submit the data in a form.
- @type {String}
- @default ""
- @htmlattribute name
- @htmlattributereflected
- */
- get name() {
- return this._name || '';
- }
-
- set name(value) {
- this._name = transform.string(value);
- this._reflectAttribute('name', value);
-
- this.items.getAll().forEach((item) => {
- if (item._input) {
- item._input.name = this._name;
- }
- });
- }
-
- /**
- This field's current value.
- @type {String}
- @default ""
- @htmlattribute value
- */
- get value() {
- const all = this.items.getAll();
- return all.length ? all[0].value : '';
- }
-
- set value(value) {
- this.items.clear();
-
- if (value) {
- const item = new Tag().set({
- label: {
- innerHTML: value
- },
- value: value
- });
-
- this._attachInputToItem(item);
-
- this.items.add(item);
- }
- }
-
- /**
- Whether this field is disabled or not.
- @type {Boolean}
- @default false
- @htmlattribute disabled
- @htmlattributereflected
- */
- get disabled() {
- return this._disabled || false;
- }
-
- set disabled(value) {
- this._disabled = transform.booleanAttr(value);
- this._reflectAttribute('disabled', this._disabled);
-
- this.items.getAll().forEach((item) => {
- item.classList.toggle('is-disabled', this._disabled);
- if (item._input) {
- item._input.disabled = this._disabled;
- }
- });
-
- // a11y
- this[this._disabled ? 'setAttribute' : 'removeAttribute']('aria-disabled', this._disabled);
- }
-
- // JSDoc inherited
- get invalid() {
- return super.invalid;
- }
-
- set invalid(value) {
- super.invalid = value;
-
- this.items.getAll().forEach((item) => {
- item.classList.toggle('is-invalid', this._invalid);
- });
- }
-
- /**
- Whether this field is readOnly or not. Indicating that the user cannot modify the value of the control.
- @type {Boolean}
- @default false
- @htmlattribute readonly
- @htmlattributereflected
- */
- get readOnly() {
- return this._readOnly || false;
- }
-
- set readOnly(value) {
- this._readOnly = transform.booleanAttr(value);
- this._reflectAttribute('readonly', this._readOnly);
-
- this.items.getAll().forEach((item) => {
- item.closable = !this._readOnly;
- });
- }
-
- /**
- Whether this field is required or not.
- @type {Boolean}
- @default false
- @htmlattribute required
- @htmlattributereflected
- */
- get required() {
- return this._required || false;
- }
-
- set required(value) {
- this._required = transform.booleanAttr(value);
- this._reflectAttribute('required', this._required);
- }
-
- /** @private */
- _attachInputToItem(item) {
- if (!item._input) {
- item._input = document.createElement('input');
- item._input.type = 'hidden';
- // We do this so it is recognized by Coral.Tag and handled if cloned
- item._input.setAttribute('handle', 'input');
- }
-
- const input = item._input;
-
- input.disabled = this.disabled;
- input.name = this.name;
- input.value = item.value;
-
- if (!item.contains(input)) {
- item.appendChild(input);
- }
- }
-
- /** @private */
- _prepareItem(attachedItem) {
- const items = this.items.getAll();
-
- // Prevents to add duplicates based on the tag value
- const duplicate = items.some((tag) => {
- if (itemValueFromDOM(tag) === itemValueFromDOM(attachedItem) && tag !== attachedItem) {
- (items.indexOf(tag) < items.indexOf(attachedItem) ? attachedItem : tag).remove();
- return true;
- }
-
- return false;
- });
-
- if (duplicate) {
- return;
- }
-
- // create corresponding input field
- this._attachInputToItem(attachedItem);
-
- // Set tag defaults
- attachedItem.setAttribute('color', Tag.color.DEFAULT);
- attachedItem.setAttribute('size', Tag.size.SMALL);
-
- // adds the role to support accessibility
- attachedItem.setAttribute('role', 'row');
-
- // adds role to parent to support accessibility, if it doesn't already have it
- if (this.getAttribute('role') === null) {
- this.setAttribute('role', 'grid');
- }
-
- if (!this.disabled) {
- attachedItem.setAttribute('tabindex', '-1');
- }
- attachedItem[this.readOnly ? 'removeAttribute' : 'setAttribute']('closable', '');
-
- // add tabindex to first item if none existing
- if (!this.disabled && !this.querySelector(`${ITEM_TAGNAME}[tabindex="0"]`)) {
- const first = items[0];
- if (first) {
- first.setAttribute('tabindex', '0');
- }
- }
-
- // Keep a reference on the host in case the tag gets removed
- attachedItem._host = this;
-
- // triggers the Coral.Collection event
- this.trigger('coral-collection:add', {
- item: attachedItem
- });
- }
-
- /** @private */
- _onItemDisconnected(detachedItem) {
- // Cleans the tag from TagList specific values
- detachedItem.removeAttribute('role');
-
- // Removes role from taglist if it has no tag elements
- if (this.items.length <= 0) {
- this.removeAttribute('role');
- }
-
- detachedItem.removeAttribute('tabindex');
- detachedItem._host = undefined;
-
- const parentElement = this.parentElement;
- if (this.items.length === 0 && parentElement) {
- // If all tags are removed, call focus method on parent element
- if (typeof parentElement.focus === 'function') {
- parentElement.focus();
- }
-
- const self = this;
-
- commons.nextFrame(() => {
- // if the parentElement did not receive focus or move focus to some other element
- if (document.activeElement.tagName === 'BODY') {
- if (this.items.length > 0) {
- self.items.first().focus();
- } else {
- // make the TagList focusable
- self.tabIndex = -1;
- self.classList.add('u-coral-screenReaderOnly');
- self.style.outline = '0';
- self.innerHTML = ' ';
- const onBlurFocusManagement = function () {
- self.removeAttribute('tabindex');
- self.classList.remove('u-coral-screenReaderOnly');
- self.style.outline = '';
- self.innerHTML = '';
- self._vent.off('blur.focusManagement');
- };
- self._vent.on('blur.focusManagement', null, onBlurFocusManagement);
- if (!parentElement.contains(document.activeElement)) {
- self.focus();
- } else {
- onBlurFocusManagement();
- }
- }
- }
- });
- } else if (this._itemToFocusAfterDelete) {
- this._itemToFocusAfterDelete.focus();
- }
-
- // triggers the Coral.Collection event
- this.trigger('coral-collection:remove', {
- item: detachedItem
- });
- }
-
- /** @private */
- _onItemFocus(event) {
- if (!this.disabled) {
- this.setAttribute('aria-live', 'polite');
-
- const tag = event.matchedTarget;
-
- // add tabindex to first item and remove from previous focused item
- this.items.getAll().forEach((item) => {
- if (item !== tag) {
- item.setAttribute('tabindex', '-1');
- }
- });
- tag.setAttribute('tabindex', '0');
-
- this._setItemToFocusOnDelete(tag);
- }
- }
-
- /** @private */
- _onItemBlur(event) {
- if (!this.disabled) {
- this.setAttribute('aria-live', 'off');
-
- const tag = event.matchedTarget;
-
- this._setItemToFocusOnDelete(tag);
- }
- }
-
- /** @private */
- _onSiblingItemFocus(event, sibling) {
- if (!this.disabled) {
- event.preventDefault();
-
- let item = event.target[sibling];
- while (item) {
- if (item.tagName.toLowerCase() === ITEM_TAGNAME && !item.hidden) {
- item.focus();
- break;
- } else {
- item = item[sibling];
- }
- }
- }
- }
-
- /** @private */
- _onNextItemFocus(event) {
- this._onSiblingItemFocus(event, 'nextElementSibling');
- }
-
- /** @private */
- _onPreviousItemFocus(event) {
- this._onSiblingItemFocus(event, 'previousElementSibling');
- }
-
- /** @private */
- _onFirstItemFocus(event) {
- event.preventDefault();
- const length = this.items.length;
- if (length > 0) {
- this.items.getAll()[0].focus();
- }
- }
-
- /** @private */
- _onLastItemFocus(event) {
- event.preventDefault();
- const length = this.items.length;
- if (length > 0) {
- this.items.getAll()[length - 1].focus();
- }
- }
-
- _onItemFocusIn(event) {
- event.matchedTarget.classList.add('focus-ring');
- }
-
- _onItemFocusOut(event) {
- event.matchedTarget.classList.remove('focus-ring');
- }
-
- /** @private */
- _onTagButtonClicked(item, event) {
- this.trigger('change');
-
- this._trackEvent('remove', 'coral-tag', event, item);
- }
-
- /** @private */
- _onTagValueChanged(event) {
- event.stopImmediatePropagation();
-
- const tag = event.target;
- if (tag._input) {
- tag._input.value = tag.value;
- }
- }
-
- /** @private */
- _setItemToFocusOnDelete(tag) {
- let itemToFocusAfterDelete = tag.nextElementSibling;
-
- // Next item will be focusable if the focused tag is removed
- while (itemToFocusAfterDelete) {
- if (itemToFocusAfterDelete.tagName.toLowerCase() === ITEM_TAGNAME && !itemToFocusAfterDelete.hidden) {
- this._itemToFocusAfterDelete = itemToFocusAfterDelete;
- return;
- }
-
- itemToFocusAfterDelete = itemToFocusAfterDelete.nextElementSibling;
- }
-
- itemToFocusAfterDelete = tag.previousElementSibling;
- // Previous item will be focusable if the focused tag is removed
- while (itemToFocusAfterDelete) {
- if (itemToFocusAfterDelete.tagName.toLowerCase() === ITEM_TAGNAME && !itemToFocusAfterDelete.hidden) {
- this._itemToFocusAfterDelete = itemToFocusAfterDelete;
- break;
- } else {
- itemToFocusAfterDelete = itemToFocusAfterDelete.previousElementSibling;
- }
- }
-
- window.requestAnimationFrame(() => {
- if (tag.parentElement !== null && !this.contains(document.activeElement)) {
- itemToFocusAfterDelete = undefined;
- }
- });
- }
-
- /** @private */
- _onTagConnected(event) {
- event.stopImmediatePropagation();
-
- const item = event.target;
- this._prepareItem(item);
- }
-
- /**
- Inherited from {@link BaseFormField#reset}.
- */
- reset() {
- // reset the values to the initial values
- this.values = this._initialValues;
- }
-
- /** @ignore */
- static get observedAttributes() {
- return super.observedAttributes.concat([
- 'closable',
- 'value',
- 'quiet',
- 'multiline',
- 'size',
- 'color'
- ]);
- }
-
- /** @ignore */
- render() {
- super.render();
-
- this.classList.add(CLASSNAME);
-
- // adds the role to support accessibility
- if (this.items.length > 0) {
- this.setAttribute('role', 'grid');
- } else {
- this.removeAttribute('role');
- };
-
- this.setAttribute('aria-live', 'off');
- this.setAttribute('aria-atomic', 'false');
- this.setAttribute('aria-relevant', 'additions');
-
- // Since tagList can have multiple values, we have to store them all to be able to reset them
- if (this.hasAttribute('value')) {
- this._initialValues = [this.getAttribute('value')];
- } else {
- this._initialValues = this.items.getAll().map((item) => itemValueFromDOM(item));
- }
-
- // Prepare items
- this.items.getAll().forEach((item) => {
- this._prepareItem(item);
- });
- }
- });
-
- export default TagList;