CommonSelect.vue 15 KB

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