ChoiceCombobox.vue 7.1 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345
  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 highlightedIndex = ref(0)
  87. const listboxId = computed(() => `${props.id}-listbox`)
  88. const activeDescendantId = computed(() => {
  89. if (!isOpen.value || filteredChoices.value.length === 0) {
  90. return undefined
  91. }
  92. return `${listboxId.value}-option-${highlightedIndex.value}`
  93. })
  94. const placeholderText = computed(() => {
  95. if (props.required) {
  96. return 'Search and select...'
  97. }
  98. return 'Search options...'
  99. })
  100. const filteredChoices = computed(() => {
  101. const search = query.value.trim().toLowerCase()
  102. if (!search) {
  103. return props.choices
  104. }
  105. return props.choices.filter(choice => {
  106. const label = choiceLabel(choice).toLowerCase()
  107. const value = String(choice.value).toLowerCase()
  108. return label.includes(search) || value.includes(search)
  109. })
  110. })
  111. watch([() => props.modelValue, () => props.choices], () => {
  112. if (!isOpen.value) {
  113. syncFromModelValue()
  114. }
  115. }, { immediate: true })
  116. function choiceLabel(choice) {
  117. return choiceDisplayLabel(choice)
  118. }
  119. function syncFromModelValue() {
  120. const next = syncStateFromModelValue(props.choices, props.modelValue)
  121. query.value = next.query
  122. if (next.modelValue !== props.modelValue) {
  123. emitValue(next.modelValue)
  124. }
  125. }
  126. function openList() {
  127. document.dispatchEvent(new CustomEvent(closeOthersEvent, { detail: { id: props.id } }))
  128. isOpen.value = true
  129. highlightedIndex.value = 0
  130. }
  131. function closeList() {
  132. isOpen.value = false
  133. syncFromModelValue()
  134. }
  135. function emitValue(value) {
  136. emit('update:modelValue', value)
  137. }
  138. function selectChoice(choice) {
  139. emitValue(choice.value)
  140. query.value = choiceLabel(choice)
  141. isOpen.value = false
  142. }
  143. function handleFocus() {
  144. if (!isOpen.value) {
  145. syncFromModelValue()
  146. }
  147. openList()
  148. }
  149. function handleSearchInput(event) {
  150. query.value = event.target.value
  151. openList()
  152. highlightedIndex.value = 0
  153. }
  154. function moveHighlight(delta) {
  155. if (filteredChoices.value.length === 0) {
  156. return
  157. }
  158. const nextIndex = highlightedIndex.value + delta
  159. if (nextIndex < 0) {
  160. highlightedIndex.value = filteredChoices.value.length - 1
  161. return
  162. }
  163. if (nextIndex >= filteredChoices.value.length) {
  164. highlightedIndex.value = 0
  165. return
  166. }
  167. highlightedIndex.value = nextIndex
  168. }
  169. function handleKeydown(event) {
  170. if (event.key === 'ArrowDown') {
  171. event.preventDefault()
  172. const wasOpen = isOpen.value
  173. openList()
  174. if (wasOpen) {
  175. moveHighlight(1)
  176. }
  177. return
  178. }
  179. if (event.key === 'ArrowUp') {
  180. event.preventDefault()
  181. const wasOpen = isOpen.value
  182. openList()
  183. if (wasOpen) {
  184. moveHighlight(-1)
  185. } else if (filteredChoices.value.length > 0) {
  186. highlightedIndex.value = filteredChoices.value.length - 1
  187. }
  188. return
  189. }
  190. if (event.key === 'Enter') {
  191. if (!isOpen.value || filteredChoices.value.length === 0) {
  192. return
  193. }
  194. event.preventDefault()
  195. selectChoice(filteredChoices.value[highlightedIndex.value])
  196. return
  197. }
  198. if (event.key === 'Escape') {
  199. event.preventDefault()
  200. closeList()
  201. searchInputRef.value?.blur()
  202. }
  203. }
  204. function handleBlur() {
  205. closeList()
  206. }
  207. function handleCloseOthers(event) {
  208. if (event.detail.id !== props.id) {
  209. closeList()
  210. }
  211. }
  212. function handleOutsideMouseDown(event) {
  213. if (!isOpen.value || rootRef.value?.contains(event.target)) {
  214. return
  215. }
  216. closeList()
  217. }
  218. watch(isOpen, open => {
  219. if (open) {
  220. document.addEventListener('mousedown', handleOutsideMouseDown, true)
  221. return
  222. }
  223. document.removeEventListener('mousedown', handleOutsideMouseDown, true)
  224. })
  225. onMounted(() => {
  226. document.addEventListener(closeOthersEvent, handleCloseOthers)
  227. })
  228. onBeforeUnmount(() => {
  229. document.removeEventListener('mousedown', handleOutsideMouseDown, true)
  230. document.removeEventListener(closeOthersEvent, handleCloseOthers)
  231. })
  232. </script>
  233. <style scoped>
  234. .choice-combobox {
  235. position: relative;
  236. width: 100%;
  237. }
  238. .choice-combobox:focus-within {
  239. z-index: 11;
  240. }
  241. .choice-combobox-input {
  242. width: 100%;
  243. }
  244. .choice-combobox-list {
  245. position: absolute;
  246. z-index: 10;
  247. left: 0;
  248. right: 0;
  249. max-height: 12rem;
  250. overflow-y: auto;
  251. margin: 0.125rem 0 0;
  252. padding: 0;
  253. list-style: none;
  254. border: 1px solid var(--border-color, #ccc);
  255. border-radius: 0.25rem;
  256. background: var(--standout-bg-color, #fff);
  257. color: var(--text-color, inherit);
  258. box-shadow: 0 2px 6px rgba(0, 0, 0, 0.12);
  259. }
  260. .choice-combobox-list li {
  261. padding: 0.375rem 0.5rem;
  262. cursor: pointer;
  263. }
  264. .choice-combobox-list li.highlighted,
  265. .choice-combobox-list li:hover {
  266. background: var(--hover-background-color, #eef3ff);
  267. color: var(--hover-text-color, inherit);
  268. }
  269. .choice-combobox-list li.selected {
  270. font-weight: 600;
  271. }
  272. .choice-combobox-empty {
  273. padding: 0.375rem 0.5rem;
  274. color: var(--disabled-text-color, #666);
  275. font-size: 0.875rem;
  276. }
  277. @media (prefers-color-scheme: dark) {
  278. .choice-combobox-list,
  279. .choice-combobox-empty {
  280. background-color: #4e4e4e;
  281. color: #ddd;
  282. border-color: var(--border-color, #595959);
  283. box-shadow: 0 2px 8px rgba(0, 0, 0, 0.45);
  284. }
  285. .choice-combobox-list li.highlighted,
  286. .choice-combobox-list li:hover {
  287. background-color: var(--hover-background-color, #1d345c);
  288. color: #fff;
  289. }
  290. .choice-combobox-empty {
  291. color: var(--disabled-text-color, #999);
  292. }
  293. }
  294. </style>