Browse Source

Feature: Mobile - Added custom select and treeselect form field.

Dusan Vuckovic 2 years ago
parent
commit
f7856db6e8

+ 1 - 1
.gitignore

@@ -97,4 +97,4 @@ yarn-error.log
 
 # Storybook
 .storybook/storybook-static
-.storybook/node_modules
+.storybook/node_modules

+ 1 - 1
app/frontend/apps/mobile/form/index.ts

@@ -16,7 +16,7 @@ const pluginModules: ImportGlobEagerOutput<FormKitPlugin> =
 const fieldModules: ImportGlobEagerOutput<FormFieldTypeImportModules> =
   import.meta.globEager('../components/form/field/**/*.ts')
 const themeExtensionModules: ImportGlobEagerOutput<FormThemeExtension> =
-  import.meta.globEager('../theme/global/extensions/*.ts')
+  import.meta.globEager('./theme/global/extensions/*.ts')
 
 const initializeForm: InitializeAppForm = (app: App) => {
   const plugins = getFormPlugins(pluginModules)

+ 11 - 3
app/frontend/apps/mobile/form/theme/global/index.ts

@@ -4,18 +4,18 @@ import type { FormThemeClasses, FormThemeExtension } from '@common/types/form'
 
 type Classes = Record<string, string>
 
-const addFloatingLabel = (classes: Classes): Classes => {
+export const addFloatingLabel = (classes: Classes): Classes => {
   const inputClass = classes.input || ''
   const labelClass = classes.label || ''
   return {
     outer: `${classes.outer || ''} floating-input`,
     wrapper: `${classes.wrapper || ''} relative`,
     input: `${inputClass} w-full h-14 text-sm bg-gray-500 rounded-xl border-none focus:outline-none placeholder:text-transparent focus-within:pt-8 formkit-populated:pt-8`,
-    label: `${labelClass} absolute top-0 left-0 py-5 px-3 h-14 text-base transition-all duration-100 ease-in-out origin-left pointer-events-none formkit-populated:-translate-y3 formkit-populated:translate-x-1 formkit-populated:scale-75 formkit-populated:opacity-75`,
+    label: `${labelClass} absolute top-0 left-0 py-5 px-3 h-14 text-base transition-all duration-100 ease-in-out origin-left pointer-events-none formkit-populated:-translate-y-3 formkit-populated:translate-x-1 formkit-populated:scale-75 formkit-populated:opacity-75`,
   }
 }
 
-const addDateLabel = (classes: Classes): Classes => {
+export const addDateLabel = (classes: Classes): Classes => {
   const newClasses = addFloatingLabel(classes)
   return {
     ...newClasses,
@@ -36,6 +36,14 @@ const getCoreClasses: FormThemeExtension = (classes: FormThemeClasses) => {
     datetimeLocal: addDateLabel(classes['datetime-local']),
     textarea: addFloatingLabel(classes.textarea),
     password: addFloatingLabel(classes.password),
+    select: addFloatingLabel({
+      ...(classes.select || {}),
+      outer: `${classes.select && classes.select.outer} field-select`,
+    }),
+    treeselect: addFloatingLabel({
+      ...(classes.select || {}),
+      outer: `${classes.select && classes.select.outer} field-treeselect`,
+    }),
   }
 }
 

+ 64 - 0
app/frontend/common/components/common/CommonTicketStateIndicator.vue

@@ -0,0 +1,64 @@
+<!-- Copyright (C) 2012-2022 Zammad Foundation, https://zammad-foundation.org/ -->
+
+<script setup lang="ts">
+import { computed } from 'vue'
+import type TicketState from '@common/types/ticket'
+
+interface Props {
+  status: TicketState
+  label: string
+  pill?: boolean
+}
+
+const props = withDefaults(defineProps<Props>(), {
+  pill: false,
+})
+
+const statusIndicator = computed(() => `state-${props.status}`)
+</script>
+
+<template>
+  <div
+    v-bind:class="{
+      'status-pill': pill,
+      [`status-${status}`]: pill,
+    }"
+    class="flex items-center"
+    role="group"
+  >
+    <img
+      v-bind:src="`/assets/images/icons/${statusIndicator}.svg`"
+      v-bind:alt="label"
+      v-bind:width="pill ? 12 : 24"
+      v-bind:height="pill ? 12 : 24"
+    />
+    <div v-if="pill" class="ml-[2px] text-xs uppercase leading-[14px]">
+      {{ label }}
+    </div>
+  </div>
+</template>
+
+<style scoped lang="scss">
+.status {
+  &-pill {
+    @apply rounded px-px pr-1.5 pl-1;
+  }
+
+  &-closed {
+    @apply bg-green-highlight text-green;
+  }
+
+  &-waiting-for-closure,
+  &-waiting-for-reminder {
+    @apply bg-gray-highlight text-gray;
+  }
+
+  &-open {
+    @apply bg-yellow-highlight text-yellow;
+  }
+
+  &-escalated {
+    @apply bg-red-highlight text-red;
+  }
+}
+</style>

+ 8 - 6
app/frontend/common/components/form/Form.vue

@@ -84,7 +84,7 @@ const props = withDefaults(defineProps<Props>(), {
 const localClass = toRef(props, 'class')
 
 const emit = defineEmits<{
-  (e: 'changed', fieldName: string, newValue: unknown): void
+  (e: 'changed', newValue: unknown, fieldName: string): void
   (e: 'node', node: FormKitNode): void
 }>()
 
@@ -384,16 +384,18 @@ if (props.formSchemaId) {
     v-on:submit="onSubmit"
   >
     <slot name="before-fields" />
-    <template v-if="!$slots.fields">
+    <slot
+      name="fields"
+      v-bind:schema="staticSchema"
+      v-bind:data="schemaData"
+      v-bind:library="additionalComponentLibrary"
+    >
       <FormKitSchema
         v-bind:schema="staticSchema"
         v-bind:data="schemaData"
         v-bind:library="additionalComponentLibrary"
       />
-    </template>
-    <template v-else>
-      <slot name="fields" />
-    </template>
+    </slot>
     <slot name="after-fields" />
   </FormKit>
   <div

+ 0 - 13
app/frontend/common/components/form/field/FieldSelect.ts

@@ -1,13 +0,0 @@
-// Copyright (C) 2012-2022 Zammad Foundation, https://zammad-foundation.org/
-
-import initializeFieldDefinition from '@common/form/core/initializeFieldDefinition'
-import { select as selectDefinition } from '@formkit/inputs'
-
-// TODO: at the moment only the FormKit-BuildIn, but will be replaces with a own version.
-
-initializeFieldDefinition(selectDefinition)
-
-export default {
-  fieldType: 'select',
-  definition: selectDefinition,
-}

+ 271 - 0
app/frontend/common/components/form/field/FieldSelect/FieldSelectInput.vue

@@ -0,0 +1,271 @@
+<!-- Copyright (C) 2012-2022 Zammad Foundation, https://zammad-foundation.org/ -->
+
+<script setup lang="ts">
+import { computed, toRef } from 'vue'
+import {
+  Dialog,
+  DialogOverlay,
+  TransitionRoot,
+  TransitionChild,
+} from '@headlessui/vue'
+import { i18n } from '@common/i18n'
+import CommonTicketStateIndicator from '@common/components/common/CommonTicketStateIndicator.vue'
+import useValue from '@common/composables/form/field/useValue'
+import useSelectDialog from '@common/composables/form/field/useSelectDialog'
+import useSelectOptions from '@common/composables/form/field/useSelectOptions'
+import useSelectAutoselect from '@common/composables/form/field/useSelectAutoselect'
+import type { FormFieldContext } from '@common/types/form'
+import type {
+  SelectOption,
+  SelectOptionSorting,
+  SelectSize,
+} from '@/common/types/form/field/select'
+
+interface Props {
+  context: FormFieldContext<{
+    autoselect?: boolean
+    clearable?: boolean
+    disabled?: boolean
+    multiple?: boolean
+    noOptionsLabelTranslation?: boolean
+    options: SelectOption[]
+    size?: SelectSize
+    sorting?: SelectOptionSorting
+  }>
+}
+
+const props = defineProps<Props>()
+
+const { hasValue, valueContainer, isCurrentValue, clearValue } = useValue(
+  toRef(props, 'context'),
+)
+
+const { isOpen, setIsOpen } = useSelectDialog()
+
+const {
+  dialog,
+  hasStatusProperty,
+  sortedOptions,
+  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)
+}
+
+const isSizeSmall = computed(() => props.context.size === 'small')
+
+useSelectAutoselect(sortedOptions, toRef(props, 'context'))
+</script>
+
+<template>
+  <div
+    v-bind:class="{
+      [context.classes.input]: true,
+      'min-h-[3.5rem] bg-transparent rounded-none': !isSizeSmall,
+      'bg-gray-600 rounded-lg w-auto': isSizeSmall,
+    }"
+    class="flex h-auto focus-within:bg-blue-highlight focus-within:pt-0 formkit-populated:pt-0"
+    data-test-id="field-select"
+  >
+    <output
+      v-bind:id="context.id"
+      v-bind:name="context.node.name"
+      v-bind:class="{
+        'px-3 grow': !isSizeSmall,
+        'px-2 py-1': isSizeSmall,
+      }"
+      class="flex cursor-pointer items-center focus:outline-none formkit-disabled:pointer-events-none"
+      v-bind:aria-disabled="context.disabled"
+      v-bind:aria-label="i18n.t('Select...')"
+      v-bind:tabindex="context.disabled ? '-1' : '0'"
+      v-bind="context.attrs"
+      role="list"
+      v-on:click="setIsOpen(true)"
+      v-on:keypress.space="setIsOpen(true)"
+      v-on:blur="context.handlers.blur"
+    >
+      <div
+        v-bind:class="{
+          'translate-y-2 grow': !isSizeSmall,
+        }"
+        class="flex flex-wrap gap-1"
+      >
+        <template v-if="hasValue && hasStatusProperty">
+          <CommonTicketStateIndicator
+            v-for="selectedValue in valueContainer"
+            v-bind:key="selectedValue"
+            v-bind:status="getSelectedOptionStatus(selectedValue)"
+            v-bind:label="getSelectedOptionLabel(selectedValue)"
+            v-bind:data-test-status="getSelectedOptionStatus(selectedValue)"
+            role="listitem"
+            pill
+          />
+        </template>
+        <template v-else-if="hasValue">
+          <div
+            v-for="selectedValue in valueContainer"
+            v-bind:key="selectedValue"
+            v-bind: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)"
+              v-bind:name="getSelectedOptionIcon(selectedValue)"
+              v-bind: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"
+        v-bind:aria-label="i18n.t('Clear Selection')"
+        v-bind:fixed-size="{ width: 16, height: 16 }"
+        class="mr-2 shrink-0"
+        name="close-small"
+        role="button"
+        tabindex="0"
+        v-on:click.stop="clearValue"
+        v-on:keypress.space.prevent.stop="clearValue"
+      />
+      <CommonIcon
+        v-bind:fixed-size="{ width: 16, height: 16 }"
+        class="shrink-0"
+        name="caret-down"
+        decorative
+      />
+    </output>
+    <TransitionRoot v-bind:show="isOpen" as="template" appear>
+      <Dialog
+        class="fixed inset-0 z-10 flex overflow-y-auto py-6"
+        role="dialog"
+        v-on: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
+            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"
+          >
+            <div
+              v-for="option in sortedOptions"
+              v-bind:key="option.value"
+              v-bind:class="{
+                'pointer-events-none': option.disabled,
+              }"
+              v-bind:tabindex="option.disabled ? '-1' : '0'"
+              v-bind: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"
+              v-on:click="select(option)"
+              v-on:keypress.space="select(option)"
+              v-on:keydown="advanceDialogFocus"
+            >
+              <CommonIcon
+                v-if="context.multiple"
+                v-bind:class="{
+                  '!text-white': isCurrentValue(option.value),
+                  'opacity-30': option.disabled,
+                }"
+                v-bind:fixed-size="{ width: 24, height: 24 }"
+                v-bind:name="
+                  isCurrentValue(option.value) ? 'checked-yes' : 'checked-no'
+                "
+                class="mr-3 text-white/50"
+              />
+              <CommonTicketStateIndicator
+                v-if="option.status"
+                v-bind:status="option.status"
+                v-bind:label="option.label"
+                v-bind:class="{
+                  'opacity-30': option.disabled,
+                }"
+                class="mr-[11px]"
+              />
+              <CommonIcon
+                v-else-if="option.icon"
+                v-bind:name="option.icon"
+                v-bind:fixed-size="{ width: 16, height: 16 }"
+                v-bind:class="{
+                  '!text-white': isCurrentValue(option.value),
+                  'opacity-30': option.disabled,
+                }"
+                class="mr-[11px] text-white/80"
+              />
+              <span
+                v-bind: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)"
+                v-bind:class="{
+                  'opacity-30': option.disabled,
+                }"
+                v-bind:fixed-size="{ width: 16, height: 16 }"
+                name="check"
+              />
+            </div>
+          </div>
+        </TransitionChild>
+      </Dialog>
+    </TransitionRoot>
+  </div>
+</template>
+
+<style lang="scss">
+.field-select {
+  &.floating-input:focus-within:not([data-populated]) label {
+    @apply translate-y-0 translate-x-0 scale-100 opacity-100;
+  }
+
+  .formkit-label {
+    @apply py-4;
+  }
+
+  .formkit-inner {
+    @apply flex;
+  }
+}
+</style>

