Browse Source

Maintenance: Desktop - Add form field select.

Dusan Vuckovic 1 year ago
parent
commit
4a6053aa43

+ 1 - 1
app/frontend/apps/desktop/AppDesktop.vue

@@ -98,7 +98,7 @@ const appTheme = useAppTheme()
     </aside>
 
     <article
-      class="w-full h-full antialiased bg-white dark:bg-gray-500 text-gray-100 dark:text-neutral-400"
+      class="w-full h-full antialiased bg-white dark:bg-gray-500 text-gray-100 dark:text-neutral-400 overflow-hidden"
     >
       <RouterView />
     </article>

+ 86 - 0
app/frontend/apps/desktop/components/CommonInputSearch/CommonInputSearch.vue

@@ -0,0 +1,86 @@
+<!-- Copyright (C) 2012-2024 Zammad Foundation, https://zammad-foundation.org/ -->
+
+<script setup lang="ts">
+import { useVModel } from '@vueuse/core'
+import { shallowRef } from 'vue'
+
+export interface CommonInputSearchProps {
+  modelValue?: string
+  wrapperClass?: string
+  placeholder?: string
+}
+
+export interface CommonInputSearchEmits {
+  (e: 'update:modelValue', filter: string): void
+}
+
+export interface CommonInputSearchExpose {
+  focus(): void
+}
+
+const props = withDefaults(defineProps<CommonInputSearchProps>(), {
+  placeholder: __('Search…'),
+})
+const emit = defineEmits<CommonInputSearchEmits>()
+
+const filter = useVModel(props, 'modelValue', emit)
+
+const filterInput = shallowRef<HTMLInputElement>()
+
+const focus = () => {
+  filterInput.value?.focus()
+}
+
+defineExpose({ focus })
+
+const clearFilter = () => {
+  filter.value = ''
+  focus()
+}
+</script>
+
+<script lang="ts">
+export default {
+  inheritAttrs: false,
+}
+</script>
+
+<template>
+  <div
+    class="grow inline-flex justify-start items-center gap-1"
+    :class="wrapperClass"
+  >
+    <CommonIcon
+      class="shrink-0 fill-stone-200 dark:fill-neutral-500"
+      size="tiny"
+      name="search"
+      decorative
+    />
+    <input
+      ref="filterInput"
+      v-model="filter"
+      v-bind="$attrs"
+      :placeholder="i18n.t(placeholder)"
+      class="grow bg-blue-200 dark:bg-gray-700 text-black dark:text-white outline-none"
+      type="text"
+      role="searchbox"
+    />
+    <div class="flex shrink-0 items-center gap-1">
+      <slot name="controls" />
+      <CommonIcon
+        class="fill-stone-200 dark:fill-neutral-500 focus-visible:outline focus-visible:rounded-sm focus-visible:outline-1 focus-visible:outline-offset-1 focus-visible:outline-blue-800"
+        :class="{
+          invisible: !filter?.length,
+        }"
+        :aria-label="i18n.t('Clear Search')"
+        :aria-hidden="!filter?.length ? 'true' : undefined"
+        name="backspace"
+        size="tiny"
+        role="button"
+        :tabindex="!filter?.length ? '-1' : '0'"
+        @click.stop="clearFilter()"
+        @keypress.space.prevent.stop="clearFilter()"
+      />
+    </div>
+  </div>
+</template>

+ 56 - 0
app/frontend/apps/desktop/components/CommonInputSearch/__tests__/CommonInputSearch.spec.ts

@@ -0,0 +1,56 @@
+// Copyright (C) 2012-2024 Zammad Foundation, https://zammad-foundation.org/
+
+import { renderComponent } from '#tests/support/components/index.ts'
+import { onMounted, ref } from 'vue'
+import CommonInputSearch, {
+  type CommonInputSearchExpose,
+} from '../CommonInputSearch.vue'
+
+describe('testing input for searching', () => {
+  it('renders input', async () => {
+    const view = renderComponent(CommonInputSearch, {
+      vModel: {
+        modelValue: '',
+      },
+    })
+
+    expect(view.getByIconName('search')).toBeInTheDocument()
+
+    const search = view.getByRole('searchbox')
+
+    expect(search).toHaveAttribute('placeholder', 'Search…')
+
+    const clearButton = view.getByIconName('backspace')
+
+    expect(clearButton).toHaveClass('invisible')
+
+    await view.events.type(search, 'test')
+
+    expect(clearButton).not.toHaveClass('invisible')
+
+    await view.events.click(clearButton)
+
+    expect(search).toHaveDisplayValue('')
+  })
+
+  it('can focus outside of the component', async () => {
+    let focus: () => void
+    const component = {
+      components: { CommonInputSearch },
+      template: `<CommonInputSearch ref="searchInput" />`,
+      setup() {
+        const searchInput = ref<null | CommonInputSearchExpose>()
+        onMounted(() => {
+          ;({ focus } = searchInput.value!)
+        })
+        return { searchInput }
+      },
+    }
+
+    const view = renderComponent(component)
+
+    focus!()
+
+    expect(view.getByRole('searchbox')).toHaveFocus()
+  })
+})

+ 370 - 0
app/frontend/apps/desktop/components/CommonSelect/CommonSelect.vue

