123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536 |
- <!-- Copyright (C) 2012-2024 Zammad Foundation, https://zammad-foundation.org/ -->
- <script setup lang="ts">
- import {
- type UseElementBoundingReturn,
- onClickOutside,
- onKeyDown,
- useVModel,
- } from '@vueuse/core'
- import {
- useTemplateRef,
- onUnmounted,
- computed,
- nextTick,
- ref,
- toRef,
- } from 'vue'
- import type {
- FlatSelectOption,
- MatchedFlatSelectOption,
- } from '#shared/components/Form/fields/FieldTreeSelect/types.ts'
- import { useFocusWhenTyping } from '#shared/composables/useFocusWhenTyping.ts'
- import { useTrapTab } from '#shared/composables/useTrapTab.ts'
- import { useTraverseOptions } from '#shared/composables/useTraverseOptions.ts'
- import { i18n } from '#shared/i18n.ts'
- import { useLocaleStore } from '#shared/stores/locale.ts'
- import stopEvent from '#shared/utils/events.ts'
- import testFlags from '#shared/utils/testFlags.ts'
- import { useCommonSelect } from '#desktop/components/CommonSelect/useCommonSelect.ts'
- import { useTransitionCollapse } from '#desktop/composables/useTransitionCollapse.ts'
- import FieldTreeSelectInputDropdownItem from './FieldTreeSelectInputDropdownItem.vue'
- import type { FieldTreeSelectInputDropdownInternalInstance } from './types.ts'
- import type { Ref } from 'vue'
- export interface Props {
- // we cannot move types into separate file, because Vue would not be able to
- // transform these into runtime types
- modelValue?: string | number | boolean | (string | number | boolean)[] | null
- options: FlatSelectOption[]
- /**
- * Do not modify local value
- */
- passive?: boolean
- multiple?: boolean
- noClose?: boolean
- noRefocus?: boolean
- owner?: string
- noOptionsLabelTranslation?: boolean
- currentPath: FlatSelectOption[]
- filter: string
- flatOptions: FlatSelectOption[]
- currentOptions: FlatSelectOption[]
- optionValueLookup: { [index: string | number]: FlatSelectOption }
- isTargetVisible?: boolean
- }
- const props = defineProps<Props>()
- const emit = defineEmits<{
- 'update:modelValue': [option: string | number | (string | number)[]]
- select: [option: FlatSelectOption]
- close: []
- push: [option: FlatSelectOption]
- pop: []
- 'clear-filter': []
- }>()
- const locale = useLocaleStore()
- const dropdownElement = useTemplateRef('dropdown')
- const localValue = useVModel(props, 'modelValue', emit)
- // TODO: do we really want this initial transforming of the value, when it's null?
- if (localValue.value == null && props.multiple) {
- localValue.value = []
- }
- const getFocusableOptions = () => {
- return Array.from<HTMLElement>(
- dropdownElement.value?.querySelectorAll('[tabindex="0"]') || [],
- )
- }
- const showDropdown = ref(false)
- let inputElementBounds: UseElementBoundingReturn
- let windowHeight: Ref<number>
- const hasDirectionUp = computed(() => {
- if (!inputElementBounds || !windowHeight) return false
- return inputElementBounds.y.value > windowHeight.value / 2
- })
- const dropdownStyle = computed(() => {
- if (!inputElementBounds) return { top: 0, left: 0, width: 0, maxHeight: 0 }
- const style: Record<string, string> = {
- left: `${inputElementBounds.left.value}px`,
- width: `${inputElementBounds.width.value}px`,
- maxHeight: `calc(50vh - ${inputElementBounds.height.value}px)`,
- }
- if (hasDirectionUp.value) {
- style.bottom = `${windowHeight.value - inputElementBounds.top.value}px`
- } else {
- style.top = `${
- inputElementBounds.top.value + inputElementBounds.height.value
- }px`
- }
- return style
- })
- const { activateTabTrap, deactivateTabTrap } = useTrapTab(
- dropdownElement as Ref<HTMLElement>,
- )
- let lastFocusableOutsideElement: HTMLElement | null = null
- const getActiveElement = () => {
- if (props.owner) {
- return document.getElementById(props.owner)
- }
- return document.activeElement as HTMLElement
- }
- const { instances } = useCommonSelect()
- const closeDropdown = () => {
- deactivateTabTrap()
- showDropdown.value = false
- emit('close')
- if (!props.noRefocus) {
- nextTick(() => lastFocusableOutsideElement?.focus())
- }
- nextTick(() => {
- testFlags.set('field-tree-select-input-dropdown.closed')
- })
- }
- const openDropdown = (
- bounds: UseElementBoundingReturn,
- height: Ref<number>,
- ) => {
- inputElementBounds = bounds
- windowHeight = toRef(height)
- instances.value.forEach((instance) => {
- if (instance.isOpen) instance.closeDropdown()
- })
- showDropdown.value = true
- lastFocusableOutsideElement = getActiveElement()
- onClickOutside(dropdownElement, closeDropdown, {
- ignore: [lastFocusableOutsideElement as unknown as HTMLElement],
- })
- requestAnimationFrame(() => {
- nextTick(() => {
- testFlags.set('field-tree-select-input-dropdown.opened')
- })
- })
- }
- const moveFocusToDropdown = (lastOption = false) => {
- // Focus selected or first available option.
- // https://developer.mozilla.org/en-US/docs/Web/Accessibility/ARIA/Roles/listbox_role#keyboard_interactions
- const focusableElements = getFocusableOptions()
- if (!focusableElements?.length) return
- let focusElement = focusableElements[0]
- if (lastOption) {
- focusElement = focusableElements[focusableElements.length - 1]
- } else {
- const selected = focusableElements.find(
- (el) => el.getAttribute('aria-selected') === 'true',
- )
- if (selected) focusElement = selected
- }
- focusElement?.focus()
- activateTabTrap()
- }
- const exposedInstance: FieldTreeSelectInputDropdownInternalInstance = {
- isOpen: computed(() => showDropdown.value),
- openDropdown,
- closeDropdown,
- getFocusableOptions,
- moveFocusToDropdown,
- }
- instances.value.add(exposedInstance)
- onUnmounted(() => {
- instances.value.delete(exposedInstance)
- })
- defineExpose(exposedInstance)
- // https://developer.mozilla.org/en-US/docs/Web/Accessibility/ARIA/Roles/listbox_role#keyboard_interactions
- useTraverseOptions(dropdownElement, { direction: 'vertical' })
- // - Type-ahead is recommended for all listboxes, especially those with more than seven options
- useFocusWhenTyping(dropdownElement)
- onKeyDown(
- 'Escape',
- (event) => {
- stopEvent(event)
- closeDropdown()
- },
- { target: dropdownElement as Ref<EventTarget> },
- )
- const isCurrentValue = (value: string | number | boolean) => {
- if (props.multiple && Array.isArray(localValue.value)) {
- return localValue.value.includes(value)
- }
- return localValue.value === value
- }
- const select = (option: FlatSelectOption) => {
- if (option.disabled) return
- emit('select', option)
- if (props.passive) {
- if (!props.multiple) {
- closeDropdown()
- }
- return
- }
- if (props.multiple && Array.isArray(localValue.value)) {
- if (localValue.value.includes(option.value)) {
- localValue.value = localValue.value.filter((v) => v !== option.value)
- } else {
- localValue.value.push(option.value)
- }
- return
- }
- if (props.modelValue === option.value) {
- localValue.value = undefined
- } else {
- localValue.value = option.value
- }
- if (!props.multiple && !props.noClose) {
- closeDropdown()
- }
- }
- const hasMoreSelectableOptions = computed(() => {
- if (props.currentPath.length)
- return props.currentOptions.some(
- (option) => !option.disabled && !isCurrentValue(option.value),
- )
- return (
- props.options.filter(
- (option) => !option.disabled && !isCurrentValue(option.value),
- ).length > 0
- )
- })
- const focusFirstOption = () => {
- const focusableElements = getFocusableOptions()
- if (!focusableElements?.length) return
- const focusElement = focusableElements[0]
- focusElement?.focus()
- }
- const selectAll = (noFocus?: boolean) => {
- // If currently viewing a parent, select visible only.
- if (props.currentPath.length) {
- props.currentOptions
- .filter((option) => !option.disabled && !isCurrentValue(option.value))
- .forEach((option) => select(option))
- } else {
- props.options
- .filter((option) => !option.disabled && !isCurrentValue(option.value))
- .forEach((option) => select(option))
- }
- if (noFocus) return
- nextTick(() => {
- focusFirstOption()
- })
- }
- const previousPageCallback = (noFocus?: boolean) => {
- emit('pop')
- emit('clear-filter')
- if (noFocus) return
- nextTick(() => {
- focusFirstOption()
- })
- }
- const goToPreviousPage = (noFocus?: boolean) => {
- previousPageCallback(noFocus)
- }
- const nextPageCallback = (option?: FlatSelectOption, noFocus?: boolean) => {
- if (option?.hasChildren) {
- emit('push', option)
- if (noFocus) return
- nextTick(() => {
- focusFirstOption()
- })
- }
- }
- const goToNextPage = ({
- option,
- noFocus,
- }: {
- option: FlatSelectOption
- noFocus?: boolean
- }) => {
- nextPageCallback(option, noFocus)
- }
- const maybeGoToNextOrPreviousPage = (
- option: FlatSelectOption,
- direction: 'left' | 'right',
- ) => {
- if (
- (locale.localeData?.dir === 'rtl' && direction === 'right') ||
- (locale.localeData?.dir === 'ltr' && direction === 'left')
- ) {
- goToPreviousPage()
- return
- }
- goToNextPage({ option })
- }
- const getCurrentIndex = (option: FlatSelectOption) => {
- return props.flatOptions.findIndex((o) => o.value === option.value)
- }
- const highlightedOptions = computed(() =>
- props.options.map((option) => {
- let parentPaths: string[] = []
- if (option.parents) {
- parentPaths = option.parents.map((parentValue) => {
- const parentOption =
- props.optionValueLookup[parentValue as string | number]
- return `${parentOption.label || parentOption.value} \u203A `
- })
- }
- let label = option.label || i18n.t('%s (unknown)', option.value.toString())
- // Highlight the matched text within the option label by re-using passed regex match object.
- // This approach has several benefits:
- // - no repeated regex matching in order to identify matched text
- // - support for matched text with accents, in case the search keyword didn't contain them (and vice-versa)
- if (option.match && option.match[0]) {
- const labelBeforeMatch = label.slice(0, option.match.index)
- // Do not use the matched text here, instead use part of the original label in the same length.
- // This is because the original match does not include accented characters.
- const labelMatchedText = label.slice(
- option.match.index,
- option.match.index + option.match[0].length,
- )
- const labelAfterMatch = label.slice(
- option.match.index + option.match[0].length,
- )
- const highlightClasses = option.disabled
- ? 'bg-blue-200 dark:bg-gray-300'
- : 'bg-blue-600 dark:bg-blue-900 group-hover:bg-blue-800 group-hover:group-focus:bg-blue-600 group-hover:text-white group-focus:text-black group-hover:group-focus:text-black'
- label = `${labelBeforeMatch}<span class="${highlightClasses}">${labelMatchedText}</span>${labelAfterMatch}`
- }
- return {
- ...option,
- matchedPath: parentPaths.join('') + label,
- } as MatchedFlatSelectOption
- }),
- )
- const { collapseDuration, collapseEnter, collapseAfterEnter, collapseLeave } =
- useTransitionCollapse()
- </script>
- <template>
- <slot
- :state="showDropdown"
- :open="openDropdown"
- :close="closeDropdown"
- :focus="moveFocusToDropdown"
- />
- <Teleport to="body">
- <Transition
- :name="isTargetVisible ? 'collapse' : 'none'"
- :duration="collapseDuration"
- @enter="collapseEnter"
- @after-enter="collapseAfterEnter"
- @leave="collapseLeave"
- >
- <div
- v-if="showDropdown"
- v-show="isTargetVisible"
- id="field-tree-select-input-dropdown"
- ref="dropdown"
- class="fixed z-10 flex min-h-9 antialiased"
- :style="dropdownStyle"
- >
- <div class="w-full" role="menu">
- <div
- class="flex h-full flex-col items-start border-x border-neutral-100 bg-neutral-50 dark:border-gray-900 dark:bg-gray-500"
- :class="{
- 'rounded-t-lg border-t': hasDirectionUp,
- 'rounded-b-lg border-b': !hasDirectionUp,
- }"
- >
- <div
- v-if="
- currentPath.length || (multiple && hasMoreSelectableOptions)
- "
- class="flex w-full justify-between gap-2 px-2.5 py-1.5"
- >
- <CommonLabel
- v-if="currentPath.length"
- class="text-blue-800 hover:text-black focus-visible:rounded-sm focus-visible:outline focus-visible:outline-1 focus-visible:outline-offset-1 focus-visible:outline-blue-800 dark:text-blue-800 dark:hover:text-white"
- :prefix-icon="
- locale.localeData?.dir === 'rtl'
- ? 'chevron-right'
- : 'chevron-left'
- "
- :aria-label="$t('Back to previous page')"
- size="small"
- role="button"
- tabindex="0"
- @click.stop="goToPreviousPage(true)"
- @keypress.enter.prevent.stop="goToPreviousPage()"
- @keypress.space.prevent.stop="goToPreviousPage()"
- >
- {{ $t('Back') }}
- </CommonLabel>
- <CommonLabel
- v-if="multiple && hasMoreSelectableOptions"
- class="ms-auto text-blue-800 hover:text-black focus-visible:rounded-sm focus-visible:outline focus-visible:outline-1 focus-visible:outline-offset-1 focus-visible:outline-blue-800 dark:text-blue-800 dark:hover:text-white"
- prefix-icon="check-all"
- size="small"
- role="button"
- tabindex="0"
- @click.stop="selectAll(true)"
- @keypress.enter.prevent.stop="selectAll()"
- @keypress.space.prevent.stop="selectAll()"
- >
- {{
- currentPath.length
- ? $t('select visible options')
- : $t('select all options')
- }}
- </CommonLabel>
- </div>
- <div
- :aria-label="$t('Select…')"
- role="listbox"
- :aria-multiselectable="multiple"
- tabindex="-1"
- class="w-full overflow-y-auto"
- >
- <FieldTreeSelectInputDropdownItem
- v-for="option in filter ? highlightedOptions : currentOptions"
- :key="String(option.value)"
- :class="{
- 'first:rounded-t-[7px]':
- hasDirectionUp &&
- !currentPath.length &&
- (!multiple || !hasMoreSelectableOptions),
- 'last:rounded-b-[7px]': !hasDirectionUp,
- }"
- :aria-setsize="flatOptions.length"
- :aria-posinset="getCurrentIndex(option) + 1"
- :selected="isCurrentValue(option.value)"
- :multiple="multiple"
- :filter="filter"
- :option="option"
- :no-label-translate="noOptionsLabelTranslation"
- @select="select($event)"
- @next="goToNextPage($event)"
- @keydown.right.prevent="
- maybeGoToNextOrPreviousPage(option, 'right')
- "
- @keydown.left.prevent="
- maybeGoToNextOrPreviousPage(option, 'left')
- "
- />
- <FieldTreeSelectInputDropdownItem
- v-if="!options.length"
- :option="
- {
- label: __('No results found'),
- value: '',
- disabled: true,
- } as MatchedFlatSelectOption
- "
- no-selection-indicator
- />
- <slot name="footer" />
- </div>
- </div>
- </div>
- </div>
- </Transition>
- </Teleport>
- </template>
|