+ 42 - 0
app/frontend/common/components/form/field/FieldSelect/index.ts

@@ -0,0 +1,42 @@
+// Copyright (C) 2012-2022 Zammad Foundation, https://zammad-foundation.org/
+
+import FieldSelectInput from '@common/components/form/field/FieldSelect/FieldSelectInput.vue'
+import createInput from '@common/form/core/createInput'
+import type { FormKitNode } from '@formkit/core'
+
+const hideLabelForSmallSelects = (node: FormKitNode) => {
+  const { props } = node
+
+  const toggleLabel = (isHidden: boolean) => {
+    props.labelClass = isHidden ? 'hidden' : undefined
+  }
+
+  node.on('created', () => {
+    toggleLabel(props.size === 'small')
+
+    node.on('prop:size', ({ payload }) => {
+      toggleLabel(payload === 'small')
+    })
+  })
+}
+
+const fieldDefinition = createInput(
+  FieldSelectInput,
+  [
+    'autoselect',
+    'clearable',
+    'multiple',
+    'noOptionsLabelTranslation',
+    'options',
+    'size',
+    'sorting',
+  ],
+  {
+    features: [hideLabelForSmallSelects],
+  },
+)
+
+export default {
+  fieldType: 'select',
+  definition: fieldDefinition,
+}