@@ -0,0 +1,370 @@
+<!-- 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 { useFocusWhenTyping } from '#shared/composables/useFocusWhenTyping.ts'
+import { useTrapTab } from '#shared/composables/useTrapTab.ts'
+import { useTraverseOptions } from '#shared/composables/useTraverseOptions.ts'
+import stopEvent from '#shared/utils/events.ts'
+import {
+  type UseElementBoundingReturn,
+  onClickOutside,
+  onKeyDown,
+  useVModel,
+} from '@vueuse/core'
+import type { SelectOption } from '#shared/components/CommonSelect/types.ts'
+import testFlags from '#shared/utils/testFlags.ts'
+import CommonLabel from '#shared/components/CommonLabel/CommonLabel.vue'
+import CommonSelectItem from './CommonSelectItem.vue'
+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[]
+  /**
+   * Do not modify local value
+   */
+  passive?: boolean
+  multiple?: boolean
+  noClose?: boolean
+  noRefocus?: boolean
+  owner?: string
+  noOptionsLabelTranslation?: boolean
+}
+
+const props = defineProps<Props>()
+
+const emit = defineEmits<{
+  (e: 'update:modelValue', option: string | number | (string | number)[]): void
+  (e: 'select', option: SelectOption): void
+  (e: 'close'): void
+}>()
+
+const dropdownElement = ref<HTMLElement>()
+const localValue = useVModel(props, 'modelValue', emit)
+
+// TODO: do we really want this initial transforming of the value, when it's null?
+if (localValue.value == null && props.multiple) {
+  localValue.value = []
+}
+
+const getFocusableOptions = () => {
+  return Array.from<HTMLElement>(
+    dropdownElement.value?.querySelectorAll('[tabindex="0"]') || [],
+  )
+}
+
+const showDropdown = ref(false)
+
+let inputElementBounds: UseElementBoundingReturn
+let windowHeight: Ref<number>
+
+const hasDirectionUp = computed(() => {
+  if (!inputElementBounds || !windowHeight) return false
+  return inputElementBounds.y.value > windowHeight.value / 2
+})
+
+const dropdownStyle = computed(() => {
+  if (!inputElementBounds) return { top: 0, left: 0, width: 0, maxHeight: 0 }
+
+  const style: Record<string, string> = {
+    left: `${inputElementBounds.left.value}px`,
+    width: `${inputElementBounds.width.value}px`,
+    maxHeight: `calc(50vh - ${inputElementBounds.height.value}px)`,
+  }
+
+  if (hasDirectionUp.value) {
+    style.bottom = `${windowHeight.value - inputElementBounds.top.value}px`
+  } else {
+    style.top = `${
+      inputElementBounds.top.value + inputElementBounds.height.value
+    }px`
+  }
+
+  return style
+})
+
+const { activateTabTrap, deactivateTabTrap } = useTrapTab(dropdownElement)
+
+let lastFocusableOutsideElement: HTMLElement | null = null
+
+const getActiveElement = () => {
+  if (props.owner) {
+    return document.getElementById(props.owner)
+  }
+
+  return document.activeElement as HTMLElement
+}
+
+const { instances } = useCommonSelect()
+
+const closeDropdown = () => {
+  deactivateTabTrap()
+  showDropdown.value = false
+  emit('close')
+  if (!props.noRefocus) {
+    nextTick(() => lastFocusableOutsideElement?.focus())
+  }
+
+  nextTick(() => {
+    testFlags.set('common-select.closed')
+  })
+}
+
+const openDropdown = (
+  bounds: UseElementBoundingReturn,
+  height: Ref<number>,
+) => {
+  inputElementBounds = bounds
+  windowHeight = toRef(height)
+  instances.value.forEach((instance) => {
+    if (instance.isOpen) instance.closeDropdown()
+  })
+  showDropdown.value = true
+  lastFocusableOutsideElement = getActiveElement()
+
+  onClickOutside(dropdownElement, closeDropdown, {
+    ignore: [lastFocusableOutsideElement as unknown as HTMLElement],
+  })
+
+  requestAnimationFrame(() => {
+    nextTick(() => {
+      testFlags.set('common-select.opened')
+    })
+  })
+}
+
+const moveFocusToDropdown = (lastOption = false) => {
+  // Focus selected or first available option.
+  //   https://developer.mozilla.org/en-US/docs/Web/Accessibility/ARIA/Roles/listbox_role#keyboard_interactions
+  const focusableElements = getFocusableOptions()
+  if (!focusableElements?.length) return
+
+  let focusElement = focusableElements[0]
+
+  if (lastOption) {
+    focusElement = focusableElements[focusableElements.length - 1]
+  } else {
+    const selected = focusableElements.find(
+      (el) => el.getAttribute('aria-selected') === 'true',
+    )
+    if (selected) focusElement = selected
+  }
+
+  focusElement?.focus()
+  activateTabTrap()
+}
+
+const exposedInstance: CommonSelectInternalInstance = {
+  isOpen: computed(() => showDropdown.value),
+  openDropdown,
+  closeDropdown,
+  getFocusableOptions,
+  moveFocusToDropdown,
+}
+
+instances.value.add(exposedInstance)
+
+onUnmounted(() => {
+  instances.value.delete(exposedInstance)
+})
+
+defineExpose(exposedInstance)
+
+// https://developer.mozilla.org/en-US/docs/Web/Accessibility/ARIA/Roles/listbox_role#keyboard_interactions
+useTraverseOptions(dropdownElement, { direction: 'vertical' })
+
+// - Type-ahead is recommended for all listboxes, especially those with more than seven options
+useFocusWhenTyping(dropdownElement)
+
+onKeyDown(
+  'Escape',
+  (event) => {
+    stopEvent(event)
+    closeDropdown()
+  },
+  { target: dropdownElement as Ref<EventTarget> },
+)
+
+const isCurrentValue = (value: string | number | boolean) => {
+  if (props.multiple && Array.isArray(localValue.value)) {
+    return localValue.value.includes(value)
+  }
+
+  return localValue.value === value
+}
+
+const select = (option: SelectOption) => {
+  if (option.disabled) return
+
+  emit('select', option)
+
+  if (props.passive) {
+    if (!props.multiple) {
+      closeDropdown()
+    }
+    return
+  }
+
+  if (props.multiple && Array.isArray(localValue.value)) {
+    if (localValue.value.includes(option.value)) {
+      localValue.value = localValue.value.filter((v) => v !== option.value)
+    } else {
+      localValue.value.push(option.value)
+    }
+
+    return
+  }
+
+  if (props.modelValue === option.value) {
+    localValue.value = undefined
+  } else {
+    localValue.value = option.value
+  }
+
+  if (!props.multiple && !props.noClose) {
+    closeDropdown()
+  }
+}
+
+const hasSelectableOptions = computed(() =>
+  props.options.some(
+    (option) => !option.disabled && !isCurrentValue(option.value),
+  ),
+)
+
+const selectAll = () => {
+  props.options
+    .filter((option) => !option.disabled && !isCurrentValue(option.value))
+    .forEach((option) => select(option))
+}
+
+const duration = VITE_TEST_MODE ? undefined : { enter: 300, leave: 200 }
+</script>
+
+<template>
+  <slot
+    :state="showDropdown"
+    :open="openDropdown"
+    :close="closeDropdown"
+    :focus="moveFocusToDropdown"
+  />
+  <Teleport to="body">
+    <Transition :duration="duration">
+      <div
+        v-if="showDropdown"
+        id="common-select"
+        ref="dropdownElement"
+        class="fixed z-10 flex antialiased"
+        :style="dropdownStyle"
+      >
+        <div
+          class="select-dialog w-full"
+          role="menu"
+          :class="{
+            'select-dialog--up': hasDirectionUp,
+            'select-dialog--down': !hasDirectionUp,
+          }"
+        >
+          <div
+            class="h-full flex flex-col items-start bg-white dark:bg-gray-500 border-x border-neutral-100 dark:border-gray-900"
+            :class="{
+              'rounded-t-lg border-t': hasDirectionUp,
+              'rounded-b-lg border-b': !hasDirectionUp,
+            }"
+          >
+            <div
+              v-if="multiple && hasSelectableOptions"
+              class="w-full flex justify-end px-2.5 py-1.5 gap-2"
+            >
+              <CommonLabel
+                class="!text-blue-800 focus-visible:outline focus-visible:rounded-sm focus-visible:outline-1 focus-visible:outline-offset-1 focus-visible:outline-blue-800"
+                prefix-icon="check-all"
+                role="button"
+                tabindex="0"
+                @click.stop="selectAll()"
+                @keypress.enter.prevent.stop="selectAll()"
+                @keypress.space.prevent.stop="selectAll()"
+              >
+                {{ $t('select all') }}
+              </CommonLabel>
+            </div>
+            <div
+              :aria-label="$t('Select…')"
+              role="listbox"
+              :aria-multiselectable="multiple"
+              tabindex="-1"
+              class="w-full overflow-y-auto"
+            >
+              <CommonSelectItem
+                v-for="option in options"
+                :key="String(option.value)"
+                :class="{
+                  'first:rounded-t-lg':
+                    hasDirectionUp && (!multiple || !hasSelectableOptions),
+                  'last:rounded-b-lg': !hasDirectionUp,
+                }"
+                :selected="isCurrentValue(option.value)"
+                :multiple="multiple"
+                :option="option"
+                :no-label-translate="noOptionsLabelTranslation"
+                @select="select($event)"
+              />
+              <CommonSelectItem
+                v-if="!options.length"
+                :option="{
+                  label: __('No results found'),
+                  value: '',
+                  disabled: true,
+                }"
+              />
+              <slot name="footer" />
+            </div>
+          </div>
+        </div>
+      </div>
+    </Transition>
+  </Teleport>
+</template>
+
+<style scoped>
+.select-dialog {
+  &--down {
+    @apply origin-top;
+  }
+
+  &--up {
+    @apply origin-bottom;
+  }
+}
+
+.v-enter-active {
+  .select-dialog {
+    @apply duration-200 ease-out;
+  }
+}
+
+.v-leave-active {
+  .select-dialog {
+    @apply duration-200 ease-in;
+  }
+}
+
+.v-enter-to,
+.v-leave-from {
+  .select-dialog {
+    @apply scale-y-100 opacity-100;
+  }
+}
+
+.v-enter-from,
+.v-leave-to {
+  .select-dialog {
+    @apply scale-y-50 opacity-0;
+  }
+}
+</style>

