mirror of
https://github.com/zammad/zammad
synced 2026-05-24 09:48:36 +00:00
252 lines
6.8 KiB
Vue
252 lines
6.8 KiB
Vue
<!-- Copyright (C) 2012-2026 Zammad Foundation, https://zammad-foundation.org/ -->
|
|
|
|
<script setup lang="ts">
|
|
import { onClickOutside, onKeyDown, useVModel } from '@vueuse/core'
|
|
import { useTemplateRef, onUnmounted, computed, nextTick, ref } from 'vue'
|
|
|
|
import type { SelectOption } from '#shared/components/CommonSelect/types.ts'
|
|
import { useFocusWhenTyping } from '#shared/composables/useFocusWhenTyping.ts'
|
|
import { useTrapTab } from '#shared/composables/useTrapTab.ts'
|
|
import { useTraverseOptions } from '#shared/composables/useTraverseOptions.ts'
|
|
import stopEvent from '#shared/utils/events.ts'
|
|
import testFlags from '#shared/utils/testFlags.ts'
|
|
|
|
import CommonSelectItem from './CommonSelectItem.vue'
|
|
import { useCommonSelect } from './useCommonSelect.ts'
|
|
|
|
import type { CommonSelectInternalInstance } 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: SelectOption[]
|
|
/**
|
|
* Do not modify local value
|
|
*/
|
|
passive?: boolean
|
|
multiple?: boolean
|
|
noClose?: boolean
|
|
noRefocus?: boolean
|
|
owner?: string
|
|
noOptionsLabelTranslation?: boolean
|
|
}
|
|
|
|
const props = defineProps<Props>()
|
|
|
|
const emit = defineEmits<{
|
|
'update:modelValue': [option: string | number | (string | number)[]]
|
|
select: [option: SelectOption]
|
|
close: []
|
|
}>()
|
|
|
|
const dialogElement = useTemplateRef('dialog')
|
|
const localValue = useVModel(props, 'modelValue', emit)
|
|
|
|
// TODO: do we really want this initial transfomring of the value, when it's null?
|
|
if (localValue.value == null && props.multiple) {
|
|
localValue.value = []
|
|
}
|
|
|
|
const getFocusableOptions = () => {
|
|
return Array.from<HTMLElement>(dialogElement.value?.querySelectorAll('[tabindex="0"]') || [])
|
|
}
|
|
|
|
const showDialog = ref(false)
|
|
let lastFocusableOutsideElement: HTMLElement | null = null
|
|
|
|
const getActiveElement = () => {
|
|
if (props.owner) {
|
|
return document.getElementById(props.owner)
|
|
}
|
|
return document.activeElement as HTMLElement
|
|
}
|
|
|
|
const openDialog = () => {
|
|
showDialog.value = true
|
|
lastFocusableOutsideElement = getActiveElement()
|
|
requestAnimationFrame(() => {
|
|
// https://developer.mozilla.org/en-US/docs/Web/Accessibility/ARIA/Roles/listbox_role#keyboard_interactions
|
|
// focus selected or first available option
|
|
const focusableElements = getFocusableOptions()
|
|
const selected = focusableElements.find((el) => el.getAttribute('aria-selected') === 'true')
|
|
const focusElement = selected || focusableElements[0]
|
|
focusElement?.focus()
|
|
|
|
nextTick(() => {
|
|
testFlags.set('common-select.opened')
|
|
})
|
|
})
|
|
}
|
|
|
|
const closeDialog = () => {
|
|
showDialog.value = false
|
|
emit('close')
|
|
if (!props.noRefocus) {
|
|
nextTick(() => lastFocusableOutsideElement?.focus())
|
|
}
|
|
|
|
nextTick(() => {
|
|
testFlags.set('common-select.closed')
|
|
})
|
|
}
|
|
|
|
const exposedInstance: CommonSelectInternalInstance = {
|
|
isOpen: computed(() => showDialog.value),
|
|
openDialog,
|
|
closeDialog,
|
|
getFocusableOptions,
|
|
}
|
|
|
|
const { instances } = useCommonSelect()
|
|
|
|
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(dialogElement, { direction: 'vertical' })
|
|
|
|
// - Type-ahead is recommended for all listboxes, especially those with more than seven options
|
|
useFocusWhenTyping(dialogElement)
|
|
|
|
onClickOutside(dialogElement, closeDialog)
|
|
useTrapTab(dialogElement)
|
|
onKeyDown(
|
|
'Escape',
|
|
(event) => {
|
|
stopEvent(event)
|
|
closeDialog()
|
|
},
|
|
{ target: dialogElement 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) {
|
|
closeDialog()
|
|
}
|
|
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) {
|
|
closeDialog()
|
|
}
|
|
}
|
|
|
|
const duration = VITE_TEST_MODE ? undefined : { enter: 300, leave: 200 }
|
|
</script>
|
|
|
|
<template>
|
|
<slot :state="showDialog" :open="openDialog" :close="closeDialog" />
|
|
<Teleport to="body">
|
|
<Transition :duration="duration">
|
|
<div
|
|
v-if="showDialog"
|
|
id="common-select"
|
|
class="fixed inset-0 z-10 flex overflow-y-auto"
|
|
:aria-label="$t('Dialog window with selections')"
|
|
role="dialog"
|
|
>
|
|
<!-- empty @click is needed for https://stackoverflow.com/a/39712411 -->
|
|
<div
|
|
class="select-overlay fixed inset-0 flex h-full w-full bg-gray-500 opacity-60"
|
|
data-test-id="dialog-overlay"
|
|
role="presentation"
|
|
@click="void 0"
|
|
></div>
|
|
<div class="select-dialog relative m-auto">
|
|
<div
|
|
class="flex max-w-[90vw] min-w-[294px] flex-col items-start rounded-xl bg-gray-400/80 backdrop-blur-[15px]"
|
|
>
|
|
<div
|
|
ref="dialog"
|
|
:aria-label="$t('Select…')"
|
|
role="listbox"
|
|
:aria-multiselectable="multiple"
|
|
class="max-h-[50vh] w-full divide-y divide-solid divide-white/10 overflow-y-auto"
|
|
>
|
|
<CommonSelectItem
|
|
v-for="option in options"
|
|
:key="String(option.value)"
|
|
:selected="isCurrentValue(option.value)"
|
|
:multiple="multiple"
|
|
:option="option"
|
|
:no-label-translate="noOptionsLabelTranslation"
|
|
@select="select($event)"
|
|
/>
|
|
<slot name="footer" />
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</Transition>
|
|
</Teleport>
|
|
</template>
|
|
|
|
<style scoped>
|
|
.v-enter-active .select-overlay,
|
|
.v-enter-active .select-dialog {
|
|
transition: all 300ms ease-out;
|
|
}
|
|
|
|
.v-leave-active .select-overlay,
|
|
.v-leave-active .select-dialog {
|
|
transition: all 200ms ease-in;
|
|
}
|
|
|
|
.v-enter-to .select-dialog,
|
|
.v-leave-from .select-dialog {
|
|
transform: scale(1);
|
|
opacity: 1;
|
|
}
|
|
|
|
.v-enter-to .select-overlay,
|
|
.v-leave-from .select-overlay {
|
|
opacity: 0.6;
|
|
}
|
|
|
|
.v-enter-from .select-dialog,
|
|
.v-leave-to .select-dialog {
|
|
transform: scale(0.95);
|
|
opacity: 0;
|
|
}
|
|
|
|
.v-enter-from .select-overlay,
|
|
.v-leave-to .select-overlay {
|
|
opacity: 0;
|
|
}
|
|
</style>
|