123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401 |
- <!-- Copyright (C) 2012-2024 Zammad Foundation, https://zammad-foundation.org/ -->
- <script setup lang="ts">
- import {
- useElementBounding,
- useElementVisibility,
- useWindowSize,
- } from '@vueuse/core'
- import { escapeRegExp } from 'lodash-es'
- import { computed, nextTick, ref, toRef, watch, useTemplateRef } from 'vue'
- import useValue from '#shared/components/Form/composables/useValue.ts'
- import type {
- FlatSelectOption,
- TreeSelectContext,
- } from '#shared/components/Form/fields/FieldTreeSelect/types.ts'
- import useSelectOptions from '#shared/composables/useSelectOptions.ts'
- import useSelectPreselect from '#shared/composables/useSelectPreselect.ts'
- import { useTrapTab } from '#shared/composables/useTrapTab.ts'
- import { useFormBlock } from '#shared/form/useFormBlock.ts'
- import { i18n } from '#shared/i18n.ts'
- import stopEvent from '#shared/utils/events.ts'
- import CommonInputSearch from '#desktop/components/CommonInputSearch/CommonInputSearch.vue'
- import FieldTreeSelectInputDropdown from './FieldTreeSelectInputDropdown.vue'
- import useFlatSelectOptions from './useFlatSelectOptions.ts'
- interface Props {
- context: TreeSelectContext & {
- alternativeBackground?: boolean
- }
- }
- const props = defineProps<Props>()
- const contextReactive = toRef(props, 'context')
- const {
- hasValue,
- valueContainer,
- currentValue,
- clearValue: clearInternalValue,
- } = useValue(contextReactive)
- const { flatOptions } = useFlatSelectOptions(toRef(props.context, 'options'))
- const {
- sortedOptions,
- optionValueLookup,
- selectOption,
- getSelectedOption,
- getSelectedOptionIcon,
- getSelectedOptionLabel,
- getSelectedOptionFullPath,
- setupMissingOrDisabledOptionHandling,
- } = useSelectOptions<FlatSelectOption[]>(flatOptions, toRef(props, 'context'))
- const currentPath = ref<FlatSelectOption[]>([])
- const clearPath = () => {
- currentPath.value = []
- }
- const currentParent = computed<FlatSelectOption>(
- () => currentPath.value[currentPath.value.length - 1] ?? null,
- )
- const inputElement = useTemplateRef('input')
- const outputElement = useTemplateRef('output')
- const filterInputElement = useTemplateRef('filter-input')
- const selectInstance = useTemplateRef('select')
- const filter = ref('')
- const { activateTabTrap, deactivateTabTrap } = useTrapTab(inputElement, true)
- const clearFilter = () => {
- filter.value = ''
- }
- watch(() => contextReactive.value.noFiltering, clearFilter)
- 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.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',
- )
- return options
- .map(
- (option) =>
- ({
- ...option,
- // Match options via their de-accented labels.
- match: filterRegex.exec(
- deaccent(option.label || String(option.value)),
- ),
- }) as FlatSelectOption,
- )
- .filter((option) => option.match)
- })
- const suggestedOptionLabel = computed(() => {
- if (!filter.value || !filteredOptions.value.length) return undefined
- const exactMatches = filteredOptions.value.filter(
- (option) =>
- (getSelectedOptionLabel(option.value) || option.value.toString())
- .toLowerCase()
- .indexOf(filter.value.toLowerCase()) === 0 &&
- (getSelectedOptionLabel(option.value) || option.value.toString()).length >
- filter.value.length,
- )
- if (!exactMatches.length) return undefined
- return getSelectedOptionLabel(exactMatches[0].value)
- })
- 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.parents?.length)
- // Otherwise, return all options which are children of the current parent.
- return sortedOptions.value.filter(
- (option) =>
- option.parents.length &&
- option.parents[option.parents.length - 1] === currentParent.value?.value,
- )
- })
- const focusOutputElement = () => {
- if (!props.context.disabled) {
- outputElement.value?.focus()
- }
- }
- const clearValue = () => {
- if (props.context.disabled) return
- clearInternalValue()
- focusOutputElement()
- }
- const inputElementBounds = useElementBounding(inputElement)
- const isInputVisible = !!VITE_TEST_MODE || useElementVisibility(inputElement)
- const windowSize = useWindowSize()
- const isBelowHalfScreen = computed(() => {
- return inputElementBounds.y.value > windowSize.height.value / 2
- })
- const openSelectDropdown = () => {
- if (selectInstance.value?.isOpen || props.context.disabled) return
- selectInstance.value?.openDropdown(inputElementBounds, windowSize.height)
- requestAnimationFrame(() => {
- activateTabTrap()
- if (props.context.noFiltering) outputElement.value?.focus()
- else filterInputElement.value?.focus()
- })
- }
- const openOrMoveFocusToDropdown = (lastOption = false) => {
- if (!selectInstance.value?.isOpen) {
- openSelectDropdown()
- return
- }
- deactivateTabTrap()
- nextTick(() => {
- requestAnimationFrame(() => {
- selectInstance.value?.moveFocusToDropdown(lastOption)
- })
- })
- }
- const onCloseDropdown = () => {
- clearFilter()
- clearPath()
- deactivateTabTrap()
- }
- const onPathPush = (option: FlatSelectOption) => {
- currentPath.value.push(option)
- }
- const onPathPop = () => {
- currentPath.value.pop()
- }
- const onHandleToggleDropdown = (event: MouseEvent) => {
- if ((event.target as HTMLElement)?.closest('input')) return
- if (selectInstance.value?.isOpen) {
- selectInstance.value.closeDropdown()
- return onCloseDropdown()
- }
- openSelectDropdown()
- }
- const handleCloseDropdown = (
- event: KeyboardEvent,
- expanded: boolean,
- closeDropdown: () => void,
- ) => {
- if (expanded) {
- stopEvent(event)
- closeDropdown()
- }
- }
- useFormBlock(contextReactive, openSelectDropdown)
- useSelectPreselect(flatOptions, contextReactive)
- setupMissingOrDisabledOptionHandling()
- </script>
- <template>
- <div
- ref="input"
- class="flex h-auto min-h-10 hover:outline hover:outline-1 hover:outline-offset-1 hover:outline-blue-600 has-[output:focus,input:focus]:outline has-[output:focus,input:focus]:outline-1 has-[output:focus,input:focus]:outline-offset-1 has-[output:focus,input:focus]:outline-blue-800 dark:hover:outline-blue-900 dark:has-[output:focus,input:focus]:outline-blue-800"
- :class="[
- context.classes.input,
- {
- 'rounded-lg': !selectInstance?.isOpen,
- 'rounded-t-lg': selectInstance?.isOpen && !isBelowHalfScreen,
- 'rounded-b-lg': selectInstance?.isOpen && isBelowHalfScreen,
- 'bg-blue-200 dark:bg-gray-700': !context.alternativeBackground,
- 'bg-neutral-50 dark:bg-gray-500': context.alternativeBackground,
- },
- ]"
- data-test-id="field-treeselect"
- >
- <FieldTreeSelectInputDropdown
- ref="select"
- #default="{ state: expanded, close: closeDropdown }"
- :model-value="currentValue"
- :options="filteredOptions"
- :multiple="context.multiple"
- :owner="context.id"
- :current-path="currentPath"
- :filter="filter"
- :flat-options="flatOptions"
- :current-options="currentOptions"
- :option-value-lookup="optionValueLookup"
- :is-target-visible="isInputVisible"
- no-options-label-translation
- no-close
- passive
- @clear-filter="clearFilter"
- @close="onCloseDropdown"
- @push="onPathPush"
- @pop="onPathPop"
- @select="selectOption"
- >
- <!-- https://www.w3.org/WAI/ARIA/apg/patterns/combobox/ -->
- <output
- :id="context.id"
- ref="output"
- role="combobox"
- :name="context.node.name"
- class="flex grow items-center gap-2.5 px-2.5 py-2 text-black focus:outline-none dark:text-white"
- tabindex="0"
- :aria-labelledby="`label-${context.id}`"
- :aria-disabled="context.disabled ? 'true' : undefined"
- v-bind="context.attrs"
- :data-multiple="context.multiple"
- aria-autocomplete="none"
- aria-controls="field-tree-select-input-dropdown"
- aria-owns="field-tree-select-input-dropdown"
- aria-haspopup="menu"
- :aria-expanded="expanded"
- :aria-describedby="context.describedBy"
- @keydown.escape="handleCloseDropdown($event, expanded, closeDropdown)"
- @keypress.enter.prevent="openSelectDropdown()"
- @keydown.down.prevent="openOrMoveFocusToDropdown()"
- @keydown.up.prevent="openOrMoveFocusToDropdown(true)"
- @keypress.space.prevent="openSelectDropdown()"
- @blur="context.handlers.blur"
- @click.stop="onHandleToggleDropdown"
- >
- <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 cursor-default items-center gap-1 rounded px-1.5 py-0.5 text-xs text-black dark:text-white"
- :class="{
- 'bg-white dark:bg-gray-200': !context.alternativeBackground,
- 'bg-neutral-100 dark:bg-gray-200':
- context.alternativeBackground,
- }"
- >
- <CommonIcon
- v-if="getSelectedOptionIcon(selectedValue)"
- :name="getSelectedOptionIcon(selectedValue)"
- class="shrink-0 fill-gray-100 dark:fill-neutral-400"
- size="xs"
- decorative
- />
- <span
- class="line-clamp-3 whitespace-pre-wrap break-words"
- :title="getSelectedOptionFullPath(selectedValue)"
- >
- {{ getSelectedOptionFullPath(selectedValue) }}
- </span>
- <CommonIcon
- :aria-label="i18n.t('Unselect Option')"
- class="shrink-0 fill-stone-200 hover:fill-black focus:outline-none focus-visible:rounded-sm focus-visible:outline focus-visible:outline-1 focus-visible:outline-offset-1 focus-visible:outline-blue-800 dark:fill-neutral-500 dark:hover:fill-white"
- name="x-lg"
- size="xs"
- role="button"
- tabindex="0"
- @click.stop="selectOption(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="filter-input"
- v-model="filter"
- :suggestion="suggestedOptionLabel"
- :alternative-background="context.alternativeBackground"
- @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="shrink-0 fill-gray-100 dark:fill-neutral-400"
- size="tiny"
- decorative
- />
- <span
- class="line-clamp-3 whitespace-pre-wrap break-words"
- :title="getSelectedOptionFullPath(currentValue)"
- >
- {{ getSelectedOptionFullPath(currentValue) }}
- </span>
- </div>
- </div>
- <CommonIcon
- v-if="context.clearable && hasValue && !context.disabled"
- :aria-label="i18n.t('Clear Selection')"
- class="shrink-0 fill-stone-200 hover:fill-black focus:outline-none focus-visible:rounded-sm focus-visible:outline focus-visible:outline-1 focus-visible:outline-offset-1 focus-visible:outline-blue-800 dark:fill-neutral-500 dark:hover:fill-white"
- name="x-lg"
- size="xs"
- role="button"
- tabindex="0"
- @click.stop="clearValue()"
- @keypress.enter.prevent.stop="clearValue()"
- @keypress.space.prevent.stop="clearValue()"
- />
- <CommonIcon
- class="shrink-0 fill-stone-200 dark:fill-neutral-500"
- name="chevron-down"
- size="xs"
- decorative
- />
- </output>
- </FieldTreeSelectInputDropdown>
- </div>
- </template>
|