+ 91 - 0
app/frontend/apps/desktop/components/CommonSelect/CommonSelectItem.vue

@@ -0,0 +1,91 @@
+<!-- Copyright (C) 2012-2024 Zammad Foundation, https://zammad-foundation.org/ -->
+
+<script setup lang="ts">
+import { computed } from 'vue'
+import { i18n } from '#shared/i18n.ts'
+import type { SelectOption } from '#shared/components/CommonSelect/types.ts'
+
+const props = defineProps<{
+  option: SelectOption
+  selected?: boolean
+  multiple?: boolean
+  noLabelTranslate?: boolean
+}>()
+
+const emit = defineEmits<{
+  (e: 'select', option: SelectOption): void
+}>()
+
+const select = (option: SelectOption) => {
+  if (props.option.disabled) {
+    return
+  }
+  emit('select', option)
+}
+
+const label = computed(() => {
+  const { option } = props
+  if (props.noLabelTranslate) {
+    return option.label
+  }
+
+  return i18n.t(option.label, ...(option.labelPlaceholder || []))
+})
+</script>
+
+<template>
+  <div
+    :class="{
+      'pointer-events-none': option.disabled,
+    }"
+    :tabindex="option.disabled ? '-1' : '0'"
+    :aria-selected="selected"
+    :aria-disabled="option.disabled ? 'true' : undefined"
+    class="group flex cursor-pointer items-center self-stretch px-2.5 py-2 gap-1.5 text-sm text-black dark:text-white outline-none hover:bg-blue-600 dark:hover:bg-blue-900 focus:bg-blue-800 hover:focus:focus:bg-blue-800 focus:text-white"
+    role="option"
+    :data-value="option.value"
+    @click="select(option)"
+    @keypress.space.prevent="select(option)"
+    @keypress.enter.prevent="select(option)"
+  >
+    <CommonIcon
+      v-if="multiple"
+      :class="{
+        'fill-stone-200 dark:fill-neutral-500': option.disabled,
+      }"
+      size="xs"
+      decorative
+      :name="selected ? 'check-square' : 'square'"
+      class="fill-gray-100 dark:fill-neutral-400 group-hover:fill-black dark:group-hover:fill-white group-focus:fill-white"
+    />
+    <CommonIcon
+      v-if="option.icon"
+      :name="option.icon"
+      size="tiny"
+      :class="{
+        'fill-stone-200 dark:fill-neutral-500': option.disabled,
+      }"
+      decorative
+      class="fill-gray-100 dark:fill-neutral-400 group-hover:fill-black dark:group-hover:fill-white group-focus:fill-white"
+    />
+    <span
+      :class="{
+        'text-stone-200 dark:text-neutral-500': option.disabled,
+      }"
+      class="grow"
+    >
+      {{ label || option.value }}
+    </span>
+    <CommonIcon
+      v-if="!multiple"
+      class="fill-stone-200 dark:fill-neutral-500 group-hover:fill-black dark:group-hover:fill-white group-focus:fill-white"
+      :class="{
+        invisible: !selected,
+        'fill-gray-100 dark:fill-neutral-400': option.disabled,
+      }"
+      decorative
+      size="tiny"
+      name="check2"
+    />
+  </div>
+</template>

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

@@ -0,0 +1,178 @@
+// Copyright (C) 2012-2024 Zammad Foundation, https://zammad-foundation.org/
+
+import { i18n } from '#shared/i18n.ts'
+import { renderComponent } from '#tests/support/components/index.ts'
+import type { Ref } from 'vue'
+import { ref } from 'vue'
+import CommonSelect, { type Props } from '../CommonSelect.vue'
+
+const options = [
+  {
+    value: 0,
+    label: 'Item A',
+  },
+  {
+    value: 1,
+    label: 'Item B',
+  },
+  {
+    value: 2,
+    label: 'Item C',
+  },
+]
+
+const html = String.raw
+
+const renderSelect = (props: Props, modelValue?: Ref) => {
+  return renderComponent(CommonSelect, {
+    props,
+    slots: {
+      default: html` <template #default="{ open, focus }">
+        <button @click="open()">Open Select</button>
+        <button @click="focus()">Move Focus</button>
+      </template>`,
+    },
+    vModel: {
+      modelValue,
+    },
+  })
+}
+
+beforeEach(() => {
+  i18n.setTranslationMap(new Map([]))
+})
+
+describe('CommonSelect.vue', () => {
+  it('can select and unselect value', async () => {
+    const modelValue = ref()
+    const view = renderSelect({ options }, modelValue)
+
+    await view.events.click(view.getByText('Open Select'))
+    await view.events.click(view.getByText('Item A'))
+
+    expect(view.emitted().select).toEqual([[options[0]]])
+
+    expect(
+      view.queryByRole('menu'),
+      'dropdown is hidden',
+    ).not.toBeInTheDocument()
+
+    expect(modelValue.value).toBe(0)
+
+    await view.events.click(view.getByText('Open Select'))
+
+    expect(
+      view.getByIconName((name, node) => {
+        return (
+          name === '#icon-check2' &&
+          !node?.parentElement?.classList.contains('invisible')
+        )
+      }),
+    ).toBeInTheDocument()
+
+    await view.events.click(view.getByText('Item A'))
+
+    expect(view.emitted().select).toEqual([[options[0]], [options[0]]])
+    expect(modelValue.value).toBe(undefined)
+  })
+
+  it('does not close select with noClose prop', async () => {
+    const view = renderSelect({ options, noClose: true })
+
+    await view.events.click(view.getByText('Open Select'))
+    await view.events.click(view.getByRole('option', { name: 'Item A' }))
+
+    expect(view.getByRole('menu')).toBeInTheDocument()
+  })
+
+  it('can select and unselect multiple values', async () => {
+    const modelValue = ref()
+    const view = renderSelect({ options, multiple: true }, modelValue)
+
+    await view.events.click(view.getByText('Open Select'))
+    await view.events.click(view.getByText('Item A'))
+
+    expect(modelValue.value).toEqual([0])
+
+    expect(view.queryAllByIconName('check-square')).toHaveLength(1)
+    await view.events.click(view.getByText('Item A'))
+
+    expect(modelValue.value).toEqual([])
+
+    await view.events.click(view.getByText('Item A'))
+    await view.events.click(view.getByText('Item B'))
+
+    expect(modelValue.value).toEqual([0, 1])
+
+    expect(view.queryAllByIconName('check-square')).toHaveLength(2)
+  })
+
+  it('passive mode does not change local value, but emits select', async () => {
+    const modelValue = ref()
+    const view = renderSelect({ options, passive: true }, modelValue)
+
+    await view.events.click(view.getByText('Open Select'))
+    await view.events.click(view.getByText('Item A'))
+
+    expect(view.emitted().select).toBeDefined()
+
+    expect(modelValue.value).toBeUndefined()
+  })
+
+  it('cannot select disabled values', async () => {
+    const modelValue = ref()
+    const view = renderSelect(
+      { options: [{ ...options[0], disabled: true }] },
+      modelValue,
+    )
+
+    await view.events.click(view.getByText('Open Select'))
+
+    expect(view.getByRole('option')).toHaveAttribute('aria-disabled', 'true')
+
+    await view.events.click(view.getByText('Item A'))
+
+    expect(view.emitted().select).toBeUndefined()
+    expect(modelValue.value).toBeUndefined()
+  })
+
+  it('translates labels', async () => {
+    i18n.setTranslationMap(new Map([[options[0].label, 'Translated Item A']]))
+    const view = renderSelect({ options })
+
+    await view.events.click(view.getByText('Open Select'))
+    expect(view.getByText('Translated Item A')).toBeInTheDocument()
+  })
+
+  it('does not translate with noOptionsLabelTranslation prop', async () => {
+    i18n.setTranslationMap(new Map([[options[0].label, 'Translated Item A']]))
+    const view = renderSelect({ options, noOptionsLabelTranslation: true })
+
+    await view.events.click(view.getByText('Open Select'))
+    expect(view.getByText(/^Item A$/)).toBeInTheDocument()
+  })
+
+  it('can use boolean as value', async () => {
+    const modelValue = ref()
+    const view = renderSelect(
+      {
+        options: [
+          { value: true, label: 'Yes' },
+          { value: false, label: 'No' },
+        ],
+      },
+      modelValue,
+    )
+    await view.events.click(view.getByText('Open Select'))
+    await view.events.click(view.getByText('Yes'))
+    expect(modelValue.value).toBe(true)
+  })
+
+  it('has an accessible name', async () => {
+    const view = renderSelect({ options })
+
+    await view.events.click(view.getByText('Open Select'))
+
+    expect(view.getByRole('listbox')).toHaveAccessibleName('Select…')
+  })
+})

