ChoiceCombobox.vue 7.5 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358
  1. <template>
  2. <div class="choice-combobox" ref="rootRef">
  3. <input
  4. ref="searchInputRef"
  5. :id="id"
  6. type="text"
  7. class="choice-combobox-input"
  8. role="combobox"
  9. autocomplete="off"
  10. :aria-expanded="isOpen"
  11. :aria-controls="listboxId"
  12. :aria-activedescendant="activeDescendantId"
  13. :placeholder="placeholderText"
  14. :value="query"
  15. :required="required"
  16. @focus="handleFocus"
  17. @input="handleSearchInput"
  18. @keydown="handleKeydown"
  19. @blur="handleBlur"
  20. />
  21. <input
  22. :name="name"
  23. type="hidden"
  24. :value="modelValue"
  25. />
  26. <ul
  27. v-if="isOpen && filteredChoices.length > 0"
  28. :id="listboxId"
  29. role="listbox"
  30. class="choice-combobox-list"
  31. >
  32. <li
  33. v-for="(choice, index) in filteredChoices"
  34. :id="`${listboxId}-option-${index}`"
  35. :key="choice.value"
  36. role="option"
  37. :aria-selected="choice.value === modelValue"
  38. :class="{
  39. highlighted: index === highlightedIndex,
  40. selected: choice.value === modelValue
  41. }"
  42. @mousedown.prevent="selectChoice(choice)"
  43. >
  44. {{ choiceLabel(choice) }}
  45. </li>
  46. </ul>
  47. <div v-else-if="isOpen && query" class="choice-combobox-list choice-combobox-empty">
  48. No matching options
  49. </div>
  50. </div>
  51. </template>
  52. <script setup>
  53. import { computed, onBeforeUnmount, onMounted, ref, watch } from 'vue'
  54. import {
  55. choiceDisplayLabel,
  56. syncStateFromModelValue
  57. } from './choiceComboboxHelpers.js'
  58. const props = defineProps({
  59. id: {
  60. type: String,
  61. required: true
  62. },
  63. name: {
  64. type: String,
  65. required: true
  66. },
  67. choices: {
  68. type: Array,
  69. required: true
  70. },
  71. modelValue: {
  72. type: String,
  73. default: ''
  74. },
  75. required: {
  76. type: Boolean,
  77. default: false
  78. }
  79. })
  80. const emit = defineEmits(['update:modelValue'])
  81. const closeOthersEvent = 'olivetin-choice-combobox-close-others'
  82. const rootRef = ref(null)
  83. const searchInputRef = ref(null)
  84. const isOpen = ref(false)
  85. const query = ref('')
  86. const isUserFiltering = ref(false)
  87. const highlightedIndex = ref(0)
  88. const listboxId = computed(() => `${props.id}-listbox`)
  89. const activeDescendantId = computed(() => {
  90. if (!isOpen.value || filteredChoices.value.length === 0) {
  91. return undefined
  92. }
  93. return `${listboxId.value}-option-${highlightedIndex.value}`
  94. })
  95. const placeholderText = computed(() => {
  96. if (props.required) {
  97. return 'Search and select...'
  98. }
  99. return 'Search options...'
  100. })
  101. const filteredChoices = computed(() => {
  102. if (!isUserFiltering.value) {
  103. return props.choices
  104. }
  105. const search = query.value.trim().toLowerCase()
  106. if (!search) {
  107. return props.choices
  108. }
  109. return props.choices.filter(choice => {
  110. const label = choiceLabel(choice).toLowerCase()
  111. const value = String(choice.value).toLowerCase()
  112. return label.includes(search) || value.includes(search)
  113. })
  114. })
  115. watch([() => props.modelValue, () => props.choices], () => {
  116. if (!isOpen.value) {
  117. syncFromModelValue()
  118. }
  119. }, { immediate: true })
  120. function choiceLabel(choice) {
  121. return choiceDisplayLabel(choice)
  122. }
  123. function syncFromModelValue() {
  124. const next = syncStateFromModelValue(props.choices, props.modelValue)
  125. query.value = next.query
  126. if (next.modelValue !== props.modelValue) {
  127. emitValue(next.modelValue)
  128. }
  129. }
  130. function selectedChoiceIndex(choices) {
  131. const index = choices.findIndex(choice => choice.value === props.modelValue)
  132. return index >= 0 ? index : 0
  133. }
  134. function openList() {
  135. document.dispatchEvent(new CustomEvent(closeOthersEvent, { detail: { id: props.id } }))
  136. isOpen.value = true
  137. highlightedIndex.value = selectedChoiceIndex(filteredChoices.value)
  138. }
  139. function closeList() {
  140. isOpen.value = false
  141. isUserFiltering.value = false
  142. syncFromModelValue()
  143. }
  144. function emitValue(value) {
  145. emit('update:modelValue', value)
  146. }
  147. function selectChoice(choice) {
  148. emitValue(choice.value)
  149. query.value = choiceLabel(choice)
  150. isOpen.value = false
  151. }
  152. function handleFocus() {
  153. if (!isOpen.value) {
  154. syncFromModelValue()
  155. isUserFiltering.value = false
  156. }
  157. openList()
  158. }
  159. function handleSearchInput(event) {
  160. isUserFiltering.value = true
  161. query.value = event.target.value
  162. openList()
  163. highlightedIndex.value = 0
  164. }
  165. function moveHighlight(delta) {
  166. if (filteredChoices.value.length === 0) {
  167. return
  168. }
  169. const nextIndex = highlightedIndex.value + delta
  170. if (nextIndex < 0) {
  171. highlightedIndex.value = filteredChoices.value.length - 1
  172. return
  173. }
  174. if (nextIndex >= filteredChoices.value.length) {
  175. highlightedIndex.value = 0
  176. return
  177. }
  178. highlightedIndex.value = nextIndex
  179. }
  180. function handleKeydown(event) {
  181. if (event.key === 'ArrowDown') {
  182. event.preventDefault()
  183. const wasOpen = isOpen.value
  184. openList()
  185. if (wasOpen) {
  186. moveHighlight(1)
  187. }
  188. return
  189. }
  190. if (event.key === 'ArrowUp') {
  191. event.preventDefault()
  192. const wasOpen = isOpen.value
  193. openList()
  194. if (wasOpen) {
  195. moveHighlight(-1)
  196. } else if (filteredChoices.value.length > 0) {
  197. highlightedIndex.value = filteredChoices.value.length - 1
  198. }
  199. return
  200. }
  201. if (event.key === 'Enter') {
  202. if (!isOpen.value || filteredChoices.value.length === 0) {
  203. return
  204. }
  205. event.preventDefault()
  206. selectChoice(filteredChoices.value[highlightedIndex.value])
  207. return
  208. }
  209. if (event.key === 'Escape') {
  210. event.preventDefault()
  211. closeList()
  212. searchInputRef.value?.blur()
  213. }
  214. }
  215. function handleBlur() {
  216. closeList()
  217. }
  218. function handleCloseOthers(event) {
  219. if (event.detail.id !== props.id) {
  220. closeList()
  221. }
  222. }
  223. function handleOutsideMouseDown(event) {
  224. if (!isOpen.value || rootRef.value?.contains(event.target)) {
  225. return
  226. }
  227. closeList()
  228. }
  229. watch(isOpen, open => {
  230. if (open) {
  231. document.addEventListener('mousedown', handleOutsideMouseDown, true)
  232. return
  233. }
  234. document.removeEventListener('mousedown', handleOutsideMouseDown, true)
  235. })
  236. onMounted(() => {
  237. document.addEventListener(closeOthersEvent, handleCloseOthers)
  238. })
  239. onBeforeUnmount(() => {
  240. document.removeEventListener('mousedown', handleOutsideMouseDown, true)
  241. document.removeEventListener(closeOthersEvent, handleCloseOthers)
  242. })
  243. </script>
  244. <style scoped>
  245. .choice-combobox {
  246. position: relative;
  247. width: 100%;
  248. }
  249. .choice-combobox:focus-within {
  250. z-index: 11;
  251. }
  252. .choice-combobox-input {
  253. width: 100%;
  254. }
  255. .choice-combobox-list {
  256. position: absolute;
  257. z-index: 10;
  258. left: 0;
  259. right: 0;
  260. max-height: 12rem;
  261. overflow-y: auto;
  262. margin: 0.125rem 0 0;
  263. padding: 0;
  264. list-style: none;
  265. border: 1px solid var(--border-color, #ccc);
  266. border-radius: 0.25rem;
  267. background: var(--standout-bg-color, #fff);
  268. color: var(--text-color, inherit);
  269. box-shadow: 0 2px 6px rgba(0, 0, 0, 0.12);
  270. }
  271. .choice-combobox-list li {
  272. padding: 0.375rem 0.5rem;
  273. cursor: pointer;
  274. }
  275. .choice-combobox-list li.highlighted,
  276. .choice-combobox-list li:hover {
  277. background: var(--hover-background-color, #eef3ff);
  278. color: var(--hover-text-color, inherit);
  279. }
  280. .choice-combobox-list li.selected {
  281. font-weight: 600;
  282. }
  283. .choice-combobox-empty {
  284. padding: 0.375rem 0.5rem;
  285. color: var(--disabled-text-color, #666);
  286. font-size: 0.875rem;
  287. }
  288. @media (prefers-color-scheme: dark) {
  289. .choice-combobox-list,
  290. .choice-combobox-empty {
  291. background-color: #4e4e4e;
  292. color: #ddd;
  293. border-color: var(--border-color, #595959);
  294. box-shadow: 0 2px 8px rgba(0, 0, 0, 0.45);
  295. }
  296. .choice-combobox-list li.highlighted,
  297. .choice-combobox-list li:hover {
  298. background-color: var(--hover-background-color, #1d345c);
  299. color: #fff;
  300. }
  301. .choice-combobox-empty {
  302. color: var(--disabled-text-color, #999);
  303. }
  304. }
  305. </style>