FieldAutoCompleteInput.vue 20 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675
  1. <!-- Copyright (C) 2012-2024 Zammad Foundation, https://zammad-foundation.org/ -->
  2. <script setup lang="ts">
  3. import { useLazyQuery } from '@vue/apollo-composable'
  4. import {
  5. refDebounced,
  6. useDebounceFn,
  7. useElementBounding,
  8. useWindowSize,
  9. watchOnce,
  10. } from '@vueuse/core'
  11. import gql from 'graphql-tag'
  12. import { cloneDeep, escapeRegExp, isEqual, uniqBy } from 'lodash-es'
  13. import { useTemplateRef } from 'vue'
  14. import {
  15. computed,
  16. markRaw,
  17. nextTick,
  18. ref,
  19. toRef,
  20. watch,
  21. type ConcreteComponent,
  22. } from 'vue'
  23. import type { SelectOption } from '#shared/components/CommonSelect/types'
  24. import useValue from '#shared/components/Form/composables/useValue.ts'
  25. import type {
  26. AutoCompleteOption,
  27. AutocompleteSelectValue,
  28. } from '#shared/components/Form/fields/FieldAutocomplete/types.ts'
  29. import type { FormFieldContext } from '#shared/components/Form/types/field.ts'
  30. import useSelectOptions from '#shared/composables/useSelectOptions.ts'
  31. import { useTrapTab } from '#shared/composables/useTrapTab.ts'
  32. import { useFormBlock } from '#shared/form/useFormBlock.ts'
  33. import { i18n } from '#shared/i18n.ts'
  34. import { QueryHandler } from '#shared/server/apollo/handler/index.ts'
  35. import type { ObjectLike } from '#shared/types/utils.ts'
  36. import CommonInputSearch from '#desktop/components/CommonInputSearch/CommonInputSearch.vue'
  37. import CommonSelect from '#desktop/components/CommonSelect/CommonSelect.vue'
  38. import FieldAutoCompleteOptionIcon from './FieldAutoCompleteOptionIcon.vue'
  39. import type {
  40. AutoCompleteProps,
  41. SelectOptionFunction,
  42. ClearFilterInputFunction,
  43. AutoCompleteOptionValueDictionary,
  44. } from './types.ts'
  45. import type { FormKitNode } from '@formkit/core'
  46. import type { NameNode, OperationDefinitionNode, SelectionNode } from 'graphql'
  47. interface Props {
  48. context: FormFieldContext<AutoCompleteProps>
  49. }
  50. const emit = defineEmits<{
  51. 'search-interaction-update': [
  52. filter: string,
  53. optionValues: AutoCompleteOptionValueDictionary,
  54. selectOption: SelectOptionFunction,
  55. clearFilter: ClearFilterInputFunction,
  56. ]
  57. 'keydown-filter-input': [
  58. event: KeyboardEvent,
  59. filter: string,
  60. optionValues: AutoCompleteOptionValueDictionary,
  61. selectOption: SelectOptionFunction,
  62. clearFilter: ClearFilterInputFunction,
  63. ]
  64. 'close-select-dropdown': []
  65. }>()
  66. const props = defineProps<Props>()
  67. const contextReactive = toRef(props, 'context')
  68. const { hasValue, valueContainer, currentValue, isCurrentValue, clearValue } =
  69. useValue<AutocompleteSelectValue>(contextReactive)
  70. // TODO: I think clearValue needs to wrapper for the full clear of the field (to remove some of the remembered stuff).
  71. const localOptions = ref(props.context.options || [])
  72. const {
  73. sortedOptions,
  74. appendedOptions,
  75. optionValueLookup,
  76. getSelectedOption,
  77. getSelectedOptionLabel,
  78. } = useSelectOptions<AutoCompleteOption[]>(localOptions, contextReactive)
  79. watch(
  80. () => props.context.options,
  81. (options) => {
  82. localOptions.value = options || []
  83. },
  84. )
  85. // Remember current optionValueLookup in node context.
  86. contextReactive.value.optionValueLookup = optionValueLookup
  87. // Initial options prefill for non-multiple fields (multiple fields needs to be handled in
  88. // the form updater or via options prop).
  89. let rememberedInitialOptionFromBuilder: AutoCompleteOption | undefined
  90. const initialOptionBuilderHandler = (rootNode: FormKitNode) => {
  91. if (
  92. hasValue.value &&
  93. props.context.initialOptionBuilder &&
  94. !getSelectedOptionLabel(currentValue.value)
  95. ) {
  96. const initialOption = props.context.initialOptionBuilder(
  97. rootNode?.context?.initialEntityObject as ObjectLike,
  98. currentValue.value,
  99. props.context,
  100. )
  101. if (initialOption) {
  102. localOptions.value.push(initialOption)
  103. if (rememberedInitialOptionFromBuilder) {
  104. const rememberedOptionValue = rememberedInitialOptionFromBuilder.value
  105. localOptions.value = localOptions.value.filter(
  106. (option) => option.value !== rememberedOptionValue,
  107. )
  108. }
  109. rememberedInitialOptionFromBuilder = initialOption
  110. }
  111. }
  112. }
  113. if (!props.context.multiple && props.context.initialOptionBuilder) {
  114. const rootNode = props.context.node.at('$root')
  115. if (rootNode) {
  116. initialOptionBuilderHandler(rootNode)
  117. rootNode?.on('reset', ({ origin }) => {
  118. initialOptionBuilderHandler(origin)
  119. })
  120. }
  121. }
  122. const input = useTemplateRef('input')
  123. const outputElement = useTemplateRef('output')
  124. const filterInput = useTemplateRef('filter-input')
  125. const select = useTemplateRef('select')
  126. const filter = ref('')
  127. const { activateTabTrap, deactivateTabTrap } = useTrapTab(input, true)
  128. const clearFilter = () => {
  129. filter.value = ''
  130. }
  131. const trimmedFilter = computed(() => filter.value.trim())
  132. const debouncedFilter = refDebounced(
  133. trimmedFilter,
  134. props.context.debounceInterval ?? 500,
  135. )
  136. const AutocompleteSearchDocument = gql`
  137. ${props.context.gqlQuery}
  138. `
  139. const additionalQueryParams = () => {
  140. if (typeof props.context.additionalQueryParams === 'function') {
  141. return props.context.additionalQueryParams()
  142. }
  143. return props.context.additionalQueryParams || {}
  144. }
  145. const defaultFilter = computed(() => {
  146. if (props.context.alwaysApplyDefaultFilter) return props.context.defaultFilter
  147. if (hasValue.value) return ''
  148. return props.context.defaultFilter
  149. })
  150. const autocompleteQueryHandler = new QueryHandler(
  151. useLazyQuery(
  152. AutocompleteSearchDocument,
  153. () => ({
  154. input: {
  155. query: debouncedFilter.value || defaultFilter.value || '',
  156. limit: props.context.limit,
  157. ...(additionalQueryParams() || {}),
  158. },
  159. }),
  160. () => ({
  161. enabled: !!(debouncedFilter.value || defaultFilter.value),
  162. cachePolicy: 'no-cache', // Do not use cache, because we want always up-to-date results.
  163. }),
  164. ),
  165. )
  166. if (defaultFilter.value) {
  167. autocompleteQueryHandler.load()
  168. } else {
  169. watchOnce(
  170. () => debouncedFilter.value,
  171. (newValue) => {
  172. if (!newValue.length) return
  173. autocompleteQueryHandler.load()
  174. },
  175. )
  176. }
  177. const autocompleteQueryResultKey = (
  178. (AutocompleteSearchDocument.definitions[0] as OperationDefinitionNode)
  179. .selectionSet.selections[0] as SelectionNode & { name: NameNode }
  180. ).name.value
  181. const autocompleteQueryResultOptions = computed<AutoCompleteOption[]>(
  182. (oldValue) => {
  183. const resultOptions =
  184. autocompleteQueryHandler.result().value?.[autocompleteQueryResultKey] ||
  185. []
  186. if (oldValue && isEqual(oldValue, resultOptions)) return oldValue
  187. return resultOptions
  188. },
  189. )
  190. const autocompleteOptions = computed(
  191. () => cloneDeep(autocompleteQueryResultOptions.value) || [],
  192. )
  193. const {
  194. sortedOptions: sortedAutocompleteOptions,
  195. selectOption: selectAutocompleteOption,
  196. getSelectedOption: getSelectedAutocompleteOption,
  197. getSelectedOptionIcon: getSelectedAutocompleteOptionIcon,
  198. optionValueLookup: autocompleteOptionValueLookup,
  199. } = useSelectOptions<AutoCompleteOption[]>(
  200. autocompleteOptions,
  201. toRef(props, 'context'),
  202. )
  203. const preprocessedAutocompleteOptions = computed(() => {
  204. if (!props.context.autocompleteOptionsPreprocessor)
  205. return sortedAutocompleteOptions.value
  206. return props.context.autocompleteOptionsPreprocessor(
  207. sortedAutocompleteOptions.value,
  208. )
  209. })
  210. const selectOption = (option: SelectOption, focus = false) => {
  211. selectAutocompleteOption(option as AutoCompleteOption)
  212. if (!props.context.multiple) {
  213. localOptions.value = [option as AutoCompleteOption]
  214. select.value?.closeDropdown()
  215. return
  216. }
  217. if (!sortedOptions.value.some((elem) => elem.value === option.value)) {
  218. appendedOptions.value.push(option as AutoCompleteOption)
  219. }
  220. appendedOptions.value = appendedOptions.value.filter((elem) =>
  221. isCurrentValue(elem.value),
  222. )
  223. if (!focus) return
  224. filterInput.value?.focus()
  225. }
  226. const isLoading = autocompleteQueryHandler.loading()
  227. const isUserTyping = ref(false)
  228. const selectNewOption = (option: SelectOption, focus = false) => {
  229. if (isCurrentValue(option.value)) return
  230. selectOption(option, focus)
  231. }
  232. const availableOptions = computed<AutoCompleteOption[]>((oldValue) => {
  233. const currentOptions =
  234. filter.value || defaultFilter.value
  235. ? preprocessedAutocompleteOptions.value
  236. : sortedOptions.value
  237. if (oldValue && isEqual(oldValue, currentOptions)) return oldValue
  238. // :TODO check why bug occurs when selecting by keyboard
  239. // Remove duplicates. Sometimes option appears twice in the list.
  240. return uniqBy(currentOptions, 'value')
  241. // return currentOptions
  242. })
  243. const emitResultUpdated = () => {
  244. nextTick(() => {
  245. emit(
  246. 'search-interaction-update',
  247. debouncedFilter.value,
  248. { ...autocompleteOptionValueLookup.value, ...optionValueLookup.value },
  249. selectNewOption,
  250. clearFilter,
  251. )
  252. })
  253. }
  254. // Controls state to avoid user showing no results while typing and previous search was no results.
  255. const debouncedSetTypingFalse = useDebounceFn(() => {
  256. isUserTyping.value = false
  257. }, 500)
  258. watch(debouncedFilter, (newValue) => {
  259. if (newValue !== '' || defaultFilter.value) return
  260. emitResultUpdated()
  261. })
  262. watch(isLoading, (newValue, oldValue) => {
  263. // We need not to trigger when query was started.
  264. if (newValue && !oldValue) return
  265. emitResultUpdated()
  266. })
  267. const onKeydownFilterInput = (event: KeyboardEvent) => {
  268. nextTick(() => {
  269. emit(
  270. 'keydown-filter-input',
  271. event,
  272. filter.value,
  273. { ...autocompleteOptionValueLookup.value, ...optionValueLookup.value },
  274. selectNewOption,
  275. clearFilter,
  276. )
  277. })
  278. }
  279. const deaccent = (s: string) =>
  280. s.normalize('NFD').replace(/[\u0300-\u036f]/g, '')
  281. const availableOptionsWithMatches = computed(() => {
  282. // Trim and de-accent search keywords and compile them as a case-insensitive regex.
  283. // Make sure to escape special regex characters!
  284. const filterRegex = new RegExp(
  285. escapeRegExp(deaccent(filter.value.trim())),
  286. 'i',
  287. )
  288. return availableOptions.value.map(
  289. (option) =>
  290. ({
  291. ...option,
  292. // Match options via their de-accented labels.
  293. match: filterRegex.exec(deaccent(option.label || String(option.value))),
  294. }) as AutoCompleteOption,
  295. )
  296. })
  297. const childOptions = ref<AutoCompleteOption[]>([])
  298. const showChildOptions = (option: AutoCompleteOption) => {
  299. if (!option.children) return
  300. childOptions.value = option.children
  301. }
  302. const clearChildOptions = () => {
  303. if (!childOptions.value.length) return
  304. childOptions.value = []
  305. }
  306. const displayOptions = computed(() => {
  307. if (childOptions.value.length) return childOptions.value
  308. return availableOptionsWithMatches.value
  309. })
  310. const suggestedOptionLabel = computed(() => {
  311. if (!filter.value || !availableOptionsWithMatches.value.length)
  312. return undefined
  313. const exactMatches = availableOptionsWithMatches.value.filter(
  314. (option) =>
  315. (option.label || option.value.toString())
  316. .toLowerCase()
  317. .indexOf(filter.value.toLowerCase()) === 0 &&
  318. (option.label || option.value.toString()).length > filter.value.length,
  319. )
  320. if (!exactMatches.length) return undefined
  321. return exactMatches[0].label || exactMatches[0].value.toString()
  322. })
  323. const inputElementBounds = useElementBounding(input)
  324. const windowSize = useWindowSize()
  325. const isBelowHalfScreen = computed(() => {
  326. return inputElementBounds.y.value > windowSize.height.value / 2
  327. })
  328. const onCloseDropdown = () => {
  329. clearChildOptions()
  330. clearFilter()
  331. deactivateTabTrap()
  332. emit('close-select-dropdown')
  333. }
  334. const foldDropdown = (event?: MouseEvent) => {
  335. if ((event?.target as HTMLElement)?.tagName !== 'INPUT' && select.value) {
  336. select.value.closeDropdown()
  337. return onCloseDropdown()
  338. }
  339. }
  340. const openSelectDropdown = () => {
  341. if (props.context.disabled) return
  342. select.value?.openDropdown(inputElementBounds, windowSize.height)
  343. requestAnimationFrame(() => {
  344. activateTabTrap()
  345. if (props.context.noFiltering) outputElement.value?.focus()
  346. else filterInput.value?.focus()
  347. })
  348. }
  349. defineExpose({ openSelectDropdown })
  350. const openOrMoveFocusToDropdown = (lastOption = false) => {
  351. if (!select.value?.isOpen) {
  352. return openSelectDropdown()
  353. }
  354. deactivateTabTrap()
  355. nextTick(() => {
  356. requestAnimationFrame(() => {
  357. select.value?.moveFocusToDropdown(lastOption)
  358. })
  359. })
  360. }
  361. const onFocusFilterInput = () => {
  362. filterInput.value?.focus()
  363. }
  364. const handleToggleDropdown = (event: MouseEvent) => {
  365. if (select.value?.isOpen) return foldDropdown(event)
  366. openSelectDropdown()
  367. }
  368. const OptionIconComponent =
  369. props.context.optionIconComponent ??
  370. (FieldAutoCompleteOptionIcon as ConcreteComponent)
  371. watch(filter, (newValue, oldValue) => {
  372. if (newValue !== oldValue) {
  373. isUserTyping.value = true
  374. if (newValue === '') {
  375. // Instant update if filter is empty.
  376. isUserTyping.value = false
  377. }
  378. debouncedSetTypingFalse()
  379. }
  380. })
  381. useFormBlock(
  382. contextReactive,
  383. useDebounceFn((event) => {
  384. if (select.value?.isOpen) foldDropdown(event)
  385. openSelectDropdown()
  386. }, 500),
  387. )
  388. </script>
  389. <template>
  390. <div
  391. ref="input"
  392. class="flex h-auto min-h-10 hover:outline hover:outline-1 hover:outline-offset-1 hover:outline-blue-600 has-[output:focus,input:focus]:outline has-[output:focus,input:focus]:outline-1 has-[output:focus,input:focus]:outline-offset-1 has-[output:focus,input:focus]:outline-blue-800 dark:hover:outline-blue-900 dark:has-[output:focus,input:focus]:outline-blue-800"
  393. :class="[
  394. context.classes.input,
  395. {
  396. 'rounded-lg': !select?.isOpen,
  397. 'rounded-t-lg': select?.isOpen && !isBelowHalfScreen,
  398. 'rounded-b-lg': select?.isOpen && isBelowHalfScreen,
  399. 'bg-blue-200 dark:bg-gray-700': !context.alternativeBackground,
  400. 'bg-neutral-50 dark:bg-gray-500': context.alternativeBackground,
  401. },
  402. ]"
  403. data-test-id="field-autocomplete"
  404. >
  405. <CommonSelect
  406. ref="select"
  407. #default="{ state: expanded, close: closeDropdown }"
  408. :model-value="currentValue"
  409. :options="displayOptions"
  410. :multiple="context.multiple"
  411. :owner="context.id"
  412. :filter="filter"
  413. :option-icon-component="markRaw(OptionIconComponent)"
  414. :empty-initial-label-text="contextReactive.emptyInitialLabelText"
  415. :actions="context.actions"
  416. :is-child-page="childOptions.length > 0"
  417. no-options-label-translation
  418. :is-loading="isLoading || isUserTyping"
  419. no-close
  420. passive
  421. initially-empty
  422. @select="selectOption"
  423. @push="showChildOptions"
  424. @pop="clearChildOptions"
  425. @close="onCloseDropdown"
  426. @focus-filter-input="onFocusFilterInput"
  427. >
  428. <output
  429. :id="context.id"
  430. ref="output"
  431. role="combobox"
  432. aria-controls="common-select"
  433. aria-owns="common-select"
  434. aria-haspopup="menu"
  435. :aria-expanded="expanded"
  436. :name="context.node.name"
  437. class="formkit-disabled:pointer-events-none flex grow items-center gap-2.5 px-2.5 py-2 text-black focus:outline-none dark:text-white"
  438. :aria-labelledby="`label-${context.id}`"
  439. :aria-disabled="context.disabled"
  440. :aria-describedby="context.describedBy"
  441. aria-autocomplete="none"
  442. :data-multiple="context.multiple"
  443. tabindex="0"
  444. v-bind="context.attrs"
  445. @keydown.escape.prevent="closeDropdown()"
  446. @keypress.enter.prevent="openSelectDropdown()"
  447. @keydown.down.prevent="openOrMoveFocusToDropdown()"
  448. @keydown.up.prevent="openOrMoveFocusToDropdown(true)"
  449. @keypress.space.prevent="openSelectDropdown()"
  450. @blur="context.handlers.blur"
  451. @click.stop="handleToggleDropdown"
  452. >
  453. <div
  454. v-if="hasValue && context.multiple"
  455. class="flex flex-wrap gap-1.5"
  456. role="list"
  457. >
  458. <div
  459. v-for="selectedValue in valueContainer"
  460. :key="selectedValue.toString()"
  461. class="flex items-center gap-1.5"
  462. role="listitem"
  463. >
  464. <div
  465. class="inline-flex items-center gap-1 rounded px-1.5 py-0.5 text-xs text-black dark:text-white"
  466. :class="{
  467. 'bg-white dark:bg-gray-200': !context.alternativeBackground,
  468. 'bg-neutral-100 dark:bg-gray-200':
  469. context.alternativeBackground,
  470. }"
  471. >
  472. <CommonIcon
  473. v-if="getSelectedAutocompleteOptionIcon(selectedValue)"
  474. :name="getSelectedAutocompleteOptionIcon(selectedValue)"
  475. class="shrink-0 fill-gray-100 dark:fill-neutral-400"
  476. size="xs"
  477. decorative
  478. />
  479. <span
  480. v-tooltip="
  481. getSelectedOptionLabel(selectedValue) ||
  482. i18n.t('%s (unknown)', selectedValue.toString())
  483. "
  484. class="line-clamp-3 whitespace-pre-wrap break-words"
  485. >
  486. {{
  487. getSelectedOptionLabel(selectedValue) ||
  488. i18n.t('%s (unknown)', selectedValue.toString())
  489. }}
  490. </span>
  491. <CommonIcon
  492. :aria-label="i18n.t('Unselect Option')"
  493. class="shrink-0 fill-stone-200 hover:fill-black focus:outline-none focus-visible:rounded-sm focus-visible:outline focus-visible:outline-1 focus-visible:outline-offset-1 focus-visible:outline-blue-800 dark:fill-neutral-500 dark:hover:fill-white"
  494. name="x-lg"
  495. size="xs"
  496. role="button"
  497. tabindex="0"
  498. @click.stop="
  499. selectOption(
  500. getSelectedAutocompleteOption(selectedValue) ||
  501. getSelectedOption(selectedValue),
  502. )
  503. "
  504. @keypress.enter.prevent.stop="
  505. selectOption(
  506. getSelectedAutocompleteOption(selectedValue) ||
  507. getSelectedOption(selectedValue),
  508. )
  509. "
  510. @keypress.space.prevent.stop="
  511. selectOption(
  512. getSelectedAutocompleteOption(selectedValue) ||
  513. getSelectedOption(selectedValue),
  514. )
  515. "
  516. />
  517. </div>
  518. </div>
  519. </div>
  520. <CommonInputSearch
  521. v-if="expanded || !hasValue"
  522. ref="filter-input"
  523. v-model="filter"
  524. :class="{ 'pointer-events-none': !expanded }"
  525. :tabindex="!expanded ? '-1' : undefined"
  526. :suggestion="suggestedOptionLabel"
  527. :alternative-background="context.alternativeBackground"
  528. @keypress.space.stop
  529. @keydown="onKeydownFilterInput"
  530. />
  531. <div
  532. v-if="!expanded"
  533. class="flex grow flex-wrap gap-1"
  534. :class="{ grow: hasValue && !context.multiple }"
  535. role="list"
  536. >
  537. <div
  538. v-if="hasValue && !context.multiple"
  539. class="flex items-center gap-1.5 text-sm"
  540. role="listitem"
  541. >
  542. <CommonIcon
  543. v-if="getSelectedAutocompleteOptionIcon(currentValue)"
  544. :name="getSelectedAutocompleteOptionIcon(currentValue)"
  545. class="shrink-0 fill-gray-100 dark:fill-neutral-400"
  546. size="tiny"
  547. decorative
  548. />
  549. <span
  550. v-tooltip="
  551. getSelectedOptionLabel(currentValue) ||
  552. i18n.t('%s (unknown)', currentValue.toString())
  553. "
  554. class="line-clamp-3 whitespace-pre-wrap break-words"
  555. >
  556. {{
  557. getSelectedOptionLabel(currentValue) ||
  558. i18n.t('%s (unknown)', currentValue.toString())
  559. }}
  560. </span>
  561. </div>
  562. </div>
  563. <CommonIcon
  564. v-if="context.clearable && hasValue && !context.disabled"
  565. :aria-label="i18n.t('Clear Selection')"
  566. class="shrink-0 fill-stone-200 hover:fill-black focus:outline-none focus-visible:rounded-sm focus-visible:outline focus-visible:outline-1 focus-visible:outline-offset-1 focus-visible:outline-blue-800 dark:fill-neutral-500 dark:hover:fill-white"
  567. name="x-lg"
  568. size="xs"
  569. role="button"
  570. tabindex="0"
  571. @click.stop="clearValue()"
  572. @keypress.enter.prevent.stop="clearValue()"
  573. @keypress.space.prevent.stop="clearValue()"
  574. />
  575. <CommonIcon
  576. class="shrink-0 fill-stone-200 dark:fill-neutral-500"
  577. name="chevron-down"
  578. size="xs"
  579. decorative
  580. />
  581. </output>
  582. </CommonSelect>
  583. </div>
  584. </template>