+ 17 - 0
app/frontend/apps/desktop/components/CommonSelect/types.ts

@@ -0,0 +1,17 @@
+// Copyright (C) 2012-2024 Zammad Foundation, https://zammad-foundation.org/
+
+import type { UseElementBoundingReturn } from '@vueuse/core'
+import type { Ref } from 'vue'
+
+export interface CommonSelectInstance {
+  openDropdown(bounds: UseElementBoundingReturn, height: Ref<number>): void
+  closeDropdown(): void
+  getFocusableOptions(): HTMLElement[]
+  moveFocusToDropdown(lastOption: boolean): void
+  isOpen: boolean
+}
+
+export interface CommonSelectInternalInstance
+  extends Omit<CommonSelectInstance, 'isOpen'> {
+  isOpen: Ref<boolean>
+}

+ 25 - 0
app/frontend/apps/desktop/components/CommonSelect/useCommonSelect.ts

@@ -0,0 +1,25 @@
+// Copyright (C) 2012-2024 Zammad Foundation, https://zammad-foundation.org/
+
+import { computed, ref } from 'vue'
+import type { ComputedRef } from 'vue'
+import type { CommonSelectInternalInstance } from './types.ts'
+
+const instances = ref(
+  new Set<CommonSelectInternalInstance>(),
+) as unknown as ComputedRef<Set<CommonSelectInternalInstance>>
+
+export const useCommonSelect = () => {
+  const isOpened = computed(() => {
+    for (const instance of instances.value) {
+      if (instance.isOpen.value) {
+        return true
+      }
+    }
+    return false
+  })
+
+  return {
+    isOpened,
+    instances,
+  }
+}

+ 256 - 0
app/frontend/apps/desktop/components/Form/fields/FieldSelect/FieldSelectInput.vue

