import { IComponentController } from 'angular' import _ from 'lodash' import { Component, Inject, Input, Output } from '../decorators' export type AutoCompleteMatch = string | AutoCompleteMatchObject /** * The match object must contain a `displayValue` property which will be displayed in the dropdown, * and can be extended with a `cssClass` property which will be applied to the match row in the dropdown. */ export interface AutoCompleteMatchObject, S = unknown> { cssClass?: string cssStyle?: S displayValue: string value: T } export function isAutoCompleteMatchObject(x: AutoCompleteMatch): x is AutoCompleteMatchObject { return x.hasOwnProperty('value') } /** * This component is used internally by the primary autocomplete input component. * Its purpose is to decouple the rendering of matches, presentation, and input * handling so that the dropdown functionality can be implemented in other contexts * than just an input field (e.g., tag component, comment @ lookups). */ @Component({ selector: 'autoCompleteMatchRenderer', template: require('./auto-complete-match-renderer.component.html'), }) export default class AutoCompleteMatchRendererComponentController implements IComponentController { @Input() delay!: number @Input('@') keyEventElementId!: string // it must be an id to an HTMLInputElement @Input() minLength!: number @Input() testValue!: string | undefined @Output() getMatches!: (params: { input: string, origin: string }) => AutoCompleteMatch[] | ng.IPromise @Output() onDoneFetching!: () => void @Output() onSelect!: (params: { displayValue: string | undefined | null, origin: string, value: string | Record | undefined | null, }) => void @Output() onStartedFetching!: () => void protected interactionMode: 'mouse' | 'keyboard' = 'mouse' protected kbSelectedIndex = -1 protected keyEventElement: HTMLInputElement | null = null protected matches: AutoCompleteMatch[] = [] private debouncedFetchAndSetMatches: () => void private fetchRequestCount: number = 0 private inputArray: string[] = [] private observer: MutationObserver constructor( @Inject('$document') protected $document: ng.IDocumentService, @Inject('$element') protected $element: ng.IAugmentedJQuery, @Inject('$q') protected $q: ng.IQService, @Inject('$scope') protected $scope: ng.IScope, @Inject('$timeout') protected $timeout: ng.ITimeoutService, ) {} $onChanges(changes: { delay: ng.IChangesObject }) { if (changes.delay) { this.debouncedFetchAndSetMatches = _.debounce(() => this.fetchAndSetMatches(), changes.delay.currentValue) } } $onDestroy() { if (this.keyEventElement) { this.keyEventElement.removeEventListener('input', this.debouncedFetchAndSetMatches) this.keyEventElement.removeEventListener('keydown', this.onKeyDown) } const body = (this.$document[0] as Document).body body.removeEventListener('click', this.onClickElsewhere) this.observer.disconnect() } $onInit() { if (!this.getMatches) { throw new Error(`You must provide a function to get-matches on the AutoCompleteMatchRenderer.`) } if (!this.onSelect) { throw new Error(`You must provide a function to on-select on the AutoCompleteMatchRenderer.`) } if (!this.keyEventElementId) { throw new Error(`You must provide a string to key-event-element-id on the AutoCompleteMatchRenderer.`) } const body = (this.$document[0] as Document).body body.addEventListener('click', this.onClickElsewhere) // Set up an observer that will find the input element and attach events when it is present. this.observer = new MutationObserver(() => { // expect the tag-input component to be defined in a div with the id autocomplete-tag-lookup // this allows us to more reliably find the input to listen to when there are multiple instances of this combination const target = this.$element.parent().find(`#${this.keyEventElementId}`)[0] if (target) { this.keyEventElement = target as HTMLInputElement this.keyEventElement.addEventListener('input', this.debouncedFetchAndSetMatches) this.keyEventElement.addEventListener('keydown', this.onKeyDown) this.observer.disconnect() } }) this.observer.observe(body, { attributes: true, childList: true, characterData: false, subtree: true }) } // When the user mouses over the dropdown, we should remove any keyboard // highlighting, since the mouse hover provides it too. dropdownWasMousedOver() { this.interactionMode = 'mouse' this.kbSelectedIndex = -1 } // Note that we getMatches on `testValue` and not the input's value here. // This is to support a case where a consumer wants to match against a // partial string (e.g., autocompleting user name in a text area). // // If the `testValue` is not provided, we check against the keyEventElement's // value instead. This supports a scenario where there is no easy representation // of the `testValue` in the parent scope. fetchAndSetMatches() { const input = this.testValue || (this.keyEventElement ? this.keyEventElement.value : '') // If min length is set, skip fetching if the input string is not long enough. // Also, set matches to [] so that the dropdown updates if a user backspaces the // input to a string that is shorter than min-length. if (this.minLength && input.length < this.minLength) { this.$timeout(() => { this.matches = [] }) return } // each keystroke above the minLength will fire a new fetch request and we'll want to end with the 'last' input so put each 'input' in an array this.inputArray.push(input) if (this.onStartedFetching) { this.fetchRequestCount = this.fetchRequestCount + 1 this.onStartedFetching() } // Wrap the entire fetch in a timeout so that fetching callbacks will // be called correctly while the async part happens. this.$timeout(() => { const inputArrayIndex = this.fetchRequestCount - 1 this.$q.when(this.getMatches({ input: this.inputArray[inputArrayIndex], origin: this.keyEventElementId })) .then((matches) => { if (this.fetchRequestCount === 0) { return } // each set of matches could be shown when received but this can give a weird "flickering" effect depending on the async order of responses // instead, when the last thing in the current array of inputs is returned, use it & ignore the rest of the responses if ((inputArrayIndex === this.inputArray.length - 1) && this.onDoneFetching) { this.matches = matches this.onDoneFetching() // reset the count and array this.fetchRequestCount = 0 this.inputArray = [] } }) }) } matchWasClicked(match: AutoCompleteMatch) { this.matches = [] this.kbSelectedIndex = -1 this.onSelect({ displayValue: isAutoCompleteMatchObject(match) ? match.displayValue : match, origin: this.keyEventElementId, value: isAutoCompleteMatchObject(match) ? match.value : match, }) } private onClickElsewhere = ($event: MouseEvent) => { this.$timeout(() => { this.matches = [] }) } private onKeyDown = ($event: KeyboardEvent) => this.$timeout(() => { this.interactionMode = 'keyboard' // Enter with no selection: let the $event through if ($event.key === 'Enter' && this.kbSelectedIndex === -1) { return } // If the dropdown is closed, all we can do is arrow down to open the thing if (this.matches.length === 0 && $event.key !== 'ArrowDown') { return } // Escape: close the dropdown if ($event.key === 'Escape') { this.matches = [] } // Arrow down with dropdown closed: fetch matches if ($event.key === 'ArrowDown' && this.matches.length === 0) { this.fetchAndSetMatches() } // Arrow keys with dropdown open: move the selection highlight if ($event.key === 'ArrowDown' && this.matches.length > 0) { const idx = Math.min(this.kbSelectedIndex + 1, this.matches.length - 1) this.kbSelectedIndex = idx this.scrollToMatch(idx) } if ($event.key === 'ArrowUp') { const idx = Math.max(this.kbSelectedIndex - 1, 0) this.kbSelectedIndex = idx this.scrollToMatch(idx) } // Enter with a selection: set the selection to the currently highlighted match if ($event.key === 'Enter' && this.kbSelectedIndex > -1) { const match = this.matches[this.kbSelectedIndex] this.onSelect({ displayValue: isAutoCompleteMatchObject(match) ? match.displayValue : match, origin: this.keyEventElementId, value: isAutoCompleteMatchObject(match) ? match.value : match, }) this.matches = [] this.kbSelectedIndex = -1 $event.preventDefault() } $event.stopPropagation() }) private scrollToMatch(index: number) { const row = this.$element.find('.c-auto-complete__match-row')[index] as HTMLDivElement if (row) { row.scrollIntoView({ behavior: 'smooth', block: 'nearest' }) } } }