From 1ff5c35de5dbd70a782875a91dd2232fd01b002b Mon Sep 17 00:00:00 2001 From: Daniel Baumann Date: Sun, 28 Apr 2024 14:38:04 +0200 Subject: Adding upstream version 0.10.1. Signed-off-by: Daniel Baumann --- asset/js/widget/BaseInput.js | 899 +++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 899 insertions(+) create mode 100644 asset/js/widget/BaseInput.js (limited to 'asset/js/widget/BaseInput.js') diff --git a/asset/js/widget/BaseInput.js b/asset/js/widget/BaseInput.js new file mode 100644 index 0000000..0a05580 --- /dev/null +++ b/asset/js/widget/BaseInput.js @@ -0,0 +1,899 @@ +define(["../notjQuery", "Completer"], function ($, Completer) { + + "use strict"; + + class BaseInput { + constructor(input) { + this.input = input; + this.disabled = false; + this.separator = ''; + this.usedTerms = []; + this.completer = null; + this.lastCompletedTerm = null; + this._dataInput = null; + this._termInput = null; + this._termContainer = null; + } + + get dataInput() { + if (this._dataInput === null) { + this._dataInput = document.querySelector(this.input.dataset.dataInput); + } + + return this._dataInput; + } + + get termInput() { + if (this._termInput === null) { + this._termInput = document.querySelector(this.input.dataset.termInput); + } + + return this._termInput; + } + + get termContainer() { + if (this._termContainer === null) { + this._termContainer = document.querySelector(this.input.dataset.termContainer); + } + + return this._termContainer; + } + + bind() { + // Form submissions + $(this.input.form).on('submit', this.onSubmit, this); + $(this.input.form).on( + 'click', 'button:not([type]), button[type="submit"], input[type="submit"]', this.onButtonClick, this); + + // User interactions + $(this.input).on('input', this.onInput, this); + $(this.input).on('keydown', this.onKeyDown, this); + $(this.input).on('keyup', this.onKeyUp, this); + $(this.input).on('blur', this.onInputBlur, this); + $(this.input).on('focusin', this.onTermFocus, this); + $(this.termContainer).on('input', '[data-label]', this.onInput, this); + $(this.termContainer).on('keydown', '[data-label]', this.onKeyDown, this); + $(this.termContainer).on('keyup', '[data-label]', this.onKeyUp, this); + $(this.termContainer).on('focusout', '[data-index]', this.onTermFocusOut, this); + $(this.termContainer).on('focusin', '[data-index]', this.onTermFocus, this); + + // Copy/Paste + $(this.input).on('paste', this.onPaste, this); + $(this.input).on('copy', this.onCopyAndCut, this); + $(this.input).on('cut', this.onCopyAndCut, this); + + // Should terms be completed? + if (this.input.dataset.suggestUrl) { + if (this.completer === null) { + this.completer = new Completer(this.input, true); + this.completer.bind(this.termContainer); + } + + $(this.input).on('suggestion', this.onSuggestion, this); + $(this.input).on('completion', this.onCompletion, this); + $(this.termContainer).on('suggestion', '[data-label]', this.onSuggestion, this); + $(this.termContainer).on('completion', '[data-label]', this.onCompletion, this); + } + + return this; + } + + refresh(input) { + if (input === this.input) { + // If the DOM node is still the same, nothing has changed + return; + } + + this._termInput = null; + this._termContainer = null; + + this.input = input; + this.bind(); + + if (this.completer !== null) { + this.completer.refresh(input, this.termContainer); + } + + if (! this.restoreTerms()) { + this.reset(); + } + } + + reset() { + this.usedTerms = []; + this.lastCompletedTerm = null; + + this.togglePlaceholder(); + this.termInput.value = ''; + this.termContainer.innerHTML = ''; + } + + destroy() { + this._termContainer = null; + this._termInput = null; + this.input = null; + + if (this.completer !== null) { + this.completer.destroy(); + this.completer = null; + } + } + + disable() { + this.disabled = true; + this.input.disabled = true; + this.input.form.classList.add('disabled'); + this.termContainer.querySelectorAll('[data-index]').forEach(el => el.firstChild.disabled = true); + + if (this.completer !== null) { + this.completer.reset(); + } + } + + enable() { + this.input.disabled = false; + this.input.form.classList.remove('disabled'); + this.termContainer.querySelectorAll('[data-index]').forEach(el => el.firstChild.disabled = false); + this.disabled = false; + } + + restoreTerms() { + if (this.hasTerms()) { + this.usedTerms.forEach((termData, termIndex) => this.addTerm(termData, termIndex)); + this.togglePlaceholder(); + this.clearPartialTerm(this.input); + } else { + this.registerTerms(); + this.togglePlaceholder(); + } + + return this.hasTerms(); + } + + registerTerms() { + this.termContainer.querySelectorAll('[data-index]').forEach((label) => { + let termData = { ...label.dataset }; + delete termData.index; + + if (label.className) { + termData['class'] = label.className; + } + + if (label.title) { + termData['title'] = label.title; + } + + this.registerTerm(this.decodeTerm(termData), label.dataset.index); + }); + } + + registerTerm(termData, termIndex = null) { + if (termIndex !== null) { + this.usedTerms.splice(termIndex, 0, termData); + return termIndex; + } else { + return this.usedTerms.push(termData) - 1; + } + } + + updateTerms(changedTerms) { + // Reset the data input, otherwise the value remains and is sent continuously with subsequent requests + this.dataInput.value = ''; + + for (const termIndex of Object.keys(changedTerms)) { + let label = this.termContainer.querySelector(`[data-index="${ termIndex }"]`); + if (! label) { + continue; + } + + let input = label.firstChild; + let termData = changedTerms[termIndex]; + + if (termData.label) { + this.writePartialTerm(termData.label, input); + } + + this.updateTermData(termData, input); + this.usedTerms[termIndex] = termData; + } + } + + clearPartialTerm(input) { + if (this.completer !== null) { + this.completer.reset(); + } + + this.writePartialTerm('', input); + } + + writePartialTerm(value, input) { + input.value = value; + this.updateTermData({ label: value }, input); + } + + readPartialTerm(input) { + return input.value.trim(); + } + + readFullTerm(input, termIndex = null) { + let value = this.readPartialTerm(input); + if (! value) { + return false; + } + + let termData = {}; + + if (termIndex !== null) { + termData = { ...this.usedTerms[termIndex] }; + } + + termData.label = value; + termData.search = value; + + if (this.lastCompletedTerm !== null) { + if (termData.label === this.lastCompletedTerm.label) { + Object.assign(termData, this.lastCompletedTerm); + } + + this.lastCompletedTerm = null; + } + + return termData; + } + + exchangeTerm() { + if (this.completer !== null) { + this.completer.reset(); + } + + let termData = this.readFullTerm(this.input); + if (! termData) { + return {}; + } + + let addedTerms = {}; + if (Array.isArray(termData)) { + for (let data of termData) { + this.addTerm(data); + addedTerms[this.usedTerms.length - 1] = data; + } + } else { + this.addTerm(termData); + addedTerms[this.usedTerms.length - 1] = termData; + } + + this.clearPartialTerm(this.input); + + return addedTerms; + } + + insertTerm(termData, termIndex) { + this.reIndexTerms(termIndex, 1, true); + this.registerTerm(termData, termIndex); + return this.insertRenderedTerm(this.renderTerm(termData, termIndex)); + } + + insertRenderedTerm(label) { + let next = this.termContainer.querySelector(`[data-index="${ label.dataset.index + 1 }"]`); + this.termContainer.insertBefore(label, next); + return label; + } + + addTerm(termData, termIndex = null) { + if (termIndex === null) { + termIndex = this.registerTerm(termData); + } + + this.addRenderedTerm(this.renderTerm(termData, termIndex)); + } + + addRenderedTerm(label) { + this.termContainer.appendChild(label); + } + + hasTerms() { + return this.usedTerms.length > 0; + } + + hasSyntaxError(input) { + if (typeof input === 'undefined') { + input = this.input; + } + + return 'hasSyntaxError' in input.dataset; + } + + clearSyntaxError(input) { + if (typeof input === 'undefined') { + input = this.input; + } + + delete input.dataset.hasSyntaxError; + input.removeAttribute('pattern'); + input.removeAttribute('title'); + } + + getQueryString() { + return this.termsToQueryString(this.usedTerms); + } + + saveTerm(input, updateDOM = true, force = false) { + let termIndex = input.parentNode.dataset.index; + let termData = this.readFullTerm(input, termIndex); + + // Only save if something has changed, unless forced + if (termData === false) { + console.warn('[BaseInput] Input is empty, cannot save'); + } else if (force || this.usedTerms[termIndex].label !== termData.label) { + let oldTermData = this.usedTerms[termIndex]; + this.usedTerms[termIndex] = termData; + this.updateTermData(termData, input); + + return oldTermData; + } + + return false; + } + + updateTermData(termData, input) { + let label = input.parentNode; + label.dataset.label = termData.label; + + if (!! termData.search || termData.search === '') { + label.dataset.search = termData.search; + } + + if (!! termData.title) { + label.title = termData.title; + } else { + label.title = ''; + } + } + + termsToQueryString(terms) { + return terms.map(e => this.encodeTerm(e).search).join(this.separator).trim(); + } + + lastTerm() { + if (! this.hasTerms()) { + return null; + } + + return this.usedTerms[this.usedTerms.length - 1]; + } + + popTerm() { + let lastTermIndex = this.usedTerms.length - 1; + return this.removeTerm(this.termContainer.querySelector(`[data-index="${ lastTermIndex }"]`)); + } + + removeTerm(label, updateDOM = true) { + if (this.completer !== null) { + this.completer.reset(); + } + + let termIndex = Number(label.dataset.index); + + // Re-index following remaining terms + this.reIndexTerms(termIndex); + + // Cut the term's data + let [termData] = this.usedTerms.splice(termIndex, 1); + + // Avoid saving the term, it's removed after all + label.firstChild.skipSaveOnBlur = true; + + if (updateDOM) { + // Remove it from the DOM + this.removeRenderedTerm(label); + } + + return termData; + } + + removeRenderedTerm(label) { + label.remove(); + } + + removeRange(labels) { + let from = Number(labels[0].dataset.index); + let to = Number(labels[labels.length - 1].dataset.index); + let deleteCount = to - from + 1; + + if (to < this.usedTerms.length - 1) { + // Only re-index if there's something left + this.reIndexTerms(to, deleteCount); + } + + let removedData = this.usedTerms.splice(from, deleteCount); + + this.removeRenderedRange(labels); + + let removedTerms = {}; + for (let i = from; removedData.length; i++) { + removedTerms[i] = removedData.shift(); + } + + return removedTerms; + } + + removeRenderedRange(labels) { + labels.forEach(label => this.removeRenderedTerm(label)); + } + + reIndexTerms(from, howMuch = 1, forward = false) { + if (forward) { + for (let i = this.usedTerms.length - 1; i >= from; i--) { + let label = this.termContainer.querySelector(`[data-index="${ i }"]`); + label.dataset.index = `${ i + howMuch }`; + } + } else { + for (let i = ++from; i < this.usedTerms.length; i++) { + let label = this.termContainer.querySelector(`[data-index="${ i }"]`); + label.dataset.index = `${ i - howMuch }`; + } + } + } + + complete(input, data) { + if (this.completer !== null) { + $(input).trigger('complete', data); + } + } + + selectTerms() { + this.termContainer.querySelectorAll('[data-index]').forEach(el => el.classList.add('selected')); + } + + deselectTerms() { + this.termContainer.querySelectorAll('.selected').forEach(el => el.classList.remove('selected')); + } + + clearSelectedTerms() { + if (this.hasTerms()) { + let labels = this.termContainer.querySelectorAll('.selected'); + if (labels.length) { + return this.removeRange(Array.from(labels)); + } + } + + return {}; + } + + togglePlaceholder() { + let placeholder = ''; + + if (! this.hasTerms()) { + if (this.input.dataset.placeholder) { + placeholder = this.input.dataset.placeholder; + } else { + return; + } + } else if (this.input.placeholder) { + if (! this.input.dataset.placeholder) { + this.input.dataset.placeholder = this.input.placeholder; + } + } + + this.input.placeholder = placeholder; + } + + renderTerm(termData, termIndex) { + let label = $.render(''); + + if (termData.class) { + label.classList.add(termData.class); + } + + if (termData.title) { + label.title = termData.title; + } + + label.dataset.label = termData.label; + label.dataset.search = termData.search; + label.dataset.index = termIndex; + + label.firstChild.value = termData.label; + + return label; + } + + encodeTerm(termData) { + termData = { ...termData }; + termData.search = encodeURIComponent(termData.search); + + return termData; + } + + decodeTerm(termData) { + termData.search = decodeURIComponent(termData.search); + + return termData; + } + + shouldNotAutoSubmit() { + return 'noAutoSubmit' in this.input.dataset; + } + + autoSubmit(input, changeType, changedTerms) { + if (this.shouldNotAutoSubmit()) { + return; + } + + if (changeType === 'save') { + // Replace old term data with the new one, as required by the backend + for (const termIndex of Object.keys(changedTerms)) { + changedTerms[termIndex] = this.usedTerms[termIndex]; + } + } + + this.dataInput.value = JSON.stringify({ + type: changeType, + terms: changedTerms + }); + + if (Object.keys(changedTerms).length) { + $(this.input.form).trigger('submit', { submittedBy: input }); + } + } + + submitTerms(terms) { + $(this.input.form).trigger( + 'submit', + { terms: terms } + ); + } + + moveFocusForward(from = null) { + let toFocus; + + let inputs = Array.from(this.termContainer.querySelectorAll('input')); + if (from === null) { + let focused = this.termContainer.querySelector('input:focus'); + from = inputs.indexOf(focused); + } + + if (from === -1) { + toFocus = inputs.shift(); + } else if (from + 1 < inputs.length) { + toFocus = inputs[from + 1]; + } else { + toFocus = this.input; + } + + toFocus.selectionStart = toFocus.selectionEnd = 0; + $(toFocus).focus(); + + return toFocus; + } + + moveFocusBackward(from = null) { + let toFocus; + + let inputs = Array.from(this.termContainer.querySelectorAll('input')); + if (from === null) { + let focused = this.termContainer.querySelector('input:focus'); + from = inputs.indexOf(focused); + } + + if (from === -1) { + toFocus = inputs.pop(); + } else if (from > 0 && from - 1 < inputs.length) { + toFocus = inputs[from - 1]; + } else { + toFocus = this.input; + } + + toFocus.selectionStart = toFocus.selectionEnd = toFocus.value.length; + $(toFocus).focus(); + + return toFocus; + } + + /** + * Event listeners + */ + + onSubmit(event) { + // Unset the input's name, to prevent its submission (It may actually have a name, as no-js fallback) + this.input.name = ''; + + // Set the hidden input's value, it's what's sent + if (event.detail && 'terms' in event.detail) { + this.termInput.value = event.detail.terms; + } else { + let renderedTerms = this.termsToQueryString(this.usedTerms); + if (this.hasSyntaxError()) { + renderedTerms += this.input.value; + } + + this.termInput.value = renderedTerms; + } + + // Enable the hidden input, otherwise it's not submitted + this.termInput.disabled = false; + } + + onSuggestion(event) { + let data = event.detail; + let input = event.target; + + let termData; + if (typeof data === 'object') { + termData = data; + } else { + termData = { label: data, search: data }; + } + + this.lastCompletedTerm = termData; + this.writePartialTerm(termData.label, input); + } + + onCompletion(event) { + let input = event.target; + let termData = event.detail; + let termIndex = Number(input.parentNode.dataset.index); + + this.lastCompletedTerm = termData; + this.writePartialTerm(termData.label, input); + + if (termIndex >= 0) { + this.autoSubmit(input, 'save', { [termIndex]: this.saveTerm(input, false, true) }); + } else { + this.autoSubmit(input, 'exchange', this.exchangeTerm()); + this.togglePlaceholder(); + } + } + + onInput(event) { + let input = event.target; + let isTerm = input.parentNode.dataset.index >= 0; + + let termData = { label: this.readPartialTerm(input) }; + this.updateTermData(termData, input); + + if (! input.value && this.hasSyntaxError(input)) { + this.clearSyntaxError(input); + } + + if (! this.hasSyntaxError(input)) { + this.complete(input, { term: termData }); + } + + if (! isTerm) { + this.autoSubmit(this.input, 'remove', this.clearSelectedTerms()); + this.togglePlaceholder(); + } + } + + onKeyDown(event) { + let input = event.target; + let termIndex = Number(input.parentNode.dataset.index); + + if (this.hasSyntaxError(input) && ! (/[A-Z]/.test(event.key.charAt(0)) || event.ctrlKey || event.metaKey)) { + // Clear syntax error flag if the user types entirely new input after having selected the entire input + // (This way the input isn't empty but switches from input to input immediately, causing the clearing + // in onInput to not work) + if (input.selectionEnd - input.selectionStart === input.value.length) { + this.clearSyntaxError(input); + } + } + + let removedTerms; + switch (event.key) { + case ' ': + if (! this.readPartialTerm(input)) { + this.complete(input, { term: { label: '' } }); + event.preventDefault(); + } + break; + case 'Backspace': + removedTerms = this.clearSelectedTerms(); + + if (termIndex >= 0 && ! input.value) { + let removedTerm = this.removeTerm(input.parentNode); + if (removedTerm !== false) { + input = this.moveFocusBackward(termIndex); + if (event.ctrlKey || event.metaKey) { + this.clearPartialTerm(input); + } else { + this.writePartialTerm(input.value.slice(0, -1), input); + } + + removedTerms[termIndex] = removedTerm; + event.preventDefault(); + } + } else if (isNaN(termIndex)) { + if (! input.value && this.hasTerms()) { + let termData = this.popTerm(); + if (! event.ctrlKey && ! event.metaKey) { + // Removing the last char programmatically is not + // necessary since the browser default is not prevented + this.writePartialTerm(termData.label, input); + } + + removedTerms[this.usedTerms.length] = termData; + } + } + + this.togglePlaceholder(); + this.autoSubmit(input, 'remove', removedTerms); + break; + case 'Delete': + removedTerms = this.clearSelectedTerms(); + + if (termIndex >= 0 && ! input.value) { + let removedTerm = this.removeTerm(input.parentNode); + if (removedTerm !== false) { + input = this.moveFocusForward(termIndex - 1); + if (event.ctrlKey || event.metaKey) { + this.clearPartialTerm(input); + } else { + this.writePartialTerm(input.value.slice(1), input); + } + + removedTerms[termIndex] = removedTerm; + event.preventDefault(); + } + } + + this.togglePlaceholder(); + this.autoSubmit(input, 'remove', removedTerms); + break; + case 'Enter': + if (termIndex >= 0) { + if (this.readPartialTerm(input)) { + this.saveTerm(input, false); + } else { + this.removeTerm(input.parentNode, false); + } + } + break; + case 'ArrowLeft': + if (input.selectionStart === 0 && this.hasTerms()) { + event.preventDefault(); + this.moveFocusBackward(); + } + break; + case 'ArrowRight': + if (input.selectionStart === input.value.length && this.hasTerms()) { + event.preventDefault(); + this.moveFocusForward(); + } + break; + case 'a': + if ((event.ctrlKey || event.metaKey) && ! this.readPartialTerm(input)) { + this.selectTerms(); + } + } + } + + onKeyUp(event) { + if (event.target.parentNode.dataset.index >= 0) { + return; + } + + switch (event.key) { + case 'End': + case 'ArrowLeft': + case 'ArrowRight': + this.deselectTerms(); + break; + case 'Home': + if (this.input.selectionStart === 0 && this.input.selectionEnd === 0) { + if (event.shiftKey) { + this.selectTerms(); + } else { + this.deselectTerms(); + } + } + + break; + case 'Delete': + this.autoSubmit(event.target, 'remove', this.clearSelectedTerms()); + this.togglePlaceholder(); + break; + } + } + + onInputBlur() { + this.deselectTerms(); + } + + onTermFocusOut(event) { + let input = event.target; + if (this.hasSyntaxError(input)) { + return; + } + + // skipSaveOnBlur is set if the input is about to be removed anyway. + // If we remove the input as well, the other removal will fail without + // any chance to handle it. (Element.remove() blurs the input) + if (typeof input.skipSaveOnBlur === 'undefined' || ! input.skipSaveOnBlur) { + setTimeout(() => { + if (this.completer === null || ! this.completer.isBeingCompleted(input)) { + let termIndex = Number(input.parentNode.dataset.index); + if (this.readPartialTerm(input)) { + let previousTerm = this.saveTerm(input); + if (previousTerm !== false) { + this.autoSubmit(input, 'save', { [termIndex]: previousTerm }); + } + } else { + this.autoSubmit(input, 'remove', { [termIndex]: this.removeTerm(input.parentNode) }); + } + } + }, 0); + } + } + + onTermFocus(event) { + if (event.detail.scripted) { + // Only request suggestions if the user manually focuses the term + return; + } + + this.deselectTerms(); + + let input = event.target; + if (! this.hasSyntaxError(input) && ! this.completer.isBeingCompleted(input, false)) { + // Only request suggestions if the input is valid and not already being completed + let value = this.readPartialTerm(input); + this.complete(input, { trigger: 'script', term: { label: value } }); + } + } + + onButtonClick(event) { + if (! this.hasSyntaxError()) { + // Register current input value, otherwise it's not included + this.exchangeTerm(); + } + + if (this.hasTerms()) { + this.input.required = false; + + // This is not part of `onSubmit()` because otherwise it would override what `autoSubmit()` does + this.dataInput.value = JSON.stringify({ type: 'submit', terms: this.usedTerms }); + } else if (typeof this.input.dataset.manageRequired !== 'undefined') { + this.input.required = true; + } + } + + onPaste(event) { + if (this.hasTerms() || this.input.value) { + return; + } + + this.submitTerms(event.clipboardData.getData('text/plain')); + + event.preventDefault(); + } + + onCopyAndCut(event) { + if (! this.hasTerms()) { + return; + } + + let data = ''; + + let selectedTerms = this.termContainer.querySelectorAll('.selected'); + if (selectedTerms.length) { + data = Array.from(selectedTerms).map(label => label.dataset.search).join(this.separator); + } + + if (this.input.selectionStart < this.input.selectionEnd) { + data += this.separator + this.input.value.slice(this.input.selectionStart, this.input.selectionEnd); + } + + event.clipboardData.setData('text/plain', data); + event.preventDefault(); + + if (event.type === 'cut') { + this.clearPartialTerm(this.input); + this.autoSubmit(this.input, 'remove', this.clearSelectedTerms()); + this.togglePlaceholder(); + } + } + } + + return BaseInput; +}); -- cgit v1.2.3