@@ -0,0 +1,256 @@
+<!-- Copyright (C) 2012-2024 Zammad Foundation, https://zammad-foundation.org/ -->
+
+<script setup lang="ts">
+import { computed, nextTick, ref, toRef, watch } from 'vue'
+import { useElementBounding, useWindowSize } from '@vueuse/core'
+import { escapeRegExp } from 'lodash-es'
+import { i18n } from '#shared/i18n.ts'
+import { useTrapTab } from '#shared/composables/useTrapTab.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.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 useSelectPreselect from '#shared/composables/useSelectPreselect.ts'
+import type { SelectContext } from '#shared/components/Form/fields/FieldSelect/types.ts'
+
+interface Props {
+  context: SelectContext
+}
+
+const props = defineProps<Props>()
+
+const contextReactive = toRef(props, 'context')
+
+const { hasValue, valueContainer, currentValue, clearValue } =
+  useValue(contextReactive)
+const {
+  sortedOptions,
+  selectOption,
+  getSelectedOption,
+  getSelectedOptionIcon,
+  getSelectedOptionLabel,
+  setupMissingOrDisabledOptionHandling,
+} = useSelectOptions(toRef(props.context, 'options'), contextReactive)
+
+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 = ''
+}
+
+watch(() => contextReactive.value.noFiltering, clearFilter)
+
+const deaccent = (s: string) =>
+  s.normalize('NFD').replace(/[\u0300-\u036f]/g, '')
+
+const filteredOptions = 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',
+  )
+
+  // Search across options via their de-accented labels.
+  return sortedOptions.value.filter((option) =>
+    filterRegex.test(deaccent(option.label || String(option.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 = () => {
+  clearFilter()
+  deactivateTabTrap()
+}
+
+useFormBlock(contextReactive, openSelectDropdown)
+
+useSelectPreselect(sortedOptions, contextReactive)
+setupMissingOrDisabledOptionHandling()
+</script>
+
+<template>
+  <div
+    ref="input"
+    :class="[
+      context.classes.input,
+      'flex h-auto min-h-10 bg-blue-200 dark:bg-gray-700 duration-200 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',
+      {
+        'rounded-lg': !select?.isOpen,
+        'rounded-t-lg': select?.isOpen && !isBelowHalfScreen,
+        'rounded-b-lg': select?.isOpen && isBelowHalfScreen,
+      },
+    ]"
+    data-test-id="field-select"
+  >
+    <CommonSelect
+      ref="select"
+      #default="{ state: expanded, close: closeDropdown }"
+      :model-value="currentValue"
+      :options="filteredOptions"
+      :multiple="context.multiple"
+      :owner="context.id"
+      no-options-label-translation
+      no-close
+      passive
+      @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="px-2.5 py-2 flex grow gap-2.5 items-center text-black dark:text-white focus:outline-none formkit-disabled:pointer-events-none"
+        :aria-labelledby="`label-${context.id}`"
+        :aria-disabled="context.disabled"
+        :data-multiple="context.multiple"
+        :tabindex="
+          context.disabled || (expanded && !context.noFiltering) ? '-1' : '0'
+        "
+        v-bind="{
+          ...context.attrs,
+          onBlur: undefined,
+        }"
+        @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"
+            class="flex items-center gap-1.5"
+            role="listitem"
+          >
+            <div
+              class="inline-flex items-center px-1.5 py-0.5 gap-1 rounded bg-white dark:bg-gray-200 text-black dark:text-white text-xs"
+            >
+              <CommonIcon
+                v-if="getSelectedOptionIcon(selectedValue)"
+                :name="getSelectedOptionIcon(selectedValue)"
+                class="fill-gray-100 dark:fill-neutral-400"
+                size="xs"
+                decorative
+              />
+              {{
+                getSelectedOptionLabel(selectedValue) ||
+                i18n.t('%s (unknown)', selectedValue)
+              }}
+              <CommonIcon
+                :aria-label="i18n.t('Unselect Option')"
+                class="fill-stone-200 dark:fill-neutral-500 focus-visible:outline focus-visible:rounded-sm focus-visible:outline-1 focus-visible:outline-offset-1 focus-visible:outline-blue-800"
+                name="x-lg"
+                size="xs"
+                role="button"
+                tabindex="0"
+                @click.stop="selectOption(getSelectedOption(selectedValue))"
+                @keypress.enter.prevent.stop="
+                  selectOption(getSelectedOption(selectedValue))
+                "
+                @keypress.space.prevent.stop="
+                  selectOption(getSelectedOption(selectedValue))
+                "
+              />
+            </div>
+          </div>
+        </div>
+        <CommonInputSearch
+          v-if="expanded && !context.noFiltering"
+          ref="filterInput"
+          v-model="filter"
+          @keypress.space.stop
+        />
+        <div v-else 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="getSelectedOptionIcon(currentValue)"
+              :name="getSelectedOptionIcon(currentValue)"
+              class="fill-gray-100 dark:fill-neutral-400"
+              size="tiny"
+              decorative
+            />
+            {{
+              getSelectedOptionLabel(currentValue) ||
+              i18n.t('%s (unknown)', currentValue)
+            }}
+          </div>
+        </div>
+        <CommonIcon
+          v-if="context.clearable && hasValue && !context.disabled"
+          :aria-label="i18n.t('Clear Selection')"
+          class="shrink-0 fill-stone-200 dark:fill-neutral-500 focus-visible:outline focus-visible:rounded-sm focus-visible:outline-1 focus-visible:outline-offset-1 focus-visible:outline-blue-800"
+          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>

+ 1390 - 0
app/frontend/apps/desktop/components/Form/fields/FieldSelect/__tests__/FieldSelect.spec.ts

@@ -0,0 +1,1390 @@
+// Copyright (C) 2012-2024 Zammad Foundation, https://zammad-foundation.org/
+
+import type { SetRequired } from 'type-fest'
+import { cloneDeep, keyBy } from 'lodash-es'
+import {
+  getAllByRole,
+  getByRole,
+  getByText,
+  waitFor,
+} from '@testing-library/vue'
+import { FormKit } from '@formkit/vue'
+import { renderComponent } from '#tests/support/components/index.ts'
+import { i18n } from '#shared/i18n.ts'
+import { getNode } from '@formkit/core'
+import { waitForNextTick } from '#tests/support/utils.ts'
+import type { SelectOption } from '#shared/components/CommonSelect/types.ts'
+import {
+  queryAllByIconName,
+  queryByIconName,
+} from '#tests/support/components/iconQueries.ts'
+
+const testOptions: SetRequired<SelectOption, 'label'>[] = [
+  {
+    value: 0,
+    label: 'Item A',
+  },
+  {
+    value: 1,
+    label: 'Item B',
+  },
+  {
+    value: 2,
+    label: 'Item C',
+  },
+]
+
+const wrapperParameters = {
+  form: true,
+  formField: true,
+}
+
+const commonProps = {
+  label: 'Select',
+  type: 'select',
+}
+
+describe('Form - Field - Select - Dropdown', () => {
+  it('renders select options in a dropdown menu', async () => {
+    const wrapper = renderComponent(FormKit, {
+      ...wrapperParameters,
+      props: {
+        ...commonProps,
+        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)
+    })
+
+    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: {
+        ...commonProps,
+        options: testOptions,
+        clearable: true,
+      },
+    })
+
+    await wrapper.events.click(wrapper.getByLabelText('Select'))
+
+    const listbox = wrapper.getByRole('listbox')
+
+    await 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: {
+        ...commonProps,
+        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 - Select - Options', () => {
+  it('supports unknown options', async () => {
+    const optionsProp = cloneDeep(testOptions)
+
+    const wrapper = renderComponent(FormKit, {
+      ...wrapperParameters,
+      props: {
+        ...commonProps,
+        value: 3,
+        options: optionsProp,
+      },
+    })
+
+    expect(wrapper.getByRole('listitem')).toHaveTextContent('3 (unknown)')
+
+    await wrapper.events.click(wrapper.getByLabelText('Select'))
+
+    const listbox = wrapper.getByRole('listbox')
+
+    let selectOptions = getAllByRole(listbox, 'option')
+
+    expect(selectOptions).toHaveLength(optionsProp.length)
+
+    selectOptions.forEach((selectOption, index) => {
+      expect(selectOption).toHaveTextContent(testOptions[index].label)
+    })
+
+    optionsProp.push({
+      value: 3,
+      label: 'Item D',
+    })
+
+    await wrapper.rerender({
+      options: optionsProp,
+    })
+
+    selectOptions = getAllByRole(listbox, 'option')
+
+    expect(selectOptions).toHaveLength(optionsProp.length)
+
+    await wrapper.events.click(wrapper.baseElement)
+
+    expect(wrapper.getByRole('listitem')).toHaveTextContent('Item D')
+  })
+
+  it('supports clearing of the existing value when option goes away', async () => {
+    const optionsProp = cloneDeep(testOptions)
+
+    optionsProp.push({
+      value: 3,
+      label: 'Item D',
+    })
+
+    const wrapper = renderComponent(FormKit, {
+      ...wrapperParameters,
+      props: {
+        ...commonProps,
+        clearable: true, // otherwise it defaults to the first option
+        value: 3,
+        options: optionsProp,
+      },
+    })
+
+    expect(wrapper.getByRole('listitem')).toHaveTextContent('Item D')
+
+    optionsProp.pop()
+
+    await wrapper.rerender({
+      options: optionsProp,
+    })
+
+    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()
+  })
+
+  it('supports clearing of the existing multiple values when options go away', async () => {
+    const optionsProp = cloneDeep(testOptions)
+
+    optionsProp.push(
+      ...[
+        {
+          value: 3,
+          label: 'Item D',
+        },
+        {
+          value: 4,
+          label: 'Item E',
+        },
+      ],
+    )
+
+    const wrapper = renderComponent(FormKit, {
+      ...wrapperParameters,
+      props: {
+        ...commonProps,
+        value: [2, 3, 4],
+        options: optionsProp,
+        multiple: true,
+      },
+    })
+
+    expect(wrapper.getAllByRole('listitem')).toHaveLength(3)
+
+    await wrapper.rerender({
+      options: testOptions,
+    })
+
+    await waitFor(() => {
+      expect(wrapper.emitted().inputRaw).toBeTruthy()
+    })
+
+    const emittedInput = wrapper.emitted().inputRaw as Array<Array<InputEvent>>
+
+    expect(emittedInput[0][0]).toEqual([2])
+    expect(wrapper.getByRole('listitem')).toHaveTextContent('Item C')
+  })
+
+  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: {
+        ...commonProps,
+        options: disabledOptions,
+      },
+    })
+
+    await wrapper.events.click(wrapper.getByLabelText('Select'))
+
+    const listbox = wrapper.getByRole('listbox')
+
+    const selectOptions = getAllByRole(listbox, 'option')
+
+    expect(selectOptions[1]).toHaveAttribute('aria-disabled', 'true')
+    expect(selectOptions[1]).toHaveClass('pointer-events-none')
+
+    expect(getByText(listbox, disabledOptions[1].label)).toHaveClasses([
+      '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: {
+        ...commonProps,
+        options: iconOptions,
+        clearable: true,
+      },
+    })
+
+    await wrapper.events.click(wrapper.getByLabelText('Select'))
+
+    const listbox = wrapper.getByRole('listbox')
+
+    expect(queryByIconName(listbox, iconOptions[0].icon)).toBeInTheDocument()
+    expect(queryByIconName(listbox, iconOptions[1].icon)).toBeInTheDocument()
+
+    await wrapper.events.click(getAllByRole(listbox, 'option')[0])
+
+    const listitem = wrapper.getByRole('listitem')
+
+    expect(queryByIconName(listitem, iconOptions[0].icon)).toBeInTheDocument()
+  })
+
+  it('supports historical options', async () => {
+    const wrapper = renderComponent(FormKit, {
+      ...wrapperParameters,
+      props: {
+        ...commonProps,
+        value: 3,
+        options: testOptions,
+        historicalOptions: {
+          ...keyBy(testOptions, 'value'),
+          3: 'Item D',
+        },
+      },
+    })
+
+    expect(wrapper.getByRole('listitem')).toHaveTextContent('Item D')
+
+    await wrapper.events.click(wrapper.getByLabelText('Select'))
+
+    let listbox = wrapper.getByRole('listbox')
+
+    let selectOptions = getAllByRole(listbox, 'option')
+
+    expect(selectOptions).toHaveLength(testOptions.length + 1)
+
+    selectOptions.forEach((selectOption, index) => {
+      if (index === 3) expect(selectOption).toHaveTextContent('Item D')
+      else expect(selectOption).toHaveTextContent(testOptions[index].label)
+    })
+
+    await 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]).toBe(testOptions[0].value)
+    expect(wrapper.getByRole('listitem')).toHaveTextContent(
+      testOptions[0].label,
+    )
+
+    await wrapper.events.click(wrapper.getByLabelText('Select'))
+
+    listbox = wrapper.getByRole('listbox')
+
+    selectOptions = getAllByRole(listbox, 'option')
+
+    expect(selectOptions).toHaveLength(testOptions.length + 1)
+
+    selectOptions.forEach((selectOption, index) => {
+      if (index === 3) expect(selectOption).toHaveTextContent('Item D')
+      else expect(selectOption).toHaveTextContent(testOptions[index].label)
+    })
+  })
+
+  it('supports rejection of non-existent values', async () => {
+    const wrapper = renderComponent(FormKit, {
+      ...wrapperParameters,
+      props: {
+        ...commonProps,
+        value: 3,
+        options: testOptions,
+        clearable: true, // otherwise it defaults to the first option
+        rejectNonExistentValues: true,
+      },
+    })
+
+    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()
+
+    await wrapper.events.click(wrapper.getByLabelText('Select'))
+
+    const listbox = wrapper.getByRole('listbox')
+
+    const selectOptions = getAllByRole(listbox, 'option')
+
+    expect(selectOptions).toHaveLength(testOptions.length)
+
+    selectOptions.forEach((selectOption, index) => {
+      expect(selectOption).toHaveTextContent(testOptions[index].label)
+    })
+  })
+
+  it('removes value for non-existent option on value update (single)', async () => {
+    const wrapper = renderComponent(FormKit, {
+      ...wrapperParameters,
+      props: {
+        ...commonProps,
+        id: 'select',
+        value: 1,
+        options: testOptions,
+      },
+    })
+
+    expect(wrapper.getByRole('listitem')).toHaveTextContent('Item B')
+
+    // Change values with one which does not exist inside the options (e.g. coming from core workflow).
+    const node = getNode('select')
+    await node?.settled
+    node?.input(3)
+
+    await waitForNextTick(true)
+
+    expect(wrapper.getByRole('listitem')).toHaveTextContent('Item B')
+  })
+
+  it('removes values for non-existent options on value update (multiple)', async () => {
+    const wrapper = renderComponent(FormKit, {
+      ...wrapperParameters,
+      props: {
+        ...commonProps,
+        id: 'select',
+        value: [1, 2],
+        options: testOptions,
+        multiple: true,
+      },
+    })
+
+    expect(wrapper.getAllByRole('listitem')).toHaveLength(2)
+
+    // Change values with one which not exists inside the options (e.g. coming from core workflow).
+    const node = getNode('select')
+    await node?.settled
+    node?.input([2, 3])
+
+    await waitForNextTick(true)
+
+    expect(wrapper.getAllByRole('listitem')).toHaveLength(1)
+  })
+
+  it('pre-selects also on value change when init value no longer exists in options (and pre-select mode is active)', async () => {
+    const optionsProp = cloneDeep(testOptions)
+
+    optionsProp.push({
+      value: 3,
+      label: 'Item D',
+    })
+
+    const wrapper = renderComponent(FormKit, {
+      ...wrapperParameters,
+      props: {
+        ...commonProps,
+        id: 'select',
+        value: 1,
+        options: optionsProp,
+      },
+    })
+
+    await wrapper.rerender({
+      options: [optionsProp[0], optionsProp[2]],
+    })
+
+    // Change values with one which not exists inside the options (e.g. coming from core workflow).
+    const node = getNode('select')
+    await node?.settled
+    node?.input(3)
+
+    await waitForNextTick(true)
+
+    expect(wrapper.getByRole('listitem')).toHaveTextContent('Item A')
+  })
+
+  it('removes values for disabled options on value update (multiple)', async () => {
+    const optionsProp = cloneDeep(testOptions)
+    optionsProp[2].disabled = true
+
+    const wrapper = renderComponent(FormKit, {
+      ...wrapperParameters,
+      props: {
+        ...commonProps,
+        id: 'select',
+        value: [0, 1],
+        options: optionsProp,
+        multiple: true,
+      },
+    })
+
+    expect(wrapper.getAllByRole('listitem')).toHaveLength(2)
+
+    // Change values with one which not exists inside the options (e.g. coming from core workflow).
+    const node = getNode('select')
+    await node?.settled
+    node?.input([1, 2])
+
+    await waitForNextTick(true)
+
+    expect(wrapper.getAllByRole('listitem')).toHaveLength(1)
+  })
+
+  it('removes values for disabled options on initial value (multiple)', async () => {
+    const optionsProp = cloneDeep(testOptions)
+    optionsProp[2].disabled = true
+
+    const wrapper = renderComponent(FormKit, {
+      ...wrapperParameters,
+      props: {
+        ...commonProps,
+        id: 'select',
+        type: 'select',
+        value: [0, 1, 2],
+        options: optionsProp,
+        multiple: true,
+      },
+    })
+
+    expect(wrapper.getAllByRole('listitem')).toHaveLength(2)
+  })
+
+  it('supports option filtering', async () => {
+    const wrapper = renderComponent(FormKit, {
+      ...wrapperParameters,
+      props: {
+        ...commonProps,
+        options: [
+          ...testOptions,
+          {
+            value: 3,
+            label: 'Ítem D',
+          },
+        ],
+      },
+    })
+
+    await wrapper.events.click(wrapper.getByLabelText('Select'))
+
+    let search = wrapper.getByRole('searchbox')
+
+    let selectOptions = wrapper.getAllByRole('option')
+
+    expect(selectOptions).toHaveLength(4)
+
+    // Search is always case-insensitive.
+    await wrapper.events.type(search, 'c')
+
+    selectOptions = wrapper.getAllByRole('option')
+
+    expect(selectOptions).toHaveLength(1)
+    expect(selectOptions[0]).toHaveTextContent('Item C')
+
+    await wrapper.events.click(
+      wrapper.getByRole('button', { name: 'Clear Search' }),
+    )
+
+    await wrapper.events.click(wrapper.getByLabelText('Select'))
+
+    search = wrapper.getByRole('searchbox')
+
+    expect(search).toHaveValue('')
+
+    selectOptions = wrapper.getAllByRole('option')
+
+    expect(selectOptions).toHaveLength(4)
+
+    // Search for non-accented characters matches items with accents too.
+    await wrapper.events.type(search, 'item d')
+
+    selectOptions = wrapper.getAllByRole('option')
+
+    expect(selectOptions).toHaveLength(1)
+    expect(selectOptions[0]).toHaveTextContent('Ítem D')
+
+    await wrapper.events.clear(search)
+
+    expect(search).toHaveValue('')
+
+    selectOptions = wrapper.getAllByRole('option')
+
+    expect(selectOptions).toHaveLength(4)
+
+    // Search for accented characters matches items with accents too.
+    await wrapper.events.type(search, 'ítem d')
+
+    selectOptions = wrapper.getAllByRole('option')
+
+    expect(selectOptions).toHaveLength(1)
+    expect(selectOptions[0]).toHaveTextContent('Ítem D')
+  })
+
+  it('supports disabling filtering', async () => {
+    const wrapper = renderComponent(FormKit, {
+      ...wrapperParameters,
+      props: {
+        ...commonProps,
+        options: testOptions,
+        noFiltering: true,
+      },
+    })
+
+    await wrapper.events.click(wrapper.getByLabelText('Select'))
+
+    expect(wrapper.queryByRole('searchbox')).not.toBeInTheDocument()
+  })
+})
+
+describe('Form - Field - Select - Features', () => {
+  it('supports value mutation', async () => {
+    const wrapper = renderComponent(FormKit, {
+      ...wrapperParameters,
+      props: {
+        ...commonProps,
+        id: 'select',
+        options: testOptions,
+        clearable: false,
+        value: testOptions[1].value,
+      },
+    })
+
+    expect(wrapper.getByRole('listitem')).toHaveTextContent(
+      testOptions[1].label,
+    )
+
+    const node = getNode('select')
+    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: {
+        ...commonProps,
+        type: 'select',
+        options: testOptions,
+        value: testOptions[1].value,
+        clearable: true,
+      },
+    })
+
+    const listitem = wrapper.getByRole('listitem')
+
+    expect(listitem).toHaveTextContent(testOptions[1].label)
+
+    const clearSelectionButton = wrapper.getByRole('button', {
+      name: 'Clear Selection',
+    })
+
+    await wrapper.events.click(clearSelectionButton)
+
+    await waitFor(() => {
+      expect(wrapper.emitted().inputRaw).toBeTruthy()
+    })
+
+    const emittedInput = wrapper.emitted().inputRaw as Array<Array<InputEvent>>
+
+    expect(emittedInput[0][0]).toBe(null)
+
+    expect(listitem).not.toBeInTheDocument()
+    expect(clearSelectionButton).not.toBeInTheDocument()
+  })
+
+  it('supports multiple selection', async () => {
+    const wrapper = renderComponent(FormKit, {
+      ...wrapperParameters,
+      props: {
+        ...commonProps,
+        options: testOptions,
+        multiple: true,
+      },
+    })
+
+    await wrapper.events.click(wrapper.getByLabelText('Select'))
+
+    const menu = wrapper.getByRole('menu')
+
+    let selectAllButton = getByRole(menu, 'button', {
+      name: 'select all',
+    })
+
+    const listbox = getByRole(menu, 'listbox')
+
+    const selectOptions = getAllByRole(listbox, 'option')
+
+    expect(selectAllButton).toBeInTheDocument()
+    expect(selectOptions).toHaveLength(
+      queryAllByIconName(listbox, 'square').length,
+    )
+
+    await 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(selectAllButton).toBeInTheDocument()
+    expect(queryAllByIconName(listbox, 'square')).toHaveLength(2)
+    expect(queryAllByIconName(listbox, '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)
+    })
+
+    await wrapper.events.click(selectOptions[1])
+
+    await waitFor(() => {
+      expect(emittedInput[1][0]).toStrictEqual([
+        testOptions[0].value,
+        testOptions[1].value,
+      ])
+    })
+
+    expect(selectAllButton).toBeInTheDocument()
+    expect(queryAllByIconName(listbox, 'square')).toHaveLength(1)
+    expect(queryAllByIconName(listbox, '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)
+    })
+
+    await wrapper.events.click(selectOptions[2])
+
+    await waitFor(() => {
+      expect(emittedInput[2][0]).toStrictEqual([
+        testOptions[0].value,
+        testOptions[1].value,
+        testOptions[2].value,
+      ])
+    })
+
+    expect(selectAllButton).not.toBeInTheDocument()
+    expect(queryAllByIconName(listbox, 'square')).toHaveLength(0)
+    expect(queryAllByIconName(listbox, '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)
+    })
+
+    await wrapper.events.click(selectOptions[2])
+
+    await waitFor(() => {
+      expect(emittedInput[3][0]).toStrictEqual([
+        testOptions[0].value,
+        testOptions[1].value,
+      ])
+    })
+
+    selectAllButton = getByRole(menu, 'button', {
+      name: 'select all',
+    })
+
+    expect(selectAllButton).toBeInTheDocument()
+    expect(queryAllByIconName(listbox, 'square')).toHaveLength(1)
+    expect(queryAllByIconName(listbox, '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)
+    })
+
+    await wrapper.events.click(selectAllButton)
+
+    await waitFor(() => {
+      expect(emittedInput[4][0]).toStrictEqual([
+        testOptions[0].value,
+        testOptions[1].value,
+        testOptions[2].value,
+      ])
+    })
+
+    expect(selectAllButton).not.toBeInTheDocument()
+    expect(queryAllByIconName(listbox, 'square')).toHaveLength(0)
+    expect(queryAllByIconName(listbox, 'check-square')).toHaveLength(3)
+    expect(wrapper.queryByRole('menu')).toBeInTheDocument()
+    expect(wrapper.queryAllByRole('listitem')).toHaveLength(3)
+
+    await wrapper.events.click(wrapper.baseElement)
+  })
+
+  it('supports option sorting', async (context) => {
+    context.skipConsole = true
+
+    const reversedOptions = cloneDeep(testOptions).reverse()
+
+    const wrapper = renderComponent(FormKit, {
+      ...wrapperParameters,
+      props: {
+        ...commonProps,
+        options: reversedOptions,
+        sorting: 'label',
+      },
+    })
+
+    await wrapper.events.click(wrapper.getByLabelText('Select'))
+
+    const listbox = wrapper.getByRole('listbox')
+
+    const selectOptions = getAllByRole(listbox, 'option')
+
+    selectOptions.forEach((selectOption, index) => {
+      expect(selectOption).toHaveTextContent(testOptions[index].label)
+    })
+
+    await wrapper.rerender({
+      sorting: 'value',
+    })
+
+    selectOptions.forEach((selectOption, index) => {
+      expect(selectOption).toHaveTextContent(testOptions[index].label)
+    })
+
+    vi.spyOn(console, 'warn')
+
+    await wrapper.rerender({
+      sorting: 'foobar',
+    })
+
+    expect(console.warn).toHaveBeenCalledWith(
+      'Unsupported sorting option "foobar"',
+    )
+  })
+
+  it('supports label translation', async () => {
+    const untranslatedOptions = [
+      {
+        value: 0,
+        label: 'Item A (%s)',
+        labelPlaceholder: [0],
+      },
+      {
+        value: 1,
+        label: 'Item B (%s)',
+        labelPlaceholder: [1],
+      },
+      {
+        value: 2,
+        label: 'Item C (%s)',
+        labelPlaceholder: [2],
+      },
+    ]
+
+    const translatedOptions = untranslatedOptions.map((untranslatedOption) => ({
+      ...untranslatedOption,
+      label: i18n.t(
+        untranslatedOption.label,
+        untranslatedOption.labelPlaceholder as never,
+      ),
+    }))
+
+    const wrapper = renderComponent(FormKit, {
+      ...wrapperParameters,
+      props: {
+        ...commonProps,
+        options: untranslatedOptions,
+      },
+    })
+
+    await wrapper.events.click(wrapper.getByLabelText('Select'))
+
+    let listbox = wrapper.getByRole('listbox')
+
+    let selectOptions = getAllByRole(listbox, 'option')
+
+    selectOptions.forEach((selectOption, index) => {
+      expect(selectOption).toHaveTextContent(translatedOptions[index].label)
+    })
+
+    await wrapper.events.click(selectOptions[0])
+
+    expect(wrapper.getByRole('listitem')).toHaveTextContent(
+      translatedOptions[0].label,
+    )
+
+    await wrapper.rerender({
+      noOptionsLabelTranslation: true,
+    })
+
+    await wrapper.events.click(wrapper.getByLabelText('Select'))
+
+    listbox = wrapper.getByRole('listbox')
+
+    selectOptions = getAllByRole(listbox, 'option')
+
+    selectOptions.forEach((selectOption, index) => {
+      expect(selectOption).toHaveTextContent(untranslatedOptions[index].label)
+    })
+
+    await wrapper.events.click(selectOptions[1])
+
+    expect(wrapper.getByRole('listitem')).toHaveTextContent(
+      untranslatedOptions[1].label,
+    )
+  })
+
+  it('supports option pre-select', async () => {
+    const wrapper = renderComponent(FormKit, {
+      ...wrapperParameters,
+      props: {
+        ...commonProps,
+        name: 'select',
+        id: 'select',
+        options: testOptions,
+      },
+    })
+
+    await waitFor(() => {
+      expect(wrapper.emitted().inputRaw).toBeTruthy()
+    })
+
+    expect(wrapper.getByRole('listitem')).toHaveTextContent('Item A')
+
+    await wrapper.rerender({
+      clearable: true,
+    })
+
+    await wrapper.events.click(
+      wrapper.getByRole('button', { name: 'Clear Selection' }),
+    )
+
+    await waitFor(() => {
+      expect(wrapper.emitted().inputRaw).toBeTruthy()
+    })
+
+    expect(wrapper.queryByRole('listitem')).not.toBeInTheDocument()
+
+    await wrapper.rerender({
+      clearable: false,
+    })
+
+    await waitFor(() => {
+      expect(wrapper.emitted().inputRaw).toBeTruthy()
+    })
+
+    expect(wrapper.getByRole('listitem')).toHaveTextContent('Item A')
+
+    await wrapper.rerender({
+      clearable: true,
+    })
+
+    // Reset the value before the next test case
+    const node = getNode('select')
+    node?.input(null)
+
+    await wrapper.rerender({
+      clearable: false,
+      options: [
+        {
+          value: 2,
+          label: 'Item C',
+        },
+      ],
+    })
+
+    await waitFor(() => {
+      expect(wrapper.emitted().inputRaw).toBeTruthy()
+    })
+
+    expect(wrapper.getByRole('listitem')).toHaveTextContent('Item C')
+
+    await wrapper.rerender({
+      clearable: true,
+      multiple: true,
+    })
+
+    await wrapper.events.click(
+      wrapper.getByRole('button', { name: 'Clear Selection' }),
+    )
+
+    await waitFor(() => {
+      expect(wrapper.emitted().inputRaw).toBeTruthy()
+    })
+
+    expect(wrapper.queryByRole('listitem')).not.toBeInTheDocument()
+
+    await wrapper.rerender({
+      clearable: false,
+    })
+
+    expect(wrapper.queryByRole('listitem')).not.toBeInTheDocument()
+
+    await wrapper.rerender({
+      clearable: true,
+      multiple: false,
+      disabled: true,
+    })
+
+    expect(wrapper.queryByRole('listitem')).not.toBeInTheDocument()
+
+    await wrapper.rerender({
+      clearable: false,
+    })
+
+    expect(wrapper.queryByRole('listitem')).not.toBeInTheDocument()
+  })
+
+  it('considers only enabled options for pre-selection', async () => {
+    const disabledOptions = [
+      {
+        value: 0,
+        label: 'Item A',
+        disabled: true,
+      },
+      {
+        value: 1,
+        label: 'Item B',
+      },
+      {
+        value: 2,
+        label: 'Item C',
+      },
+    ]
+
+    const wrapper = renderComponent(FormKit, {
+      ...wrapperParameters,
+      props: {
+        ...commonProps,
+        name: 'select',
+        id: 'select',
+        options: disabledOptions,
+      },
+    })
+
+    await waitFor(() => {
+      expect(wrapper.emitted().inputRaw).toBeTruthy()
+    })
+
+    expect(wrapper.getByRole('listitem')).toHaveTextContent('Item B')
+  })
+})
+
+describe('Form - Field - Select - Accessibility', () => {
+  it('supports element focusing', async () => {
+    const wrapper = renderComponent(FormKit, {
+      ...wrapperParameters,
+      props: {
+        ...commonProps,
+        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' })
+
+    expect(selectAllButton).toHaveAttribute('tabindex', '0')
+
+    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: {
+        ...commonProps,
+        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: {
+        ...commonProps,
+        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: {
+        ...commonProps,
+        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: {
+        ...commonProps,
+        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('No results found')
+  })
+
+  it('provides labels for screen readers', async () => {
+    const wrapper = renderComponent(FormKit, {
+      ...wrapperParameters,
+      props: {
+        ...commonProps,
+        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: {
+        ...commonProps,
+        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/essentials/custom-inputs#input-checklist
+describe('Form - Field - Select - Input Checklist', () => {
+  it('implements input id attribute', async () => {
+    const wrapper = renderComponent(FormKit, {
+      ...wrapperParameters,
+      props: {
+        ...commonProps,
+        id: 'test_id',
+        options: testOptions,
+      },
+    })
+
+    expect(wrapper.getByLabelText('Select')).toHaveAttribute('id', 'test_id')
+  })
+
+  it('implements input name', async () => {
+    const wrapper = renderComponent(FormKit, {
+      ...wrapperParameters,
+      props: {
+        ...commonProps,
+        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: {
+        ...commonProps,
+        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: {
+        ...commonProps,
+        options: testOptions,
+        clearable: true,
+      },
+    })
+
+    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: {
+          ...commonProps,
+          options: testOptions,
+          value: testOption.value,
+        },
+      })
+
+      expect(wrapper.getByRole('listitem')).toHaveTextContent(testOption.label)
+    },
+  )
+
+  it('implements disabled', async () => {
+    const wrapper = renderComponent(FormKit, {
+      ...wrapperParameters,
+      props: {
+        ...commonProps,
+        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: {
+        ...commonProps,
+        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: {
+        ...commonProps,
+        options: testOptions,
+      },
+    })
+
+    expect(wrapper.getByTestId('field-select')).toHaveClass('formkit-input')
+  })
+})

Some files were not shown because too many files changed in this diff