@anypoint-web-components/anypoint-selector
Version:
Port of iron-selector that works with ES6 classes. Manages a list of elements that can be selected.
954 lines (861 loc) • 26.4 kB
JavaScript
import { dedupeMixin } from '@open-wc/dedupe-mixin';
import { AnypointSelection } from './AnypointSelection.js';
/* eslint-disable no-plusplus */
/**
* The set of excluded elements where the key is the `localName`
* of the element that will be ignored from the item list.
*/
const excludedLocalNames = {
template: 1,
'dom-bind': 1,
'dom-if': 1,
'dom-repeat': 1,
};
const p = Element.prototype;
const normalizedMatchesSelector =
p.matches ||
// @ts-ignore
p.matchesSelector ||
// @ts-ignore
p.mozMatchesSelector ||
// @ts-ignore
p.msMatchesSelector ||
// @ts-ignore
p.oMatchesSelector ||
p.webkitMatchesSelector;
/**
* Cross-platform `element.matches` shim.
*
* @function matchesSelector
* @param {!Node} node Node to check selector against
* @param {string} selector Selector to match
* @return {boolean} True if node matched selector
*/
export const matchesSelector = (node, selector) => normalizedMatchesSelector.call(node, selector);
const filterItem = (node) => !excludedLocalNames[node.localName];
const toggleClass = (klass, selected, node) => {
if (selected) {
node.classList.add(klass);
} else {
node.classList.remove(klass);
}
};
/**
* @param {typeof HTMLElement} base
*/
const mxFunction = (base) => {
class SelectableMixinImpl extends base {
/**
* Fired when anypoint-selector is activated (selected or deselected).
* It is fired before the selected items are changed.
* Cancel the event to abort selection.
*
* @event activate
*/
/**
* Fired when an item is selected
*
* @event select
*/
/**
* Fired when an item is deselected
*
* @event deselect
*/
/**
* Fired when the list of selectable items changes (e.g., items are
* added or removed). The detail of the event is a mutation record that
* describes what changed.
*
* @event children-changed
*/
/**
* Fired when anypoint-selector is activated (selected or deselected).
* It is fired before the selected items are changed.
* Cancel the event to abort selection.
*
* @event iron-activate
* @deprecated Use `activate`instead. It is for compatibility with Polymer elements.
*/
/**
* Fired when an item is selected
*
* @event iron-select
* @deprecated Use `select. It is for compatibility with Polymer elements.
*/
/**
* Fired when an item is deselected
*
* @event iron-deselect
* @deprecated Use `deselect`instead. It is for compatibility with Polymer elements.
*/
/**
* Fired when the list of selectable items changes (e.g., items are
* added or removed). The detail of the event is a mutation record that
* describes what changed.
*
* @event iron-items-changed
* @deprecated Use `children-changed`instead. It is for compatibility with Polymer elements.
*/
static get properties() {
return {
/**
* If you want to use an attribute value or property of an element for
* `selected` instead of the index, set this to the name of the attribute
* or property. Hyphenated values are converted to camel case when used to
* look up the property of a selectable element. Camel cased values are
* *not* converted to hyphenated values for attribute lookup. It's
* recommended that you provide the hyphenated form of the name so that
* selection works in both cases. (Use `attr-or-property-name` instead of
* `attrOrPropertyName`.)
*/
attrForSelected: { type: String },
/**
* Gets or sets the selected element. The default is to use the index of the
* item.
*/
selected: {},
/**
* Returns the currently selected item.
*/
_selectedItem: { type: Object },
/**
* The event that fires from items when they are selected. Selectable
* will listen for this event from items and update the selection state.
* Set to empty string to listen to no events.
*
* @default click
*/
activateEvent: { type: String },
/**
* This is a CSS selector string. If this is set, only items that match the
* CSS selector are selectable.
*/
selectable: { type: String },
/**
* The class to set on elements when selected.
*
* @default selected
*/
selectedClass: { type: String },
/**
* The attribute to set on elements when selected.
*/
selectedAttribute: { type: String },
/**
* Default fallback if the selection based on selected with `attrForSelected` is not found.
*/
fallbackSelection: {},
/**
* The list of items from which a selection can be made.
*/
items: { type: Array },
};
}
get attrForSelected() {
return this._attrForSelected;
}
set attrForSelected(value) {
const old = this._attrForSelected;
/* istanbul ignore if */
if (old === value) {
return;
}
this._attrForSelected = value;
this._updateAttrForSelected();
}
get selected() {
return this._selected;
}
set selected(value) {
const old = this._selected;
/* istanbul ignore if */
if (old === value) {
const detail = { bubbles: true, composed: true };
this.dispatchEvent(new CustomEvent('closed', detail));
return;
}
this._selected = value;
/* istanbul ignore else */
// @ts-ignore
if (this.requestUpdate) {
// @ts-ignore
this.requestUpdate('selected', value);
}
this._updateSelected();
this.dispatchEvent(
new CustomEvent('selected-changed', {
detail: {
value,
},
})
);
// new events API. Keep the above for compatibility
this.dispatchEvent(new CustomEvent('selectedchange'));
}
get items() {
return this._items;
}
get _items() {
return this.__items;
}
set _items(value) {
const old = this.__items;
/* istanbul ignore if */
if (old === value) {
return;
}
this.__items = value;
/* istanbul ignore else */
// @ts-ignore
if (this.requestUpdate) {
// @ts-ignore
this.requestUpdate('_items', value);
}
this.dispatchEvent(
new CustomEvent('items-changed', {
detail: {
value,
},
})
);
// new events API. Keep the above for compatibility
this.dispatchEvent(new CustomEvent('itemschange'));
}
get selectedItem() {
return this._selectedItem;
}
get _selectedItem() {
return this.__selectedItem;
}
set _selectedItem(value) {
const old = this.__selectedItem;
/* istanbul ignore if */
if (old === value) {
return;
}
this.__selectedItem = value;
/* istanbul ignore else */
// @ts-ignore
if (this.requestUpdate) {
// @ts-ignore
this.requestUpdate('_selectedItem', value);
}
this.dispatchEvent(
new CustomEvent('selecteditem-changed', {
detail: {
value,
},
})
);
// new events API. Keep the above for compatibility
this.dispatchEvent(new CustomEvent('selecteditemchange'));
}
get activateEvent() {
return this._activateEvent;
}
set activateEvent(value) {
const old = this._activateEvent;
/* istanbul ignore if */
if (old === value) {
return;
}
this._activateEvent = value;
this._activateEventChanged(value, old);
}
get fallbackSelection() {
return this._fallbackSelection;
}
set fallbackSelection(value) {
const old = this._fallbackSelection;
/* istanbul ignore if */
if (old === value) {
return;
}
this._fallbackSelection = value;
this._checkFallback();
}
/**
* @return {EventListener} Previously registered handler for `selected-changed` event
*/
get onselectedchanged() {
return this['_onselected-changed'];
}
/**
* Registers a callback function for `selected-changed` event
* @param {EventListener} value A callback to register. Pass `null` or `undefined`
* to clear the listener.
*/
set onselectedchanged(value) {
this._registerCallback('selected-changed', value);
}
/**
* @return {EventListener} Previously registered handler for `selected-changed` event
*/
get onselectedchange() {
return this._onselectedchange;
}
/**
* Registers a callback function for `selectedchange` event
* @param {EventListener} value A callback to register. Pass `null` or `undefined`
* to clear the listener.
*/
set onselectedchange(value) {
this._registerCallback('selectedchange', value);
}
/**
* @return {EventListener} Previously registered handler for `selected-changed` event
*/
get onselected() {
return this._onselected;
}
/**
* Registers a callback function for `selected` event.
*
* This event is dispatched only through user interaction (the activateEvent). Dispatched after the `select` event.
*
* @param {EventListener} value A callback to register. Pass `null` or `undefined` to clear the listener.
*/
set onselected(value) {
this._registerCallback('selected', value);
}
/**
* @return {EventListener} Previously registered handler for `selectedchange` event
*/
get onselecteditemchanged() {
return this['_onselecteditem-changed'];
}
/**
* Registers a callback function for `selecteditem-changed` event
* @param {EventListener} value A callback to register. Pass `null` or `undefined`
* to clear the listener.
*/
set onselecteditemchanged(value) {
this._registerCallback('selecteditem-changed', value);
}
/**
* @return {EventListener} Previously registered handler for `selecteditemchange` event
*/
get onselecteditemchange() {
return this._onselecteditemchange;
}
/**
* Registers a callback function for `selecteditemchange` event
* @param {EventListener} value A callback to register. Pass `null` or `undefined`
* to clear the listener.
*/
set onselecteditemchange(value) {
this._registerCallback('selecteditemchange', value);
}
/**
* @return {EventListener} Previously registered handler for `items-changed` event
*/
get onitemschanged() {
return this['_onitems-changed'];
}
/**
* Registers a callback function for `items-changed` event
* @param {EventListener} value A callback to register. Pass `null` or `undefined`
* to clear the listener.
*/
set onitemschanged(value) {
this._registerCallback('items-changed', value);
}
/**
* @return {EventListener} Previously registered handler for `itemschange` event
*/
get onitemschange() {
return this._onitemschange;
}
/**
* Registers a callback function for `itemschange` event
* @param {EventListener} value A callback to register. Pass `null` or `undefined`
* to clear the listener.
*/
set onitemschange(value) {
this._registerCallback('itemschange', value);
}
/**
* @return {EventListener} Previously registered handler for `select` event
*/
get onselect() {
// @ts-ignore
return this._onselect;
}
/**
* Registers a callback function for `select` event
* @param {EventListener} value A callback to register. Pass `null` or `undefined`
* to clear the listener.
*/
set onselect(value) {
this._registerCallback('select', value);
}
/**
* @return {EventListener} Previously registered handler for `deselect` event
*/
get ondeselect() {
// @ts-ignore
return this._ondeselect;
}
/**
* Registers a callback function for `deselect` event
* @param {EventListener} value A callback to register. Pass `null` or `undefined`
* to clear the listener.
*/
set ondeselect(value) {
this._registerCallback('deselect', value);
}
/**
* @return {EventListener} Previously registered handler for `activate` event
*/
get onactivate() {
// @ts-ignore
return this._onactivate;
}
/**
* Registers a callback function for `activate` event
* @param {EventListener} value A callback to register. Pass `null` or `undefined`
* to clear the listener.
*/
set onactivate(value) {
this._registerCallback('activate', value);
}
constructor() {
super();
this.attrForSelected = null;
this.fallbackSelection = null;
this.selectedAttribute = null;
this.selectable = null;
this.selectedClass = 'selected';
this.activateEvent = 'click';
this._items = [];
this._activateHandler = this._activateHandler.bind(this);
this._selection = new AnypointSelection(this._applySelection.bind(this));
this._mutationHandler = this._mutationHandler.bind(this);
this._slotchangeHandler = this._slotchangeHandler.bind(this);
this._onselectedchange = null;
this._onselecteditemchange = null;
this._onitemschange = null;
this._onselected = null;
}
connectedCallback() {
/* istanbul ignore else */
// @ts-ignore
if (super.connectedCallback) {
// @ts-ignore
super.connectedCallback();
}
this._observer = this._observeItems();
this._observeSlotItems();
this._updateItems();
this._updateSelected();
}
disconnectedCallback() {
/* istanbul ignore else */
// @ts-ignore
if (super.disconnectedCallback) {
// @ts-ignore
super.disconnectedCallback();
}
if (this._observer) {
this._observer.disconnect();
this._observer = null;
}
this._removeListener(this.activateEvent);
this._unobserveSlotItems();
}
/**
* Registers an event handler for given type
* @param {String} eventType Event type (name)
* @param {EventListener} value The handler to register
*/
_registerCallback(eventType, value) {
const key = `_on${eventType}`;
if (this[key]) {
this.removeEventListener(eventType, this[key]);
}
if (typeof value !== 'function') {
this[key] = null;
return;
}
this[key] = value;
this.addEventListener(eventType, value);
}
_addListener(eventName) {
this.addEventListener(eventName, this._activateHandler);
}
_removeListener(eventName) {
this.removeEventListener(eventName, this._activateHandler);
}
/**
* Observe items change in the element's light DOM
* @return {MutationObserver} The observer handler
*/
_observeItems() {
const config = { attributes: true, childList: true, subtree: false };
const observer = new MutationObserver(this._mutationHandler);
observer.observe(this, config);
return observer;
}
/**
* Observers changes in slot children where slot is in the light DOM.
*/
_observeSlotItems() {
const nodes = this.querySelectorAll('slot');
for (let i = 0, len = nodes.length; i < len; i++) {
nodes[i].addEventListener('slotchange', this._slotchangeHandler);
}
}
/**
* Removes change observers from slot children where slot is in the light DOM.
*/
_unobserveSlotItems() {
const nodes = this.querySelectorAll('slot');
for (let i = 0, len = nodes.length; i < len; i++) {
nodes[i].removeEventListener('slotchange', this._slotchangeHandler);
}
}
/**
* When light DOM mutate this method is called to remove listener from
* removed `<slot>` children.
* @param {NodeList} nodeList List of removed children.
*/
_checkRemovedSlot(nodeList) {
for (let i = 0, len = nodeList.length; i < len; i++) {
if (/** @type HTMLElement */ (nodeList[i]).localName === 'slot') {
nodeList[i].removeEventListener(
'slotchange',
this._slotchangeHandler
);
}
}
}
/**
* Handler for the `slotchange` event dispatched on slot.
* Updates items and selection.
*/
_slotchangeHandler() {
this._updateItems();
this._updateSelected();
}
/**
* Callback for a mutation event dispatched by the MutationObserver.
* @param {MutationRecord[]} mutationsList List of mutations.
*/
_mutationHandler(mutationsList) {
this._updateItems();
this._updateSelected();
for (const mutation of mutationsList) {
if (mutation.type === 'childList') {
this._checkRemovedSlot(mutation.removedNodes);
}
}
// Let other interested parties know about the change so that
// we don't have to recreate mutation observers everywhere.
const config = {
bubbles: true,
composed: true,
detail: mutationsList,
};
this.dispatchEvent(new CustomEvent('children-changed', config));
this.dispatchEvent(new CustomEvent('iron-items-changed', config));
}
/**
* Returns the index of the given item.
*
* @method indexOf
* @param {*} item
* @return {number} Returns the index of the item
*/
indexOf(item) {
return this.items ? this.items.indexOf(item) : -1;
}
/**
* Selects the given value.
*
* @method select
* @param {string|number} value the value to select.
*/
select(value) {
this.selected = value;
}
/**
* Selects the previous item.
*
* @method selectPrevious
*/
selectPrevious() {
const { length } = this.items;
let index = length - 1;
if (this.selected !== undefined) {
index =
(Number(this._valueToIndex(this.selected)) - 1 + length) % length;
}
this.selected = this._indexToValue(index);
}
/**
* Selects the next item.
*
* @method selectNext
*/
selectNext() {
let index = 0;
if (this.selected !== undefined) {
index =
(Number(this._valueToIndex(this.selected)) + 1) % this.items.length;
}
this.selected = this._indexToValue(index);
}
/**
* Selects the item at the given index.
*
* @method selectIndex
* @param {Number} index
*/
selectIndex(index) {
this.select(this._indexToValue(index));
}
_checkFallback() {
this._updateSelected();
}
/**
* @param {string} eventName [description]
* @param {string} old
*/
_activateEventChanged(eventName, old) {
this._removeListener(old);
this._addListener(eventName);
}
_updateItems() {
let nodes = this._queryDistributedElements(this.selectable || '*');
nodes = nodes.filter(filterItem);
this._items = /** @type HTMLElement[] */ (nodes);
}
/**
* @param {string} selector
* @return {HTMLElement[]}
*/
_queryDistributedElements(selector) {
const nodes = Array.from(this.children);
// checks for slots and replaces a slot with it's nodes.
for (let i = 0; i < nodes.length; i++) {
const node = nodes[i];
if (node.localName === 'slot') {
const tmp = /** @type HTMLSlotElement */ (node).assignedElements({
flatten: true,
});
nodes.splice(i, 1, ...tmp);
}
}
for (let i = nodes.length - 1; i >= 0; i--) {
const node = nodes[i];
if (
node.nodeType !== Node.ELEMENT_NODE ||
!matchesSelector(node, selector)
) {
nodes.splice(i, 1);
}
}
return /** @type HTMLElement[] */ (nodes);
}
_updateAttrForSelected() {
if (this.selectedItem) {
this.selected = this._valueForItem(this.selectedItem);
}
}
_updateSelected() {
this._selectSelected(this.selected);
}
/**
* Applies selection to the `selected` item
* @param {string|number} selected Currently selected value
*/
_selectSelected(selected) {
if (!this.items) {
return;
}
const item = this._valueToItem(selected);
if (item) {
this._selection.select(item);
} else {
this._selection.clear();
}
// Check for items, since this array is populated only when attached
// Since Number(0) is falsy, explicitly check for undefined
if (
this.fallbackSelection &&
this.items.length &&
this._selection.get() === undefined
) {
this.selected = this.fallbackSelection;
}
}
/**
* Searches for an item that corresponds to given `value`
* @param {string|number} value
* @return {HTMLElement} An item for given `value`
*/
_valueToItem(value) {
return value === null ? null : this.items[this._valueToIndex(value)];
}
/**
* Searches for an index that corresponds to given `value`
* @param {string|number} value
* @return {number} An index of the `value`
*/
_valueToIndex(value) {
if (this.attrForSelected) {
for (let i = 0, len = this.items.length; i < len; i++) {
const item = this.items[i];
if (this._valueForItem(item) === value) {
return i;
}
}
}
return Number(value);
}
/**
* Reads a value for an item in the `items` array for given index.
* When `attrForSelected` is set then it returns attribute value for the item. If not
* it returns the same index.
*
* @param {number} index Index of an item in the `items` array
* @return {string|number|null} A value for given index
*/
_indexToValue(index) {
if (this.attrForSelected) {
const item = this.items[index];
if (item) {
return this._valueForItem(item);
}
}
return index;
}
/**
* Reads a value of an item depending on whether `attrForSelected` is set ot not.
* When it's set then it returns attribute value for the item. If not
* it returns the index of the item in the `items` array.
*
* @param {HTMLElement} item An item to get a value from.
* @return {string|number|null} A value for the passed item
*/
_valueForItem(item) {
if (!item) {
return null;
}
if (!this.attrForSelected) {
const i = this.indexOf(item);
return i === -1 ? null : i;
}
const dash = this.attrForSelected;
const prop =
dash.indexOf('-') < 0
? dash
: dash.replace(/-[a-z]/g, (m) => m[1].toUpperCase());
const propValue = item[prop];
return propValue !== undefined
? propValue
: item.getAttribute(this.attrForSelected);
}
/**
* Applies selection state to an item. It updates class names
* and selection attribute.
* It also propagates selection change listeners.
*
* @param {HTMLElement} item The item to apply the state to
* @param {Boolean} isSelected Whether the item is currently selected.
*/
_applySelection(item, isSelected) {
const { selectedClass, selectedAttribute } = this;
if (selectedClass) {
toggleClass(selectedClass, isSelected, item);
}
if (selectedAttribute) {
if (isSelected) {
item.setAttribute(selectedAttribute, '');
} else {
item.removeAttribute(selectedAttribute);
}
}
this._selectionChange();
const opts = {
bubbles: true,
composed: true,
detail: {
item,
},
};
const name = isSelected ? 'select' : 'deselect';
this.dispatchEvent(new CustomEvent(name, opts));
this.dispatchEvent(new CustomEvent(`iron-${name}`, opts));
}
/**
* Applies current selection to the selected item.
* Do not remove this function. It is used in multi selectable to
* update multi selection.
*/
_selectionChange() {
this._selectedItem = this._selection.get();
}
/**
* A handler for the event which `type` is described as
* `activateEvent`. By default it is a `ClickEvent`.
*
* @param {Event} e
*/
_activateHandler(e) {
let t = /** @type HTMLElement */ (e.target);
const { items } = this;
while (t && t !== this) {
const i = items.indexOf(t);
if (i >= 0) {
const value = this._indexToValue(i);
this._itemActivate(value, t);
return;
}
t = /** @type HTMLElement */ (t.parentNode);
}
}
/**
* Dispatches `activate` item and selects the item.
*
* @param {number|string|null} value Selected value
* @param {HTMLElement} item The selected item.
*/
_itemActivate(value, item) {
const opts = {
cancelable: true,
bubbles: true,
composed: true,
detail: {
selected: value,
item,
},
};
let e = new CustomEvent('activate', opts);
this.dispatchEvent(e);
if (e.defaultPrevented) {
return;
}
// compatibility with polymer based components
e = new CustomEvent('iron-activate', opts);
this.dispatchEvent(e);
if (e.defaultPrevented) {
return;
}
this.select(value);
this.dispatchEvent(new CustomEvent('selected'));
}
}
return SelectableMixinImpl;
};
/**
* Port of `@polymer/iron-selector/iron-selectable.js`.
*
* A mixin to be applied to a class where child elements can be selected.
*
* Note, by default the mixin works with LitElement. If used with different class
* make sure that attributes are reflected to properties correctly.
* @mixin
*/
export const SelectableMixin = dedupeMixin(mxFunction);