Просмотр исходного кода

Feature: Desktop View - Add new FieldAutocomplete for desktop view.

Martin Gruner 11 месяцев назад
Родитель
Сommit
13bf181e7e

+ 26 - 7
app/frontend/apps/desktop/components/CommonSelect/CommonSelect.vue

@@ -1,8 +1,15 @@
 <!-- Copyright (C) 2012-2024 Zammad Foundation, https://zammad-foundation.org/ -->
 
 <script setup lang="ts">
-import type { Ref } from 'vue'
-import { onUnmounted, computed, nextTick, ref, toRef } from 'vue'
+import {
+  computed,
+  type ConcreteComponent,
+  nextTick,
+  onUnmounted,
+  ref,
+  type Ref,
+  toRef,
+} from 'vue'
 import { useFocusWhenTyping } from '#shared/composables/useFocusWhenTyping.ts'
 import { useTrapTab } from '#shared/composables/useTrapTab.ts'
 import { useTraverseOptions } from '#shared/composables/useTraverseOptions.ts'
@@ -16,7 +23,9 @@ import {
 import type {
   MatchedSelectOption,
   SelectOption,
+  SelectValue,
 } from '#shared/components/CommonSelect/types.ts'
+import type { AutoCompleteOption } from '#shared/components/Form/fields/FieldAutocomplete/types.ts'
 import testFlags from '#shared/utils/testFlags.ts'
 import CommonLabel from '#shared/components/CommonLabel/CommonLabel.vue'
 import { i18n } from '#shared/i18n.ts'
@@ -25,10 +34,12 @@ import { useCommonSelect } from './useCommonSelect.ts'
 import type { CommonSelectInternalInstance } from './types.ts'
 
 export interface Props {
-  // we cannot move types into separate file, because Vue would not be able to
-  // transform these into runtime types
-  modelValue?: string | number | boolean | (string | number | boolean)[] | null
-  options: SelectOption[]
+  modelValue?:
+    | SelectValue
+    | SelectValue[]
+    | { value: SelectValue; label: string }
+    | null
+  options: AutoCompleteOption[] | SelectOption[]
   /**
    * Do not modify local value
    */
@@ -39,6 +50,8 @@ export interface Props {
   owner?: string
   noOptionsLabelTranslation?: boolean
   filter?: string
+  optionIconComponent?: ConcreteComponent
+  initiallyEmpty?: boolean
 }
 
 const props = defineProps<Props>()
@@ -287,6 +300,11 @@ const highlightedOptions = computed(() =>
   }),
 )
 
+const emptyLabelText = computed(() => {
+  if (!props.initiallyEmpty) return __('No results found')
+  return props.filter ? __('No results found') : __('Start typing to search…')
+})
+
 const duration = VITE_TEST_MODE ? undefined : { enter: 300, leave: 200 }
 </script>
 
@@ -357,12 +375,13 @@ const duration = VITE_TEST_MODE ? undefined : { enter: 300, leave: 200 }
                 :option="option"
                 :no-label-translate="noOptionsLabelTranslation"
                 :filter="filter"
+                :option-icon-component="optionIconComponent"
                 @select="select($event)"
               />
               <CommonSelectItem
                 v-if="!options.length"
                 :option="{
-                  label: __('No results found'),
+                  label: emptyLabelText,
                   value: '',
                   disabled: true,
                 }"

+ 22 - 3
app/frontend/apps/desktop/components/CommonSelect/CommonSelectItem.vue

@@ -2,12 +2,13 @@
 
 <script setup lang="ts">
 /* eslint-disable vue/no-v-html */
-import { computed } from 'vue'
+import { computed, type ConcreteComponent } from 'vue'
 import { i18n } from '#shared/i18n.ts'
 import type {
   MatchedSelectOption,
   SelectOption,
 } from '#shared/components/CommonSelect/types.ts'
