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; });