define(["../notjQuery"], function ($) { "use strict"; class Completer { constructor(input, instrumented = false) { this.input = input; this.instrumented = instrumented; this.selectionStartInput = null; this.selectionActive = false; this.mouseSelectionActive = false; 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.selectionEnabled()) { $(this.termSuggestions).on('keyup', '[type="button"]', this.onSuggestionKeyUp, this); $(this.termSuggestions).on('mouseover', '[type="button"]', this.onSuggestionMouseOver, this); $(this.termSuggestions).on('mousedown', '[type="button"]', this.onSuggestionMouseDown, this); $(this.termSuggestions).on('mouseup', '[type="button"]', this.onSuggestionsMouseUp, this); $(this.termSuggestions).on('mouseleave', this.onSuggestionsMouseLeave, 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; this.endSelection(); } 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('X-Requested-With', 'XMLHttpRequest'); 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, stopAtEdge = 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 if (stopAtEdge) { return null; } else { input = this.completedInput; } } else { input = inputs[backwards ? inputs.length - 1 : 0]; } $(input).focus(); if (! stopAtEdge && 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 && this.hasSuggestions() && (! activeElement || input === activeElement || this.termSuggestions.contains(activeElement)); } selectionEnabled() { return this.instrumented && 'withMultiCompletion' in this.input.dataset; } selectionAllowed() { return this.completedInput === this.input && this.selectionEnabled(); } startSelection(input) { this.selectionActive = true; this.selectionStartInput = input; } isSelectionActive() { return this.selectionActive; } endSelection() { this.selectionStartInput = null; this.selectionActive = false; this.mouseSelectionActive = false; } selectSuggestion(input) { input.classList.add('selected'); } deselectSuggestion(input) { input.classList.remove('selected'); } toggleSelection(input) { input.classList.toggle('selected'); let selected = input.classList.contains('selected'); if (selected && ! this.isSelectionActive()) { this.startSelection(input); $(input).focus(); } if (! selected && input === this.selectionStartInput) { this.selectionStartInput = this.termSuggestions.querySelector('[type="button"].selected'); if (! this.selectionStartInput) { this.endSelection(); $(this.input).focus(); } else { $(this.selectionStartInput).focus(); } } return selected; } isSelectedSuggestion(input) { return input.classList.contains('selected'); } getSelectedSuggestions() { return this.termSuggestions.querySelectorAll('[type="button"].selected'); } clearSelection() { if (! this.isSelectionActive()) { return; } for (const selectedInput of this.getSelectedSuggestions()) { this.deselectSuggestion(selectedInput); } this.endSelection(); } handleKeySelection(input, newInput) { if (! this.isSelectionActive()) { this.startSelection(input); this.selectSuggestion(input); this.selectSuggestion(newInput); this.suggest(this.completedInput, ''); } else if (this.isSelectedSuggestion(newInput)) { this.deselectSuggestion(input); } else { this.selectSuggestion(newInput); } } startMouseSelection(input) { this.startSelection(input); this.mouseSelectionActive = true; } isMouseSelectionActive() { return this.mouseSelectionActive; } finishMouseSelection() { if (! this.mouseSelectionActive) { return; } this.mouseSelectionActive = false; this.selectSuggestion(this.selectionStartInput); let selectionFound = false; let selectionCandidates = []; for (const input of this.termSuggestions.querySelectorAll('[type="button"]')) { if (input.classList.contains('selected')) { if (selectionFound) { for (const candidate of selectionCandidates) { this.selectSuggestion(candidate); } selectionCandidates = []; } else { selectionFound = true; } } else if (selectionFound) { selectionCandidates.push(input); } } } /** * Event listeners */ onSubmit(event) { if (! event.detail || ! event.detail.submittedBy) { // 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); } onSuggestionMouseDown(event) { if (! this.selectionAllowed()) { return; } if (event.ctrlKey || event.metaKey) { // onSuggestionClick only toggles the suggestion's selection and should // be the only one who decides which other suggestion should be focused event.preventDefault(); } else { this.clearSelection(); this.startMouseSelection(event.target); } } onSuggestionsMouseUp(event) { if (! event.ctrlKey && ! event.metaKey) { this.finishMouseSelection(); } } onSuggestionsMouseLeave(_) { this.finishMouseSelection(); } onSuggestionMouseOver(event) { if (this.isMouseSelectionActive()) { this.selectSuggestion(event.target); } } onSuggestionKeyUp(event) { if (this.completedInput === null) { return; } let input = event.target; switch (event.key) { case 'Shift': if (this.isSelectionActive()) { event.preventDefault(); if (input === this.selectionStartInput && this.getSelectedSuggestions().length === 1) { this.deselectSuggestion(input); this.endSelection(); } } break; } } onSuggestionKeyDown(event) { if (this.completedInput === null) { return; } let newInput; let input = event.target; let allowSelection = event.shiftKey && this.selectionAllowed(); switch (event.key) { case 'Escape': $(this.completedInput).focus({ scripted: true }); this.suggest(this.completedInput, this.completedValue); this.clearSelection(); break; case 'Tab': event.preventDefault(); this.moveToSuggestion(event.shiftKey); break; case 'ArrowLeft': case 'ArrowUp': event.preventDefault(); newInput = this.moveToSuggestion(true, allowSelection); if (allowSelection) { if (newInput !== null) { this.handleKeySelection(input, newInput); } } else { this.clearSelection(); } break; case 'ArrowRight': case 'ArrowDown': event.preventDefault(); newInput = this.moveToSuggestion(false, allowSelection); if (allowSelection) { if (newInput !== null) { this.handleKeySelection(input, newInput); } } else { this.clearSelection(); } break; } } onSuggestionClick(event) { if (this.completedInput === null) { return; } if (event.ctrlKey || event.metaKey) { if (this.selectionAllowed()) { this.toggleSelection(event.target); event.preventDefault(); } } else if (this.isSelectionActive() && this.isSelectedSuggestion(event.target)) { let terms = []; for (const suggestion of this.getSelectedSuggestions()) { terms.push({ ...suggestion.dataset }); } this.complete(this.completedInput, null, { type: 'terms', terms: terms }); } else { 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; });