feat(filter): add autocompletion poc for labels

This commit is contained in:
kolaente 2024-03-06 17:59:00 +01:00
parent 356399f853
commit 7fc1f27ef5
Signed by: konrad
GPG Key ID: F40E70337AB24C9B
2 changed files with 307 additions and 25 deletions

View File

@ -0,0 +1,237 @@
<script setup lang="ts">
import {type ComponentPublicInstance, computed, nextTick, ref, watch} from 'vue'
const TAB = 9,
ENTER = 13,
ESCAPE = 27,
ARROW_UP = 38,
ARROW_DOWN = 40
type state = 'unfocused' | 'focused'
const selectedIndex = ref(-1)
const state = ref<state>('unfocused')
const val = ref<string>('')
const isResizing = ref(false)
const model = defineModel<string>()
const suggestionScrollerRef = ref<HTMLInputElement | null>(null)
const containerRef = ref<HTMLInputElement | null>(null)
const editorRef = ref<HTMLInputElement | null>(null)
watch(
() => model.value,
newValue => {
val.value = newValue
},
)
const emit = defineEmits(['blur'])
const placeholderText = computed(() => {
const value = (model.value || '').replace(/[\n\r\t]/gi, ' ')
if (state.value === 'unfocused') {
return value ? '' : props.suggestion
}
if (!value || !value.trim()) {
return props.suggestion
}
return lookahead()
})
const spacerText = computed(() => {
const value = (model.value || '').replace(/[\n\r\t]/gi, ' ')
if (!value || !value.trim()) {
return props.suggestion
}
return value
})
const props = withDefaults(defineProps<{
options: any[],
suggestion?: string,
maxHeight?: number,
}>(), {
maxHeight: 200,
})
function lookahead() {
if (!props.options.length) {
return model.value
}
const index = Math.max(0, Math.min(selectedIndex.value, props.options.length - 1))
const match = props.options[index]
return model.value + (match ? match.substring(model.value?.length) : '')
}
function updateSuggestionScroll() {
nextTick(() => {
const scroller = suggestionScrollerRef.value
const selectedItem = scroller?.querySelector('.selected')
scroller.scrollTop = selectedItem ? selectedItem.offsetTop : 0
})
}
function updateScrollWindowSize() {
if (isResizing.value) {
return
}
isResizing.value = true
nextTick(() => {
isResizing.value = false
const scroller = suggestionScrollerRef.value
const parent = containerRef.value
if (scroller) {
const rect = parent.getBoundingClientRect()
const pxTop = rect.top
const pxBottom = window.innerHeight - rect.bottom
const maxHeight = Math.max(pxTop, pxBottom, props.maxHeight)
const isReversed = pxBottom < props.maxHeight && pxTop > pxBottom
scroller.style.maxHeight = Math.min(isReversed ? pxTop : pxBottom, props.maxHeight) + 'px'
scroller.parentNode.style.transform =
isReversed ? 'translateY(-100%) translateY(-1.4rem)'
: 'translateY(.4rem)'
}
})
}
function setState(stateName: state) {
state.value = stateName
if (stateName === 'unfocused') {
emit('blur')
} else {
updateScrollWindowSize()
}
}
function onFocusField(e) {
setState('focused')
}
function onKeydown(e) {
switch (e.keyCode || e.which) {
case ESCAPE:
e.preventDefault()
setState('unfocused')
break
case ARROW_UP:
e.preventDefault()
select(-1)
break
case ARROW_DOWN:
e.preventDefault()
select(1)
break
}
}
const resultRefs = ref<(HTMLElement | null)[]>([])
function setResultRefs(el: Element | ComponentPublicInstance | null, index: number) {
resultRefs.value[index] = el as (HTMLElement | null)
}
function select(offset: number) {
let index = selectedIndex.value + offset
if (!isFinite(index)) {
index = 0
}
if (index >= props.options.length) {
// At the last index, now moving back to the top
index = 0
}
if (index < 0) {
// Arrow up but we're already at the top
index = props.options.length - 1
}
let elems = resultRefs.value[index]
if (
typeof elems === 'undefined'
) {
return
}
selectedIndex.value = index
updateSuggestionScroll()
if (Array.isArray(elems)) {
elems[0].focus()
return
}
elems?.focus()
}
function onSelectValue(value) {
model.value = value
selectedIndex.value = 0
setState('unfocused')
}
function onUpdateField(e) {
setState('focused')
model.value = e.currentTarget.value
}
</script>
<template>
<div class="autocomplete" ref="containerRef">
<div class="entry-box">
<slot
name="input"
:spacerText
:placeholderText
:onUpdateField
:onFocusField
:onKeydown
>
<div class="spacer">{{ spacerText }}</div>
<div class="placeholder">{{ placeholderText }}</div>
<textarea class="field"
@input="onUpdateField"
@focus="onFocusField"
@keydown="onKeydown"
:class="state"
:value="val"
ref="editorRef"></textarea>
</slot>
</div>
<div class="suggestion-list" v-if="state === 'focused' && options.length">
<div v-if="options && options.length" class="scroll-list">
<div
class="items"
ref="suggestionScrollerRef"
@keydown="onKeydown"
>
<button
v-for="(item, index) in options"
class="item"
@click="onSelectValue(item)"
:class="{ selected: index === selectedIndex }"
:key="item"
:ref="(el: Element | ComponentPublicInstance | null) => setResultRefs(el, index)"
>
<slot
name="result"
:item
:selected="index === selectedIndex"
>
{{ item }}
</slot>
</button>
</div>
</div>
</div>
</div>
</template>

