123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627 |
- <!-- Copyright (C) 2012-2024 Zammad Foundation, https://zammad-foundation.org/ -->
- <script setup lang="ts">
- import { useLazyQuery } from '@vue/apollo-composable'
- import {
- refDebounced,
- useElementBounding,
- useWindowSize,
- watchOnce,
- } from '@vueuse/core'
- import gql from 'graphql-tag'
- import { cloneDeep, escapeRegExp, isEqual } from 'lodash-es'
- import {
- computed,
- markRaw,
- nextTick,
- onMounted,
- ref,
- toRef,
- watch,
- type ConcreteComponent,
- type Ref,
- } from 'vue'
- import type { SelectOption } from '#shared/components/CommonSelect/types'
- import useValue from '#shared/components/Form/composables/useValue.ts'
- import type {
- AutoCompleteOption,
- AutocompleteSelectValue,
- } from '#shared/components/Form/fields/FieldAutocomplete/types.ts'
- import type { FormFieldContext } from '#shared/components/Form/types/field.ts'
- import useSelectOptions from '#shared/composables/useSelectOptions.ts'
- import { useTrapTab } from '#shared/composables/useTrapTab.ts'
- import { useFormBlock } from '#shared/form/useFormBlock.ts'
- import { i18n } from '#shared/i18n.ts'
- import { QueryHandler } from '#shared/server/apollo/handler/index.ts'
- import type { ObjectLike } from '#shared/types/utils.ts'
- import CommonInputSearch from '#desktop/components/CommonInputSearch/CommonInputSearch.vue'
- import CommonSelect from '#desktop/components/CommonSelect/CommonSelect.vue'
- import type { CommonSelectInstance } from '#desktop/components/CommonSelect/types'
- import FieldAutoCompleteOptionIcon from './FieldAutoCompleteOptionIcon.vue'
- import type {
- AutoCompleteProps,
- SelectOptionFunction,
- AutoCompleteOptionValueDictionary,
- } from './types.ts'
- import type { FormKitNode } from '@formkit/core'
- import type { NameNode, OperationDefinitionNode, SelectionNode } from 'graphql'
- interface Props {
- context: FormFieldContext<AutoCompleteProps>
- }
- const emit = defineEmits<{
- searchInteractionUpdate: [
- filter: string,
- optionValues: AutoCompleteOptionValueDictionary,
- selectOption: SelectOptionFunction,
- ]
- }>()
- const props = defineProps<Props>()
- const contextReactive = toRef(props, 'context')
- const { hasValue, valueContainer, currentValue, isCurrentValue, clearValue } =
- useValue<AutocompleteSelectValue>(contextReactive)
- // TODO: I think clearValue needs to wrapper for the full clear of the field (to remove some of the remembered stuff).
- const localOptions = ref(props.context.options || [])
- const {
- sortedOptions,
- appendedOptions,
- optionValueLookup,
- getSelectedOption,
- getSelectedOptionLabel,
- } = useSelectOptions<AutoCompleteOption[]>(localOptions, contextReactive)
- let areLocalOptionsReplaced = false
- const replacementLocalOptions: Ref<AutoCompleteOption[]> = ref(
- cloneDeep(localOptions),
- )
- onMounted(() => {
- if (props.context.options && areLocalOptionsReplaced) {
- replacementLocalOptions.value = [...props.context.options]
- }
- })
- watch(
- () => props.context.options,
- (options) => {
- localOptions.value = options || []
- },
- )
- // Remember current optionValueLookup in node context.
- contextReactive.value.optionValueLookup = optionValueLookup
- // Initial options prefill for non-multiple fields (multiple fields needs to be handled in
- // the form updater or via options prop).
- let rememberedInitialOptionFromBuilder: AutoCompleteOption | undefined
- const initialOptionBuilderHandler = (rootNode: FormKitNode) => {
- if (
- hasValue.value &&
- props.context.initialOptionBuilder &&
- !getSelectedOptionLabel(currentValue.value)
- ) {
- const initialOption = props.context.initialOptionBuilder(
- rootNode?.context?.initialEntityObject as ObjectLike,
- currentValue.value,
- props.context,
- )
- if (initialOption) {
- localOptions.value.push(initialOption)
- if (rememberedInitialOptionFromBuilder) {
- const rememberedOptionValue = rememberedInitialOptionFromBuilder.value
- localOptions.value = localOptions.value.filter(
- (option) => option.value !== rememberedOptionValue,
- )
- }
- rememberedInitialOptionFromBuilder = initialOption
- }
- }
- }
- if (!props.context.multiple && props.context.initialOptionBuilder) {
- const rootNode = props.context.node.at('$root')
- if (rootNode) {
- initialOptionBuilderHandler(rootNode)
- rootNode?.on('reset', ({ origin }) => {
- initialOptionBuilderHandler(origin)
- })
- }
- }
- const input = ref<HTMLDivElement>()
- const outputElement = ref<HTMLOutputElement>()
- const filter = ref('')
- const filterInput = ref<HTMLInputElement>()
- const select = ref<CommonSelectInstance>()
- const { activateTabTrap, deactivateTabTrap } = useTrapTab(input, true)
- const clearFilter = () => {
- filter.value = ''
- }
- const trimmedFilter = computed(() => filter.value.trim())
- const debouncedFilter = refDebounced(
- trimmedFilter,
- props.context.debounceInterval ?? 500,
- )
- const AutocompleteSearchDocument = gql`
- ${props.context.gqlQuery}
- `
- const additionalQueryParams = () => {
- if (typeof props.context.additionalQueryParams === 'function') {
- return props.context.additionalQueryParams()
- }
- return props.context.additionalQueryParams || {}
- }
- const autocompleteQueryHandler = new QueryHandler(
- useLazyQuery(
- AutocompleteSearchDocument,
- () => ({
- input: {
- query: debouncedFilter.value || props.context.defaultFilter || '',
- limit: props.context.limit,
- ...(additionalQueryParams() || {}),
- },
- }),
- () => ({
- enabled: !!(debouncedFilter.value || props.context.defaultFilter),
- cachePolicy: 'no-cache', // Do not use cache, because we want always up-to-date results.
- }),
- ),
- )
- if (props.context.defaultFilter) {
- autocompleteQueryHandler.load()
- } else {
- watchOnce(
- () => debouncedFilter.value,
- (newValue) => {
- if (!newValue.length) return
- autocompleteQueryHandler.load()
- },
- )
- }
- const autocompleteQueryResultKey = (
- (AutocompleteSearchDocument.definitions[0] as OperationDefinitionNode)
- .selectionSet.selections[0] as SelectionNode & { name: NameNode }
- ).name.value
- const autocompleteQueryResultOptions = computed<AutoCompleteOption[]>(
- (oldValue) => {
- const resultOptions =
- autocompleteQueryHandler.result().value?.[autocompleteQueryResultKey] ||
- []
- if (oldValue && isEqual(oldValue, resultOptions)) return oldValue
- return resultOptions
- },
- )
- const autocompleteOptions = computed(
- () => cloneDeep(autocompleteQueryResultOptions.value) || [],
- )
- const {
- sortedOptions: sortedAutocompleteOptions,
- selectOption: selectAutocompleteOption,
- getSelectedOption: getSelectedAutocompleteOption,
- getSelectedOptionIcon: getSelectedAutocompleteOptionIcon,
- optionValueLookup: autocompleteOptionValueLookup,
- } = useSelectOptions<AutoCompleteOption[]>(
- autocompleteOptions,
- toRef(props, 'context'),
- )
- const preprocessedAutocompleteOptions = computed(() => {
- if (!props.context.autocompleteOptionsPreprocessor)
- return sortedAutocompleteOptions.value
- return props.context.autocompleteOptionsPreprocessor(
- sortedAutocompleteOptions.value,
- )
- })
- const selectOption = (option: SelectOption, focus = false) => {
- selectAutocompleteOption(option as AutoCompleteOption)
- if (!props.context.multiple) {
- localOptions.value = [option as AutoCompleteOption]
- select.value?.closeDropdown()
- return
- }
- // If the current value contains the selected option, make sure it's added to the replacement list
- // if it's not already there.
- if (
- isCurrentValue(option.value) &&
- !replacementLocalOptions.value.some(
- (replacementLocalOption) => replacementLocalOption.value === option.value,
- )
- ) {
- replacementLocalOptions.value.push(option as AutoCompleteOption)
- }
- // Remove any extra options from the replacement list.
- replacementLocalOptions.value = replacementLocalOptions.value.filter(
- (replacementLocalOption) => isCurrentValue(replacementLocalOption.value),
- )
- if (!sortedOptions.value.some((elem) => elem.value === option.value)) {
- appendedOptions.value.push(option as AutoCompleteOption)
- }
- appendedOptions.value = appendedOptions.value.filter((elem) =>
- isCurrentValue(elem.value),
- )
- // Sort the replacement list according to the original order.
- replacementLocalOptions.value.sort(
- (a, b) =>
- sortedOptions.value.findIndex((option) => option.value === a.value) -
- sortedOptions.value.findIndex((option) => option.value === b.value),
- )
- if (focus !== true) return
- filterInput.value?.focus()
- }
- const availableOptions = computed<AutoCompleteOption[]>((oldValue) => {
- const currentOptions =
- filter.value || props.context.defaultFilter
- ? preprocessedAutocompleteOptions.value
- : sortedOptions.value
- if (oldValue && isEqual(oldValue, currentOptions)) return oldValue
- return currentOptions
- })
- const emitResultUpdated = () => {
- nextTick(() => {
- emit(
- 'searchInteractionUpdate',
- debouncedFilter.value,
- { ...autocompleteOptionValueLookup.value, ...optionValueLookup.value },
- selectOption,
- )
- })
- }
- watch(debouncedFilter, (newValue) => {
- if (newValue !== '' || props.context.defaultFilter) return
- emitResultUpdated()
- })
- watch(autocompleteQueryHandler.loading(), (newValue, oldValue) => {
- // We need not to trigger when query was started.
- if (newValue && !oldValue) return
- emitResultUpdated()
- })
- const deaccent = (s: string) =>
- s.normalize('NFD').replace(/[\u0300-\u036f]/g, '')
- const availableOptionsWithMatches = computed(() => {
- // Trim and de-accent search keywords and compile them as a case-insensitive regex.
- // Make sure to escape special regex characters!
- const filterRegex = new RegExp(
- escapeRegExp(deaccent(filter.value.trim())),
- 'i',
- )
- return availableOptions.value.map(
- (option) =>
- ({
- ...option,
- // Match options via their de-accented labels.
- match: filterRegex.exec(deaccent(option.label || String(option.value))),
- }) as AutoCompleteOption,
- )
- })
- const childOptions = ref<AutoCompleteOption[]>([])
- const showChildOptions = (option: AutoCompleteOption) => {
- if (!option.children) return
- childOptions.value = option.children
- }
- const clearChildOptions = () => {
- if (!childOptions.value.length) return
- childOptions.value = []
- }
- const displayOptions = computed(() => {
- if (childOptions.value.length) return childOptions.value
- return availableOptionsWithMatches.value
- })
- const suggestedOptionLabel = computed(() => {
- if (!filter.value || !availableOptionsWithMatches.value.length)
- return undefined
- const exactMatches = availableOptionsWithMatches.value.filter(
- (option) =>
- (option.label || option.value.toString())
- .toLowerCase()
- .indexOf(filter.value.toLowerCase()) === 0 &&
- (option.label || option.value.toString()).length > filter.value.length,
- )
- if (!exactMatches.length) return undefined
- return exactMatches[0].label || exactMatches[0].value.toString()
- })
- const inputElementBounds = useElementBounding(input)
- const windowSize = useWindowSize()
- const isBelowHalfScreen = computed(() => {
- return inputElementBounds.y.value > windowSize.height.value / 2
- })
- const openSelectDropdown = () => {
- if (select.value?.isOpen || props.context.disabled) return
- select.value?.openDropdown(inputElementBounds, windowSize.height)
- requestAnimationFrame(() => {
- activateTabTrap()
- if (props.context.noFiltering) outputElement.value?.focus()
- else filterInput.value?.focus()
- })
- }
- const openOrMoveFocusToDropdown = (lastOption = false) => {
- if (!select.value?.isOpen) {
- openSelectDropdown()
- return
- }
- deactivateTabTrap()
- nextTick(() => {
- requestAnimationFrame(() => {
- select.value?.moveFocusToDropdown(lastOption)
- })
- })
- }
- const onCloseDropdown = () => {
- if (props.context.multiple) {
- replacementLocalOptions.value = []
- areLocalOptionsReplaced = true
- }
- clearChildOptions()
- clearFilter()
- deactivateTabTrap()
- }
- const onFocusFilterInput = () => {
- filterInput.value?.focus()
- }
- const OptionIconComponent =
- props.context.optionIconComponent ??
- (FieldAutoCompleteOptionIcon as ConcreteComponent)
- useFormBlock(contextReactive, openSelectDropdown)
- </script>
- <template>
- <div
- ref="input"
- class="flex h-auto min-h-10 hover:outline hover:outline-1 hover:outline-offset-1 hover:outline-blue-600 has-[output:focus,input:focus]:outline has-[output:focus,input:focus]:outline-1 has-[output:focus,input:focus]:outline-offset-1 has-[output:focus,input:focus]:outline-blue-800 dark:hover:outline-blue-900 dark:has-[output:focus,input:focus]:outline-blue-800"
- :class="[
- context.classes.input,
- {
- 'rounded-lg': !select?.isOpen,
- 'rounded-t-lg': select?.isOpen && !isBelowHalfScreen,
- 'rounded-b-lg': select?.isOpen && isBelowHalfScreen,
- 'bg-blue-200 dark:bg-gray-700': !context.alternativeBackground,
- 'bg-white dark:bg-gray-500': context.alternativeBackground,
- },
- ]"
- data-test-id="field-autocomplete"
- >
- <CommonSelect
- ref="select"
- #default="{ state: expanded, close: closeDropdown }"
- :model-value="currentValue"
- :options="displayOptions"
- :multiple="context.multiple"
- :owner="context.id"
- :filter="filter"
- :option-icon-component="markRaw(OptionIconComponent)"
- :empty-initial-label-text="contextReactive.emptyInitialLabelText"
- :actions="context.actions"
- :is-child-page="childOptions.length > 0"
- no-options-label-translation
- no-close
- passive
- initially-empty
- @select="selectOption"
- @push="showChildOptions"
- @pop="clearChildOptions"
- @close="onCloseDropdown"
- @focus-filter-input="onFocusFilterInput"
- >
- <output
- :id="context.id"
- ref="outputElement"
- role="combobox"
- aria-controls="common-select"
- aria-owns="common-select"
- aria-haspopup="menu"
- :aria-expanded="expanded"
- :name="context.node.name"
- class="formkit-disabled:pointer-events-none flex grow items-center gap-2.5 px-2.5 py-2 text-black focus:outline-none dark:text-white"
- :aria-labelledby="`label-${context.id}`"
- :aria-disabled="context.disabled"
- :aria-describedby="context.describedBy"
- aria-autocomplete="none"
- :data-multiple="context.multiple"
- tabindex="0"
- v-bind="context.attrs"
- @keydown.escape.prevent="closeDropdown()"
- @keypress.enter.prevent="openSelectDropdown()"
- @keydown.down.prevent="openOrMoveFocusToDropdown()"
- @keydown.up.prevent="openOrMoveFocusToDropdown(true)"
- @keypress.space.prevent="openSelectDropdown()"
- @blur="context.handlers.blur"
- >
- <div
- v-if="hasValue && context.multiple"
- class="flex flex-wrap gap-1.5"
- role="list"
- >
- <div
- v-for="selectedValue in valueContainer"
- :key="selectedValue.toString()"
- class="flex items-center gap-1.5"
- role="listitem"
- >
- <div
- class="inline-flex items-center gap-1 rounded px-1.5 py-0.5 text-xs text-black dark:text-white"
- :class="{
- 'bg-white dark:bg-gray-200': !context.alternativeBackground,
- 'bg-neutral-100 dark:bg-gray-200':
- context.alternativeBackground,
- }"
- >
- <CommonIcon
- v-if="getSelectedAutocompleteOptionIcon(selectedValue)"
- :name="getSelectedAutocompleteOptionIcon(selectedValue)"
- class="shrink-0 fill-gray-100 dark:fill-neutral-400"
- size="xs"
- decorative
- />
- <span
- class="line-clamp-3 whitespace-pre-wrap break-words"
- :title="
- getSelectedOptionLabel(selectedValue) ||
- i18n.t('%s (unknown)', selectedValue.toString())
- "
- >
- {{
- getSelectedOptionLabel(selectedValue) ||
- i18n.t('%s (unknown)', selectedValue.toString())
- }}
- </span>
- <CommonIcon
- :aria-label="i18n.t('Unselect Option')"
- class="shrink-0 fill-stone-200 hover:fill-black focus:outline-none focus-visible:rounded-sm focus-visible:outline focus-visible:outline-1 focus-visible:outline-offset-1 focus-visible:outline-blue-800 dark:fill-neutral-500 dark:hover:fill-white"
- name="x-lg"
- size="xs"
- role="button"
- tabindex="0"
- @click.stop="
- selectOption(
- getSelectedAutocompleteOption(selectedValue) ||
- getSelectedOption(selectedValue),
- )
- "
- @keypress.enter.prevent.stop="
- selectOption(
- getSelectedAutocompleteOption(selectedValue) ||
- getSelectedOption(selectedValue),
- )
- "
- @keypress.space.prevent.stop="
- selectOption(
- getSelectedAutocompleteOption(selectedValue) ||
- getSelectedOption(selectedValue),
- )
- "
- />
- </div>
- </div>
- </div>
- <CommonInputSearch
- v-if="expanded || !hasValue"
- ref="filterInput"
- v-model="filter"
- :class="{ 'pointer-events-none': !expanded }"
- :suggestion="suggestedOptionLabel"
- :alternative-background="context.alternativeBackground"
- @keypress.space.stop
- />
- <div v-if="!expanded" class="flex grow flex-wrap gap-1" role="list">
- <div
- v-if="hasValue && !context.multiple"
- class="flex items-center gap-1.5 text-sm"
- role="listitem"
- >
- <CommonIcon
- v-if="getSelectedAutocompleteOptionIcon(currentValue)"
- :name="getSelectedAutocompleteOptionIcon(currentValue)"
- class="shrink-0 fill-gray-100 dark:fill-neutral-400"
- size="tiny"
- decorative
- />
- <span
- class="line-clamp-3 whitespace-pre-wrap break-words"
- :title="
- getSelectedOptionLabel(currentValue) ||
- i18n.t('%s (unknown)', currentValue.toString())
- "
- >
- {{
- getSelectedOptionLabel(currentValue) ||
- i18n.t('%s (unknown)', currentValue.toString())
- }}
- </span>
- </div>
- </div>
- <CommonIcon
- v-if="context.clearable && hasValue && !context.disabled"
- :aria-label="i18n.t('Clear Selection')"
- class="shrink-0 fill-stone-200 hover:fill-black focus:outline-none focus-visible:rounded-sm focus-visible:outline focus-visible:outline-1 focus-visible:outline-offset-1 focus-visible:outline-blue-800 dark:fill-neutral-500 dark:hover:fill-white"
- name="x-lg"
- size="xs"
- role="button"
- tabindex="0"
- @click.stop="clearValue()"
- @keypress.enter.prevent.stop="clearValue()"
- @keypress.space.prevent.stop="clearValue()"
- />
- <CommonIcon
- class="shrink-0 fill-stone-200 dark:fill-neutral-500"
- name="chevron-down"
- size="xs"
- decorative
- />
- </output>
- </CommonSelect>
- </div>
- </template>
|