Files
tp-tag-input/tp-tag-input.js
2025-11-08 18:49:13 +01:00

394 lines
9.3 KiB
JavaScript

/**
@license
Copyright (c) 2024 trading_peter
This program is available under Apache License Version 2.0
*/
import '@tp/tp-input/tp-input.js';
import '@tp/tp-icon/tp-icon.js';
import './tp-tag-input-popup.js';
import { LitElement, html, css, svg } from 'lit';
import { FormElement } from '@tp/helpers/form-element.js';
import { EventHelpers } from '@tp/helpers/event-helpers.js';
import { DomQuery } from '@tp/helpers/dom-query.js';
import { reach } from '@tp/helpers/reach.js';
import { debounce } from '@tp/helpers/debounce.js';
import { clone } from '@tp/helpers/clone.js';
import { closest } from '@tp/helpers/closest.js';
const mixins = [
FormElement,
EventHelpers,
DomQuery
];
/* @litElement */
const BaseElement = mixins.reduce((baseClass, mixin) => {
return mixin(baseClass);
}, LitElement);
class TpTagInput extends BaseElement {
static get styles() {
return [
css`
:host {
display: block;
border-radius: 2px;
border: solid 1px #BDBDBD;
}
:host([invalid]) {
border: solid 1px #B71C1C;
}
[hidden] {
display: none;
}
.wrap {
display: flex;
flex-direction: row;
flex-wrap: wrap;
}
tp-input {
min-height: 15px;
outline: none;
margin: 0;
padding: 0;
display: flex;
flex: 1;
}
tp-input::part(wrap) {
border: none;
flex: 1;
}
:host(.has-items) tp-input {
margin-top: 10px;
}
.item {
display: inline-block;
margin: 5px;
border-radius: 2px;
border: solid 1px #9E9E9E;
background: #FFFFFF;
font-size: 14px;
padding: 3px 5px 3px 3px;
margin-right: 5px;
display: flex;
flex-direction: row;
align-items: center;
}
.item tp-icon {
--tp-icon-width: 12px;
--tp-icon-height: 12px;
margin-right: 2px;
}
`
];
}
static get icon() {
return svg`<path fill="var(--tp-icon-color)" d="M20 6.91L17.09 4L12 9.09L6.91 4L4 6.91L9.09 12L4 17.09L6.91 20L12 14.91L17.09 20L20 17.09L14.91 12L20 6.91Z" />`;
}
render() {
const value = this.value || [];
return html`
<div class="wrap" part="wrap" @click=${this._onClick}>
${value.map(item => html`
<div class="item" .item=${item}>
<tp-icon class="remove-icon" .icon=${TpTagInput.icon}></tp-icon>
${item.label}
</div>
`)}
<tp-input id="input" .validator=${this.validator} @keydown=${e => this._inputChanged(e)} required>
<input type="text" id="innerInput" .pattern=${this.pattern} autocomplete="off">
</tp-input>
</div>
<tp-tag-input-popup id="autoCpl" .items=${this.items} @selection-changed=${e => this._itemSelected(e.detail)}></tp-tag-input-popup>
`;
}
static get properties() {
return {
value: { type: Array },
items: { type: Array },
validator: { type: Object },
allowUnknown: { type: Boolean },
allowDuplicates: { type: Boolean },
tooltipRemove: { type: String },
min: { type: Number },
max: { type: Number },
invalid: { type: Boolean },
filter: { type: String },
pattern: { type: String },
triggerKeys: { type: String },
_input: { type: String },
};
}
constructor() {
super();
this.allowUnknown = false;
this.allowDuplicates = false;
this.triggerKeys = 'enter';
this.tooltipRemove = 'Remove';
this._input = '';
this._callFilterApiDebounced = debounce(this._callFilterApi.bind(this), 300, true);
}
firstUpdated() {
this.listen(this.$.innerInput, 'keydown', '_inputKeyDown');
this.listen(this.$.input, 'blur', '_inputBlur');
this.listen(this, 'focus', '_onFocus');
}
disconnectedCallback() {
super.disconnectedCallback();
this.unlisten(this.$.innerInput, 'keydown', '_inputKeyDown');
this.unlisten(this.$.innerInput, 'blur', '_inputBlur');
this.unlisten(this, 'focus', '_onFocus');
}
// shouldUpdate(changes) {
// if (changes.has('_input')) {
// this._inputChanged();
// }
// }
validate() {
const val = Array.isArray(this.value) ? this.value : [];
if ((this.required && val.length === 0) ||
(this.min > 0 && val.length < this.min) ||
(this.max > 0 && val.length > this.max)) {
this.invalid = true;
return false;
}
return true;
}
_onClick(e) {
if (closest(e.composedPath()[0], '.remove-icon', true)) {
const item = closest(e.composedPath()[0], '.item', true);
if (!item) return;
this.value = this.value.filter(i => i.value !== item.item.value);
}
}
_inputChanged(e) {
this._input = this.$.input.value;
if (this.api && this._input.length > 2) {
this._callFilterApiDebounced();
return;
}
if (this._input.length > 0) {
this.$.autoCpl.show();
} else {
this.$.autoCpl.hide();
}
}
async _callFilterApi() {
const apiFunc = typeof this.api === 'function' ? this.api : reach(this.api, window);
if (typeof apiFunc !== 'function') return;
this.items = [];
const apiResp = apiFunc({ value: this._input });
const promisedResult = apiResp.completes ? apiResp.completes : apiResp;
const result = await promisedResult;
this.$.autoCpl.loading = false;
if (Array.isArray(result)) {
this.items = result;
this.$.autoCpl.show();
return;
}
const resp = result.response;
if (resp && resp.statusCode === 200) {
this.items = resp.data;
this.$.autoCpl.show();
}
}
_itemSelected(item) {
if (item !== null) {
if (this.allowDuplicates || !this._isAlreadySelected(item.label)) {
if (!Array.isArray(this.value)) {
this.value = [ clone(item) ];
} else {
this.value = [...this.value, clone(item)];
}
}
this._clearInput();
this.$.autoCpl.selection = null;
this.$.autoCpl.value = null;
}
}
_inputBlur() {
if (!this._tryToAddItem()) {
this._clearInput();
this.$.autoCpl.selection = null;
this.$.autoCpl.value = null;
this.$.autoCpl.hide();
}
}
_prepFilter(filter, api) {
if (api) {
return ''; // No filter because items come from api called and should already be filtered by a server.
} else {
return filter;
}
}
_maxLengthReached() {
return (this.value || []).length === this.max && this.max > 0;
}
_inputKeyDown(e) {
if (this._keyboardEventMatchesKeys(e, this.triggerKeys)) {
e.preventDefault();
this._tryToAddItem();
return;
}
if (this._keyboardEventMatchesKeys(e, 'backspace')) {
if (this._input === '' && this.value) {
this.value = this.value.slice(0, -1);
return;
}
}
if (this._maxLengthReached() && !this._keyboardEventMatchesKeys(e, 'tab')) {
e.preventDefault();
}
}
_tryToAddItem() {
if (this._maxLengthReached()) {
return;
}
if (typeof this.$.autoCpl.selection === 'number') {
return;
}
if (this.allowUnknown) {
if (this.$.input.validate()) {
var item = this._getItemByLabel(this._input);
if (!item) {
item = {
value: this._getFreeValue(),
label: this._input,
custom: true
};
this.push('items', item);
}
if (!this.allowDuplicates && this._isAlreadySelected(this._input)) {
return false;
}
this._itemSelected(item);
return true;
}
} else {
if (this.$.autoCpl.filteredItems.length > 0 && this.$.autoCpl.visible) {
if (this.allowDuplicates || !this._isAlreadySelected(this.$.autoCpl.filteredItems[0].label)) {
this._itemSelected(this.$.autoCpl.filteredItems[0]);
return true;
}
return false;
}
}
}
_keyboardEventMatchesKeys(e, keys) {
const keyList = keys.split(' ');
for (const key of keyList) {
if (key.toLowerCase() === e.key.toLowerCase()) {
return true;
}
}
}
_valueChanged() {
this.toggleClass('has-items', Array.isArray(this.value) && this.value.length > 0);
}
_removeItem(e) {
this.splice('value', e.model.index, 1)[0];
}
_isAlreadySelected(label) {
if (!Array.isArray(this.value)) return false;
for (let i = 0, li = this.value.length; i < li; ++i) {
const item = this.value[i];
if (item.label.toLocaleLowerCase() === label.toLocaleLowerCase()) {
return true;
}
}
return false;
}
_getItemByLabel(label) {
for (var i = 0, li = this.items.length; i < li; ++i) {
var item = this.items[i];
if (item.label === label) {
return item;
}
}
}
_getFreeValue() {
var newValue = 0;
var _this = this;
while(!isFree(newValue)) {
newValue++;
}
function isFree() {
for (var i = 0, li = _this.items.length; i < li; ++i) {
if (_this.items[i].value == newValue) {
return false;
}
}
return true;
}
return newValue;
}
_onFocus() {
this.$.input.focus();
}
_clearInput() {
this._input = '';
this.$.input.value = '';
}
}
window.customElements.define('tp-tag-input', TpTagInput);