310 lines
7.2 KiB
TypeScript
310 lines
7.2 KiB
TypeScript
import {
|
|
AfterViewInit,
|
|
ChangeDetectorRef,
|
|
Component,
|
|
EventEmitter,
|
|
HostListener,
|
|
Input,
|
|
OnInit,
|
|
Output,
|
|
ViewChild,
|
|
ViewEncapsulation
|
|
} from '@angular/core'
|
|
|
|
export type OnLoadingMoreEvent = {
|
|
loadMoreFinished: (moreExists: boolean) => void
|
|
}
|
|
|
|
@Component({
|
|
selector: 'app-autocomplete',
|
|
templateUrl: './autocomplete.component.html',
|
|
styleUrls: ['./autocomplete.component.scss'],
|
|
encapsulation: ViewEncapsulation.None,
|
|
standalone: false
|
|
})
|
|
export class AutocompleteComponent implements OnInit, AfterViewInit {
|
|
@ViewChild('input') inputElement: any
|
|
|
|
@Input() inputId: string = ''
|
|
@Input() placeholder: string = ''
|
|
@Input() value: string = ''
|
|
@Input() disabled: boolean = false
|
|
@Input() type: string = 'text'
|
|
@Input() autocomplete: string = 'off'
|
|
@Input() tabindex: string = ''
|
|
@Input() emitOnlySelected: boolean = false
|
|
@Input() enableLoadMore: boolean = false
|
|
|
|
@Output() onInputKeydown: EventEmitter<any> = new EventEmitter()
|
|
@Output() onInputChange: EventEmitter<any> = new EventEmitter()
|
|
@Output() valueChange: EventEmitter<any> = new EventEmitter()
|
|
@Output() onLoadingMore: EventEmitter<OnLoadingMoreEvent> = new EventEmitter()
|
|
|
|
datalistOpen: boolean = false
|
|
afterOpenLockDelay: number = 150
|
|
afterOpenLockDelayTimeout: any
|
|
afterOpenLock: boolean = false
|
|
|
|
inputFocused: boolean = false
|
|
inputClientRect: {
|
|
width?: number
|
|
bottom?: number
|
|
} = {}
|
|
innerHeight: number | undefined
|
|
datalistCloseTimeout: any
|
|
preventDatalistClose: boolean = false
|
|
|
|
loadingMore: boolean = false
|
|
|
|
constructor(private cdr: ChangeDetectorRef) {}
|
|
|
|
ngOnInit(): void {}
|
|
|
|
ngAfterViewInit(): void {
|
|
setTimeout(() => {
|
|
this.calculateInputPosition()
|
|
}, 500)
|
|
}
|
|
|
|
onInputFired(event?: any, emitEvent?: boolean, initiator?: 'click') {
|
|
this.openDatalist(initiator !== 'click')
|
|
this.unselectAllOptions()
|
|
this.filterOptionsList()
|
|
|
|
if (!this.emitOnlySelected || emitEvent) {
|
|
this.onInputChange.emit({ target: { value: this.value } })
|
|
this.valueChange.emit(this.value)
|
|
}
|
|
}
|
|
|
|
onInputClick(event: any) {
|
|
this.openDatalist()
|
|
}
|
|
|
|
openDatalist(lockDelay: boolean = true) {
|
|
this.datalistOpen = true
|
|
|
|
if (lockDelay) {
|
|
this.afterOpenLock = true
|
|
|
|
this.afterOpenLockDelayTimeout = setTimeout(() => {
|
|
this.afterOpenLock = false
|
|
}, this.afterOpenLockDelay)
|
|
}
|
|
}
|
|
|
|
closeDatalist() {
|
|
clearTimeout(this.afterOpenLockDelayTimeout)
|
|
this.afterOpenLock = false
|
|
this.datalistOpen = false
|
|
}
|
|
|
|
resetDatalistFilter() {
|
|
this.filterOptionsList(true)
|
|
}
|
|
|
|
onInputFocusin(event: any) {
|
|
this.inputFocused = true
|
|
}
|
|
|
|
onInputFocusout(event: any) {
|
|
this.inputFocused = false
|
|
|
|
this.setToCloseDatalist()
|
|
}
|
|
|
|
onInputMouseenter(event: any) {
|
|
// If width not set it means calculation failed on init (input was hidden)
|
|
if (!this.inputClientRect.width) this.calculateInputPosition()
|
|
}
|
|
|
|
filterOptionsList(showAll?: boolean) {
|
|
const options = document.querySelectorAll(
|
|
`#datalist_${this.inputId} option`
|
|
)
|
|
|
|
for (let i = 0; i < options.length; i++) {
|
|
const option = options[i]
|
|
|
|
if (this.value.length === 0) {
|
|
option.classList.remove('hidden')
|
|
|
|
continue
|
|
}
|
|
|
|
if (showAll) {
|
|
option.classList.remove('hidden')
|
|
|
|
continue
|
|
}
|
|
|
|
const optionLabel = (option as any).innerText || ''
|
|
const optionValue = (option as any).value || ''
|
|
const combined = optionLabel + optionValue
|
|
|
|
if (combined?.toLowerCase().includes(this.value.toLowerCase())) {
|
|
option.classList.remove('hidden')
|
|
} else {
|
|
option.classList.add('hidden')
|
|
}
|
|
}
|
|
}
|
|
|
|
setToCloseDatalist() {
|
|
this.datalistCloseTimeout = setTimeout(() => {
|
|
if (this.preventDatalistClose || this.afterOpenLock) return
|
|
|
|
this.closeDatalist()
|
|
this.unselectAllOptions()
|
|
this.resetDatalistFilter()
|
|
}, 100)
|
|
}
|
|
|
|
calculateInputPosition() {
|
|
const inputElement = this.inputElement.nativeElement
|
|
const clientRect = inputElement?.getBoundingClientRect()
|
|
|
|
if (clientRect.width > 0 && clientRect.height > 0) {
|
|
this.inputClientRect = {
|
|
width: clientRect.width,
|
|
bottom: clientRect.bottom
|
|
}
|
|
}
|
|
|
|
this.innerHeight = window.innerHeight
|
|
}
|
|
|
|
onDataListMouseEnter(event: any) {
|
|
this.preventDatalistClose = true
|
|
}
|
|
|
|
onDataListMouseLeave(event: any) {
|
|
this.preventDatalistClose = false
|
|
this.inputElement.nativeElement.focus()
|
|
}
|
|
|
|
onDataListScroll(event: any) {
|
|
this.setToCloseDatalist()
|
|
}
|
|
|
|
onDataListClick(event: any) {
|
|
const clickedOption = event.target
|
|
const itemType = clickedOption.dataset?.type
|
|
|
|
if (itemType === 'load-more') {
|
|
this.loadMore()
|
|
return
|
|
}
|
|
|
|
if (clickedOption.tagName === 'OPTION') {
|
|
this.setOptionValue(clickedOption.innerText, clickedOption.value)
|
|
return
|
|
}
|
|
}
|
|
|
|
loadMore() {
|
|
this.onLoadingMore.emit({
|
|
loadMoreFinished: (moreExists: boolean) => {
|
|
this.loadingMore = false
|
|
this.enableLoadMore = moreExists
|
|
}
|
|
})
|
|
this.loadingMore = true
|
|
}
|
|
|
|
setOptionValue(label: string, value: string) {
|
|
if (label || value) {
|
|
if (!value) value = label
|
|
}
|
|
|
|
if (value) {
|
|
this.preventDatalistClose = false
|
|
this.value = value
|
|
this.onInputFired(null, true, 'click')
|
|
this.setToCloseDatalist()
|
|
}
|
|
}
|
|
|
|
onMainKeydown(event: KeyboardEvent) {
|
|
switch (event.key) {
|
|
case 'ArrowUp': {
|
|
this.getOptionsMoveFocus('up')
|
|
break
|
|
}
|
|
case 'ArrowDown': {
|
|
this.getOptionsMoveFocus('down')
|
|
break
|
|
}
|
|
case 'Enter': {
|
|
this.selectFocusedOption()
|
|
break
|
|
}
|
|
}
|
|
}
|
|
|
|
onInputKeyup(event: any) {
|
|
if (!this.datalistOpen) {
|
|
return true
|
|
}
|
|
|
|
// We prevent propagation of Escape event
|
|
// since it would close modal also if input is
|
|
// inside of it (or trigger any other actions that are listening on escape)
|
|
if (event.key === 'Escape') {
|
|
event.stopPropagation()
|
|
event.stopImmediatePropagation()
|
|
|
|
this.setToCloseDatalist()
|
|
|
|
return false
|
|
}
|
|
}
|
|
|
|
unselectAllOptions() {
|
|
const options = document.querySelectorAll(
|
|
`#datalist_${this.inputId} option`
|
|
)
|
|
|
|
for (let i = 0; i < options.length; i++) {
|
|
options[i].classList.remove('focused')
|
|
}
|
|
}
|
|
|
|
selectFocusedOption() {
|
|
const option = document.querySelector(
|
|
`#datalist_${this.inputId} option.focused`
|
|
)
|
|
|
|
if (option) this.onDataListClick({ target: option })
|
|
|
|
this.setToCloseDatalist()
|
|
}
|
|
|
|
getOptionsMoveFocus(direction: 'up' | 'down') {
|
|
const options = document.querySelectorAll(
|
|
`#datalist_${this.inputId} option:not(.hidden)`
|
|
)
|
|
let focusedIndex = 0
|
|
|
|
for (let i = 0; i < options.length; i++) {
|
|
const option = options[i]
|
|
|
|
if (option.classList.contains('focused')) {
|
|
focusedIndex = direction === 'down' ? i + 1 : i - 1
|
|
if (focusedIndex < 0) focusedIndex = options.length - 1
|
|
if (focusedIndex > options.length - 1) focusedIndex = 0
|
|
option.classList.remove('focused')
|
|
|
|
break
|
|
}
|
|
}
|
|
|
|
options[focusedIndex].classList.add('focused')
|
|
}
|
|
|
|
@HostListener('window:resize', ['$event'])
|
|
onResize(event: any) {
|
|
this.calculateInputPosition()
|
|
}
|
|
}
|