diff options
Diffstat (limited to 'asset/js/widget/Completer.js')
-rw-r--r-- | asset/js/widget/Completer.js | 523 |
1 files changed, 523 insertions, 0 deletions
diff --git a/asset/js/widget/Completer.js b/asset/js/widget/Completer.js new file mode 100644 index 0000000..09f59bd --- /dev/null +++ b/asset/js/widget/Completer.js @@ -0,0 +1,523 @@ +define(["../notjQuery"], function ($) { + + "use strict"; + + class Completer { + constructor(input, instrumented = false) { + this.input = input; + this.instrumented = instrumented; + this.nextSuggestion = null; + this.activeSuggestion = null; + this.suggestionKiller = null; + this.completedInput = null; + this.completedValue = null; + this.completedData = null; + this._termSuggestions = null; + } + + get termSuggestions() { + if (this._termSuggestions === null) { + this._termSuggestions = document.querySelector(this.input.dataset.termSuggestions); + } + + return this._termSuggestions; + } + + bind(to = null) { + // Form submissions + $(this.input.form).on('submit', this.onSubmit, this); + + // User interactions + $(this.termSuggestions).on('focusout', '[type="button"]', this.onFocusOut, this); + $(this.termSuggestions).on('click', '[type="button"]', this.onSuggestionClick, this); + $(this.termSuggestions).on('keydown', '[type="button"]', this.onSuggestionKeyDown, this); + + if (this.instrumented) { + if (to !== null) { + $(to).on('focusout', 'input[type="text"]', this.onFocusOut, this); + $(to).on('keydown', 'input[type="text"]', this.onKeyDown, this); + $(to).on('complete', 'input[type="text"]', this.onComplete, this); + } + + $(this.input).on('complete', this.onComplete, this); + } else { + $(this.input).on('input', this.onInput, this); + } + + $(this.input).on('focusout', this.onFocusOut, this); + $(this.input).on('keydown', this.onKeyDown, this); + + return this; + } + + refresh(input, bindTo = null) { + if (input === this.input) { + // If the DOM node is still the same, nothing has changed + return; + } + + this._termSuggestions = null; + this.abort(); + + this.input = input; + this.bind(bindTo); + } + + reset() { + this.abort(); + this.hideSuggestions(); + } + + destroy() { + this._termSuggestions = null; + this.input = null; + } + + renderSuggestions(html) { + let template = document.createElement('template'); + template.innerHTML = html; + + return template.content; + } + + showSuggestions(suggestions, input) { + this.termSuggestions.innerHTML = ''; + this.termSuggestions.appendChild(suggestions); + this.termSuggestions.style.display = ''; + + let containingBlock = this.termSuggestions.offsetParent || document.body; + let containingBlockRect = containingBlock.getBoundingClientRect(); + let inputRect = input.getBoundingClientRect(); + let inputPosX = inputRect.left - containingBlockRect.left; + let inputPosY = inputRect.bottom - containingBlockRect.top; + let suggestionWidth = this.termSuggestions.offsetWidth; + + let maxAvailableHeight = document.body.clientHeight - inputRect.bottom; + let localMarginBottom = window.getComputedStyle(this.termSuggestions).marginBottom; + + this.termSuggestions.style.top = `${ inputPosY }px`; + this.termSuggestions.style.maxHeight = `calc(${maxAvailableHeight}px - ${localMarginBottom})`; + if (inputPosX + suggestionWidth > containingBlockRect.right - containingBlockRect.left) { + this.termSuggestions.style.left = + `${ containingBlockRect.right - containingBlockRect.left - suggestionWidth }px`; + } else { + this.termSuggestions.style.left = `${ inputPosX }px`; + } + } + + hasSuggestions() { + return this.termSuggestions.childNodes.length > 0; + } + + hideSuggestions() { + if (this.nextSuggestion !== null || this.activeSuggestion !== null) { + return; + } + + if (this.suggestionKiller !== null) { + // onFocusOut initiates this timer in order to hide the suggestions if the user + // doesn't navigate them. Since it does this by checking after a short interval + // if the focus is inside the suggestions, the interval has to be long enough to + // have a chance to detect the focus. `focusout` runs before `blur` and `focus`, + // so this may lead to a race condition which is addressed by the timeout. Though, + // to not close the newly opened suggestions of the next input the timer has to + // be cancelled here since it's purpose is already fulfilled. + clearTimeout(this.suggestionKiller); + this.suggestionKiller = null; + } + + this.termSuggestions.style.display = 'none'; + this.termSuggestions.innerHTML = ''; + + this.completedInput = null; + this.completedValue = null; + this.completedData = null; + } + + prepareCompletionData(input, data = null) { + if (data === null) { + data = { term: { ...input.dataset } }; + data.term.label = input.value; + } + + let value = data.term.label; + data.term.search = value; + data.term.label = this.addWildcards(value); + + if (input.parentElement instanceof HTMLFieldSetElement) { + for (let element of input.parentElement.elements) { + if (element !== input + && element.name !== input.name + '-search' + && (element.name.substr(-7) === '-search' + || typeof input.form[element.name + '-search'] === 'undefined') + ) { + // Make sure we'll use a key that the server can understand.. + let dataName = element.name; + if (dataName.substr(-7) === '-search') { + dataName = dataName.substr(0, dataName.length - 7); + } + if (dataName.substr(0, input.parentElement.name.length) === input.parentElement.name) { + dataName = dataName.substr(input.parentElement.name.length); + } + + if (! dataName in data || element.value) { + data[dataName] = element.value; + } + } + } + } + + return [value, data]; + } + + addWildcards(value) { + if (! value) { + return '*'; + } + + if (value.slice(0, 1) !== '*' && value.slice(-1) !== '*') { + return '*' + value + '*'; + } + + return value; + } + + abort() { + if (this.activeSuggestion !== null) { + this.activeSuggestion.abort(); + this.activeSuggestion = null; + } + + if (this.nextSuggestion !== null) { + clearTimeout(this.nextSuggestion); + this.nextSuggestion = null; + } + } + + requestCompletion(input, data, trigger = 'user') { + this.abort(); + + this.nextSuggestion = setTimeout(() => { + let req = new XMLHttpRequest(); + req.open('POST', this.input.dataset.suggestUrl, true); + req.setRequestHeader('Content-Type', 'application/json'); + + if (typeof icinga !== 'undefined') { + let windowId = icinga.ui.getWindowId(); + let containerId = icinga.ui.getUniqueContainerId(this.termSuggestions); + if (containerId) { + req.setRequestHeader('X-Icinga-WindowId', windowId + '_' + containerId); + } else { + req.setRequestHeader('X-Icinga-WindowId', windowId); + } + } + + req.addEventListener('loadend', () => { + if (req.readyState > 0) { + if (req.responseText) { + let suggestions = this.renderSuggestions(req.responseText); + if (trigger === 'script') { + // If the suggestions are to be displayed due to a scripted event, + // show them only if the completed input is still focused.. + if (document.activeElement === input) { + this.showSuggestions(suggestions, input); + } + } else { + this.showSuggestions(suggestions, input); + } + } else { + this.hideSuggestions(); + } + } + + this.activeSuggestion = null; + this.nextSuggestion = null; + }); + + req.send(JSON.stringify(data)); + + this.activeSuggestion = req; + }, 200); + } + + suggest(input, value, data = {}) { + if (this.instrumented) { + if (! Object.keys(data).length) { + data = value; + } + + $(input).trigger('suggestion', data); + } else { + input.value = value; + } + } + + complete(input, value, data) { + $(input).focus({ scripted: true }); + + if (this.instrumented) { + if (! Object.keys(data).length) { + data = value; + } + + $(input).trigger('completion', data); + } else { + input.value = value; + + for (let name in data) { + let dataElement = input.form[input.name + '-' + name]; + if (typeof dataElement !== 'undefined') { + if (dataElement instanceof RadioNodeList) { + dataElement = dataElement[dataElement.length - 1]; + } + + dataElement.value = data[name]; + } else if (name === 'title') { + input.title = data[name]; + } + } + } + + this.hideSuggestions(); + } + + moveToSuggestion(backwards = false) { + let focused = this.termSuggestions.querySelector('[type="button"]:focus'); + let inputs = Array.from(this.termSuggestions.querySelectorAll('[type="button"]')); + + let input; + if (focused !== null) { + let sibling = inputs[backwards ? inputs.indexOf(focused) - 1 : inputs.indexOf(focused) + 1]; + if (sibling) { + input = sibling; + } else { + input = this.completedInput; + } + } else { + input = inputs[backwards ? inputs.length - 1 : 0]; + } + + $(input).focus(); + + if (this.completedValue !== null) { + if (input === this.completedInput) { + this.suggest(this.completedInput, this.completedValue); + } else { + this.suggest(this.completedInput, input.value, { ...input.dataset }); + } + } + + return input; + } + + isBeingCompleted(input, activeElement = null) { + if (activeElement === null) { + activeElement = document.activeElement; + } + + return input === this.completedInput && ( + (! activeElement && this.hasSuggestions()) + || (activeElement && this.termSuggestions.contains(activeElement)) + ); + } + + /** + * Event listeners + */ + + onSubmit(event) { + // Reset all states, the user is about to navigate away + this.reset(); + } + + onFocusOut(event) { + if (this.completedInput === null) { + // If there are multiple instances of Completer bound to the same suggestion container + // all of them try to handle the event. Though, only one of them is responsible and + // that's the one which has a completed input set. + return; + } + + let input = event.target; + let completedInput = this.completedInput; + this.suggestionKiller = setTimeout(() => { + if (completedInput !== this.completedInput) { + // Don't hide another input's suggestions + } else if (document.activeElement !== completedInput + && ! this.termSuggestions.contains(document.activeElement) + ) { + // Hide the suggestions if the user doesn't navigate them + if (input !== completedInput) { + // Restore input if a suggestion lost focus + this.suggest(completedInput, this.completedValue); + } + + this.hideSuggestions(); + } + }, 250); + } + + onSuggestionKeyDown(event) { + if (this.completedInput === null) { + return; + } + + switch (event.key) { + case 'Escape': + $(this.completedInput).focus({ scripted: true }); + this.suggest(this.completedInput, this.completedValue); + break; + case 'Tab': + event.preventDefault(); + this.moveToSuggestion(event.shiftKey); + break; + case 'ArrowLeft': + case 'ArrowUp': + event.preventDefault(); + this.moveToSuggestion(true); + break; + case 'ArrowRight': + case 'ArrowDown': + event.preventDefault(); + this.moveToSuggestion(); + break; + } + } + + onSuggestionClick(event) { + if (this.completedInput === null) { + return; + } + + let input = event.currentTarget; + + this.complete(this.completedInput, input.value, { ...input.dataset }); + } + + onKeyDown(event) { + let suggestions; + + switch (event.key) { + case ' ': + if (this.instrumented) { + break; + } + + let input = event.target; + + if (! input.value) { + if (input.minLength <= 0) { + let [value, data] = this.prepareCompletionData(input); + this.completedInput = input; + this.completedValue = value; + this.completedData = data; + this.requestCompletion(input, data); + } + + event.preventDefault(); + } + + break; + case 'Tab': + suggestions = this.termSuggestions.querySelectorAll('[type="button"]'); + if (suggestions.length === 1) { + event.preventDefault(); + let input = event.target; + let suggestion = suggestions[0]; + + this.complete(input, suggestion.value, { ...suggestion.dataset }); + } + + break; + case 'Enter': + let defaultSuggestion = this.termSuggestions.querySelector('.default > [type="button"]'); + if (defaultSuggestion !== null) { + event.preventDefault(); + let input = event.target; + + this.complete(input, defaultSuggestion.value, { ...defaultSuggestion.dataset }); + } + + break; + case 'Escape': + if (this.hasSuggestions()) { + this.hideSuggestions() + event.preventDefault(); + } + + break; + case 'ArrowUp': + suggestions = this.termSuggestions.querySelectorAll('[type="button"]'); + if (suggestions.length) { + event.preventDefault(); + this.moveToSuggestion(true); + } + + break; + case 'ArrowDown': + suggestions = this.termSuggestions.querySelectorAll('[type="button"]'); + if (suggestions.length) { + event.preventDefault(); + this.moveToSuggestion(); + } + + break; + default: + if (/[A-Z]/.test(event.key.charAt(0)) || event.key === '"') { + // Ignore control keys not resulting in new input data + break; + } + + let typedSuggestion = this.termSuggestions.querySelector(`[value="${ event.key }"]`); + if (typedSuggestion !== null) { + this.hideSuggestions(); + } + } + } + + onInput(event) { + let input = event.target; + + if (input.minLength > 0 && input.value.length < input.minLength) { + return; + } + + // Set the input's value as search value. This ensures that if the user doesn't + // choose a suggestion, an up2date contextual value will be transmitted with + // completion requests and the server can properly identify a new value upon submit + input.dataset.search = input.value; + if (typeof input.form[input.name + '-search'] !== 'undefined') { + let dataElement = input.form[input.name + '-search']; + if (dataElement instanceof RadioNodeList) { + dataElement = dataElement[dataElement.length - 1]; + } + + dataElement.value = input.value; + } + + let [value, data] = this.prepareCompletionData(input); + this.completedInput = input; + this.completedValue = value; + this.completedData = data; + this.requestCompletion(input, data); + } + + onComplete(event) { + let input = event.target; + let { trigger = 'user' , ...detail } = event.detail; + + let [value, data] = this.prepareCompletionData(input, detail); + this.completedInput = input; + this.completedValue = value; + this.completedData = data; + + if (typeof data.suggestions !== 'undefined') { + this.showSuggestions(data.suggestions, input); + } else { + this.requestCompletion(input, data, trigger); + } + } + } + + return Completer; +}); |