Files
zammad/app/frontend/apps/desktop/components/CommonSelect/CommonSelect.vue
Dominik Klein 0b7ea44b25 Maintenance: Switch from eslint only setup to oxlint together with eslint.
Co-authored-by: Benjamin Scharf <bs@zammad.com>
Co-authored-by: Dominik Klein <dk@zammad.com>
Co-authored-by: Dusan Vuckovic <dv@zammad.com>
2025-06-11 19:45:28 +02:00

489 lines
15 KiB
Vue

<!-- Copyright (C) 2012-2025 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-xs 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-xs 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>