+import type { AutoCompleteOption } from '#shared/components/Form/fields/FieldAutocomplete/types'
 
 const props = defineProps<{
   option: MatchedSelectOption | SelectOption
@@ -15,6 +16,7 @@ const props = defineProps<{
   multiple?: boolean
   noLabelTranslate?: boolean
   filter?: string
+  optionIconComponent?: ConcreteComponent
 }>()
 
 const emit = defineEmits<{
@@ -38,6 +40,19 @@ const label = computed(() => {
     option.value.toString()
   )
 })
+
+const heading = computed(() => {
+  const { option } = props
+
+  if (props.noLabelTranslate) return (option as AutoCompleteOption).heading
+
+  return i18n.t(
+    (option as AutoCompleteOption).heading,
+    ...((option as AutoCompleteOption).headingPlaceholder || []),
+  )
+})
+
+const OptionIconComponent = props.optionIconComponent
 </script>
 
 <template>
@@ -67,8 +82,9 @@ const label = computed(() => {
       :name="selected ? 'check-square' : 'square'"
       class="shrink-0"
     />
+    <OptionIconComponent v-if="optionIconComponent" :option="option" />
     <CommonIcon
-      v-if="option.icon"
+      v-else-if="option.icon"
       :name="option.icon"
       size="tiny"
       :class="{
@@ -92,9 +108,12 @@ const label = computed(() => {
         'text-stone-200 dark:text-neutral-500': option.disabled,
       }"
       class="grow truncate"
-      :title="label"
+      :title="label + (heading ? ` – ${heading}` : '')"
     >
       {{ label }}
+      <span v-if="heading" class="text-stone-200 dark:text-neutral-500"
+        >– {{ heading }}</span
+      >
     </span>
     <CommonIcon
       v-if="!multiple"

+ 21 - 0
app/frontend/apps/desktop/components/CommonSelect/__tests__/CommonSelect.spec.ts

@@ -175,4 +175,25 @@ describe('CommonSelect.vue', () => {
 
     expect(view.getByRole('listbox')).toHaveAccessibleName('Select…')
   })
+
+  it('supports optional headings', async () => {
+    const view = renderSelect({
+      options: [
+        {
+          value: 0,
+          label: 'foo (%s)',
+          labelPlaceholder: ['1'],
+          heading: 'bar (%s)',
+          headingPlaceholder: ['2'],
+        },
+      ],
+    })
+
+    await view.events.click(view.getByText('Open Select'))
+
+    const option = view.getByRole('option')
+
+    expect(option).toHaveTextContent('foo (1) – bar (2)')
+    expect(option.children[0]).toHaveAttribute('title', 'foo (1) – bar (2)')
+  })
 })

+ 22 - 0
app/frontend/apps/desktop/components/Form/fields/FieldAgent/FieldAgentOptionIcon.vue

@@ -0,0 +1,22 @@
+<!-- Copyright (C) 2012-2024 Zammad Foundation, https://zammad-foundation.org/ -->
+
+<script setup lang="ts">
+import CommonUserAvatar from '#shared/components/CommonUserAvatar/CommonUserAvatar.vue'
+import type { AutoCompleteAgentOption } from '#shared/components/Form/fields/FieldAgent/types'
+
+defineProps<{
+  option: AutoCompleteAgentOption
+}>()
+</script>
+
+<template>
+  <CommonUserAvatar
+    v-if="option.user"
+    :entity="option.user"
+    :class="{
+      'opacity-30': option.disabled,
+    }"
+    size="xs"
+    personal
+  />
+</template>

+ 60 - 0
app/frontend/apps/desktop/components/Form/fields/FieldAgent/FieldAgentWrapper.vue

@@ -0,0 +1,60 @@
+<!-- Copyright (C) 2012-2024 Zammad Foundation, https://zammad-foundation.org/ -->
+<script setup lang="ts">
+import { markRaw, defineAsyncComponent } from 'vue'
+import { AutocompleteSearchAgentDocument } from '#shared/components/Form/fields/FieldAgent/graphql/queries/autocompleteSearch/agent.api.ts'
+import type { ObjectLike } from '#shared/types/utils.ts'
+import type { FormFieldContext } from '#shared/components/Form/types/field.ts'
+import type { AutoCompleteProps } from '#shared/components/Form/fields/FieldAutocomplete/types.ts'
+import type { AutoCompleteAgentOption } from '#shared/components/Form/fields/FieldAgent/types'
+import type { SelectValue } from '#shared/components/CommonSelect/types.ts'
+import type { User } from '#shared/graphql/types.ts'
+import FieldAgentOptionIcon from './FieldAgentOptionIcon.vue'
+
+const FieldAutoCompleteInput = defineAsyncComponent(
+  () =>
+    import(
+      '#desktop/components/Form/fields/FieldAutoComplete/FieldAutoCompleteInput.vue'
+    ),
+)
+
+interface Props {
+  context: FormFieldContext<
+    AutoCompleteProps & {
+      options?: AutoCompleteAgentOption[]
+    }
+  >
+}
+
+const props = defineProps<Props>()
+
+const buildEntityOption = (entity: User) => {
+  return {
+    value: entity.internalId,
+    label: entity.fullname || entity.phone || entity.login,
+    heading: entity.organization?.name,
+    user: entity,
+  }
+}
+
+Object.assign(props.context, {
+  optionIconComponent: markRaw(FieldAgentOptionIcon),
+  initialOptionBuilder: (
+    initialEntityObject: ObjectLike,
+    value: SelectValue,
+    context: Props['context'],
+  ) => {
+    if (!context.belongsToObjectField || !initialEntityObject) return null
+
+    const belongsToObject = initialEntityObject[context.belongsToObjectField]
+
+    if (!belongsToObject) return null
+
+    return buildEntityOption(belongsToObject)
+  },
+  gqlQuery: AutocompleteSearchAgentDocument,
+})
+</script>
+
+<template>
+  <FieldAutoCompleteInput :context="context" v-bind="$attrs" />
+</template>

+ 264 - 0
app/frontend/apps/desktop/components/Form/fields/FieldAgent/__tests__/FieldAgent.spec.ts

@@ -0,0 +1,264 @@
+// Copyright (C) 2012-2024 Zammad Foundation, https://zammad-foundation.org/
+
+import { getByTestId, waitFor } from '@testing-library/vue'
+import { FormKit } from '@formkit/vue'
+import { renderComponent } from '#tests/support/components/index.ts'
+import type { AutocompleteSearchUserEntry } from '#shared/graphql/types.ts'
+import { getNode } from '@formkit/core'
+import { nullableMock, waitForNextTick } from '#tests/support/utils.ts'
+import {
+  mockAutocompleteSearchAgentQuery,
+  waitForAutocompleteSearchAgentQueryCalls,
+} from '#shared/components/Form/fields/FieldAgent/graphql/queries/autocompleteSearch/agent.mocks.ts'
+import { convertToGraphQLId } from '#shared/graphql/utils.ts'
+
+const testOptions: AutocompleteSearchUserEntry[] = [
+  {
+    __typename: 'AutocompleteSearchUserEntry',
+    value: 0,
+    label: 'foo',
+    labelPlaceholder: [],
+    heading: 'autocomplete sample 1',
+    headingPlaceholder: [],
+    disabled: false,
+    icon: null,
+    user: nullableMock({
+      id: convertToGraphQLId('User', 1),
+      internalId: 1,
+      fullname: 'sample 1',
+      createdAt: '2022-11-30T12:40:15Z',
+      updatedAt: '2022-11-30T12:40:15Z',
+      policy: {
+        update: true,
+        destroy: false,
+      },
+    }),
+  },
+  {
+    __typename: 'AutocompleteSearchUserEntry',
+    value: 1,
+    label: 'bar',
+    labelPlaceholder: [],
+    heading: 'autocomplete sample 2',
+    headingPlaceholder: [],
+    disabled: false,
+    icon: null,
+    user: nullableMock({
+      id: convertToGraphQLId('User', 2),
+      internalId: 2,
+      fullname: 'sample 2',
+      image:
+        'data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAUAAAAFCAYAAACNbyblAAAAHElEQVQI12P4//2/w38GIAXDIBKE0DHxgljNBAAO9TXL0Y4OHwAAAABJRU5ErkJggg==',
+      createdAt: '2022-11-30T12:40:15Z',
+      updatedAt: '2022-11-30T12:40:15Z',
+      policy: {
+        update: true,
+        destroy: false,
+      },
+    }),
+  },
+  {
+    __typename: 'AutocompleteSearchUserEntry',
+    value: 2,
+    label: 'baz',
+    labelPlaceholder: [],
+    heading: 'autocomplete sample 3',
+    headingPlaceholder: [],
+    disabled: false,
+    icon: null,
+    user: nullableMock({
+      id: convertToGraphQLId('User', 3),
+      internalId: 3,
+      firstname: 'foo',
+      lastname: 'bar',
+      fullname: 'sample 3',
+      image: null,
+      createdAt: '2022-11-30T12:40:15Z',
+      updatedAt: '2022-11-30T12:40:15Z',
+      policy: {
+        update: true,
+        destroy: false,
+      },
+    }),
+  },
+]
+
+const wrapperParameters = {
+  form: true,
+  formField: true,
+  router: true,
+  dialog: true,
+  store: true,
+}
+
+const testProps = {
+  type: 'agent',
+  label: 'Select…',
+}
+
+describe('Form - Field - Agent - Features', () => {
+  it('supports value prefill with existing entity object in root node', async () => {
+    const wrapper = renderComponent(FormKit, {
+      ...wrapperParameters,
+      props: {
+        ...testProps,
+        id: 'agent',
+        name: 'agent_id',
+        value: 123,
+        belongsToObjectField: 'user',
+      },
+    })
+
+    const node = getNode('agent')
+
+    node!.context!.initialEntityObject = {
+      user: {
+        internalId: 123,
+        fullname: 'John Doe',
+      },
+    }
+
+    await waitForNextTick(true)
+
+    expect(wrapper.getByRole('listitem')).toHaveTextContent('John Doe')
+  })
+})
+
+describe('Form - Field - Agent - Query', () => {
+  it('fetches remote options via GraphQL query', async () => {
+    const wrapper = renderComponent(FormKit, {
+      ...wrapperParameters,
+      props: {
+        ...testProps,
+        debounceInterval: 0,
+      },
+    })
+
+    await wrapper.events.click(await wrapper.findByLabelText('Select…'))
+
+    const filterElement = wrapper.getByRole('searchbox')
+
+    expect(filterElement).toBeInTheDocument()
+
+    expect(wrapper.queryByText('Start typing to search…')).toBeInTheDocument()
+
+    mockAutocompleteSearchAgentQuery({
+      autocompleteSearchAgent: [testOptions[0]],
+    })
+
+    await wrapper.events.type(filterElement, testOptions[0].label)
+
+    await waitForAutocompleteSearchAgentQueryCalls()
+
+    expect(
+      wrapper.queryByText('Start typing to search…'),
+    ).not.toBeInTheDocument()
+
+    let selectOptions = wrapper.getAllByRole('option')
+
+    selectOptions = wrapper.getAllByRole('option')
+
+    expect(selectOptions).toHaveLength(1)
+    expect(selectOptions[0]).toHaveTextContent(testOptions[0].label)
+
+    // User with ID 1 should show the logo.
+    expect(getByTestId(selectOptions[0], 'common-avatar')).toHaveStyle({
+      'background-image':
+        'url(/app/frontend/shared/components/CommonUserAvatar/assets/logo.svg)',
+    })
+
+    await wrapper.events.click(wrapper.getByLabelText('Clear Search'))
+
+    expect(filterElement).toHaveValue('')
+
+    expect(wrapper.queryByText('Start typing to search…')).toBeInTheDocument()
+
+    mockAutocompleteSearchAgentQuery({
+      autocompleteSearchAgent: [testOptions[1]],
+    })
+
+    await wrapper.events.type(filterElement, testOptions[1].label)
+
+    await waitForAutocompleteSearchAgentQueryCalls()
+
+    expect(
+      wrapper.queryByText('Start typing to search…'),
+    ).not.toBeInTheDocument()
+
+    selectOptions = wrapper.getAllByRole('option')
+
+    expect(selectOptions).toHaveLength(1)
+    expect(selectOptions[0]).toHaveTextContent(testOptions[1].label)
+
+    expect(getByTestId(selectOptions[0], 'common-avatar')).toHaveStyle({
+      'background-image': `url(${testOptions[1].user.image})`,
+    })
+
+    await wrapper.events.clear(filterElement)
+
+    expect(wrapper.queryByText('Start typing to search…')).toBeInTheDocument()
+
+    mockAutocompleteSearchAgentQuery({
+      autocompleteSearchAgent: [testOptions[2]],
+    })
+
+    await wrapper.events.type(filterElement, testOptions[2].label)
+
+    await waitForAutocompleteSearchAgentQueryCalls()
+
+    expect(
+      wrapper.queryByText('Start typing to search…'),
+    ).not.toBeInTheDocument()
+
+    selectOptions = wrapper.getAllByRole('option')
+
+    expect(selectOptions).toHaveLength(1)
+    expect(selectOptions[0]).toHaveTextContent(testOptions[2].label)
+
+    expect(getByTestId(selectOptions[0], 'common-avatar')).toHaveTextContent(
+      'fb',
+    )
+  })
+
+  it('replaces local options with selection', async () => {
+    const wrapper = renderComponent(FormKit, {
+      ...wrapperParameters,
+      props: {
+        ...testProps,
+        debounceInterval: 0,
+      },
+    })
+
+    await wrapper.events.click(await wrapper.findByLabelText('Select…'))
+
+    const filterElement = wrapper.getByRole('searchbox')
+
+    mockAutocompleteSearchAgentQuery({
+      autocompleteSearchAgent: [testOptions[0]],
+    })
+
+    await wrapper.events.type(filterElement, testOptions[0].label)
+
+    await waitForAutocompleteSearchAgentQueryCalls()
+
+    wrapper.events.click(wrapper.getAllByRole('option')[0])
+
+    await waitFor(() => {
+      expect(wrapper.emitted().inputRaw).toBeTruthy()
+    })
+
+    const emittedInput = wrapper.emitted().inputRaw as Array<Array<InputEvent>>
+
+    expect(emittedInput[0][0]).toBe(testOptions[0].value)
+
+    expect(wrapper.queryByRole('menu')).not.toBeInTheDocument()
+
+    expect(wrapper.getByRole('listitem')).toHaveTextContent(
+      testOptions[0].label,
+    )
+
+    await wrapper.events.click(wrapper.getByLabelText('Select…'))
+
+    expect(wrapper.getByIconName('check2')).toBeInTheDocument()
+  })
+})

+ 16 - 0
app/frontend/apps/desktop/components/Form/fields/FieldAgent/index.ts

@@ -0,0 +1,16 @@
+// Copyright (C) 2012-2024 Zammad Foundation, https://zammad-foundation.org/
+
+import createInput from '#shared/form/core/createInput.ts'
+import addLink from '#shared/form/features/addLink.ts'
+import formUpdaterTrigger from '#shared/form/features/formUpdaterTrigger.ts'
+import FieldAgentWrapper from './FieldAgentWrapper.vue'
+import { autoCompleteProps } from '../FieldAutoComplete/index.ts'
+
+const fieldDefinition = createInput(FieldAgentWrapper, autoCompleteProps, {
+  features: [addLink, formUpdaterTrigger()],
+})
+
+export default {
+  fieldType: 'agent',
+  definition: fieldDefinition,
+}

+ 512 - 0
app/frontend/apps/desktop/components/Form/fields/FieldAutoComplete/FieldAutoCompleteInput.vue

@@ -0,0 +1,512 @@
+<!-- Copyright (C) 2012-2024 Zammad Foundation, https://zammad-foundation.org/ -->
+
+<script setup lang="ts">
+import {
+  computed,
+  markRaw,
+  nextTick,
+  onMounted,
+  ref,
+  toRef,
+  watch,
+  type ConcreteComponent,
+  type Ref,
+} from 'vue'
+import { cloneDeep, escapeRegExp } from 'lodash-es'
+import gql from 'graphql-tag'
+import {
+  refDebounced,
+  useElementBounding,
+  useWindowSize,
+  watchOnce,
+} from '@vueuse/core'
+import { useLazyQuery } from '@vue/apollo-composable'
+import type { NameNode, OperationDefinitionNode, SelectionNode } from 'graphql'
+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 { i18n } from '#shared/i18n.ts'
+import { useFormBlock } from '#shared/form/useFormBlock.ts'
+import useValue from '#shared/components/Form/composables/useValue.ts'
+import useSelectOptions from '#shared/composables/useSelectOptions.ts'
+import { useTrapTab } from '#shared/composables/useTrapTab.ts'
+import { QueryHandler } from '#shared/server/apollo/handler/index.ts'
+import type { ObjectLike } from '#shared/types/utils.ts'
+import type { FormFieldContext } from '#shared/components/Form/types/field.ts'
+import type {
+  AutoCompleteOption,
+  AutoCompleteProps,
+  AutocompleteSelectValue,
+} from '#shared/components/Form/fields/FieldAutocomplete/types.ts'
+import type { SelectOption } from '#shared/components/CommonSelect/types'
+import FieldAutoCompleteOptionIcon from './FieldAutoCompleteOptionIcon.vue'
+
+interface Props {
+  context: FormFieldContext<AutoCompleteProps>
+}
+
+const props = defineProps<Props>()
+const contextReactive = toRef(props, 'context')
+
+const { hasValue, valueContainer, currentValue, isCurrentValue, clearValue } =
+  useValue<AutocompleteSelectValue>(contextReactive)
+
+const localOptions = ref(props.context.options || [])
+
+const {
+  sortedOptions,
+  appendedOptions,
+  optionValueLookup,
+  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).
+if (
+  !props.context.multiple &&
+  hasValue.value &&
+  props.context.initialOptionBuilder &&
+  !getSelectedOptionLabel(currentValue.value)
+) {
+  const initialOption = props.context.initialOptionBuilder(
+    props.context.node.at('$root')?.context?.initialEntityObject as ObjectLike,
+    currentValue.value,
+    props.context,
+  )
+
+  if (initialOption) localOptions.value.push(initialOption)
+}
+
+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(
+  () =>
+    autocompleteQueryHandler.result().value?.[
+      autocompleteQueryResultKey
+    ] as unknown as AutoCompleteOption[],
+)
+
+const autocompleteOptions = computed(
+  () => cloneDeep(autocompleteQueryResultOptions.value) || [],
+)
+
+const {
+  sortedOptions: sortedAutocompleteOptions,
+  selectOption: selectAutocompleteOption,
+  getSelectedOption: getSelectedAutocompleteOption,
+  getSelectedOptionIcon: getSelectedAutocompleteOptionIcon,
+  getSelectedOptionLabel: getSelectedAutocompleteOptionLabel,
+} = useSelectOptions<AutoCompleteOption[]>(
+  autocompleteOptions,
+  toRef(props, 'context'),
+)
+
+const selectOption = (option: SelectOption) => {
+  selectAutocompleteOption(option as AutoCompleteOption)
+
+  if (props.context.multiple) {
+    // 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),
+    )
+
+    clearFilter()
+
+    return
+  }
+
+  localOptions.value = [option as AutoCompleteOption]
+
+  select.value?.closeDropdown()
+}
+
+const availableOptions = computed(() =>
+  filter.value || props.context.defaultFilter
+    ? sortedAutocompleteOptions.value
+    : sortedOptions.value,
+)
+
+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 suggestedOptionLabel = computed(() => {
+  if (!filter.value || !availableOptionsWithMatches.value.length)
+    return undefined
+
+  const exactMatches = availableOptionsWithMatches.value.filter(
+    (option) =>
+      (
+        getSelectedAutocompleteOptionLabel(option.value) ||
+        option.value.toString()
+      )
+        .toLowerCase()
+        .indexOf(filter.value.toLowerCase()) === 0 &&
+      (
+        getSelectedAutocompleteOptionLabel(option.value) ||
+        option.value.toString()
+      ).length > filter.value.length,
+  )
+
+  if (!exactMatches.length) return undefined
+
+  return getSelectedAutocompleteOptionLabel(exactMatches[0].value)
+})
+
+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
+  }
+
+  clearFilter()
+  deactivateTabTrap()
+}
+
+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="availableOptionsWithMatches"
+      :multiple="context.multiple"
+      :owner="context.id"
+      :filter="filter"
+      :option-icon-component="markRaw(OptionIconComponent)"
+      no-options-label-translation
+      no-close
+      passive
+      initially-empty
+      @select="selectOption"
+      @close="onCloseDropdown"
+    >
+      <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-autocomplete="none"
+        :data-multiple="context.multiple"
+        :tabindex="context.disabled ? '-1' : '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-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="
+                  selectAutocompleteOption(
+                    getSelectedAutocompleteOption(selectedValue),
+                  )
+                "
+                @keypress.enter.prevent.stop="
+                  selectAutocompleteOption(
+                    getSelectedAutocompleteOption(selectedValue),
+                  )
+                "
+                @keypress.space.prevent.stop="
+                  selectAutocompleteOption(
+                    getSelectedAutocompleteOption(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-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>

+ 19 - 0
app/frontend/apps/desktop/components/Form/fields/FieldAutoComplete/FieldAutoCompleteOptionIcon.vue

@@ -0,0 +1,19 @@
+<!-- Copyright (C) 2012-2024 Zammad Foundation, https://zammad-foundation.org/ -->
+
+<script setup lang="ts">
+import type { AutoCompleteOption } from '#shared/components/Form/fields/FieldAutocomplete/types.ts'
+
+defineProps<{
+  option: AutoCompleteOption
+}>()
+</script>
+
+<template>
+  <CommonIcon
+    v-if="option.icon"
+    :name="option.icon"
+    :class="{
+      'opacity-30': option.disabled,
+    }"
+  />
+</template>

+ 1290 - 0
app/frontend/apps/desktop/components/Form/fields/FieldAutoComplete/__tests__/FieldAutoComplete.spec.ts

@@ -0,0 +1,1290 @@
+// Copyright (C) 2012-2024 Zammad Foundation, https://zammad-foundation.org/
+
+import { cloneDeep } from 'lodash-es'
+import {
+  getAllByRole,
+  getByRole,
+  getByText,
+  waitFor,
+} from '@testing-library/vue'
+import { getNode } from '@formkit/core'
+import { FormKit } from '@formkit/vue'
+import { renderComponent } from '#tests/support/components/index.ts'
+import { i18n } from '#shared/i18n.ts'
+import { nullableMock, waitForNextTick } from '#tests/support/utils.ts'
+import { getByIconName } from '#tests/support/components/iconQueries.ts'
+import type { ObjectLike } from '#shared/types/utils.ts'
+import type { SelectValue } from '#shared/components/CommonSelect/types.ts'
+import { AutocompleteSearchUserDocument } from '#shared/components/Form/fields/FieldCustomer/graphql/queries/autocompleteSearch/user.api.ts'
+import {
+  mockAutocompleteSearchUserQuery,
+  waitForAutocompleteSearchUserQueryCalls,
+} from '#shared/components/Form/fields/FieldCustomer/graphql/queries/autocompleteSearch/user.mocks.ts'
+import type { AutocompleteSearchUserEntry } from '#shared/graphql/types.ts'
+import { convertToGraphQLId } from '#shared/graphql/utils.ts'
+
+const testOptions: AutocompleteSearchUserEntry[] = [
+  {
+    __typename: 'AutocompleteSearchUserEntry',
+    value: 0,
+    label: 'foo',
+    labelPlaceholder: [],
+    heading: 'autocomplete sample 1',
+    headingPlaceholder: [],
+    disabled: false,
+    icon: null,
+    user: nullableMock({
+      id: convertToGraphQLId('User', 1),
+      internalId: 1,
+      fullname: 'sample 1',
+      createdAt: '2022-11-30T12:40:15Z',
+      updatedAt: '2022-11-30T12:40:15Z',
+      policy: {
+        update: true,
+        destroy: false,
+      },
+    }),
+  },
+  {
+    __typename: 'AutocompleteSearchUserEntry',
+    value: 1,
+    label: 'bar',
+    labelPlaceholder: [],
+    heading: 'autocomplete sample 2',
+    headingPlaceholder: [],
+    disabled: false,
+    icon: null,
+    user: nullableMock({
+      id: convertToGraphQLId('User', 2),
+      internalId: 2,
+      fullname: 'sample 2',
+      createdAt: '2022-11-30T12:40:15Z',
+      updatedAt: '2022-11-30T12:40:15Z',
+      policy: {
+        update: true,
+        destroy: false,
+      },
+    }),
+  },
+  {
+    __typename: 'AutocompleteSearchUserEntry',
+    value: 2,
+    label: 'baz',
+    labelPlaceholder: [],
+    heading: 'autocomplete sample 3',
+    headingPlaceholder: [],
+    disabled: false,
+    icon: null,
+    user: nullableMock({
+      id: convertToGraphQLId('User', 3),
+      internalId: 3,
+      fullname: 'sample 3',
+      createdAt: '2022-11-30T12:40:15Z',
+      updatedAt: '2022-11-30T12:40:15Z',
+      policy: {
+        update: true,
+        destroy: false,
+      },
+    }),
+  },
+]
+
+const wrapperParameters = {
+  form: true,
+  formField: true,
+  router: true,
+  dialog: true,
+  store: true,
+}
+
+const testProps = {
+  label: 'Select…',
+  type: 'autocomplete',
+  gqlQuery: AutocompleteSearchUserDocument,
+}
+
+describe('Form - Field - AutoComplete - Dropdown', () => {
+  it('renders select options in a dropdown menu', async () => {
+    const wrapper = renderComponent(FormKit, {
+      ...wrapperParameters,
+      props: {
+        ...testProps,
+        options: testOptions,
+      },
+    })
+
+    await wrapper.events.click(wrapper.getByLabelText('Select…'))
+
+    const dropdown = wrapper.getByRole('menu')
+
+    const selectOptions = getAllByRole(dropdown, 'option')
+
+    expect(selectOptions).toHaveLength(testOptions.length)
+
+    selectOptions.forEach((selectOption, index) => {
+      expect(selectOption).toHaveTextContent(testOptions[index].label)
+      expect(selectOption).toHaveTextContent(testOptions[index].heading!)
+    })
+
+    await wrapper.events.keyboard('{Escape}')
+
+    expect(dropdown).not.toBeInTheDocument()
+  })
+
+  it('sets value on selection and closes the dropdown', async () => {
+    const wrapper = renderComponent(FormKit, {
+      ...wrapperParameters,
+      props: {
+        ...testProps,
+        options: testOptions,
+      },
+    })
+
+    await wrapper.events.click(wrapper.getByLabelText('Select…'))
+
+    const listbox = wrapper.getByRole('listbox')
+
+    wrapper.events.click(getAllByRole(listbox, 'option')[0])
+
+    await waitFor(() => {
+      expect(wrapper.emitted().inputRaw).toBeTruthy()
+    })
+
+    const emittedInput = wrapper.emitted().inputRaw as Array<Array<InputEvent>>
+
+    expect(emittedInput[0][0]).toBe(testOptions[0].value)
+
+    expect(listbox).not.toBeInTheDocument()
+  })
+
+  it('renders selected option with a check mark icon', async () => {
+    const wrapper = renderComponent(FormKit, {
+      ...wrapperParameters,
+      props: {
+        ...testProps,
+        options: testOptions,
+        value: testOptions[1].value,
+      },
+    })
+
+    await wrapper.events.click(wrapper.getByLabelText('Select…'))
+
+    expect(
+      wrapper.getByIconName((name, node) => {
+        return (
+          name === '#icon-check2' &&
+          !node?.parentElement?.classList.contains('invisible')
+        )
+      }),
+    ).toBeInTheDocument()
+
+    await wrapper.events.click(wrapper.baseElement)
+
+    expect(wrapper.queryByRole('menu')).not.toBeInTheDocument()
+  })
+})
+
+describe('Form - Field - AutoComplete - Query', () => {
+  it('fetches remote options via GraphQL query', async () => {
+    const wrapper = renderComponent(FormKit, {
+      ...wrapperParameters,
+      props: {
+        ...testProps,
+        debounceInterval: 0,
+      },
+    })
+
+    await wrapper.events.click(wrapper.getByLabelText('Select…'))
+
+    const filterElement = wrapper.getByRole('searchbox')
+
+    expect(filterElement).toBeInTheDocument()
+
+    expect(wrapper.queryByText('Start typing to search…')).toBeInTheDocument()
+
+    mockAutocompleteSearchUserQuery({
+      autocompleteSearchUser: [testOptions[0]],
+    })
+
+    await wrapper.events.type(filterElement, testOptions[0].label)
+
+    expect(
+      wrapper.queryByText('Start typing to search…'),
+    ).not.toBeInTheDocument()
+
+    await waitForAutocompleteSearchUserQueryCalls()
+
+    let selectOptions = wrapper.getAllByRole('option')
+
+    selectOptions = wrapper.getAllByRole('option')
+
+    expect(selectOptions).toHaveLength(1)
+    expect(selectOptions[0]).toHaveTextContent(testOptions[0].label)
+
+    await wrapper.events.click(
+      wrapper.getByRole('button', { name: 'Clear Search' }),
+    )
+
+    expect(filterElement).toHaveValue('')
+
+    expect(wrapper.queryByText('Start typing to search…')).toBeInTheDocument()
+
+    mockAutocompleteSearchUserQuery({
+      autocompleteSearchUser: [testOptions[1]],
+    })
+
+    await wrapper.events.type(filterElement, testOptions[1].label)
+
+    await waitForAutocompleteSearchUserQueryCalls()
+
+    expect(
+      wrapper.queryByText('Start typing to search…'),
+    ).not.toBeInTheDocument()
+
+    selectOptions = wrapper.getAllByRole('option')
+
+    expect(selectOptions).toHaveLength(1)
+    expect(selectOptions[0]).toHaveTextContent(testOptions[1].label)
+
+    await wrapper.events.clear(filterElement)
+
+    expect(wrapper.queryByText('Start typing to search…')).toBeInTheDocument()
+
+    mockAutocompleteSearchUserQuery({
+      autocompleteSearchUser: [testOptions[2]],
+    })
+
+    await wrapper.events.type(filterElement, testOptions[2].label)
+
+    expect(
+      wrapper.queryByText('Start typing to search…'),
+    ).not.toBeInTheDocument()
+
+    selectOptions = wrapper.getAllByRole('option')
+
+    expect(selectOptions).toHaveLength(1)
+    expect(selectOptions[0]).toHaveTextContent(testOptions[2].label)
+  })
+
+  it('replaces local options with selection', async () => {
+    const wrapper = renderComponent(FormKit, {
+      ...wrapperParameters,
+      props: {
+        ...testProps,
+        debounceInterval: 0,
+      },
+    })
+
+    await wrapper.events.click(wrapper.getByLabelText('Select…'))
+
+    const filterElement = wrapper.getByRole('searchbox')
+
+    mockAutocompleteSearchUserQuery({
+      autocompleteSearchUser: [testOptions[0]],
+    })
+
+    await wrapper.events.type(filterElement, testOptions[0].label)
+
+    await waitForAutocompleteSearchUserQueryCalls()
+
+    wrapper.events.click(wrapper.getAllByRole('option')[0])
+
+    await waitFor(() => {
+      expect(wrapper.emitted().inputRaw).toBeTruthy()
+    })
+
+    const emittedInput = wrapper.emitted().inputRaw as Array<Array<InputEvent>>
+
+    expect(emittedInput[0][0]).toBe(testOptions[0].value)
+
+    expect(wrapper.queryByRole('menu')).not.toBeInTheDocument()
+
+    expect(wrapper.getByRole('listitem')).toHaveTextContent(
+      testOptions[0].label,
+    )
+
+    await wrapper.events.click(wrapper.getByLabelText('Select…'))
+
+    expect(wrapper.getByIconName('check2')).toBeInTheDocument()
+  })
+
+  it('restores selection on mixing initial and fetched options (multiple)', async () => {
+    const wrapper = renderComponent(FormKit, {
+      ...wrapperParameters,
+      props: {
+        ...testProps,
+        value: [testOptions[2].value],
+        options: [testOptions[2]],
+        multiple: true,
+        debounceInterval: 0,
+      },
+    })
+
+    await wrapper.events.click(wrapper.getByLabelText('Select…'))
+
+    let selectOptions = wrapper.getAllByRole('option')
+
+    expect(selectOptions).toHaveLength(1)
+    expect(selectOptions[0]).toHaveTextContent(testOptions[2].label)
+    expect(getByIconName(selectOptions[0], 'check-square')).toBeInTheDocument()
+
+    const filterElement = wrapper.getByRole('searchbox')
+
+    mockAutocompleteSearchUserQuery({
+      autocompleteSearchUser: testOptions,
+    })
+
+    await wrapper.events.type(filterElement, 'item')
+
+    await waitForAutocompleteSearchUserQueryCalls()
+
+    selectOptions = wrapper.getAllByRole('option')
+
+    expect(selectOptions).toHaveLength(3)
+    expect(selectOptions[0]).toHaveTextContent(testOptions[0].label)
+    expect(selectOptions[1]).toHaveTextContent(testOptions[1].label)
+    expect(selectOptions[2]).toHaveTextContent(testOptions[2].label)
+    expect(getByIconName(selectOptions[2], 'check-square')).toBeInTheDocument()
+
+    wrapper.events.click(wrapper.getAllByRole('option')[0])
+
+    await waitFor(() => {
+      expect(wrapper.emitted().inputRaw).toBeTruthy()
+    })
+
+    const emittedInput = wrapper.emitted().inputRaw as Array<Array<InputEvent>>
+
+    expect(emittedInput[0][0]).toStrictEqual([
+      testOptions[0].value,
+      testOptions[2].value,
+    ])
+
+    selectOptions = wrapper.getAllByRole('option')
+
+    expect(selectOptions).toHaveLength(2)
+    expect(selectOptions[0]).toHaveTextContent(testOptions[2].label)
+    expect(getByIconName(selectOptions[0], 'check-square')).toBeInTheDocument()
+    expect(selectOptions[1]).toHaveTextContent(testOptions[0].label)
+    expect(getByIconName(selectOptions[1], 'check-square')).toBeInTheDocument()
+  })
+
+  it('supports storing complex non-multiple values', async () => {
+    const wrapper = renderComponent(FormKit, {
+      ...wrapperParameters,
+      props: {
+        ...testProps,
+        name: 'autocomplete',
+        id: 'autocomplete',
+        complexValue: true,
+        debounceInterval: 0,
+      },
+    })
+
+    await wrapper.events.click(wrapper.getByLabelText('Select…'))
+
+    const filterElement = wrapper.getByRole('searchbox')
+
+    expect(filterElement).toBeInTheDocument()
+
+    expect(wrapper.queryByText('Start typing to search…')).toBeInTheDocument()
+
+    mockAutocompleteSearchUserQuery({
+      autocompleteSearchUser: [testOptions[0]],
+    })
+
+    await wrapper.events.type(filterElement, testOptions[0].label)
+
+    await waitForAutocompleteSearchUserQueryCalls()
+
+    const selectOptions = wrapper.getAllByRole('option')
+
+    expect(selectOptions).toHaveLength(1)
+    expect(selectOptions[0]).toHaveTextContent(testOptions[0].label)
+
+    await wrapper.events.click(selectOptions[0])
+
+    expect(wrapper.getByRole('listitem')).toHaveTextContent(
+      testOptions[0].label,
+    )
+
+    const node = getNode('autocomplete')
+
+    expect(node?._value).toEqual({
+      value: testOptions[0].value,
+      label: testOptions[0].label,
+    })
+  })
+
+  it('supports storing complex multiple values', async () => {
+    const wrapper = renderComponent(FormKit, {
+      ...wrapperParameters,
+      props: {
+        ...testProps,
+        name: 'autocomplete',
+        id: 'autocomplete',
+        multiple: true,
+        complexValue: true,
+        debounceInterval: 0,
+      },
+    })
+
+    await wrapper.events.click(wrapper.getByLabelText('Select…'))
+
+    const filterElement = wrapper.getByRole('searchbox')
+
+    expect(filterElement).toBeInTheDocument()
+
+    expect(wrapper.queryByText('Start typing to search…')).toBeInTheDocument()
+
+    mockAutocompleteSearchUserQuery({
+      autocompleteSearchUser: testOptions,
+    })
+
+    await wrapper.events.type(filterElement, '*')
+
+    await waitForAutocompleteSearchUserQueryCalls()
+
+    const listbox = wrapper.getByRole('listbox')
+
+    let selectOptions = getAllByRole(listbox, 'option')
+
+    expect(selectOptions).toHaveLength(3)
+    expect(selectOptions[0]).toHaveTextContent(testOptions[0].label)
+    expect(selectOptions[1]).toHaveTextContent(testOptions[1].label)
+    expect(selectOptions[2]).toHaveTextContent(testOptions[2].label)
+
+    await wrapper.events.click(selectOptions[0])
+
+    await wrapper.events.type(filterElement, '*')
+
+    await waitForAutocompleteSearchUserQueryCalls()
+
+    selectOptions = getAllByRole(listbox, 'option')
+
+    await wrapper.events.click(selectOptions[1])
+
+    const [item1, item2] = wrapper.getAllByRole('listitem')
+
+    expect(item1).toHaveTextContent(testOptions[0].label)
+    expect(item2).toHaveTextContent(testOptions[1].label)
+
+    const node = getNode('autocomplete')
+
+    expect(node?._value).toEqual([
+      {
+        value: testOptions[0].value,
+        label: testOptions[0].label,
+      },
+      {
+        value: testOptions[1].value,
+        label: testOptions[1].label,
+      },
+    ])
+  })
+})
+
+describe('Form - Field - AutoComplete - Initial Options', () => {
+  it('supports disabled property', async () => {
+    const disabledOptions = [
+      {
+        value: 0,
+        label: 'Item A',
+      },
+      {
+        value: 1,
+        label: 'Item B',
+        disabled: true,
+      },
+      {
+        value: 2,
+        label: 'Item C',
+      },
+    ]
+
+    const wrapper = renderComponent(FormKit, {
+      ...wrapperParameters,
+      props: {
+        ...testProps,
+        options: disabledOptions,
+      },
+    })
+
+    await wrapper.events.click(wrapper.getByLabelText('Select…'))
+
+    expect(wrapper.getAllByRole('option')[1]).toHaveClass('pointer-events-none')
+
+    expect(
+      getByText(wrapper.getByRole('listbox'), disabledOptions[1].label),
+    ).toHaveClass('text-stone-200 dark:text-neutral-500')
+  })
+
+  it('supports icon property', async () => {
+    const iconOptions = [
+      {
+        value: 1,
+        label: 'GitLab',
+        icon: 'gitlab',
+      },
+      {
+        value: 2,
+        label: 'GitHub',
+        icon: 'github',
+      },
+    ]
+
+    const wrapper = renderComponent(FormKit, {
+      ...wrapperParameters,
+      props: {
+        ...testProps,
+        options: iconOptions,
+      },
+    })
+
+    await wrapper.events.click(wrapper.getByLabelText('Select…'))
+
+    expect(wrapper.queryByIconName(iconOptions[0].icon)).toBeInTheDocument()
+    expect(wrapper.queryByIconName(iconOptions[1].icon)).toBeInTheDocument()
+  })
+})
+
+describe('Form - Field - AutoComplete - Features', () => {
+  it('supports value mutation', async () => {
+    const wrapper = renderComponent(FormKit, {
+      ...wrapperParameters,
+      props: {
+        ...testProps,
+        id: 'autocomplete',
+        options: testOptions,
+        value: testOptions[1].value,
+      },
+    })
+
+    expect(wrapper.getByRole('listitem')).toHaveTextContent(
+      testOptions[1].label,
+    )
+
+    const node = getNode('autocomplete')
+
+    node?.input(testOptions[2].value)
+
+    await waitForNextTick(true)
+
+    expect(wrapper.getByRole('listitem')).toHaveTextContent(
+      testOptions[2].label,
+    )
+  })
+
+  it('supports selection clearing', async () => {
+    const wrapper = renderComponent(FormKit, {
+      ...wrapperParameters,
+      props: {
+        ...testProps,
+        options: testOptions,
+        value: testOptions[1].value,
+        clearable: true,
+      },
+    })
+
+    expect(wrapper.getByRole('listitem')).toHaveTextContent(
+      testOptions[1].label,
+    )
+
+    await wrapper.events.click(wrapper.getByRole('button'))
+
+    await waitFor(() => {
+      expect(wrapper.emitted().inputRaw).toBeTruthy()
+    })
+
+    const emittedInput = wrapper.emitted().inputRaw as Array<Array<InputEvent>>
+
+    expect(emittedInput[0][0]).toBe(null)
+
+    expect(wrapper.queryByRole('listitem')).not.toBeInTheDocument()
+    expect(wrapper.queryByRole('button')).not.toBeInTheDocument()
+  })
+
+  it('supports custom clear value', async () => {
+    const wrapper = renderComponent(FormKit, {
+      ...wrapperParameters,
+      props: {
+        ...testProps,
+        options: testOptions,
+        value: testOptions[1].value,
+        clearable: true,
+        clearValue: {},
+      },
+    })
+
+    expect(wrapper.getByRole('listitem')).toHaveTextContent(
+      testOptions[1].label,
+    )
+
+    await wrapper.events.click(wrapper.getByRole('button'))
+
+    await waitFor(() => {
+      expect(wrapper.emitted().inputRaw).toBeTruthy()
+    })
+
+    const emittedInput = wrapper.emitted().inputRaw as Array<Array<InputEvent>>
+
+    expect(emittedInput[0][0]).toEqual({})
+
+    expect(wrapper.queryByRole('listitem')).not.toBeInTheDocument()
+    expect(wrapper.queryByRole('button')).not.toBeInTheDocument()
+  })
+
+  it('supports multiple selection', async () => {
+    const wrapper = renderComponent(FormKit, {
+      ...wrapperParameters,
+      props: {
+        ...testProps,
+        options: testOptions,
+        multiple: true,
+      },
+    })
+
+    await wrapper.events.click(wrapper.getByLabelText('Select…'))
+
+    const selectOptions = wrapper.getAllByRole('option')
+
+    expect(selectOptions).toHaveLength(
+      wrapper.queryAllByIconName('square').length,
+    )
+
+    wrapper.events.click(selectOptions[0])
+
+    await waitFor(() => {
+      expect(wrapper.emitted().inputRaw).toBeTruthy()
+    })
+
+    const emittedInput = wrapper.emitted().inputRaw as Array<Array<InputEvent>>
+
+    expect(emittedInput[0][0]).toStrictEqual([testOptions[0].value])
+    expect(wrapper.queryAllByIconName('square')).toHaveLength(2)
+    expect(wrapper.queryAllByIconName('check-square')).toHaveLength(1)
+    expect(wrapper.queryByRole('menu')).toBeInTheDocument()
+    expect(wrapper.queryAllByRole('listitem')).toHaveLength(1)
+
+    wrapper.queryAllByRole('listitem').forEach((selectedLabel, index) => {
+      expect(selectedLabel).toHaveTextContent(testOptions[index].label)
+    })
+
+    wrapper.events.click(selectOptions[1])
+
+    await waitFor(() => {
+      expect(emittedInput[1][0]).toStrictEqual([
+        testOptions[0].value,
+        testOptions[1].value,
+      ])
+    })
+
+    expect(wrapper.queryAllByIconName('square')).toHaveLength(1)
+    expect(wrapper.queryAllByIconName('check-square')).toHaveLength(2)
+    expect(wrapper.queryByRole('menu')).toBeInTheDocument()
+    expect(wrapper.queryAllByRole('listitem')).toHaveLength(2)
+
+    wrapper.queryAllByRole('listitem').forEach((selectedLabel, index) => {
+      expect(selectedLabel).toHaveTextContent(testOptions[index].label)
+    })
+
+    wrapper.events.click(selectOptions[2])
+
+    await waitFor(() => {
+      expect(emittedInput[2][0]).toStrictEqual([
+        testOptions[0].value,
+        testOptions[1].value,
+        testOptions[2].value,
+      ])
+    })
+
+    expect(wrapper.queryAllByIconName('square')).toHaveLength(0)
+    expect(wrapper.queryAllByIconName('check-square')).toHaveLength(3)
+    expect(wrapper.queryByRole('menu')).toBeInTheDocument()
+    expect(wrapper.queryAllByRole('listitem')).toHaveLength(3)
+
+    wrapper.queryAllByRole('listitem').forEach((selectedLabel, index) => {
+      expect(selectedLabel).toHaveTextContent(testOptions[index].label)
+    })
+
+    wrapper.events.click(selectOptions[2])
+
+    await waitFor(() => {
+      expect(emittedInput[3][0]).toStrictEqual([
+        testOptions[0].value,
+        testOptions[1].value,
+      ])
+    })
+
+    expect(wrapper.queryAllByIconName('square')).toHaveLength(1)
+    expect(wrapper.queryAllByIconName('check-square')).toHaveLength(2)
+    expect(wrapper.queryByRole('menu')).toBeInTheDocument()
+    expect(wrapper.queryAllByRole('listitem')).toHaveLength(2)
+
+    wrapper.queryAllByRole('listitem').forEach((selectedLabel, index) => {
+      expect(selectedLabel).toHaveTextContent(testOptions[index].label)
+    })
+  })
+
+  it('supports option sorting', async (context) => {
+    context.skipConsole = true
+
+    const reversedOptions = cloneDeep(testOptions).reverse()
+
+    const wrapper = renderComponent(FormKit, {
+      ...wrapperParameters,
+      props: {
+        ...testProps,
+        options: reversedOptions,
+        sorting: 'label',
+      },
+    })
+
+    await wrapper.events.click(wrapper.getByLabelText('Select…'))
+
+    let selectOptions = wrapper.getAllByRole('option')
+
+    expect(selectOptions[0]).toHaveTextContent(testOptions[1].label)
+    expect(selectOptions[1]).toHaveTextContent(testOptions[2].label)
+    expect(selectOptions[2]).toHaveTextContent(testOptions[0].label)
+
+    await wrapper.rerender({
+      sorting: 'value',
+    })
+
+    selectOptions = wrapper.getAllByRole('option')
+
+    selectOptions.forEach((selectOption, index) => {
+      expect(selectOption).toHaveTextContent(testOptions[index].label)
+    })
+
+    const warn = vi.spyOn(console, 'warn').mockImplementation(() => {
+      // no-op, silence warnings on the console
+    })
+
+    await wrapper.rerender({
+      sorting: 'foobar',
+    })
+
+    expect(warn).toHaveBeenCalledWith('Unsupported sorting option "foobar"')
+  })
+
+  it('supports label translation', async () => {
+    const untranslatedOptions = [
+      {
+        value: 0,
+        label: 'Item A (%s)',
+        labelPlaceholder: [0],
+        heading: 'autocomplete sample %s',
+        headingPlaceholder: [1],
+      },
+      {
+        value: 1,
+        label: 'Item B (%s)',
+        labelPlaceholder: [1],
+        heading: 'autocomplete sample %s',
+        headingPlaceholder: [2],
+      },
+      {
+        value: 2,
+        label: 'Item C (%s)',
+        labelPlaceholder: [2],
+        heading: 'autocomplete sample %s',
+        headingPlaceholder: [3],
+      },
+    ]
+
+    const translatedOptions = untranslatedOptions.map((untranslatedOption) => ({
+      ...untranslatedOption,
+      label: i18n.t(
+        untranslatedOption.label,
+        untranslatedOption.labelPlaceholder as never,
+      ),
+      heading: i18n.t(
+        untranslatedOption.heading,
+        untranslatedOption.headingPlaceholder as never,
+      ),
+    }))
+
+    let wrapper = renderComponent(FormKit, {
+      ...wrapperParameters,
+      props: {
+        ...testProps,
+        options: untranslatedOptions,
+      },
+    })
+
+    await wrapper.events.click(wrapper.getByLabelText('Select…'))
+
+    let selectOptions = wrapper.getAllByRole('option')
+
+    selectOptions.forEach((selectOption, index) => {
+      expect(selectOption).toHaveTextContent(
+        `${translatedOptions[index].label} – ${translatedOptions[index].heading}`,
+      )
+    })
+
+    await wrapper.events.click(selectOptions[0])
+
+    expect(wrapper.getByRole('listitem')).toHaveTextContent(
+      translatedOptions[0].label,
+    )
+
+    wrapper.unmount()
+
+    wrapper = renderComponent(FormKit, {
+      ...wrapperParameters,
+      props: {
+        ...testProps,
+        options: untranslatedOptions,
+        noOptionsLabelTranslation: true,
+      },
+    })
+
+    await wrapper.events.click(wrapper.getByLabelText('Select…'))
+
+    selectOptions = wrapper.getAllByRole('option')
+
+    selectOptions.forEach((selectOption, index) => {
+      expect(selectOption).toHaveTextContent(
+        `${untranslatedOptions[index].label} – ${untranslatedOptions[index].heading}`,
+      )
+    })
+
+    await wrapper.events.click(selectOptions[1])
+
+    expect(wrapper.getByRole('listitem')).toHaveTextContent(
+      untranslatedOptions[1].label,
+    )
+  })
+
+  it.skip('supports selection of unknown values', async () => {
+    const wrapper = renderComponent(FormKit, {
+      ...wrapperParameters,
+      props: {
+        ...testProps,
+        allowUnknownValues: true,
+        debounceInterval: 0,
+      },
+    })
+
+    await wrapper.events.click(wrapper.getByLabelText('Select…'))
+
+    const filterElement = wrapper.getByRole('searchbox')
+
+    mockAutocompleteSearchUserQuery({
+      autocompleteSearchUser: [],
+    })
+
+    await wrapper.events.type(filterElement, 'qux')
+
+    await waitForAutocompleteSearchUserQueryCalls()
+
+    let selectOptions = wrapper.getAllByRole('option')
+
+    expect(selectOptions).toHaveLength(1)
+    expect(selectOptions[0]).toHaveTextContent('qux')
+
+    wrapper.events.click(wrapper.getAllByRole('option')[0])
+
+    await waitFor(() => {
+      expect(wrapper.emitted().inputRaw).toBeTruthy()
+    })
+
+    const emittedInput = wrapper.emitted().inputRaw as Array<Array<InputEvent>>
+
+    expect(emittedInput[0][0]).toBe('qux')
+    expect(wrapper.getByRole('listitem')).toHaveTextContent('qux')
+
+    await wrapper.events.click(wrapper.getByLabelText('Select…'))
+
+    selectOptions = wrapper.getAllByRole('option')
+
+    expect(selectOptions).toHaveLength(1)
+    expect(selectOptions[0]).toHaveTextContent('qux')
+  })
+
+  it('supports value prefill with initial option builder', () => {
+    const wrapper = renderComponent(FormKit, {
+      ...wrapperParameters,
+      props: {
+        ...testProps,
+        value: 1234,
+        initialOptionBuilder: (object: ObjectLike, value: SelectValue) => {
+          return {
+            value,
+            label: `Item ${value}`,
+          }
+        },
+      },
+    })
+
+    expect(wrapper.getByRole('listitem')).toHaveTextContent(`Item 1234`)
+  })
+
+  it('supports non-multiple complex value', () => {
+    const wrapper = renderComponent(FormKit, {
+      ...wrapperParameters,
+      props: {
+        ...testProps,
+        value: {
+          value: 1234,
+          label: 'Item 1234',
+        },
+      },
+    })
+
+    expect(wrapper.getByRole('listitem')).toHaveTextContent('Item 1234')
+  })
+
+  it('supports multiple complex value', () => {
+    const wrapper = renderComponent(FormKit, {
+      ...wrapperParameters,
+      props: {
+        ...testProps,
+        multiple: true,
+        value: [
+          {
+            value: 1234,
+            label: 'Item 1234',
+          },
+          {
+            value: 4321,
+            label: 'Item 4321',
+          },
+        ],
+      },
+    })
+
+    const [item1, item2] = wrapper.getAllByRole('listitem')
+
+    expect(item1).toHaveTextContent('Item 1234')
+    expect(item2).toHaveTextContent('Item 4321')
+  })
+})
+
+describe('Form - Field - AutoComplete - Accessibility', () => {
+  it('supports element focusing', async () => {
+    const wrapper = renderComponent(FormKit, {
+      ...wrapperParameters,
+      props: {
+        ...testProps,
+        options: testOptions,
+        clearable: true,
+        multiple: true,
+        value: [testOptions[0].value],
+      },
+    })
+
+    expect(wrapper.getByLabelText('Select…')).toHaveAttribute('tabindex', '0')
+
+    const listitem = wrapper.getByRole('listitem')
+
+    expect(
+      getByRole(listitem, 'button', { name: 'Unselect Option' }),
+    ).toHaveAttribute('tabindex', '0')
+
+    expect(
+      wrapper.getByRole('button', { name: 'Clear Selection' }),
+    ).toHaveAttribute('tabindex', '0')
+
+    await wrapper.events.click(wrapper.getByLabelText('Select…'))
+
+    const menu = wrapper.getByRole('menu')
+
+    const selectAllButton = getByRole(menu, 'button', {
+      name: 'select all options',
+    })
+
+    expect(selectAllButton).toHaveAttribute('tabindex', '1')
+
+    const listbox = getByRole(menu, 'listbox')
+
+    const selectOptions = getAllByRole(listbox, 'option')
+
+    expect(selectOptions).toHaveLength(testOptions.length)
+
+    selectOptions.forEach((selectOption) => {
+      expect(selectOption).toHaveAttribute('tabindex', '0')
+    })
+  })
+
+  it('restores focus on close', async () => {
+    const wrapper = renderComponent(FormKit, {
+      ...wrapperParameters,
+      props: {
+        ...testProps,
+        options: testOptions,
+        clearable: true,
+        value: testOptions[1].value,
+      },
+    })
+
+    const selectField = wrapper.getByLabelText('Select…')
+
+    await wrapper.events.click(selectField)
+
+    expect(selectField).not.toHaveFocus()
+
+    const listbox = wrapper.getByRole('listbox')
+
+    const selectOptions = getAllByRole(listbox, 'option')
+
+    await wrapper.events.type(selectOptions[0], '{Space}')
+
+    expect(selectField).toHaveFocus()
+  })
+
+  it('prevents focusing of disabled field', async () => {
+    const wrapper = renderComponent(FormKit, {
+      ...wrapperParameters,
+      props: {
+        ...testProps,
+        options: testOptions,
+        disabled: true,
+      },
+    })
+
+    expect(wrapper.getByLabelText('Select…')).toHaveAttribute('tabindex', '-1')
+  })
+
+  it('prevents opening of dropdown in disabled field', async () => {
+    const wrapper = renderComponent(FormKit, {
+      ...wrapperParameters,
+      props: {
+        ...testProps,
+        options: testOptions,
+        disabled: true,
+      },
+    })
+
+    await wrapper.events.click(wrapper.getByLabelText('Select…'))
+
+    expect(wrapper.queryByRole('menu')).not.toBeInTheDocument()
+  })
+
+  it('shows a hint in case there are no options available', async () => {
+    const wrapper = renderComponent(FormKit, {
+      ...wrapperParameters,
+      props: {
+        ...testProps,
+        options: [],
+      },
+    })
+
+    await wrapper.events.click(wrapper.getByLabelText('Select…'))
+
+    const listbox = wrapper.getByRole('listbox')
+
+    const selectOptions = getAllByRole(listbox, 'option')
+
+    expect(selectOptions).toHaveLength(1)
+    expect(selectOptions[0]).toHaveAttribute('aria-disabled', 'true')
+    expect(selectOptions[0]).toHaveTextContent('Start typing to search…')
+  })
+
+  it('provides labels for screen readers', async () => {
+    const wrapper = renderComponent(FormKit, {
+      ...wrapperParameters,
+      props: {
+        ...testProps,
+        options: testOptions,
+        clearable: true,
+        value: testOptions[1].value,
+      },
+    })
+
+    expect(wrapper.getByRole('button')).toHaveAttribute(
+      'aria-label',
+      'Clear Selection',
+    )
+  })
+
+  it('supports keyboard navigation', async () => {
+    const wrapper = renderComponent(FormKit, {
+      ...wrapperParameters,
+      props: {
+        ...testProps,
+        options: testOptions,
+        clearable: true,
+        value: testOptions[1].value,
+      },
+    })
+
+    await wrapper.events.keyboard('{Tab}{Enter}')
+
+    const menu = wrapper.getByRole('menu')
+
+    expect(menu).toBeInTheDocument()
+
+    const search = wrapper.getByRole('searchbox')
+
+    expect(search).toHaveFocus()
+
+    await wrapper.events.type(search, '{Down}')
+
+    const listbox = wrapper.getByRole('listbox')
+
+    const selectOptions = getAllByRole(listbox, 'option')
+
+    expect(selectOptions[1]).toHaveFocus()
+
+    await wrapper.events.keyboard('{Tab}')
+
+    expect(selectOptions[2]).toHaveFocus()
+
+    await wrapper.events.type(selectOptions[2], '{Space}')
+
+    await waitFor(() => {
+      expect(wrapper.emitted().inputRaw).toBeTruthy()
+    })
+
+    const emittedInput = wrapper.emitted().inputRaw as Array<Array<InputEvent>>
+
+    expect(emittedInput[0][0]).toBe(testOptions[2].value)
+
+    wrapper.events.type(
+      wrapper.getByRole('button', { name: 'Clear Selection' }),
+      '{Space}',
+    )
+
+    await waitFor(() => {
+      expect(emittedInput[1][0]).toBe(null)
+    })
+  })
+})
+
+// Cover all use cases from the FormKit custom input checklist.
+//   More info here: https://formkit.com/advanced/custom-inputs#input-checklist
+describe('Form - Field - AutoComplete - Input Checklist', () => {
+  it('implements input id attribute', async () => {
+    const wrapper = renderComponent(FormKit, {
+      ...wrapperParameters,
+      props: {
+        ...testProps,
+        id: 'test_id',
+        options: testOptions,
+      },
+    })
+
+    expect(wrapper.getByLabelText('Select…')).toHaveAttribute('id', 'test_id')
+  })
+
+  it('implements input name', async () => {
+    const wrapper = renderComponent(FormKit, {
+      ...wrapperParameters,
+      props: {
+        ...testProps,
+        name: 'test_name',
+        options: testOptions,
+      },
+    })
+
+    expect(wrapper.getByLabelText('Select…')).toHaveAttribute(
+      'name',
+      'test_name',
+    )
+  })
+
+  it('implements blur handler', async () => {
+    const blurHandler = vi.fn()
+
+    const wrapper = renderComponent(FormKit, {
+      ...wrapperParameters,
+      props: {
+        ...testProps,
+        options: testOptions,
+        onBlur: blurHandler,
+      },
+    })
+
+    wrapper.getByLabelText('Select…').focus()
+    await wrapper.events.tab()
+
+    expect(blurHandler).toHaveBeenCalledOnce()
+  })
+
+  it('implements input handler', async () => {
+    const wrapper = renderComponent(FormKit, {
+      ...wrapperParameters,
+      props: {
+        ...testProps,
+        options: testOptions,
+      },
+    })
+
+    await wrapper.events.click(wrapper.getByLabelText('Select…'))
+
+    wrapper.events.click(wrapper.getAllByRole('option')[1])
+
+    await waitFor(() => {
+      expect(wrapper.emitted().inputRaw).toBeTruthy()
+    })
+
+    const emittedInput = wrapper.emitted().inputRaw as Array<Array<InputEvent>>
+
+    expect(emittedInput[0][0]).toBe(testOptions[1].value)
+  })
+
+  it.each([0, 1, 2])(
+    'implements input value display',
+    async (testOptionsIndex) => {
+      const testOption = testOptions[testOptionsIndex]
+
+      const wrapper = renderComponent(FormKit, {
+        ...wrapperParameters,
+        props: {
+          ...testProps,
+          options: testOptions,
+          value: testOption.value,
+        },
+      })
+
+      expect(wrapper.getByRole('listitem')).toHaveTextContent(testOption.label)
+    },
+  )
+
+  it('implements disabled', async () => {
+    const wrapper = renderComponent(FormKit, {
+      ...wrapperParameters,
+      props: {
+        ...testProps,
+        options: testOptions,
+        disabled: true,
+      },
+    })
+
+    expect(wrapper.getByLabelText('Select…')).toHaveClass(
+      'formkit-disabled:pointer-events-none',
+    )
+  })
+
+  it('implements attribute passthrough', async () => {
+    const wrapper = renderComponent(FormKit, {
+      ...wrapperParameters,
+      props: {
+        ...testProps,
+        options: testOptions,
+        'test-attribute': 'test_value',
+      },
+    })
+
+    expect(wrapper.getByLabelText('Select…')).toHaveAttribute(
+      'test-attribute',
+      'test_value',
+    )
+  })
+
+  it('implements standardized classes', async () => {
+    const wrapper = renderComponent(FormKit, {
+      ...wrapperParameters,
+      props: {
+        ...testProps,
+        options: testOptions,
+      },
+    })
+
+    expect(wrapper.getByTestId('field-autocomplete')).toHaveClass(
+      'formkit-input',
+    )
+  })
+})

Некоторые файлы не были показаны из-за большого количества измененных файлов