123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537 |
- <!-- 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 } from 'vue'
- import {
- computed,
- type ConcreteComponent,
- nextTick,
- onUnmounted,
- ref,
- type Ref,
- toRef,
- } from 'vue'
- import type {
- MatchedSelectOption,
- SelectOption,
- SelectValue,
- } from '#shared/components/CommonSelect/types.ts'
- import type { AutoCompleteOption } from '#shared/components/Form/fields/FieldAutocomplete/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 CommonLoader from '#desktop/components/CommonLoader/CommonLoader.vue'
- import { useTransitionCollapse } from '#desktop/composables/useTransitionCollapse.ts'
- import CommonSelectItem from './CommonSelectItem.vue'
- import { useCommonSelect } from './useCommonSelect.ts'
- import type {
- CommonSelectInternalInstance,
- DropdownOptionsAction,
- } from './types.ts'
- export interface Props {
- modelValue?:
- | SelectValue
- | SelectValue[]
- | { value: SelectValue; label: string }
- | null
- options: AutoCompleteOption[] | SelectOption[]
- /**
- * Do not modify local value
- */
- passive?: boolean
- multiple?: boolean
- noClose?: boolean
- noRefocus?: boolean
- owner?: string
- noOptionsLabelTranslation?: boolean
- filter?: string
- optionIconComponent?: ConcreteComponent
- initiallyEmpty?: boolean
- emptyInitialLabelText?: string
- actions?: DropdownOptionsAction[]
- isChildPage?: boolean
- isLoading?: boolean
- isTargetVisible?: boolean
- }
- const props = withDefaults(defineProps<Props>(), {
- emptyInitialLabelText: __('Start typing to search…'),
- })
- const emit = defineEmits<{
- 'update:modelValue': [option: string | number | (string | number)[]]
- select: [option: SelectOption]
- push: [option: AutoCompleteOption]
- pop: []
- close: []
- 'focus-filter-input': []
- }>()
- 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)
- 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 = (refocusOnEscape?: boolean) => {
- deactivateTabTrap()
- showDropdown.value = false
- emit('close')
- nextTick(() => {
- if (!props.noRefocus || refocusOnEscape)
- lastFocusableOutsideElement?.focus()
- testFlags.set('common-select.closed')
- })
- }
- const openDropdown = (
- bounds: UseElementBoundingReturn,
- height: Ref<number>,
- ) => {
- inputElementBounds = bounds
- windowHeight = toRef(height)
- instances.value.forEach((instance) => {
- if (instance.isOpen.value) instance.closeDropdown()
- })
- showDropdown.value = true
- lastFocusableOutsideElement = getActiveElement()
- onClickOutside(dropdownElement, () => closeDropdown(), {
- ignore: [lastFocusableOutsideElement as unknown as HTMLElement],
- })
- requestAnimationFrame(() => {
- nextTick(() => {
- testFlags.set('common-select.opened')
- })
- })
- }
- const moveFocusToDropdown = (lastOption = false) => {
- // Focus selected or first available option.
- // https://developer.mozilla.org/en-US/docs/Web/Accessibility/ARIA/Roles/listbox_role#keyboard_interactions
- const focusableElements = getFocusableOptions()
- if (!focusableElements?.length) return
- let focusElement = focusableElements[0]
- if (lastOption) {
- focusElement = focusableElements[focusableElements.length - 1]
- } else {
- const selected = focusableElements.find(
- (el) => el.getAttribute('aria-selected') === 'true',
- )
- if (selected) focusElement = selected
- }
- focusElement?.focus()
- activateTabTrap()
- }
- const exposedInstance: CommonSelectInternalInstance = {
- isOpen: computed(() => showDropdown.value),
- openDropdown,
- closeDropdown,
- getFocusableOptions,
- moveFocusToDropdown,
- }
- instances.value.add(exposedInstance)
- onUnmounted(() => {
- instances.value.delete(exposedInstance)
- })
- defineExpose(exposedInstance)
- // https://developer.mozilla.org/en-US/docs/Web/Accessibility/ARIA/Roles/listbox_role#keyboard_interactions
- useTraverseOptions(dropdownElement, { direction: 'vertical' })
- // - Type-ahead is recommended for all listboxes, especially those with more than seven options
- useFocusWhenTyping(dropdownElement)
- onKeyDown(
- 'Escape',
- (event) => {
- stopEvent(event)
- closeDropdown(true)
- },
- { target: dropdownElement as Ref<EventTarget> },
- )
- const isCurrentValue = (value: string | number | boolean) => {
- if (props.multiple && Array.isArray(localValue.value)) {
- return localValue.value.includes(value)
- }
- return localValue.value === value
- }
- const select = (option: SelectOption) => {
- if (option.disabled) return
- emit('select', option)
- if (props.passive) {
- if (!props.multiple) {
- closeDropdown()
- }
- return
- }
- if (props.multiple && Array.isArray(localValue.value)) {
- if (localValue.value.includes(option.value)) {
- localValue.value = localValue.value.filter((v) => v !== option.value)
- } else {
- localValue.value.push(option.value)
- }
- return
- }
- if (props.modelValue === option.value) {
- localValue.value = undefined
- } else {
- localValue.value = option.value
- }
- if (!props.multiple && !props.noClose) {
- closeDropdown()
- }
- }
- const hasMoreSelectableOptions = computed(
- () =>
- 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 = (focusInput = false) => {
- props.options
- .filter((option) => !option.disabled && !isCurrentValue(option.value))
- .forEach((option) => select(option))
- if (focusInput === true) {
- emit('focus-filter-input')
- return
- }
- focusFirstOption()
- }
- const highlightedOptions = computed(() =>
- props.options.map((option) => {
- 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 dark:group-hover:group-focus:bg-blue-900 group-hover:text-white group-focus:text-black dark:group-focus:text-white group-hover:group-focus:text-black dark:group-hover:group-focus:text-white'
- label = `${labelBeforeMatch}<span class="${highlightClasses}">${labelMatchedText}</span>${labelAfterMatch}`
- }
- return {
- ...option,
- matchedLabel: label,
- } as MatchedSelectOption
- }),
- )
- const emptyLabelText = computed(() => {
- if (!props.initiallyEmpty) return __('No results found')
- return props.filter ? __('No results found') : props.emptyInitialLabelText
- })
- const { collapseDuration, collapseEnter, collapseAfterEnter, collapseLeave } =
- useTransitionCollapse()
- const dropdownActions = computed(() => {
- return [
- ...(props.actions || []),
- ...(props.multiple && hasMoreSelectableOptions.value
- ? [
- {
- key: 'selectAll',
- label: __('select all options'),
- icon: 'check-all',
- onClick: selectAll,
- },
- ]
- : []),
- ]
- })
- const locale = useLocaleStore()
- const parentPageCallback = (noFocus?: boolean) => {
- emit('pop')
- if (noFocus) return
- nextTick(() => {
- focusFirstOption()
- })
- }
- const goToParentPage = (noFocus?: boolean) => {
- parentPageCallback(noFocus)
- }
- const childPageCallback = (option?: AutoCompleteOption, noFocus?: boolean) => {
- if (option?.children) {
- emit('push', option)
- if (noFocus) return
- nextTick(() => {
- focusFirstOption()
- })
- }
- }
- const goToChildPage = ({
- option,
- noFocus,
- }: {
- option: AutoCompleteOption
- noFocus?: boolean
- }) => {
- childPageCallback(option, noFocus)
- }
- </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="common-select"
- ref="dropdown"
- class="fixed z-50 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="isChildPage || dropdownActions.length"
- class="flex w-full justify-between gap-2 px-2.5 py-1.5"
- >
- <CommonLabel
- v-if="isChildPage"
- 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"
- :aria-label="$t('Back to previous page')"
- :prefix-icon="
- locale.localeData?.dir === 'rtl'
- ? 'chevron-right'
- : 'chevron-left'
- "
- size="small"
- role="button"
- tabindex="0"
- @click.stop="goToParentPage(true)"
- @keypress.enter.prevent.stop="goToParentPage()"
- @keypress.space.prevent.stop="goToParentPage()"
- >
- {{ $t('Back') }}
- </CommonLabel>
- <div
- v-if="dropdownActions.length"
- class="flex grow justify-end gap-2"
- >
- <CommonLabel
- v-for="action of dropdownActions"
- :key="action.key"
- :prefix-icon="action.icon"
- 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"
- size="small"
- role="button"
- tabindex="0"
- @click.stop="action.onClick(true)"
- @keypress.enter.prevent.stop="action.onClick"
- @keypress.space.prevent.stop="action.onClick"
- >
- {{ $t(action.label) }}
- </CommonLabel>
- </div>
- </div>
- <div
- :aria-label="$t('Select…')"
- role="listbox"
- :aria-multiselectable="multiple"
- tabindex="-1"
- class="w-full overflow-y-auto"
- >
- <Transition name="none" mode="out-in">
- <div v-if="options.length">
- <CommonSelectItem
- v-for="option in filter ? highlightedOptions : options"
- :key="String(option.value)"
- :class="{
- 'first:rounded-t-lg':
- hasDirectionUp &&
- !isChildPage &&
- (!multiple || !hasMoreSelectableOptions),
- 'last:rounded-b-lg': !hasDirectionUp,
- }"
- :selected="isCurrentValue(option.value)"
- :multiple="multiple"
- :option="option"
- :no-label-translate="noOptionsLabelTranslation"
- :filter="filter"
- :option-icon-component="optionIconComponent"
- @select="select($event)"
- @next="goToChildPage($event)"
- />
- </div>
- <div v-else-if="isLoading" class="flex items-center">
- <CommonLoader
- v-if="!options.length"
- class="ltr:ml-2 rtl:mr-2"
- size="small"
- loading
- />
- <CommonSelectItem
- :option="{
- label: __('Loading…'),
- value: '',
- disabled: true,
- }"
- no-selection-indicator
- />
- </div>
- <CommonSelectItem
- v-else-if="!options.length"
- :option="{
- label: emptyLabelText,
- value: '',
- disabled: true,
- }"
- no-selection-indicator
- />
- </Transition>
- <slot name="footer" />
- </div>
- </div>
- </div>
- </div>
- </Transition>
- </Teleport>
- </template>
|