+ 500 - 0
app/frontend/common/components/form/field/FieldTreeSelect/FieldTreeSelectInput.vue

@@ -0,0 +1,500 @@
+<!-- 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 { i18n } from '@common/i18n'
+import CommonTicketStateIndicator from '@common/components/common/CommonTicketStateIndicator.vue'
+import useValue from '@common/composables/form/field/useValue'
+import useSelectDialog from '@common/composables/form/field/useSelectDialog'
+import useSelectOptions from '@common/composables/form/field/useSelectOptions'
+import useSelectAutoselect from '@common/composables/form/field/useSelectAutoselect'
+import type { FormFieldContext } from '@common/types/form'
+import type {
+  SelectOption,
+  SelectOptionSorting,
+} from '@/common/types/form/field/select'
+import type {
+  TreeSelectOption,
+  FlatSelectOption,
+} from '@/common/types/form/field/treeselect'
+
+interface Props {
+  context: FormFieldContext<{
+    autoselect?: boolean
+    clearable?: boolean
+    noFiltering?: boolean
+    disabled?: boolean
+    multiple?: boolean
+    noOptionsLabelTranslation?: boolean
+    options: TreeSelectOption[]
+    sorting?: SelectOptionSorting
+  }>
+}
+
+const props = defineProps<Props>()
+
+const { hasValue, valueContainer, isCurrentValue, clearValue } = useValue(
+  toRef(props, 'context'),
+)
+
+const { isOpen, setIsOpen } = useSelectDialog()
+
+const currentPath = ref<FlatSelectOption[]>([])
+
+const currentParent = computed(
+  () => currentPath.value[currentPath.value.length - 1] ?? null,
+)
+
+const flattenOptions = (
+  options: TreeSelectOption[],
+  parents: (string | number)[] = [],
+): FlatSelectOption[] =>
+  options &&
+  options.reduce((flatOptions: FlatSelectOption[], { children, ...option }) => {
+    flatOptions.push({
+      ...option,
+      parents,
+      hasChildren: Boolean(children),
+    })
+    if (children)
+      flatOptions.push(...flattenOptions(children, [...parents, option.value]))
+    return flatOptions
+  }, [])
+
+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[]) => {
+  if (!props.context.noFiltering) {
+    const filterInputElement = filterInput.value as null | HTMLElement
+    if (filterInputElement) filterInputElement.focus()
+    return
+  }
+
+  if (!targetElements || !targetElements.length) return
+
+  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,
+  sortedOptions,
+  getSelectedOptionIcon,
+  getSelectedOptionLabel,
+  getSelectedOptionStatus,
+  selectOption,
+  getDialogFocusTargets,
+  advanceDialogFocus,
+} = useSelectOptions(
+  flatOptions,
+  toRef(props, 'context'),
+  previousPageCallback,
+  nextPageCallback,
+)
+
+const goToPreviousPage = () => {
+  previousPageCallback(undefined, getDialogFocusTargets)
+}
+
+const goToNextPage = (option: FlatSelectOption) => {
+  nextPageCallback(option, getDialogFocusTargets)
+}
+
+const toggleDialog = (isVisible: boolean) => {
+  setIsOpen(isVisible)
+
+  if (isVisible) {
+    nextTick(() => focusFirstTarget(getDialogFocusTargets(true)))
+    return
+  }
+
+  clearPath()
+  clearFilter()
+}
+
+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'))
+</script>
+
+<template>
+  <div
+    v-bind:class="{
+      [context.classes.input]: true,
+    }"
+    class="flex h-auto min-h-[3.5rem] rounded-none bg-transparent focus-within:bg-blue-highlight focus-within:pt-0 formkit-populated:pt-0"
+    data-test-id="field-treeselect"
+  >
+    <output
+      v-bind:id="context.id"
+      v-bind:name="context.node.name"
+      class="flex grow cursor-pointer items-center px-3 focus:outline-none formkit-disabled:pointer-events-none"
+      v-bind:aria-disabled="context.disabled"
+      v-bind:aria-label="i18n.t('Select...')"
+      v-bind:tabindex="context.disabled ? '-1' : '0'"
+      v-bind="context.attrs"
+      role="list"
+      v-on:click="toggleDialog(true)"
+      v-on:keypress.space="toggleDialog(true)"
+      v-on:blur="context.handlers.blur"
+    >
+      <div class="flex grow translate-y-2 flex-wrap gap-1">
+        <template v-if="hasValue && hasStatusProperty">
+          <CommonTicketStateIndicator
+            v-for="selectedValue in valueContainer"
+            v-bind:key="selectedValue"
+            v-bind:status="getSelectedOptionStatus(selectedValue)"
+            v-bind:label="getSelectedOptionLabel(selectedValue)"
+            v-bind:data-test-status="getSelectedOptionStatus(selectedValue)"
+            role="listitem"
+            pill
+          />
+        </template>
+        <template v-else-if="hasValue">
+          <div
+            v-for="selectedValue in valueContainer"
+            v-bind:key="selectedValue"
+            class="flex items-center text-base leading-[19px] after:content-[','] last:after:content-none"
+            role="listitem"
+          >
+            <CommonIcon
+              v-if="getSelectedOptionIcon(selectedValue)"
+              v-bind:name="getSelectedOptionIcon(selectedValue)"
+              v-bind:fixed-size="{ width: 12, height: 12 }"
+              class="mr-1"
+            />
+            {{ getSelectedOptionLabel(selectedValue) || selectedValue }}
+          </div>
+        </template>
+      </div>
+      <CommonIcon
+        v-if="context.clearable && hasValue && !context.disabled"
+        v-bind:aria-label="i18n.t('Clear Selection')"
+        v-bind:fixed-size="{ width: 16, height: 16 }"
+        class="mr-2 shrink-0"
+        name="close-small"
+        role="button"
+        tabindex="0"
+        v-on:click.stop="clearValue"
+        v-on:keypress.space.prevent.stop="clearValue"
+      />
+      <CommonIcon
+        v-bind:fixed-size="{ width: 24, height: 24 }"
+        class="shrink-0"
+        name="chevron-right"
+        decorative
+      />
+    </output>
+    <TransitionRoot v-bind:show="isOpen" as="template" appear>
+      <Dialog
+        class="fixed inset-0 z-10 flex overflow-y-auto"
+        role="dialog"
+        v-on: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"
+                  v-on:click="toggleDialog(false)"
+                  v-on:keypress.space="toggleDialog(false)"
+                  v-on:keydown="advanceDialogFocus"
+                >
+                  {{ i18n.t('Done') }}
+                </div>
+              </div>
+            </div>
+            <div
+              class="flex grow flex-col items-start overflow-y-auto bg-black text-white"
+            >
+              <div
+                v-if="!context.noFiltering"
+                class="relative flex items-center self-stretch p-4"
+              >
+                <CommonIcon
+                  v-bind:fixed-size="{ width: 24, height: 24 }"
+                  class="absolute left-6 shrink-0 text-gray"
+                  name="search"
+                  decorative
+                />
+                <input
+                  ref="filterInput"
+                  v-model="filter"
+                  v-bind:placeholder="i18n.t('Search')"
+                  class="h-12 grow rounded-xl bg-gray-500 px-9 placeholder:text-gray focus:border-white focus:outline-none focus:ring-0"
+                  type="text"
+                  role="searchbox"
+                />
+                <CommonIcon
+                  v-if="filter.length"
+                  v-bind:aria-label="i18n.t('Clear Search')"
+                  v-bind:fixed-size="{ width: 24, height: 24 }"
+                  class="absolute right-6 shrink-0 text-gray"
+                  name="close-small"
+                  v-on:click.stop="clearFilter"
+                />
+              </div>
+              <div
+                v-if="currentPath.length"
+                v-bind: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"
+                v-on:click="goToPreviousPage()"
+                v-on:keypress.space="goToPreviousPage()"
+                v-on:keydown="advanceDialogFocus"
+              >
+                <CommonIcon
+                  v-bind: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
+                v-bind: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"
+                  v-bind:key="option.value"
+                  v-bind:class="{
+                    'px-6': !context.noFiltering,
+                    'pointer-events-none': option.disabled,
+                  }"
+                  v-bind:tabindex="option.disabled ? '-1' : '0'"
+                  v-bind: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"
+                  v-on:click="select(option as FlatSelectOption)"
+                  v-on:keypress.space="select(option as FlatSelectOption)"
+                  v-on:keydown="advanceDialogFocus($event, option)"
+                >
+                  <div
+                    v-if="index !== 0"
+                    v-bind: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"
+                    v-bind:class="{
+                      '!text-white': isCurrentValue(option.value),
+                      'opacity-30': option.disabled,
+                    }"
+                    v-bind:fixed-size="{ width: 24, height: 24 }"
+                    v-bind:name="
+                      isCurrentValue(option.value)
+                        ? 'checked-yes'
+                        : 'checked-no'
+                    "
+                    class="mr-3 text-white/50"
+                  />
+                  <CommonTicketStateIndicator
+                    v-if="option.status"
+                    v-bind:status="option.status"
+                    v-bind:label="option.label"
+                    v-bind:class="{
+                      'opacity-30': option.disabled,
+                    }"
+                    class="mr-[11px]"
+                  />
+                  <CommonIcon
+                    v-else-if="option.icon"
+                    v-bind:name="option.icon"
+                    v-bind:fixed-size="{ width: 16, height: 16 }"
+                    v-bind:class="{
+                      '!text-white': isCurrentValue(option.value),
+                      'opacity-30': option.disabled,
+                    }"
+                    class="mr-[11px] text-white/80"
+                  />
+                  <span
+                    v-bind: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"
+                        v-bind:key="parentValue"
+                        class="opacity-50"
+                      >
+                        —
+                        {{ getSelectedOptionLabel(parentValue) || parentValue }}
+                      </span>
+                    </template>
+                  </span>
+                  <CommonIcon
+                    v-if="!context.multiple && isCurrentValue(option.value)"
+                    v-bind:class="{
+                      'opacity-30': option.disabled,
+                      'mr-3': (option as FlatSelectOption).hasChildren,
+                    }"
+                    v-bind:fixed-size="{ width: 16, height: 16 }"
+                    name="check"
+                  />
+                  <CommonIcon
+                    v-if="(option as FlatSelectOption).hasChildren && !filter"
+                    class="pointer-events-auto"
+                    v-bind:fixed-size="{ width: 24, height: 24 }"
+                    name="chevron-right"
+                    role="link"
+                    v-on: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>
+
+<style lang="scss">
+.field-treeselect {
+  &.floating-input:focus-within:not([data-populated]) {
+    label {
+      @apply translate-y-0 translate-x-0 scale-100 opacity-100;
+    }
+  }
+
+  .formkit-label {
+    @apply py-4;
+  }
+}
+</style>

+ 19 - 0
app/frontend/common/components/form/field/FieldTreeSelect/index.ts

@@ -0,0 +1,19 @@
+// Copyright (C) 2012-2022 Zammad Foundation, https://zammad-foundation.org/
+
+import FieldTreeSelectInput from '@common/components/form/field/FieldTreeSelect/FieldTreeSelectInput.vue'
+import createInput from '@common/form/core/createInput'
+
+const fieldDefinition = createInput(FieldTreeSelectInput, [
+  'autoselect',
+  'clearable',
+  'noFiltering',
+  'multiple',
+  'noOptionsLabelTranslation',
+  'options',
+  'sorting',
+])
+
+export default {
+  fieldType: 'treeselect',
+  definition: fieldDefinition,
+}

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