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

Maintenance: Mobile - Remove headlessui in favor of our own Dialog implementation

Vladimir Sheremet 2 лет назад
Родитель
Сommit
d4dac912fc

+ 54 - 0
app/frontend/apps/mobile/components/CommonSelect/CommonSelect.stories.ts

@@ -0,0 +1,54 @@
+// Copyright (C) 2012-2022 Zammad Foundation, https://zammad-foundation.org/
+
+import { Story } from '@storybook/vue3'
+import { ref } from 'vue'
+import CommonSelect, { type Props } from './CommonSelect.vue'
+
+export default {
+  title: 'CommonSelect',
+  component: CommonSelect,
+}
+
+const options = [
+  {
+    value: 0,
+    label: 'Item A',
+  },
+  {
+    value: 1,
+    label: 'Item B',
+  },
+  {
+    value: 2,
+    label: 'Item C',
+  },
+]
+
+const html = String.raw
+
+const Template: Story<Props> = (args: Props) => ({
+  components: { CommonSelect },
+  setup() {
+    const modelValue = ref()
+    return { args, modelValue }
+  },
+  template: html` <CommonSelect
+    v-model="modelValue"
+    v-bind="args"
+    v-slot="{ open }"
+  >
+    <button @click="open">Click Me!</button>
+    <div>Selected: {{ modelValue }}</div>
+  </CommonSelect>`,
+})
+
+export const Default = Template.bind({})
+Default.args = {
+  options,
+}
+
+export const Multiple = Template.bind({})
+Multiple.args = {
+  options,
+  multiple: true,
+}

+ 190 - 0
app/frontend/apps/mobile/components/CommonSelect/CommonSelect.vue

@@ -0,0 +1,190 @@
+<!-- Copyright (C) 2012-2022 Zammad Foundation, https://zammad-foundation.org/ -->
+
+<script setup lang="ts">
+import { SelectOption } from '@shared/components/Form/fields/FieldSelect/types'
+import { onClickOutside, onKeyDown, useVModel } from '@vueuse/core'
+import { ref } from 'vue'
+import CommonSelectItem from './CommonSelectItem.vue'
+
+export interface Props {
+  modelValue?: string | number | (string | number)[]
+  options: SelectOption[]
+  /**
+   * Do not modify local value
+   */
+  passive?: boolean
+  multiple?: boolean
+}
+
+const props = defineProps<Props>()
+
+const emit = defineEmits<{
+  (e: 'update:modelValue', option: string | number | (string | number)[]): void
+  (e: 'select', option: SelectOption): void
+}>()
+
+const dialogElement = ref<HTMLElement>()
+const localValue = useVModel(props, 'modelValue', emit)
+
+if (localValue.value == null && props.multiple) {
+  localValue.value = []
+}
+
+const showDialog = ref(false)
+
+const openDialog = () => {
+  showDialog.value = true
+}
+
+const closeDialog = () => {
+  showDialog.value = false
+}
+
+onClickOutside(dialogElement, closeDialog)
+onKeyDown('Escape', closeDialog)
+
+const isCurrentValue = (value: string | number) => {
+  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) {
+      closeDialog()
+    }
+    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) {
+    closeDialog()
+  }
+}
+
+const getElementUp = (currentIndex: number, elements: HTMLElement[]) => {
+  if (currentIndex === 0) {
+    return elements[elements.length - 1]
+  }
+  return elements[currentIndex - 1]
+}
+
+const getElementDown = (currentIndex: number, elements: HTMLElement[]) => {
+  if (currentIndex === elements.length - 1) {
+    return elements[0]
+  }
+  return elements[currentIndex + 1]
+}
+
+const advanceDialogFocus = (event: KeyboardEvent, currentIndex: number) => {
+  if (!['ArrowUp', 'ArrowDown'].includes(event.key)) return
+
+  const focusableElements: HTMLElement[] = Array.from(
+    dialogElement.value?.querySelectorAll('[tabindex="0"]') || [],
+  )
+  const nextElement =
+    event.key === 'ArrowUp'
+      ? getElementUp(currentIndex, focusableElements)
+      : getElementDown(currentIndex, focusableElements)
+
+  nextElement?.focus()
+}
+</script>
+
+<template>
+  <slot :open="openDialog" :close="closeDialog" />
+  <Transition :duration="{ enter: 300, leave: 200 }">
+    <div
+      v-if="showDialog"
+      class="fixed inset-0 z-10 flex overflow-y-auto"
+      role="dialog"
+    >
+      <div
+        class="select-overlay fixed inset-0 flex h-full w-full bg-gray-500 opacity-60"
+        data-test-id="dialog-overlay"
+      ></div>
+      <div class="select-dialog relative m-auto">
+        <div
+          class="flex min-w-[294px] flex-col items-start rounded-xl bg-gray-400/80 backdrop-blur-[15px]"
+        >
+          <div
+            ref="dialogElement"
+            role="listbox"
+            class="w-full divide-y divide-solid divide-white/10"
+          >
+            <CommonSelectItem
+              v-for="(option, index) in options"
+              :key="option.value"
+              :selected="isCurrentValue(option.value)"
+              :multiple="multiple"
+              :option="option"
+              @select="select($event)"
+              @keydown="advanceDialogFocus($event, index)"
+            />
+            <slot name="footer" />
+          </div>
+        </div>
+      </div>
+    </div>
+  </Transition>
+</template>
+
+<style scoped lang="scss">
+.v-enter-active {
+  .select-overlay,
+  .select-dialog {
+    @apply duration-300 ease-out;
+  }
+}
+
+.v-leave-active {
+  .select-overlay,
+  .select-dialog {
+    @apply duration-200 ease-in;
+  }
+}
+
+.v-enter-to,
+.v-leave-from {
+  .select-dialog {
+    @apply scale-100 opacity-100;
+  }
+
+  .select-overlay {
+    @apply opacity-60;
+  }
+}
+
+.v-enter-from,
+.v-leave-to {
+  .select-dialog {
+    @apply scale-95 opacity-0;
+  }
+
+  .select-overlay {
+    @apply opacity-0;
+  }
+}
+</style>

