Supercharged tags input

This commit is contained in:
Mehdi Benadel 2024-05-28 00:36:49 +02:00
parent 5cc130e417
commit cc75aadeb4
6 changed files with 318 additions and 47 deletions

View File

@ -6,6 +6,8 @@
/* stylelint-disable custom-property-pattern */
@use "sass:color";
/*
Here we are getting some Peertube variables (see _variables.scss in Peertube source code):
We are disabling stylelint-disable custom-property-pattern so we can use Peertube var without warnings.
@ -211,6 +213,9 @@ $small-view: 800px;
}
livechat-dynamic-table-form {
// We need this variable to be known at that time
$bs-green: #39cc0b;
table {
table-layout: fixed;
text-align: center;
@ -242,6 +247,30 @@ livechat-dynamic-table-form {
.dynamic-table-add-row {
background-color: var(--bs-green);
&,
&:active,
&:focus {
color: #fff;
background-color: color.adjust($bs-green, $lightness: 5%);
}
&:focus,
&.focus-visible {
box-shadow: 0 0 0 0.2rem color.adjust($bs-green, $lightness: 20%);
}
&:hover {
color: #fff;
background-color: color.adjust($bs-green, $lightness: 10%);
}
&[disabled],
&.disabled {
cursor: default;
color: #fff;
background-color: var(--inputBorderColor);
}
}
.dynamic-table-remove-row {
@ -261,7 +290,7 @@ livechat-tags-input {
input {
flex: 1;
border: none;
padding: var(--tag-padding-vertical) 0 0;
padding: 0;
color: inherit;
background-color: inherit;
width: 100%;
@ -271,16 +300,38 @@ livechat-tags-input {
}
}
#tags {
#tags,
#tags-searched {
display: flex;
flex-wrap: wrap;
padding: 0;
margin: var(--tag-padding-vertical) 0 0;
margin: var(--tag-padding-vertical) 0;
max-height: 150px;
overflow-y: scroll;
border-bottom: 1px dashed var(--greyForegroundColor);
transition: 0.3s height;
&.empty {
visibility: hidden;
}
}
.tag {
#tags-searched {
&::after {
content: "\1F50D";
flex-grow: 1;
text-align: right;
}
&.empty {
&::after {
display: none;
}
}
}
.tag,
.tag-searched {
width: auto;
height: 24px;
display: flex;
@ -292,18 +343,44 @@ livechat-tags-input {
list-style: none;
border-radius: 3px;
margin: 0 3px 3px 0;
background: var(--bs-orange);
transition: 0.3s filter;
.tag-close {
display: block;
width: 12px;
height: 12px;
line-height: 12px;
text-align: center;
font-size: 10px;
margin-left: var(--tag-padding-horizontal);
color: var(--mainColor);
border-radius: 50%;
background: #fff;
cursor: pointer;
&::before {
content: "\2715";
}
}
&,
&:active,
&:focus {
color: #fff;
background-color: var(--mainColor);
.tag-close {
color: var(--mainColor);
}
}
&:hover {
color: #fff;
background-color: var(--mainHoverColor);
.tag-close {
color: var(--mainHoverColor);
}
}
&[disabled],
@ -311,24 +388,18 @@ livechat-tags-input {
cursor: default;
color: #fff;
background-color: var(--inputBorderColor);
.tag-close {
color: var(--inputBorderColor);
}
}
.tag-name {
margin-top: 3px;
}
}
.tag-close {
display: block;
width: 12px;
height: 12px;
line-height: 10px;
text-align: center;
font-size: 14px;
margin-left: var(--tag-padding-horizontal);
color: var(--bs-orange);
border-radius: 50%;
background: #fff;
cursor: pointer;
}
#tags.unfocused .tag {
filter: opacity(50%) grayscale(80%);
}
}

View File

@ -150,7 +150,7 @@ export class ChannelConfigurationElement extends LivechatElement {
entries: {
inputType: 'tags',
default: [],
separator: '\n'
separators: ['\n', '\t', ';']
},
regex: {
inputType: 'checkbox',

View File

@ -605,7 +605,9 @@ export class DynamicTableFormElement extends LivechatElement {
rowById.row[propertyName] = value
} else {
rowById.row[propertyName] = (value as string)
.split(new RegExp(`/[${propertySchema.separators?.join('') ?? ''}]+/`))
.split(new RegExp(`(?:${propertySchema.separators
?.map((c: string) => c.replace(/^[.\\+*?[^\]$(){}=!<>|:-]$/, '\\'))
.join('|') ?? ''})+)`))
}
break
default:

View File

@ -3,9 +3,12 @@
// SPDX-License-Identifier: AGPL-3.0-only
import { html } from 'lit'
import { customElement, property } from 'lit/decorators.js'
import { customElement, property, state } from 'lit/decorators.js'
import { LivechatElement } from './livechat'
import { ifDefined } from 'lit/directives/if-defined.js'
import { classMap } from 'lit/directives/class-map.js'
import { animate, fadeOut, fadeIn } from '@lit-labs/motion'
import { repeat } from 'lit/directives/repeat.js'
@customElement('livechat-tags-input')
export class TagsInputElement extends LivechatElement {
@ -27,7 +30,8 @@ export class TagsInputElement extends LivechatElement {
@property({ attribute: false })
public minlength?: string
public _inputValue?: string = ''
@state()
private _inputValue?: string = ''
@property({ attribute: false })
public inputPlaceholder?: string = ''
@ -38,16 +42,53 @@ export class TagsInputElement extends LivechatElement {
@property({ reflect: true })
public value: Array<string | number> = []
@state()
private _searchedTagsIndex: number[] = []
@state()
private _isPressingKey: string[] = []
@property({ attribute: false })
public separators?: string[] = []
public separators?: string[] = ['\n']
@property({ attribute: false })
public animDuration: number = 200
protected override render = (): unknown => {
return html`<ul id="tags">
${this.value.map((tag, index) => html`<li key=${index} class="tag">
return html`<ul
id="tags"
class=${classMap({
empty: !this.value.length,
unfocused: this._searchedTagsIndex.length
})}>
${repeat(this.value, tag => tag,
(tag, index) => html`<li key=${index} class="tag" ${animate({
keyframeOptions: {
duration: this.animDuration,
fill: 'both'
},
in: fadeIn,
out: fadeOut
})}>
<span class='tag-name'>${tag}</span>
<span class='tag-close'
@click=${() => this._removeTag(index)}>
x
@click=${() => this._handleDeleteTag(index)}></span>
</li>`
)}
</ul>
<ul id="tags-searched" class=${classMap({ empty: !this._searchedTagsIndex.length })}>
${repeat(this._searchedTagsIndex, index => index,
(index) => html`<li key=${index} class="tag-searched" ${animate({
keyframeOptions: {
duration: this.animDuration,
fill: 'both'
},
in: fadeIn,
out: fadeOut
})}>
<span class='tag-name'>${this.value[index]}</span>
<span class='tag-close'
@click=${() => this._handleDeleteTag(index)}>
</span>
</li>`
)}
@ -62,10 +103,12 @@ export class TagsInputElement extends LivechatElement {
minlength=${ifDefined(this.minlength)}
maxlength=${ifDefined(this.maxlength)}
@paste=${(e: ClipboardEvent) => this._handlePaste(e)}
@keydown=${(e: KeyboardEvent) => this._handleKeyboardEvent(e)}
@keydown=${(e: KeyboardEvent) => this._handleKeyDown(e)}
@keyup=${(e: KeyboardEvent) => this._handleKeyUp(e)}
@input=${(e: InputEvent) => this._handleInputEvent(e)}
@change=${(e: Event) => e.stopPropagation()}
.value=${this._inputValue} .placeholder=${this.inputPlaceholder} />
.value=${this._inputValue}
.placeholder=${this.inputPlaceholder} />
${(this.datalist)
? html`<datalist id="${this.id ?? 'tags-input'}-datalist">
${(this.datalist ?? []).map((value) => html`<option value=${value}>`)}
@ -77,17 +120,24 @@ export class TagsInputElement extends LivechatElement {
private readonly _handlePaste = (e: ClipboardEvent): boolean => {
const target = e?.target as HTMLInputElement
const pastedValue = `${target?.value ?? ''}${e.clipboardData?.getData('text/plain') ?? ''}`
if (target) {
e.preventDefault()
let values = pastedValue.split(new RegExp(`/[${this.separators?.join('') ?? ''}]+/`))
const newValue = `${
target?.value?.slice(0, target.selectionStart ?? target?.value?.length ?? 0) ?? ''
}${e.clipboardData?.getData('text/plain') ?? ''}${
target?.value?.slice(target.selectionEnd ?? target?.value?.length ?? 0) ?? ''
}`
let values = newValue.split(new RegExp(`(?:${this.separators
?.map((c: string) => c.replace(/^[.\\+*?[^\]$(){}=!<>|:-]$/, '\\'))
.join('|') ?? ''})+`))
values = values.map(v => v.trim()).filter(v => v !== '')
if (values.length > 0) {
// Keep last value in input if paste doesn't finish with a separator
if (!this.separators?.some(separator => target?.value.match(/\s+$/m)?.[0]?.includes(separator))) {
// Keep last value in input if value doesn't finish with a separator
if (!this.separators?.some(separator => newValue.match(/\s+$/m)?.[0]?.includes(separator))) {
target.value = values.pop() ?? ''
} else {
target.value = ''
@ -95,7 +145,7 @@ export class TagsInputElement extends LivechatElement {
// no duplicate
this.value = [...new Set([...this.value, ...values])]
console.log(`value: ${JSON.stringify(this.value)}`)
this._updateSearchedTags() // is that necessary ?
this.requestUpdate('value')
this.dispatchEvent(new CustomEvent('change', { detail: this.value }))
@ -106,23 +156,74 @@ export class TagsInputElement extends LivechatElement {
return true
}
private readonly _handleKeyboardEvent = (e: KeyboardEvent): boolean => {
private readonly _handleKeyDown = (e: KeyboardEvent): boolean => {
const target = e?.target as HTMLInputElement
if (target) {
switch (e.key) {
case 'Enter':
// Avoid bounce
if (!this._isPressingKey.includes(e.key)) {
this._isPressingKey.push(e.key)
} else {
e.preventDefault()
return false
}
if (target.value === '') {
this._isPressingKey.push(e.key)
return true
} else {
e.preventDefault()
this._handleNewTag(e)
return false
}
// break useless as all cases returns here
case 'Backspace':
// Avoid bounce delete
if (!this._isPressingKey.includes(e.key)) {
this._isPressingKey.push(e.key)
if ((target.selectionStart === target.selectionEnd) &&
target.selectionStart === 0) {
this._handleDeleteTag((this._searchedTagsIndex.length)
? this._searchedTagsIndex.slice(-1)[0]
: (this.value.length - 1))
}
}
break
case 'Delete':
// Avoid bounce delete
if (!this._isPressingKey.includes(e.key)) {
this._isPressingKey.push(e.key)
if ((target.selectionStart === target.selectionEnd) &&
target.selectionStart === target.value.length) {
this._handleDeleteTag((this._searchedTagsIndex.length)
? this._searchedTagsIndex[0]
: 0)
}
}
break
default:
break
}
}
return true
}
private readonly _handleKeyUp = (e: KeyboardEvent): boolean => {
const target = e?.target as HTMLInputElement
if (target) {
switch (e.key) {
case 'Enter':
case 'Backspace':
case 'Delete':
if (target.value === '') {
this._removeTag(this.value.length - 1)
if (this._isPressingKey.includes(e.key)) {
this._isPressingKey.splice(this._isPressingKey.indexOf(e.key))
}
break
default:
@ -137,11 +238,14 @@ export class TagsInputElement extends LivechatElement {
const target = e?.target as HTMLInputElement
if (target) {
this._inputValue = target.value
if (this.separators?.includes(target.value.slice(-1))) {
e.preventDefault()
target.value = target.value.slice(0, -1)
this._handleNewTag(e)
return false
} else {
this._updateSearchedTags()
}
}
@ -152,11 +256,35 @@ export class TagsInputElement extends LivechatElement {
const target = e?.target as HTMLInputElement
if (target) {
this._addTag(target?.value)
this._addTag(target.value)
target.value = ''
this._updateSearchedTags()
}
}
private readonly _handleDeleteTag = (index: number): void => {
this._removeTag(index)
this._updateSearchedTags()
}
private readonly _updateSearchedTags = (): void => {
const searchedTags = []
const inputValue = this._inputValue?.trim()
if (inputValue?.length) {
for (const [i, tag] of this.value.entries()) {
if ((tag as string).toLowerCase().includes(inputValue.toLowerCase())) {
searchedTags.push(i)
}
}
}
this._searchedTagsIndex = searchedTags
this.requestUpdate('_searchedTagsIndex')
}
private readonly _addTag = (value: string | undefined): void => {
if (value === undefined) {
console.warn('Could not add tag : Target or value was undefined')
@ -170,8 +298,6 @@ export class TagsInputElement extends LivechatElement {
// no duplicate
this.value = [...new Set(this.value)]
console.log(`value: ${JSON.stringify(this.value)}`)
this.requestUpdate('value')
this.dispatchEvent(new CustomEvent('change', { detail: this.value }))
}
@ -185,8 +311,6 @@ export class TagsInputElement extends LivechatElement {
this.value.splice(index, 1)
console.log(`value: ${JSON.stringify(this.value)}`)
this.requestUpdate('value')
this.dispatchEvent(new CustomEvent('change', { detail: this.value }))
}

81
package-lock.json generated
View File

@ -9,6 +9,7 @@
"version": "10.0.2",
"license": "AGPL-3.0",
"dependencies": {
"@lit-labs/motion": "^1.0.7",
"@lit/context": "^1.1.1",
"@lit/task": "^1.0.0",
"@xmpp/jid": "^0.13.1",
@ -2667,6 +2668,42 @@
"@jridgewell/sourcemap-codec": "^1.4.14"
}
},
"node_modules/@lit-labs/motion": {
"version": "1.0.7",
"resolved": "https://registry.npmjs.org/@lit-labs/motion/-/motion-1.0.7.tgz",
"integrity": "sha512-odykI6Talw274lYRWQvrGNplHzRy5QAtYEMbqonX6oesEuDQq1nR9Mis38X587jinj68Gjria0mlzqowJ1FACw==",
"dependencies": {
"lit": "^3.1.2"
}
},
"node_modules/@lit-labs/motion/node_modules/lit": {
"version": "3.1.3",
"resolved": "https://registry.npmjs.org/lit/-/lit-3.1.3.tgz",
"integrity": "sha512-l4slfspEsnCcHVRTvaP7YnkTZEZggNFywLEIhQaGhYDczG+tu/vlgm/KaWIEjIp+ZyV20r2JnZctMb8LeLCG7Q==",
"dependencies": {
"@lit/reactive-element": "^2.0.4",
"lit-element": "^4.0.4",
"lit-html": "^3.1.2"
}
},
"node_modules/@lit-labs/motion/node_modules/lit-element": {
"version": "4.0.5",
"resolved": "https://registry.npmjs.org/lit-element/-/lit-element-4.0.5.tgz",
"integrity": "sha512-iTWskWZEtn9SyEf4aBG6rKT8GABZMrTWop1+jopsEOgEcugcXJGKuX5bEbkq9qfzY+XB4MAgCaSPwnNpdsNQ3Q==",
"dependencies": {
"@lit-labs/ssr-dom-shim": "^1.2.0",
"@lit/reactive-element": "^2.0.4",
"lit-html": "^3.1.2"
}
},
"node_modules/@lit-labs/motion/node_modules/lit-html": {
"version": "3.1.3",
"resolved": "https://registry.npmjs.org/lit-html/-/lit-html-3.1.3.tgz",
"integrity": "sha512-FwIbqDD8O/8lM4vUZ4KvQZjPPNx7V1VhT7vmRB8RBAO0AU6wuTVdoXiu2CivVjEGdugvcbPNBLtPE1y0ifplHA==",
"dependencies": {
"@types/trusted-types": "^2.0.2"
}
},
"node_modules/@lit-labs/ssr-dom-shim": {
"version": "1.2.0",
"resolved": "https://registry.npmjs.org/@lit-labs/ssr-dom-shim/-/ssr-dom-shim-1.2.0.tgz",
@ -4124,8 +4161,7 @@
"node_modules/@types/trusted-types": {
"version": "2.0.7",
"resolved": "https://registry.npmjs.org/@types/trusted-types/-/trusted-types-2.0.7.tgz",
"integrity": "sha512-ScaPdn1dQczgbl0QFTeTOmVHFULt394XJgOQNoyVhZ6r2vLnMLJfBPd53SB52T/3G36VI1/g2MZaX0cwDuXsfw==",
"dev": true
"integrity": "sha512-ScaPdn1dQczgbl0QFTeTOmVHFULt394XJgOQNoyVhZ6r2vLnMLJfBPd53SB52T/3G36VI1/g2MZaX0cwDuXsfw=="
},
"node_modules/@types/validator": {
"version": "13.7.1",
@ -14363,6 +14399,44 @@
"@jridgewell/sourcemap-codec": "^1.4.14"
}
},
"@lit-labs/motion": {
"version": "1.0.7",
"resolved": "https://registry.npmjs.org/@lit-labs/motion/-/motion-1.0.7.tgz",
"integrity": "sha512-odykI6Talw274lYRWQvrGNplHzRy5QAtYEMbqonX6oesEuDQq1nR9Mis38X587jinj68Gjria0mlzqowJ1FACw==",
"requires": {
"lit": "^3.1.2"
},
"dependencies": {
"lit": {
"version": "3.1.3",
"resolved": "https://registry.npmjs.org/lit/-/lit-3.1.3.tgz",
"integrity": "sha512-l4slfspEsnCcHVRTvaP7YnkTZEZggNFywLEIhQaGhYDczG+tu/vlgm/KaWIEjIp+ZyV20r2JnZctMb8LeLCG7Q==",
"requires": {
"@lit/reactive-element": "^2.0.4",
"lit-element": "^4.0.4",
"lit-html": "^3.1.2"
}
},
"lit-element": {
"version": "4.0.5",
"resolved": "https://registry.npmjs.org/lit-element/-/lit-element-4.0.5.tgz",
"integrity": "sha512-iTWskWZEtn9SyEf4aBG6rKT8GABZMrTWop1+jopsEOgEcugcXJGKuX5bEbkq9qfzY+XB4MAgCaSPwnNpdsNQ3Q==",
"requires": {
"@lit-labs/ssr-dom-shim": "^1.2.0",
"@lit/reactive-element": "^2.0.4",
"lit-html": "^3.1.2"
}
},
"lit-html": {
"version": "3.1.3",
"resolved": "https://registry.npmjs.org/lit-html/-/lit-html-3.1.3.tgz",
"integrity": "sha512-FwIbqDD8O/8lM4vUZ4KvQZjPPNx7V1VhT7vmRB8RBAO0AU6wuTVdoXiu2CivVjEGdugvcbPNBLtPE1y0ifplHA==",
"requires": {
"@types/trusted-types": "^2.0.2"
}
}
}
},
"@lit-labs/ssr-dom-shim": {
"version": "1.2.0",
"resolved": "https://registry.npmjs.org/@lit-labs/ssr-dom-shim/-/ssr-dom-shim-1.2.0.tgz",
@ -15698,8 +15772,7 @@
"@types/trusted-types": {
"version": "2.0.7",
"resolved": "https://registry.npmjs.org/@types/trusted-types/-/trusted-types-2.0.7.tgz",
"integrity": "sha512-ScaPdn1dQczgbl0QFTeTOmVHFULt394XJgOQNoyVhZ6r2vLnMLJfBPd53SB52T/3G36VI1/g2MZaX0cwDuXsfw==",
"dev": true
"integrity": "sha512-ScaPdn1dQczgbl0QFTeTOmVHFULt394XJgOQNoyVhZ6r2vLnMLJfBPd53SB52T/3G36VI1/g2MZaX0cwDuXsfw=="
},
"@types/validator": {
"version": "13.7.1",

View File

@ -33,6 +33,7 @@
"dist/assets/styles/configuration.css"
],
"dependencies": {
"@lit-labs/motion": "^1.0.7",
"@lit/context": "^1.1.1",
"@lit/task": "^1.0.0",
"@xmpp/jid": "^0.13.1",