|
1 | 1 | <template> |
2 | 2 | <div ref="containerRef" class="relative inline-block w-full"> |
3 | 3 | <!-- Searchable mode: input trigger --> |
4 | | - <StyledInput |
5 | | - v-if="searchable" |
6 | | - ref="searchTriggerRef" |
7 | | - v-model="searchQuery" |
8 | | - :icon="showSearchIcon ? SearchIcon : undefined" |
9 | | - type="text" |
10 | | - :placeholder="searchPlaceholder || placeholder" |
11 | | - :disabled="disabled" |
12 | | - wrapper-class="w-full" |
13 | | - :input-class="showChevron ? '!pr-9' : undefined" |
14 | | - class="relative" |
15 | | - @input="handleSearchInput" |
16 | | - @keydown="handleSearchKeydown" |
17 | | - @focus="handleSearchFocus" |
18 | | - @click="handleSearchClick" |
19 | | - > |
20 | | - <template v-if="showChevron" #right> |
21 | | - <ChevronLeftIcon |
22 | | - class="pointer-events-none absolute right-3 top-1/2 size-5 -translate-y-1/2 text-secondary transition-transform duration-150" |
23 | | - :class="isOpen ? (openDirection === 'down' ? 'rotate-90' : '-rotate-90') : '-rotate-90'" |
24 | | - /> |
25 | | - </template> |
26 | | - </StyledInput> |
| 4 | + <div v-if="searchable" class="relative w-full rounded-xl bg-surface-4"> |
| 5 | + <!-- |
| 6 | + Selection mirror: horizontal padding must match StyledInput (filled + left icon uses `pl-10`, |
| 7 | + else `pl-3`) and `searchableInputClass` when the chevron is shown (`!pr-9`), or the overlay |
| 8 | + text will not line up with the transparent input text / caret. |
| 9 | + --> |
| 10 | + <div |
| 11 | + v-if="searchSelectionOverlayVisible" |
| 12 | + class="pointer-events-none absolute inset-y-0 left-0 right-0 z-0 flex min-w-0 items-center gap-2 font-medium text-primary" |
| 13 | + :class="[showSearchIcon ? 'pl-10' : 'pl-3', showChevron ? 'pr-9' : 'pr-3']" |
| 14 | + aria-hidden="true" |
| 15 | + > |
| 16 | + <span class="min-w-0 truncate">{{ searchQuery }}</span> |
| 17 | + <slot name="search-selection-affix" :option="selectedOption" /> |
| 18 | + </div> |
| 19 | + <StyledInput |
| 20 | + ref="searchTriggerRef" |
| 21 | + v-model="searchQuery" |
| 22 | + :icon="showSearchIcon ? SearchIcon : undefined" |
| 23 | + type="text" |
| 24 | + :placeholder="searchPlaceholder || placeholder" |
| 25 | + :disabled="disabled" |
| 26 | + wrapper-class="w-full !bg-transparent" |
| 27 | + :input-class="searchableInputClass" |
| 28 | + class="relative z-[1]" |
| 29 | + @input="handleSearchInput" |
| 30 | + @keydown="handleSearchKeydown" |
| 31 | + @focus="handleSearchFocus" |
| 32 | + @click="handleSearchClick" |
| 33 | + > |
| 34 | + <template v-if="showChevron" #right> |
| 35 | + <ChevronLeftIcon |
| 36 | + class="pointer-events-none absolute right-3 top-1/2 size-5 -translate-y-1/2 text-secondary transition-transform duration-150" |
| 37 | + :class="isOpen ? (openDirection === 'down' ? 'rotate-90' : '-rotate-90') : '-rotate-90'" |
| 38 | + /> |
| 39 | + </template> |
| 40 | + </StyledInput> |
| 41 | + </div> |
27 | 42 |
|
28 | 43 | <!-- Standard mode: button trigger --> |
29 | 44 | <span |
|
108 | 123 | :class="getOptionClasses(item, index)" |
109 | 124 | tabindex="-1" |
110 | 125 | @click="handleOptionClick(item, index)" |
111 | | - @mouseenter="!item.disabled && (focusedIndex = index)" |
| 126 | + @mouseenter="handleOptionMouseEnter(item, index)" |
112 | 127 | > |
113 | | - <slot :name="`option-${item.value}`" :item="item"> |
| 128 | + <slot |
| 129 | + name="option" |
| 130 | + :item="item" |
| 131 | + :index="index" |
| 132 | + :is-selected="!!(listbox && item.value === modelValue)" |
| 133 | + > |
114 | 134 | <div class="flex w-full items-center justify-between gap-2"> |
115 | 135 | <div class="flex items-center gap-2"> |
116 | 136 | <component :is="item.icon" v-if="item.icon" class="h-5 w-5" /> |
|
151 | 171 | <script setup lang="ts" generic="T"> |
152 | 172 | import { ChevronLeftIcon, SearchIcon } from '@modrinth/assets' |
153 | 173 | import { onClickOutside } from '@vueuse/core' |
154 | | -import { type Component, computed, nextTick, onMounted, onUnmounted, ref, watch } from 'vue' |
| 174 | +import { |
| 175 | + type Component, |
| 176 | + computed, |
| 177 | + nextTick, |
| 178 | + onMounted, |
| 179 | + onUnmounted, |
| 180 | + ref, |
| 181 | + useSlots, |
| 182 | + watch, |
| 183 | +} from 'vue' |
155 | 184 |
|
156 | 185 | import StyledInput from './StyledInput.vue' |
157 | 186 |
|
@@ -223,11 +252,14 @@ const props = withDefaults( |
223 | 252 | const emit = defineEmits<{ |
224 | 253 | 'update:modelValue': [value: T] |
225 | 254 | select: [option: ComboboxOption<T>] |
| 255 | + 'option-hover': [option: ComboboxOption<T>] |
226 | 256 | open: [] |
227 | 257 | close: [] |
228 | 258 | searchInput: [query: string] |
229 | 259 | }>() |
230 | 260 |
|
| 261 | +const slots = useSlots() |
| 262 | +
|
231 | 263 | const isOpen = ref(false) |
232 | 264 | const searchQuery = ref('') |
233 | 265 | const userHasTyped = ref(false) |
@@ -261,6 +293,23 @@ const selectedOption = computed<ComboboxOption<T> | undefined>(() => { |
261 | 293 | ) |
262 | 294 | }) |
263 | 295 |
|
| 296 | +/** Extra content (e.g. channel pill) next to the label while the search field is idle */ |
| 297 | +const searchSelectionOverlayVisible = computed(() => { |
| 298 | + if (!props.searchable || !props.syncWithSelection || !selectedOption.value) return false |
| 299 | + if (!slots['search-selection-affix']) return false |
| 300 | + if (isOpen.value || userHasTyped.value) return false |
| 301 | + return true |
| 302 | +}) |
| 303 | +
|
| 304 | +const searchableInputClass = computed(() => { |
| 305 | + const parts = ['!bg-transparent'] |
| 306 | + if (props.showChevron) parts.push('!pr-9') |
| 307 | + if (searchSelectionOverlayVisible.value) { |
| 308 | + parts.push('!text-transparent [caret-color:var(--color-text-primary)] selection:bg-transparent') |
| 309 | + } |
| 310 | + return parts.join(' ') |
| 311 | +}) |
| 312 | +
|
264 | 313 | const triggerText = computed(() => { |
265 | 314 | if (props.displayValue !== undefined) return props.displayValue |
266 | 315 | if (selectedOption.value) return selectedOption.value.label |
@@ -446,6 +495,12 @@ function handleOptionClick(option: ComboboxOption<T>, index: number) { |
446 | 495 | } |
447 | 496 | } |
448 | 497 |
|
| 498 | +function handleOptionMouseEnter(option: ComboboxOption<T>, index: number) { |
| 499 | + if (option.disabled) return |
| 500 | + focusedIndex.value = index |
| 501 | + emit('option-hover', option) |
| 502 | +} |
| 503 | +
|
449 | 504 | function findNextFocusableOption(currentIndex: number, direction: 'next' | 'previous'): number { |
450 | 505 | const length = filteredOptions.value.length |
451 | 506 | let index = currentIndex |
|
0 commit comments