+ 81 - 0
app/frontend/apps/mobile/components/CommonSelect/CommonSelectItem.vue

@@ -0,0 +1,81 @@
+<!-- Copyright (C) 2012-2022 Zammad Foundation, https://zammad-foundation.org/ -->
+
+<script setup lang="ts">
+import { SelectOption } from '@shared/components/Form/fields/FieldSelect/types'
+import CommonTicketStateIndicator from '@shared/components/CommonTicketStateIndicator/CommonTicketStateIndicator.vue'
+
+defineProps<{
+  option: SelectOption
+  selected: boolean
+  multiple?: boolean
+}>()
+
+const emit = defineEmits<{
+  (e: 'select', option: SelectOption): void
+}>()
+
+const select = (option: SelectOption) => {
+  emit('select', option)
+}
+</script>
+
+<template>
+  <div
+    :class="{
+      'pointer-events-none': option.disabled,
+    }"
+    :tabindex="option.disabled ? '-1' : '0'"
+    :aria-selected="selected"
+    class="flex h-[58px] cursor-pointer items-center self-stretch py-5 px-4 text-base leading-[19px] text-white first:rounded-t-xl last:rounded-b-xl focus:bg-blue-highlight focus:outline-none"
+    role="option"
+    @click="select(option)"
+    @keypress.space="select(option)"
+  >
+    <CommonIcon
+      v-if="multiple"
+      :class="{
+        '!text-white': selected,
+        'opacity-30': option.disabled,
+      }"
+      size="base"
+      :name="selected ? 'checked-yes' : 'checked-no'"
+      class="mr-3 text-white/50"
+    />
+    <CommonTicketStateIndicator
+      v-if="option.status"
+      :status="option.status"
+      :label="option.label"
+      :class="{
+        'opacity-30': option.disabled,
+      }"
+      class="mr-[11px]"
+    />
+    <CommonIcon
+      v-else-if="option.icon"
+      :name="option.icon"
+      size="tiny"
+      :class="{
+        '!text-white': selected,
+        'opacity-30': option.disabled,
+      }"
+      class="mr-[11px] text-white/80"
+    />
+    <span
+      :class="{
+        'font-semibold !text-white': selected,
+        'opacity-30': option.disabled,
+      }"
+      class="grow text-white/80"
+    >
+      {{ option.label || option.value }}
+    </span>
+    <CommonIcon
+      v-if="!multiple && selected"
+      :class="{
+        'opacity-30': option.disabled,
+      }"
+      size="tiny"
+      name="check"
+    />
+  </div>
+</template>

+ 109 - 0
app/frontend/apps/mobile/components/CommonSelect/__tests__/CommonSelect.spec.ts