View File

@ -5,6 +5,7 @@ import DatepickerWithValues from '@/components/date/datepickerWithValues.vue'
import UserService from '@/services/user'
import {getAvatarUrl, getDisplayName} from '@/models/user'
import {createRandomID} from '@/helpers/randomId'
import AutocompleteDropdown from '@/components/input/AutocompleteDropdown.vue'
const {
modelValue,
@ -40,14 +41,18 @@ const assigneeFields = [
'assignees',
]
const labelFields = [
'labels',
]
const availableFilterFields = [
'done',
'priority',
'usePriority',
'percentDone',
'labels',
...dateFields,
...assigneeFields,
...labelFields,
]
const filterOperators = [
@ -69,6 +74,9 @@ const filterJoinOperators = [
')',
]
const FILTER_OPERATORS_REGEX = '(&lt;|&gt;|&lt;=|&gt;=|=|!=)'
const FILTER_JOIN_OPERATORS_REGEX = '(&amp;&amp;|\|\||\(|\))'
function escapeHtml(unsafe: string): string {
return unsafe
.replace(/&/g, '&amp;')
@ -91,7 +99,7 @@ const highlightedFilterQuery = computed(() => {
let highlighted = escapeHtml(filterQuery.value)
dateFields
.forEach(o => {
const pattern = new RegExp(o + '(\\s*)(&lt;|&gt;|&lt;=|&gt;=|=|!=)(\\s*)([\'"]?)([^\'"\\s]+\\1?)?', 'ig')
const pattern = new RegExp(o + '(\\s*)' + FILTER_OPERATORS_REGEX + '(\\s*)([\'"]?)([^\'"\\s]+\\1?)?', 'ig')
highlighted = highlighted.replaceAll(pattern, (match, spacesBefore, token, spacesAfter, start, value, position) => {
if (typeof value === 'undefined') {
value = ''
@ -102,7 +110,7 @@ const highlightedFilterQuery = computed(() => {
})
assigneeFields
.forEach(f => {
const pattern = new RegExp(f + '\\s*(&lt;|&gt;|&lt;=|&gt;=|=|!=)\\s*([\'"]?)([^\'"\\s]+\\1?)?', 'ig')
const pattern = new RegExp(f + '\\s*' + FILTER_OPERATORS_REGEX + '\\s*([\'"]?)([^\'"\\s]+\\1?)?', 'ig')
highlighted = highlighted.replaceAll(pattern, (match, token, start, value) => {
if (typeof value === 'undefined') {
value = ''
@ -189,33 +197,70 @@ function updateDateInQuery(newDate: string) {
currentOldDatepickerValue.value = newDate
filterQuery.value = unEscapeHtml(escaped)
}
function handleFieldInput(e, autocompleteOnInput) {
const cursorPosition = filterInput.value.selectionStart
const textUpToCursor = filterQuery.value.substring(0, cursorPosition)
labelFields.forEach(l => {
const pattern = new RegExp('(' + l + '\\s*' + FILTER_OPERATORS_REGEX + '\\s*)([\'"]?)([^\'"&\|\(\)]+\\1?)?$', 'ig')
const match = pattern.exec(textUpToCursor)
if (match !== null) {
const [matched, prefix, operator, space, keyword] = match
if (keyword) {
autocompleteResults.value = ['loool', keyword]
}
}
})
}
const autocompleteResults = ref<any[]>([])
</script>
<template>
<div class="field">
<label class="label">{{ $t('filters.query.title') }}</label>
<div class="control filter-input">
<textarea
autocomplete="off"
autocorrect="off"
autocapitalize="off"
spellcheck="false"
v-model="filterQuery"
class="input"
ref="filterInput"
></textarea>
<div
class="filter-input-highlight"
:style="{'height': height}"
v-html="highlightedFilterQuery"
></div>
<DatepickerWithValues
v-model="currentDatepickerValue"
:open="datePickerPopupOpen"
@close="() => datePickerPopupOpen = false"
@update:model-value="updateDateInQuery"
/>
</div>
<AutocompleteDropdown
:options="autocompleteResults"
@blur="filterInput?.blur()"
>
<template
v-slot:input="{ onKeydown, onFocusField, onUpdateField }"
>
<div class="control filter-input">
<textarea
@input="e => handleFieldInput(e, onUpdateField)"
@focus="onFocusField"
@keydown="onKeydown"
autocomplete="off"
autocorrect="off"
autocapitalize="off"
spellcheck="false"
v-model="filterQuery"
class="input"
ref="filterInput"
></textarea>
<div
class="filter-input-highlight"
:style="{'height': height}"
v-html="highlightedFilterQuery"
></div>
<DatepickerWithValues
v-model="currentDatepickerValue"
:open="datePickerPopupOpen"
@close="() => datePickerPopupOpen = false"
@update:model-value="updateDateInQuery"
/>
</div>
</template>
<template
v-slot:result="{ item }"
>
whoo {{ item }}
</template>
</AutocompleteDropdown>
</div>
</template>