CommonSelect.vue 15 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534
  1. <!-- Copyright (C) 2012-2024 Zammad Foundation, https://zammad-foundation.org/ -->
  2. <script setup lang="ts">
  3. import {
  4. type UseElementBoundingReturn,
  5. onClickOutside,
  6. onKeyDown,
  7. useVModel,
  8. } from '@vueuse/core'
  9. import {
  10. computed,
  11. type ConcreteComponent,
  12. nextTick,
  13. onUnmounted,
  14. ref,
  15. type Ref,
  16. toRef,
  17. } from 'vue'
  18. import type {
  19. MatchedSelectOption,
  20. SelectOption,
  21. SelectValue,
  22. } from '#shared/components/CommonSelect/types.ts'
  23. import type { AutoCompleteOption } from '#shared/components/Form/fields/FieldAutocomplete/types.ts'
  24. import { useFocusWhenTyping } from '#shared/composables/useFocusWhenTyping.ts'
  25. import { useTrapTab } from '#shared/composables/useTrapTab.ts'
  26. import { useTraverseOptions } from '#shared/composables/useTraverseOptions.ts'
  27. import { i18n } from '#shared/i18n.ts'
  28. import { useLocaleStore } from '#shared/stores/locale.ts'
  29. import stopEvent from '#shared/utils/events.ts'
  30. import testFlags from '#shared/utils/testFlags.ts'
  31. import CommonLoader from '#desktop/components/CommonLoader/CommonLoader.vue'
  32. import { useTransitionCollapse } from '#desktop/composables/useTransitionCollapse.ts'
  33. import CommonSelectItem from './CommonSelectItem.vue'
  34. import { useCommonSelect } from './useCommonSelect.ts'
  35. import type {
  36. CommonSelectInternalInstance,
  37. DropdownOptionsAction,
  38. } from './types.ts'
  39. export interface Props {
  40. modelValue?:
  41. | SelectValue
  42. | SelectValue[]
  43. | { value: SelectValue; label: string }
  44. | null
  45. options: AutoCompleteOption[] | SelectOption[]
  46. /**
  47. * Do not modify local value
  48. */
  49. passive?: boolean
  50. multiple?: boolean
  51. noClose?: boolean
  52. noRefocus?: boolean
  53. owner?: string
  54. noOptionsLabelTranslation?: boolean
  55. filter?: string
  56. optionIconComponent?: ConcreteComponent
  57. initiallyEmpty?: boolean
  58. emptyInitialLabelText?: string
  59. actions?: DropdownOptionsAction[]
  60. isChildPage?: boolean
  61. isLoading?: boolean
  62. }
  63. const props = withDefaults(defineProps<Props>(), {
  64. emptyInitialLabelText: __('Start typing to search…'),
  65. })
  66. const emit = defineEmits<{
  67. 'update:modelValue': [option: string | number | (string | number)[]]
  68. select: [option: SelectOption]
  69. push: [option: AutoCompleteOption]
  70. pop: []
  71. close: []
  72. 'focus-filter-input': []
  73. }>()
  74. const dropdownElement = ref<HTMLElement>()
  75. const localValue = useVModel(props, 'modelValue', emit)
  76. // TODO: do we really want this initial transforming of the value, when it's null?
  77. if (localValue.value == null && props.multiple) {
  78. localValue.value = []
  79. }
  80. const getFocusableOptions = () => {
  81. return Array.from<HTMLElement>(
  82. dropdownElement.value?.querySelectorAll('[tabindex="0"]') || [],
  83. )
  84. }
  85. const showDropdown = ref(false)
  86. let inputElementBounds: UseElementBoundingReturn
  87. let windowHeight: Ref<number>
  88. const hasDirectionUp = computed(() => {
  89. if (!inputElementBounds || !windowHeight) return false
  90. return inputElementBounds.y.value > windowHeight.value / 2
  91. })
  92. const dropdownStyle = computed(() => {
  93. if (!inputElementBounds) return { top: 0, left: 0, width: 0, maxHeight: 0 }
  94. const style: Record<string, string> = {
  95. left: `${inputElementBounds.left.value}px`,
  96. width: `${inputElementBounds.width.value}px`,
  97. maxHeight: `calc(50vh - ${inputElementBounds.height.value}px)`,
  98. }
  99. if (hasDirectionUp.value) {
  100. style.bottom = `${windowHeight.value - inputElementBounds.top.value}px`
  101. } else {
  102. style.top = `${
  103. inputElementBounds.top.value + inputElementBounds.height.value
  104. }px`
  105. }
  106. return style
  107. })
  108. const { activateTabTrap, deactivateTabTrap } = useTrapTab(dropdownElement)
  109. let lastFocusableOutsideElement: HTMLElement | null = null
  110. const getActiveElement = () => {
  111. if (props.owner) {
  112. return document.getElementById(props.owner)
  113. }
  114. return document.activeElement as HTMLElement
  115. }
  116. const { instances } = useCommonSelect()
  117. const closeDropdown = (refocusOnEscape?: boolean) => {
  118. deactivateTabTrap()
  119. showDropdown.value = false
  120. emit('close')
  121. nextTick(() => {
  122. if (!props.noRefocus || refocusOnEscape)
  123. lastFocusableOutsideElement?.focus()
  124. testFlags.set('common-select.closed')
  125. })
  126. }
  127. const openDropdown = (
  128. bounds: UseElementBoundingReturn,
  129. height: Ref<number>,
  130. ) => {
  131. inputElementBounds = bounds
  132. windowHeight = toRef(height)
  133. instances.value.forEach((instance) => {
  134. if (instance.isOpen.value) instance.closeDropdown()
  135. })
  136. showDropdown.value = true
  137. lastFocusableOutsideElement = getActiveElement()
  138. onClickOutside(dropdownElement, () => closeDropdown(), {
  139. ignore: [lastFocusableOutsideElement as unknown as HTMLElement],
  140. })
  141. requestAnimationFrame(() => {
  142. nextTick(() => {
  143. testFlags.set('common-select.opened')
  144. })
  145. })
  146. }
  147. const moveFocusToDropdown = (lastOption = false) => {
  148. // Focus selected or first available option.
  149. // https://developer.mozilla.org/en-US/docs/Web/Accessibility/ARIA/Roles/listbox_role#keyboard_interactions
  150. const focusableElements = getFocusableOptions()
  151. if (!focusableElements?.length) return
  152. let focusElement = focusableElements[0]
  153. if (lastOption) {
  154. focusElement = focusableElements[focusableElements.length - 1]
  155. } else {
  156. const selected = focusableElements.find(
  157. (el) => el.getAttribute('aria-selected') === 'true',
  158. )
  159. if (selected) focusElement = selected
  160. }
  161. focusElement?.focus()
  162. activateTabTrap()
  163. }
  164. const exposedInstance: CommonSelectInternalInstance = {
  165. isOpen: computed(() => showDropdown.value),
  166. openDropdown,
  167. closeDropdown,
  168. getFocusableOptions,
  169. moveFocusToDropdown,
  170. }
  171. instances.value.add(exposedInstance)
  172. onUnmounted(() => {
  173. instances.value.delete(exposedInstance)
  174. })
  175. defineExpose(exposedInstance)
  176. // https://developer.mozilla.org/en-US/docs/Web/Accessibility/ARIA/Roles/listbox_role#keyboard_interactions
  177. useTraverseOptions(dropdownElement, { direction: 'vertical' })
  178. // - Type-ahead is recommended for all listboxes, especially those with more than seven options
  179. useFocusWhenTyping(dropdownElement)
  180. onKeyDown(
  181. 'Escape',
  182. (event) => {
  183. stopEvent(event)
  184. closeDropdown(true)
  185. },
  186. { target: dropdownElement as Ref<EventTarget> },
  187. )
  188. const isCurrentValue = (value: string | number | boolean) => {
  189. if (props.multiple && Array.isArray(localValue.value)) {
  190. return localValue.value.includes(value)
  191. }
  192. return localValue.value === value
  193. }
  194. const select = (option: SelectOption) => {
  195. if (option.disabled) return
  196. emit('select', option)
  197. if (props.passive) {
  198. if (!props.multiple) {
  199. closeDropdown()
  200. }
  201. return
  202. }
  203. if (props.multiple && Array.isArray(localValue.value)) {
  204. if (localValue.value.includes(option.value)) {
  205. localValue.value = localValue.value.filter((v) => v !== option.value)
  206. } else {
  207. localValue.value.push(option.value)
  208. }
  209. return
  210. }
  211. if (props.modelValue === option.value) {
  212. localValue.value = undefined
  213. } else {
  214. localValue.value = option.value
  215. }
  216. if (!props.multiple && !props.noClose) {
  217. closeDropdown()
  218. }
  219. }
  220. const hasMoreSelectableOptions = computed(
  221. () =>
  222. props.options.filter(
  223. (option) => !option.disabled && !isCurrentValue(option.value),
  224. ).length > 0,
  225. )
  226. const focusFirstOption = () => {
  227. const focusableElements = getFocusableOptions()
  228. if (!focusableElements?.length) return
  229. const focusElement = focusableElements[0]
  230. focusElement?.focus()
  231. }
  232. const selectAll = (focusInput = false) => {
  233. props.options
  234. .filter((option) => !option.disabled && !isCurrentValue(option.value))
  235. .forEach((option) => select(option))
  236. if (focusInput === true) {
  237. emit('focus-filter-input')
  238. return
  239. }
  240. focusFirstOption()
  241. }
  242. const highlightedOptions = computed(() =>
  243. props.options.map((option) => {
  244. let label = option.label || i18n.t('%s (unknown)', option.value.toString())
  245. // Highlight the matched text within the option label by re-using passed regex match object.
  246. // This approach has several benefits:
  247. // - no repeated regex matching in order to identify matched text
  248. // - support for matched text with accents, in case the search keyword didn't contain them (and vice-versa)
  249. if (option.match && option.match[0]) {
  250. const labelBeforeMatch = label.slice(0, option.match.index)
  251. // Do not use the matched text here, instead use part of the original label in the same length.
  252. // This is because the original match does not include accented characters.
  253. const labelMatchedText = label.slice(
  254. option.match.index,
  255. option.match.index + option.match[0].length,
  256. )
  257. const labelAfterMatch = label.slice(
  258. option.match.index + option.match[0].length,
  259. )
  260. const highlightClasses = option.disabled
  261. ? 'bg-blue-200 dark:bg-gray-300'
  262. : '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'
  263. label = `${labelBeforeMatch}<span class="${highlightClasses}">${labelMatchedText}</span>${labelAfterMatch}`
  264. }
  265. return {
  266. ...option,
  267. matchedLabel: label,
  268. } as MatchedSelectOption
  269. }),
  270. )
  271. const emptyLabelText = computed(() => {
  272. if (!props.initiallyEmpty) return __('No results found')
  273. return props.filter ? __('No results found') : props.emptyInitialLabelText
  274. })
  275. const { collapseDuration, collapseEnter, collapseAfterEnter, collapseLeave } =
  276. useTransitionCollapse()
  277. const dropdownActions = computed(() => {
  278. return [
  279. ...(props.actions || []),
  280. ...(props.multiple && hasMoreSelectableOptions.value
  281. ? [
  282. {
  283. key: 'selectAll',
  284. label: __('select all options'),
  285. icon: 'check-all',
  286. onClick: selectAll,
  287. },
  288. ]
  289. : []),
  290. ]
  291. })
  292. const locale = useLocaleStore()
  293. const parentPageCallback = (noFocus?: boolean) => {
  294. emit('pop')
  295. if (noFocus) return
  296. nextTick(() => {
  297. focusFirstOption()
  298. })
  299. }
  300. const goToParentPage = (noFocus?: boolean) => {
  301. parentPageCallback(noFocus)
  302. }
  303. const childPageCallback = (option?: AutoCompleteOption, noFocus?: boolean) => {
  304. if (option?.children) {
  305. emit('push', option)
  306. if (noFocus) return
  307. nextTick(() => {
  308. focusFirstOption()
  309. })
  310. }
  311. }
  312. const goToChildPage = ({
  313. option,
  314. noFocus,
  315. }: {
  316. option: AutoCompleteOption
  317. noFocus?: boolean
  318. }) => {
  319. childPageCallback(option, noFocus)
  320. }
  321. </script>
  322. <template>
  323. <slot
  324. :state="showDropdown"
  325. :open="openDropdown"
  326. :close="closeDropdown"
  327. :focus="moveFocusToDropdown"
  328. />
  329. <Teleport to="body">
  330. <Transition
  331. name="collapse"
  332. :duration="collapseDuration"
  333. @enter="collapseEnter"
  334. @after-enter="collapseAfterEnter"
  335. @leave="collapseLeave"
  336. >
  337. <div
  338. v-if="showDropdown"
  339. id="common-select"
  340. ref="dropdownElement"
  341. class="fixed z-10 flex min-h-9 antialiased"
  342. :style="dropdownStyle"
  343. >
  344. <div class="w-full" role="menu">
  345. <div
  346. class="flex h-full flex-col items-start border-x border-neutral-100 bg-neutral-50 dark:border-gray-900 dark:bg-gray-500"
  347. :class="{
  348. 'rounded-t-lg border-t': hasDirectionUp,
  349. 'rounded-b-lg border-b': !hasDirectionUp,
  350. }"
  351. >
  352. <div
  353. v-if="isChildPage || dropdownActions.length"
  354. class="flex w-full justify-between gap-2 px-2.5 py-1.5"
  355. >
  356. <CommonLabel
  357. v-if="isChildPage"
  358. 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"
  359. :aria-label="$t('Back to previous page')"
  360. :prefix-icon="
  361. locale.localeData?.dir === 'rtl'
  362. ? 'chevron-right'
  363. : 'chevron-left'
  364. "
  365. size="small"
  366. role="button"
  367. tabindex="0"
  368. @click.stop="goToParentPage(true)"
  369. @keypress.enter.prevent.stop="goToParentPage()"
  370. @keypress.space.prevent.stop="goToParentPage()"
  371. >
  372. {{ $t('Back') }}
  373. </CommonLabel>
  374. <div
  375. v-if="dropdownActions.length"
  376. class="flex grow justify-end gap-2"
  377. >
  378. <CommonLabel
  379. v-for="action of dropdownActions"
  380. :key="action.key"
  381. :prefix-icon="action.icon"
  382. 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"
  383. size="small"
  384. role="button"
  385. tabindex="0"
  386. @click.stop="action.onClick(true)"
  387. @keypress.enter.prevent.stop="action.onClick"
  388. @keypress.space.prevent.stop="action.onClick"
  389. >
  390. {{ $t(action.label) }}
  391. </CommonLabel>
  392. </div>
  393. </div>
  394. <div
  395. :aria-label="$t('Select…')"
  396. role="listbox"
  397. :aria-multiselectable="multiple"
  398. tabindex="-1"
  399. class="w-full overflow-y-auto"
  400. >
  401. <Transition name="none" mode="out-in">
  402. <div v-if="options.length">
  403. <CommonSelectItem
  404. v-for="option in filter ? highlightedOptions : options"
  405. :key="String(option.value)"
  406. :class="{
  407. 'first:rounded-t-lg':
  408. hasDirectionUp &&
  409. !isChildPage &&
  410. (!multiple || !hasMoreSelectableOptions),
  411. 'last:rounded-b-lg': !hasDirectionUp,
  412. }"
  413. :selected="isCurrentValue(option.value)"
  414. :multiple="multiple"
  415. :option="option"
  416. :no-label-translate="noOptionsLabelTranslation"
  417. :filter="filter"
  418. :option-icon-component="optionIconComponent"
  419. @select="select($event)"
  420. @next="goToChildPage($event)"
  421. />
  422. </div>
  423. <div v-else-if="isLoading" class="flex items-center">
  424. <CommonLoader
  425. v-if="!options.length"
  426. class="ltr:ml-2 rtl:mr-2"
  427. size="small"
  428. loading
  429. />
  430. <CommonSelectItem
  431. :option="{
  432. label: __('Loading…'),
  433. value: '',
  434. disabled: true,
  435. }"
  436. no-selection-indicator
  437. />
  438. </div>
  439. <CommonSelectItem
  440. v-else-if="!options.length"
  441. :option="{
  442. label: emptyLabelText,
  443. value: '',
  444. disabled: true,
  445. }"
  446. no-selection-indicator
  447. />
  448. </Transition>
  449. <slot name="footer" />
  450. </div>
  451. </div>
  452. </div>
  453. </div>
  454. </Transition>
  455. </Teleport>
  456. </template>