@@ -0,0 +1,109 @@
+// Copyright (C) 2012-2022 Zammad Foundation, https://zammad-foundation.org/
+
+import { renderComponent } from '@tests/support/components'
+import { ref, 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 }">
+        <button @click="open()">Open Select</button>
+      </template>`,
+    },
+    vModel: {
+      modelValue,
+    },
+  })
+}
+
+describe('interacting with CommonSelect', () => {
+  test('can select and deselect 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.queryByTestId('dialog-overlay'),
+      'dialog is hidden',
+    ).not.toBeInTheDocument()
+
+    expect(modelValue.value).toBe(0)
+
+    await view.events.click(view.getByText('Open Select'))
+
+    expect(view.getIconByName('check')).toBeInTheDocument()
+    await view.events.click(view.getByText('Item A'))
+
+    expect(view.emitted().select).toEqual([[options[0]], [options[0]]])
+    expect(modelValue.value).toBe(undefined)
+  })
+  test('can select and deselect 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.queryAllIconsByName('checked-yes')).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.queryAllIconsByName('checked-yes')).toHaveLength(2)
+  })
+  test("passive mode doesn't 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()
+  })
+  test("can't select disabled values", async () => {
+    const modelValue = ref()
+    const view = renderSelect(
+      { options: [{ ...options[0], disabled: true }] },
+      modelValue,
+    )
+
+    await view.events.click(view.getByText('Open Select'))
+    await view.events.click(view.getByText('Item A'))
+
+    expect(view.emitted().select).toBeUndefined()
+    expect(modelValue.value).toBeUndefined()
+  })
+  // TODO e2e test on keyboard interaction (select with space, moving up/down)
+})

+ 16 - 1
app/frontend/apps/mobile/modules/playground/views/PlaygroundOverview.vue

@@ -29,8 +29,23 @@ const linkSchemaRaw = [
     name: 'select',
     label: 'Select',
     props: {
+      // multiple: true,
       link: '/tickets',
-      options: [{ label: 'Label', value: 1 }],
+      options: [
+        {
+          value: 0,
+          label: 'Item A',
+          disabled: true,
+        },
+        {
+          value: 1,
+          label: 'Item B',
+        },
+        {
+          value: 2,
+          label: 'Item C',
+        },
+      ],
     },
   },
   {

+ 5 - 5
app/frontend/shared/components/Form/composables/useSelectOptions.ts

@@ -147,21 +147,21 @@ const useSelectOptions = (
 
     let targetElement
 
-    switch (event.keyCode) {
-      case 37:
+    switch (event.key) {
+      case 'ArrowLeft':
         if (typeof arrowLeftCallback === 'function')
           arrowLeftCallback(option, getDialogFocusTargets)
         break
-      case 38:
+      case 'ArrowUp':
         targetElement =
           targetElements[originElementIndex - 1] ||
           targetElements[targetElements.length - 1]
         break
-      case 39:
+      case 'ArrowRight':
         if (typeof arrowRightCallback === 'function')
           arrowRightCallback(option, getDialogFocusTargets)
         break
-      case 40:
+      case 'ArrowDown':
         targetElement =
           targetElements[originElementIndex + 1] || targetElements[0]
         break

+ 83 - 198
app/frontend/shared/components/Form/fields/FieldSelect/FieldSelectInput.vue

@@ -2,57 +2,33 @@
 
 <script setup lang="ts">
 import { computed, toRef } from 'vue'
-import {
-  Dialog,
-  DialogOverlay,
-  TransitionRoot,
-  TransitionChild,
-} from '@headlessui/vue'
 import { i18n } from '@shared/i18n'
 import CommonTicketStateIndicator from '@shared/components/CommonTicketStateIndicator/CommonTicketStateIndicator.vue'
+import CommonSelect from '@mobile/components/CommonSelect/CommonSelect.vue'
 import useValue from '../../composables/useValue'
-import useSelectDialog from '../../composables/useSelectDialog'
 import useSelectOptions from '../../composables/useSelectOptions'
 import useSelectAutoselect from '../../composables/useSelectAutoselect'
-import type { FormFieldContext } from '../../types/field'
-import type { SelectOption, SelectOptionSorting, SelectSize } from './types'
+import type { SelectContext } from './types'
 
 interface Props {
-  context: FormFieldContext<{
-    autoselect?: boolean
-    clearable?: boolean
-    disabled?: boolean
-    multiple?: boolean
-    noOptionsLabelTranslation?: boolean
-    options: SelectOption[]
-    size?: SelectSize
-    sorting?: SelectOptionSorting
-  }>
+  context: SelectContext
 }
 
 const props = defineProps<Props>()
 
-const { hasValue, valueContainer, isCurrentValue, clearValue } = useValue(
-  toRef(props, 'context'),
-)
+const contextReactive = toRef(props, 'context')
 
-const { isOpen, setIsOpen } = useSelectDialog()
+const { hasValue, valueContainer, currentValue, clearValue } =
+  useValue(contextReactive)
 
 const {
-  dialog,
   hasStatusProperty,
   sortedOptions,
+  selectOption,
   getSelectedOptionIcon,
   getSelectedOptionLabel,
   getSelectedOptionStatus,
-  selectOption,
-  advanceDialogFocus,
-} = useSelectOptions(toRef(props.context, 'options'), toRef(props, 'context'))
-
-const select = (option: SelectOption) => {
-  selectOption(option)
-  if (!props.context.multiple) setIsOpen(false)
-}
+} = useSelectOptions(toRef(props.context, 'options'), contextReactive)
 
 const isSizeSmall = computed(() => props.context.size === 'small')
 
@@ -69,184 +45,93 @@ useSelectAutoselect(sortedOptions, toRef(props, 'context'))
     class="flex h-auto focus-within:bg-blue-highlight focus-within:pt-0 formkit-populated:pt-0"
     data-test-id="field-select"
   >
-    <output
-      :id="context.id"
-      :name="context.node.name"
-      :class="{
-        'grow pr-3': !isSizeSmall,
-        'px-2 py-1': isSizeSmall,
-      }"
-      class="flex cursor-pointer items-center focus:outline-none formkit-disabled:pointer-events-none"
-      :aria-disabled="context.disabled"
-      :aria-label="i18n.t('Select…')"
-      :tabindex="context.disabled ? '-1' : '0'"
-      v-bind="context.attrs"
-      role="list"
-      @click="setIsOpen(true)"
-      @keypress.space="setIsOpen(true)"
-      @blur="context.handlers.blur"
+    <CommonSelect
+      #default="{ open }"
+      :model-value="currentValue"
+      :options="sortedOptions"
+      :multiple="context.multiple"
+      passive
+      @select="selectOption"
     >
-      <div
+      <output
+        :id="context.id"
+        :name="context.node.name"
         :class="{
-          'grow translate-y-2': !isSizeSmall,
+          'grow pr-3': !isSizeSmall,
+          'px-2 py-1': isSizeSmall,
         }"
-        class="flex flex-wrap gap-1"
+        class="flex cursor-pointer items-center focus:outline-none formkit-disabled:pointer-events-none"
+        :aria-disabled="context.disabled"
+        :aria-label="i18n.t('Select…')"
+        :tabindex="context.disabled ? '-1' : '0'"
+        v-bind="context.attrs"
+        role="list"
+        @click="open"
+        @keypress.space="open"
+        @blur="context.handlers.blur"
       >
-        <template v-if="hasValue && hasStatusProperty">
-          <CommonTicketStateIndicator
-            v-for="selectedValue in valueContainer"
-            :key="selectedValue"
-            :status="getSelectedOptionStatus(selectedValue)"
-            :label="getSelectedOptionLabel(selectedValue)"
-            :data-test-status="getSelectedOptionStatus(selectedValue)"
-            role="listitem"
-            pill
-          />
-        </template>
-        <template v-else-if="hasValue">
-          <div
-            v-for="selectedValue in valueContainer"
-            :key="selectedValue"
-            :class="{
-              'text-base leading-[19px]': !isSizeSmall,
-              'mr-1 text-sm leading-[17px]': isSizeSmall,
-            }"
-            class="flex items-center after:content-[','] last:after:content-none"
-            role="listitem"
-          >
-            <CommonIcon
-              v-if="getSelectedOptionIcon(selectedValue)"
-              :name="getSelectedOptionIcon(selectedValue)"
-              :fixed-size="{ width: 12, height: 12 }"
-              class="mr-1"
-            />
-            {{ getSelectedOptionLabel(selectedValue) || selectedValue }}
-          </div>
-        </template>
-        <template v-else-if="isSizeSmall">
-          <div class="mr-1 text-sm leading-[17px]">
-            {{ i18n.t(context.label) }}
-          </div>
-        </template>
-      </div>
-      <CommonIcon
-        v-if="context.clearable && hasValue && !context.disabled"
-        :aria-label="i18n.t('Clear Selection')"
-        :fixed-size="{ width: 16, height: 16 }"
-        class="mr-2 shrink-0"
-        name="close-small"
-        role="button"
-        tabindex="0"
-        @click.stop="clearValue"
-        @keypress.space.prevent.stop="clearValue"
-      />
-      <CommonIcon
-        :fixed-size="{ width: 16, height: 16 }"
-        class="shrink-0"
-        name="caret-down"
-        decorative
-      />
-    </output>
-    <TransitionRoot :show="isOpen" as="template" appear>
-      <Dialog
-        class="fixed inset-0 z-10 flex overflow-y-auto py-6"
-        role="dialog"
-        @close="setIsOpen(false)"
-      >
-        <TransitionChild
-          enter="duration-300 ease-out"
-          enter-from="opacity-0"
-          enter-to="opacity-100"
-          leave="duration-200 ease-in"
-          leave-from="opacity-100"
-          leave-to="opacity-0"
-        >
-          <DialogOverlay
-            class="fixed inset-0 bg-gray-500 opacity-60"
-            data-test-id="dialog-overlay"
-          />
-        </TransitionChild>
-        <TransitionChild
-          class="relative m-auto"
-          enter="duration-300 ease-out"
-          enter-from="opacity-0 scale-95"
-          enter-to="opacity-100 scale-100"
-          leave="duration-200 ease-in"
-          leave-from="opacity-100 scale-100"
-          leave-to="opacity-0 scale-95"
+        <div
+          :class="{
+            'grow translate-y-2': !isSizeSmall,
+          }"
+          class="flex flex-wrap gap-1"
         >
-          <div
-            ref="dialog"
-            class="flex min-w-[294px] flex-col items-start divide-y divide-solid divide-white/10 rounded-xl bg-gray-400/80 backdrop-blur-[15px]"
-            role="listbox"
-          >
+          <template v-if="hasValue && hasStatusProperty">
+            <CommonTicketStateIndicator
+              v-for="selectedValue in valueContainer"
+              :key="selectedValue"
+              :status="getSelectedOptionStatus(selectedValue)"
+              :label="getSelectedOptionLabel(selectedValue)"
+              :data-test-status="getSelectedOptionStatus(selectedValue)"
+              role="listitem"
+              pill
+            />
+          </template>
+          <template v-else-if="hasValue">
             <div
-              v-for="option in sortedOptions"
-              :key="option.value"
+              v-for="selectedValue in valueContainer"
+              :key="selectedValue"
               :class="{
-                'pointer-events-none': option.disabled,
+                'text-base leading-[19px]': !isSizeSmall,
+                'mr-1 text-sm leading-[17px]': isSizeSmall,
               }"
-              :tabindex="option.disabled ? '-1' : '0'"
-              :aria-selected="isCurrentValue(option.value)"
-              class="flex h-[58px] cursor-pointer items-center self-stretch py-5 px-4 text-base leading-[19px] text-white first:rounded-t-xl last:rounded-b-xl focus:bg-blue-highlight focus:outline-none"
-              role="option"
-              @click="select(option)"
-              @keypress.space="select(option)"
-              @keydown="advanceDialogFocus"
+              class="flex items-center after:content-[','] last:after:content-none"
+              role="listitem"
             >
               <CommonIcon
-                v-if="context.multiple"
-                :class="{
-                  '!text-white': isCurrentValue(option.value),
-                  'opacity-30': option.disabled,
-                }"
-                :fixed-size="{ width: 24, height: 24 }"
-                :name="
-                  isCurrentValue(option.value) ? 'checked-yes' : 'checked-no'
-                "
-                class="mr-3 text-white/50"
-              />
-              <CommonTicketStateIndicator
-                v-if="option.status"
-                :status="option.status"
-                :label="option.label"
-                :class="{
-                  'opacity-30': option.disabled,
-                }"
-                class="mr-[11px]"
-              />
-              <CommonIcon
-                v-else-if="option.icon"
-                :name="option.icon"
-                :fixed-size="{ width: 16, height: 16 }"
-                :class="{
-                  '!text-white': isCurrentValue(option.value),
-                  'opacity-30': option.disabled,
-                }"
-                class="mr-[11px] text-white/80"
-              />
-              <span
-                :class="{
-                  'font-semibold !text-white': isCurrentValue(option.value),
-                  'opacity-30': option.disabled,
-                }"
-                class="grow text-white/80"
-              >
-                {{ option.label || option.value }}
-              </span>
-              <CommonIcon
-                v-if="!context.multiple && isCurrentValue(option.value)"
-                :class="{
-                  'opacity-30': option.disabled,
-                }"
-                :fixed-size="{ width: 16, height: 16 }"
-                name="check"
+                v-if="getSelectedOptionIcon(selectedValue)"
+                :name="getSelectedOptionIcon(selectedValue)"
+                :fixed-size="{ width: 12, height: 12 }"
+                class="mr-1"
               />
+              {{ getSelectedOptionLabel(selectedValue) || selectedValue }}
+            </div>
+          </template>
+          <template v-else-if="isSizeSmall">
+            <div class="mr-1 text-sm leading-[17px]">
+              {{ i18n.t(context.label) }}
             </div>
-          </div>
-        </TransitionChild>
-      </Dialog>
-    </TransitionRoot>
+          </template>
+        </div>
+        <CommonIcon
+          v-if="context.clearable && hasValue && !context.disabled"
+          :aria-label="i18n.t('Clear Selection')"
+          :fixed-size="{ width: 16, height: 16 }"
+          class="mr-2 shrink-0"
+          name="close-small"
+          role="button"
+          tabindex="0"
+          @click.stop="clearValue"
+          @keypress.space.prevent.stop="clearValue"
+        />
+        <CommonIcon
+          :fixed-size="{ width: 16, height: 16 }"
+          class="shrink-0"
+          name="caret-down"
+          decorative
+        />
+      </output>
+    </CommonSelect>
   </div>
 </template>
 

+ 14 - 30
app/frontend/shared/components/Form/fields/FieldSelect/__tests__/FieldSelect.spec.ts

@@ -1,11 +1,7 @@
 // Copyright (C) 2012-2022 Zammad Foundation, https://zammad-foundation.org/
 
 import { cloneDeep } from 'lodash-es'
-import {
-  getByText,
-  waitFor,
-  waitForElementToBeRemoved,
-} from '@testing-library/vue'
+import { getByText, waitFor } from '@testing-library/vue'
 import { FormKit } from '@formkit/vue'
 import { renderComponent } from '@tests/support/components'
 import { i18n } from '@shared/i18n'
@@ -46,15 +42,7 @@ const testOptions = [
 const wrapperParameters = {
   form: true,
   formField: true,
-
-  // NB: Dialog component from the Headless UI library uses a built-in Vue Teleport mechanism in order to "teleport" a
-  //   part of the dialog's template into a DOM node that exists outside the DOM hierarchy of the dialog. Vitest does
-  //   not stub this component automatically, so we need to be explicit.
-  global: {
-    stubs: {
-      teleport: true,
-    },
-  },
+  dialog: true,
 }
 
 describe('Form - Field - Select - Dialog', () => {
@@ -79,8 +67,6 @@ describe('Form - Field - Select - Dialog', () => {
 
     await wrapper.events.click(wrapper.getByTestId('dialog-overlay'))
 
-    await waitForElementToBeRemoved(() => wrapper.queryByRole('dialog'))
-
     expect(wrapper.queryByRole('dialog')).not.toBeInTheDocument()
   })
 
@@ -105,8 +91,6 @@ describe('Form - Field - Select - Dialog', () => {
 
     expect(emittedInput[0][0]).toBe(testOptions[0].value)
 
-    await waitForElementToBeRemoved(() => wrapper.queryByRole('dialog'))
-
     expect(wrapper.queryByRole('dialog')).not.toBeInTheDocument()
   })
 
@@ -130,8 +114,6 @@ describe('Form - Field - Select - Dialog', () => {
 
     await wrapper.events.click(wrapper.getByTestId('dialog-overlay'))
 
-    await waitForElementToBeRemoved(() => wrapper.queryByRole('dialog'))
-
     expect(wrapper.queryByRole('dialog')).not.toBeInTheDocument()
   })
 })
@@ -176,8 +158,6 @@ describe('Form - Field - Select - Options', () => {
 
     await wrapper.events.click(wrapper.getByTestId('dialog-overlay'))
 
-    await waitForElementToBeRemoved(() => wrapper.queryByRole('dialog'))
-
     expect(wrapper.getByRole('listitem')).toHaveTextContent('Item D')
   })
 
@@ -261,8 +241,6 @@ describe('Form - Field - Select - Options', () => {
 
     await wrapper.events.click(selectOptions[0])
 
-    await waitForElementToBeRemoved(() => wrapper.queryByRole('dialog'))
-
     expect(wrapper.getByRole('listitem')).toHaveAttribute(
       'data-test-status',
       statusOptions[0].status,
@@ -365,7 +343,7 @@ describe('Form - Field - Select - Features', () => {
 
     await wrapper.events.click(wrapper.getByRole('list'))
 
-    const selectOptions = wrapper.getAllByRole('option')
+    let selectOptions = wrapper.getAllByRole('option')
 
     expect(selectOptions).toHaveLength(
       wrapper.queryAllIconsByName('checked-no').length,
@@ -389,7 +367,9 @@ describe('Form - Field - Select - Features', () => {
       expect(selectedLabel).toHaveTextContent(testOptions[index].label)
     })
 
-    wrapper.events.click(selectOptions[1])
+    selectOptions = wrapper.getAllByRole('option')
+
+    await wrapper.events.click(selectOptions[1])
 
     await waitFor(() => {
       expect(emittedInput[0][0]).toStrictEqual([
@@ -407,7 +387,9 @@ describe('Form - Field - Select - Features', () => {
       expect(selectedLabel).toHaveTextContent(testOptions[index].label)
     })
 
-    wrapper.events.click(selectOptions[2])
+    selectOptions = wrapper.getAllByRole('option')
+
+    await wrapper.events.click(selectOptions[2])
 
     await waitFor(() => {
       expect(emittedInput[0][0]).toStrictEqual([
@@ -426,7 +408,9 @@ describe('Form - Field - Select - Features', () => {
       expect(selectedLabel).toHaveTextContent(testOptions[index].label)
     })
 
-    wrapper.events.click(selectOptions[2])
+    selectOptions = wrapper.getAllByRole('option')
+
+    await wrapper.events.click(selectOptions[2])
 
     await waitFor(() => {
       expect(emittedInput[0][0]).toStrictEqual([
@@ -445,8 +429,6 @@ describe('Form - Field - Select - Features', () => {
     })
 
     await wrapper.events.click(wrapper.getByTestId('dialog-overlay'))
-
-    await waitForElementToBeRemoved(() => wrapper.queryByRole('dialog'))
   })
 
   it('supports option sorting', async () => {
@@ -541,6 +523,8 @@ describe('Form - Field - Select - Features', () => {
       noOptionsLabelTranslation: true,
     })
 
+    await wrapper.events.click(wrapper.getByRole('list'))
+
     selectOptions = wrapper.getAllByRole('option')
 
     selectOptions.forEach((selectOption, index) => {

+ 12 - 0
app/frontend/shared/components/Form/fields/FieldSelect/types.ts

@@ -1,6 +1,7 @@
 // Copyright (C) 2012-2022 Zammad Foundation, https://zammad-foundation.org/
 
 import type { TicketState } from '@shared/entities/ticket/types'
+import { FormFieldContext } from '../../types/field'
 
 export type SelectOption = {
   value: string | number
@@ -14,3 +15,14 @@ export type SelectOption = {
 export type SelectOptionSorting = 'label' | 'value'
 
 export type SelectSize = 'small' | 'medium'
+
+export type SelectContext = FormFieldContext<{
+  autoselect?: boolean
+  clearable?: boolean
+  disabled?: boolean
+  multiple?: boolean
+  noOptionsLabelTranslation?: boolean
+  options: SelectOption[]
+  size?: SelectSize
+  sorting?: SelectOptionSorting
+}>

+ 41 - 322
app/frontend/shared/components/Form/fields/FieldTreeSelect/FieldTreeSelectInput.vue

@@ -1,47 +1,60 @@
 <!-- Copyright (C) 2012-2022 Zammad Foundation, https://zammad-foundation.org/ -->
 
 <script setup lang="ts">
-import { computed, nextTick, ref, toRef, watch } from 'vue'
-import { escapeRegExp } from 'lodash-es'
-import { Dialog, TransitionRoot, TransitionChild } from '@headlessui/vue'
+import { computed, nextTick, ref, toRef } from 'vue'
 import { i18n } from '@shared/i18n'
+import { useDialog } from '@shared/composables/useDialog'
 import CommonTicketStateIndicator from '@shared/components/CommonTicketStateIndicator/CommonTicketStateIndicator.vue'
-import CommonInputSearch from '@shared/components/CommonInputSearch/CommonInputSearch.vue'
 import useLocaleStore from '@shared/stores/locale'
 import useValue from '../../composables/useValue'
-import useSelectDialog from '../../composables/useSelectDialog'
 import useSelectOptions from '../../composables/useSelectOptions'
 import useSelectAutoselect from '../../composables/useSelectAutoselect'
-import type { FormFieldContext } from '../../types/field'
-import type { SelectOption, SelectOptionSorting } from '../FieldSelect'
-import type { TreeSelectOption, FlatSelectOption } from './types'
+import type {
+  TreeSelectOption,
+  FlatSelectOption,
+  TreeSelectContext,
+} from './types'
 
 interface Props {
-  context: FormFieldContext<{
-    autoselect?: boolean
-    clearable?: boolean
-    noFiltering?: boolean
-    disabled?: boolean
-    multiple?: boolean
-    noOptionsLabelTranslation?: boolean
-    options: TreeSelectOption[]
-    sorting?: SelectOptionSorting
-  }>
+  context: TreeSelectContext
 }
 
 const props = defineProps<Props>()
 
-const { hasValue, valueContainer, isCurrentValue, clearValue } = useValue(
+const { hasValue, valueContainer, clearValue } = useValue(
   toRef(props, 'context'),
 )
 
-const { isOpen, setIsOpen } = useSelectDialog()
-
 const currentPath = ref<FlatSelectOption[]>([])
 
-const currentParent = computed(
-  () => currentPath.value[currentPath.value.length - 1] ?? null,
-)
+const clearPath = () => {
+  currentPath.value = []
+}
+
+const nameDialog = `field-tree-select-${props.context.id}`
+
+const dialog = useDialog({
+  name: nameDialog,
+  prefetch: true,
+  component: () => import('./FieldTreeSelectInputDialog.vue'),
+  afterClose() {
+    clearPath()
+  },
+})
+
+const openModal = () => {
+  return dialog.open({
+    context: toRef(props, 'context'),
+    name: nameDialog,
+    currentPath,
+    onPush(option: FlatSelectOption) {
+      currentPath.value.push(option)
+    },
+    onPop() {
+      currentPath.value.pop()
+    },
+  })
+}
 
 const flattenOptions = (
   options: TreeSelectOption[],
@@ -61,26 +74,6 @@ const flattenOptions = (
 
 const flatOptions = computed(() => flattenOptions(props.context.options))
 
-const pushToPath = (option: FlatSelectOption) => {
-  currentPath.value.push(option)
-}
-
-const popFromPath = () => {
-  currentPath.value.pop()
-}
-
-const clearPath = () => {
-  currentPath.value = []
-}
-
-const filter = ref('')
-
-const clearFilter = () => {
-  filter.value = ''
-}
-
-watch(toRef(props.context, 'noFiltering'), clearFilter)
-
 const filterInput = ref(null)
 
 const focusFirstTarget = (targetElements?: HTMLElement[]) => {
@@ -95,46 +88,14 @@ const focusFirstTarget = (targetElements?: HTMLElement[]) => {
   targetElements[0].focus()
 }
 
-const previousPageCallback = (
-  option?: SelectOption | FlatSelectOption,
-  getDialogFocusTargets?: (optionsOnly?: boolean) => HTMLElement[],
-) => {
-  popFromPath()
-  clearFilter()
-  nextTick(() =>
-    focusFirstTarget(getDialogFocusTargets && getDialogFocusTargets(true)),
-  )
-}
-
-const nextPageCallback = (
-  option?: SelectOption | FlatSelectOption,
-  getDialogFocusTargets?: (optionsOnly?: boolean) => HTMLElement[],
-) => {
-  if (option && (option as FlatSelectOption).hasChildren) {
-    pushToPath(option as FlatSelectOption)
-    nextTick(() =>
-      focusFirstTarget(getDialogFocusTargets && getDialogFocusTargets(true)),
-    )
-  }
-}
-
 const {
-  dialog,
   hasStatusProperty,
   optionValueLookup,
-  sortedOptions,
   getSelectedOptionIcon,
   getSelectedOptionLabel,
   getSelectedOptionStatus,
-  selectOption,
   getDialogFocusTargets,
-  advanceDialogFocus,
-} = useSelectOptions(
-  flatOptions,
-  toRef(props, 'context'),
-  previousPageCallback,
-  nextPageCallback,
-)
+} = useSelectOptions(flatOptions, toRef(props, 'context'))
 
 const getSelectedOptionParents = (selectedValue: string | number) =>
   (optionValueLookup.value[selectedValue] &&
@@ -147,72 +108,16 @@ const getSelectedOptionFullPath = (selectedValue: string | number) =>
     .join('') +
   (getSelectedOptionLabel(selectedValue) || selectedValue.toString())
 
-const goToPreviousPage = () => {
-  previousPageCallback(undefined, getDialogFocusTargets)
-}
-
-const goToNextPage = (option: FlatSelectOption) => {
-  nextPageCallback(option, getDialogFocusTargets)
-}
-
-const toggleDialog = (isVisible: boolean) => {
-  setIsOpen(isVisible)
-
+const toggleDialog = async (isVisible: boolean) => {
   if (isVisible) {
+    await openModal()
     nextTick(() => focusFirstTarget(getDialogFocusTargets(true)))
     return
   }
 
-  clearPath()
-  clearFilter()
+  await dialog.close()
 }
 
-const select = (option: FlatSelectOption) => {
-  selectOption(option)
-  if (!props.context.multiple) toggleDialog(false)
-}
-
-const currentOptions = computed(() => {
-  // In case we are not currently filtering for a parent, return only top-level options.
-  if (!currentParent.value)
-    return sortedOptions.value.filter(
-      (option) => !(option as FlatSelectOption).parents.length,
-    )
-
-  // Otherwise, return all options which are children of the current parent.
-  return sortedOptions.value.filter(
-    (option) =>
-      (option as FlatSelectOption).parents.length &&
-      (option as FlatSelectOption).parents[
-        (option as FlatSelectOption).parents.length - 1
-      ] === currentParent.value?.value,
-  )
-})
-
-const deaccent = (s: string) =>
-  s.normalize('NFD').replace(/[\u0300-\u036f]/g, '')
-
-const filteredOptions = computed(() => {
-  // In case we are not currently filtering for a parent, search across all options.
-  let options = sortedOptions.value
-
-  // Otherwise, search across options which are children of the current parent.
-  if (currentParent.value)
-    options = sortedOptions.value.filter((option) =>
-      (option as FlatSelectOption).parents.includes(currentParent.value?.value),
-    )
-
-  // 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 options.filter((option) => filterRegex.test(deaccent(option.label)))
-})
-
 useSelectAutoselect(flatOptions, toRef(props, 'context'))
 
 const locale = useLocaleStore()
@@ -286,192 +191,6 @@ const locale = useLocaleStore()
         decorative
       />
     </output>
-    <TransitionRoot :show="isOpen" as="template" appear>
-      <Dialog
-        class="fixed inset-0 z-10 flex overflow-y-auto"
-        role="dialog"
-        @close="toggleDialog(false)"
-      >
-        <TransitionChild
-          class="h-full grow"
-          enter="duration-300 ease-out"
-          enter-from="opacity-0 translate-y-3/4"
-          enter-to="opacity-100 translate-y-0"
-          leave="duration-200 ease-in"
-          leave-from="opacity-100 translate-y-0"
-          leave-to="opacity-0 translate-y-3/4"
-        >
-          <div ref="dialog" class="flex h-full grow flex-col bg-black">
-            <div class="mx-4 h-2.5 shrink-0 rounded-t-xl bg-gray-150/40" />
-            <div
-              class="relative flex h-16 shrink-0 items-center justify-center rounded-t-xl bg-gray-600/80"
-            >
-              <div
-                class="grow text-center text-base font-semibold leading-[19px] text-white"
-              >
-                {{ i18n.t(context.label) }}
-              </div>
-              <div
-                class="absolute top-0 right-0 bottom-0 flex items-center pr-4"
-              >
-                <div
-                  class="grow cursor-pointer text-blue"
-                  tabindex="0"
-                  role="button"
-                  @click="toggleDialog(false)"
-                  @keypress.space="toggleDialog(false)"
-                  @keydown="advanceDialogFocus"
-                >
-                  {{ i18n.t('Done') }}
-                </div>
-              </div>
-            </div>
-            <div
-              class="flex grow flex-col items-start overflow-y-auto bg-black text-white"
-            >
-              <div class="w-full p-4">
-                <CommonInputSearch
-                  v-if="!context.noFiltering"
-                  ref="filterInput"
-                  v-model="filter"
-                />
-              </div>
-              <div
-                v-if="currentPath.length"
-                :class="{
-                  'px-6': !context.noFiltering,
-                }"
-                class="flex h-[58px] cursor-pointer items-center self-stretch py-5 px-4 text-base leading-[19px] text-white focus:bg-blue-highlight focus:outline-none"
-                tabindex="0"
-                role="button"
-                @click="goToPreviousPage()"
-                @keypress.space="goToPreviousPage()"
-                @keydown="advanceDialogFocus"
-              >
-                <CommonIcon
-                  :fixed-size="{ width: 24, height: 24 }"
-                  class="mr-3"
-                  name="chevron-left"
-                />
-                <span class="grow font-semibold text-white/80">
-                  {{ currentParent.label || currentParent.value }}
-                </span>
-              </div>
-              <div
-                :class="{
-                  'border-t border-white/30': currentPath.length,
-                }"
-                class="flex grow flex-col items-start self-stretch overflow-y-auto"
-                role="listbox"
-              >
-                <div
-                  v-for="(option, index) in filter
-                    ? filteredOptions
-                    : currentOptions"
-                  :key="option.value"
-                  :class="{
-                    'px-6': !context.noFiltering,
-                    'pointer-events-none': option.disabled,
-                  }"
-                  :tabindex="option.disabled ? '-1' : '0'"
-                  :aria-selected="isCurrentValue(option.value)"
-                  class="relative flex h-[58px] cursor-pointer items-center self-stretch py-5 px-4 text-base leading-[19px] text-white focus:bg-blue-highlight focus:outline-none"
-                  role="option"
-                  @click="select(option as FlatSelectOption)"
-                  @keypress.space="select(option as FlatSelectOption)"
-                  @keydown="advanceDialogFocus($event, option)"
-                >
-                  <div
-                    v-if="index !== 0"
-                    :class="{
-                      'left-4': !context.multiple,
-                      'left-14': context.multiple,
-                    }"
-                    class="absolute right-4 top-0 h-0 border-t border-white/10"
-                  />
-                  <CommonIcon
-                    v-if="context.multiple"
-                    :class="{
-                      '!text-white': isCurrentValue(option.value),
-                      'opacity-30': option.disabled,
-                    }"
-                    :fixed-size="{ width: 24, height: 24 }"
-                    :name="
-                      isCurrentValue(option.value)
-                        ? 'checked-yes'
-                        : 'checked-no'
-                    "
-                    class="mr-3 text-white/50"
-                  />
-                  <CommonTicketStateIndicator
-                    v-if="option.status"
-                    :status="option.status"
-                    :label="option.label"
-                    :class="{
-                      'opacity-30': option.disabled,
-                    }"
-                    class="mr-[11px]"
-                  />
-                  <CommonIcon
-                    v-else-if="option.icon"
-                    :name="option.icon"
-                    :fixed-size="{ width: 16, height: 16 }"
-                    :class="{
-                      '!text-white': isCurrentValue(option.value),
-                      'opacity-30': option.disabled,
-                    }"
-                    class="mr-[11px] text-white/80"
-                  />
-                  <span
-                    :class="{
-                      'font-semibold !text-white': isCurrentValue(option.value),
-                      'opacity-30': option.disabled,
-                    }"
-                    class="grow text-white/80"
-                  >
-                    {{ option.label || option.value }}
-                    <template v-if="filter">
-                      <span
-                        v-for="parentValue in (option as FlatSelectOption).parents"
-                        :key="parentValue"
-                        class="opacity-50"
-                      >
-                        —
-                        {{ getSelectedOptionLabel(parentValue) || parentValue }}
-                      </span>
-                    </template>
-                  </span>
-                  <CommonIcon
-                    v-if="!context.multiple && isCurrentValue(option.value)"
-                    :class="{
-                      'opacity-30': option.disabled,
-                      'mr-3': (option as FlatSelectOption).hasChildren,
-                    }"
-                    :fixed-size="{ width: 16, height: 16 }"
-                    name="check"
-                  />
-                  <CommonIcon
-                    v-if="(option as FlatSelectOption).hasChildren && !filter"
-                    class="pointer-events-auto"
-                    :fixed-size="{ width: 24, height: 24 }"
-                    name="chevron-right"
-                    role="link"
-                    @click.stop="goToNextPage(option as FlatSelectOption)"
-                  />
-                </div>
-                <div
-                  v-if="filter && !filteredOptions.length"
-                  class="relative flex h-[58px] items-center justify-center self-stretch py-5 px-4 text-base leading-[19px] text-white/50"
-                  role="alert"
-                >
-                  {{ i18n.t('No results found') }}
-                </div>
-              </div>
-            </div>
-          </div>
-        </TransitionChild>
-      </Dialog>
-    </TransitionRoot>
   </div>
 </template>
 

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