Files
AstralRinth/packages/ui/src/components/base/MultiSelect.vue
T
Truman Gao caeab46ca6 fix: clicking scrollbar closes combobox (#6443)
* fix: clicking overlay scrollbar closes combobox

* feat: adjust multiselect scrollbar style

* feat: for dropdown mutliselect too
2026-06-19 18:07:13 +00:00

1430 lines
40 KiB
Vue

<template>
<div ref="containerRef" class="relative inline-block" :class="fitContent ? 'w-auto' : 'w-full'">
<span
ref="triggerRef"
role="button"
tabindex="0"
class="relative flex items-center overflow-hidden rounded-xl bg-surface-4 px-4 py-1 text-left transition-all duration-200"
:class="[
fitContent ? 'w-auto max-w-full' : 'w-full',
triggerClass,
{
'z-[9999]': isOpen,
'cursor-not-allowed opacity-50': disabled,
'cursor-pointer hover:brightness-125 active:brightness-125': !disabled,
},
]"
:aria-expanded="isOpen"
aria-haspopup="listbox"
:aria-disabled="disabled || undefined"
@click="handleTriggerClick($event)"
@keydown="handleTriggerKeydown"
>
<slot
v-if="hasCustomInputContent"
name="input-content"
:is-open="isOpen"
:model-value="modelValue"
:selected-options="selectedOptions"
:clear-all="clearAll"
:toggle-open="toggleDropdown"
:open-direction="openDirection"
/>
<template v-else>
<div
ref="tagsContainerRef"
class="flex min-h-8 flex-1 flex-wrap items-center gap-1.5 overflow-hidden"
:style="{ maxHeight: `calc(${maxTagRows} * 30px + ${maxTagRows - 1} * 6px)` }"
>
<span
v-for="tag in visibleTags"
:key="String(tag.value)"
class="inline-flex items-center gap-1 rounded-full border border-solid border-surface-5 bg-surface-4 px-2 py-1 text-sm font-medium text-primary transition-all hover:brightness-[115%]"
@click.stop="removeTag(tag.value)"
>
{{ tag.label }}
<XIcon class="size-3.5 shrink-0 text-secondary" />
</span>
<Menu
v-show="overflowCount > 0"
:delay="{ hide: 50, show: 0 }"
no-auto-focus
:auto-hide="false"
@apply-show="popperOverflowTags = [...overflowTags]"
>
<span
class="inline-flex cursor-default select-none items-center rounded-full border border-solid border-surface-5 bg-surface-4 px-2 py-1 text-sm font-medium text-secondary"
@click.stop
>
+{{ overflowCount }}
</span>
<template #popper>
<div class="flex max-w-[20rem] flex-wrap gap-1" @mousedown.prevent>
<span
v-for="tag in overflowTags"
:key="String(tag.value)"
class="inline-flex cursor-pointer items-center gap-1 rounded-full border border-solid border-surface-5 bg-surface-4 px-2.5 py-1 text-sm font-medium text-primary hover:brightness-[115%]"
@click.stop="removeTag(tag.value)"
>
{{ tag.label }}
<XIcon class="size-3.5 shrink-0 text-secondary" />
</span>
</div>
</template>
</Menu>
<span
v-if="selectedOptions.length === 0"
class="text-primary opacity-50 text-base font-medium"
>
{{ placeholder }}
</span>
</div>
<div class="ml-2 flex shrink-0 items-center gap-1.5">
<button
v-if="clearable && modelValue.length > 0"
type="button"
class="flex cursor-pointer items-center justify-center rounded border-none bg-transparent p-0.5 text-secondary transition-all hover:text-contrast"
aria-label="Clear all"
@click.stop="clearAll"
>
<XIcon class="size-5" />
</button>
<div
v-if="clearable && modelValue.length > 0"
class="h-5 w-[1px] shrink-0 bg-surface-5"
></div>
<ChevronLeftIcon
v-if="showChevron"
class="size-5 shrink-0 text-secondary transition-transform duration-150"
:class="isOpen ? (openDirection === 'down' ? 'rotate-90' : '-rotate-90') : '-rotate-90'"
/>
</div>
</template>
</span>
<Teleport to="#teleports">
<Transition
enter-active-class="transition-opacity duration-150"
leave-active-class="transition-opacity duration-150"
enter-from-class="opacity-0"
leave-to-class="opacity-0"
>
<div
v-if="isOpen"
ref="dropdownRef"
class="fixed z-[9999] flex flex-col overflow-hidden rounded-[14px] bg-surface-4 border border-solid border-surface-5"
:class="[
openDirection === 'up' ? 'shadow-[0_-25px_50px_-12px_rgb(0,0,0,0.25)]' : 'shadow-2xl',
]"
:style="dropdownStyle"
role="listbox"
aria-multiselectable="true"
@mousedown.stop
@keydown="handleDropdownKeydown"
>
<div class="empty:hidden">
<div
v-if="searchable"
class="px-0 py-1.5 border-0 border-solid border-b border-b-surface-5 flex"
>
<StyledInput
ref="searchInputRef"
v-model="searchQuery"
:icon="SearchIcon"
type="text"
:placeholder="searchPlaceholder"
wrapper-class="grow bg-surface-4 mx-0"
input-class="ps-9 mx-1.5"
@input="handleSearchInput"
@keydown="handleSearchKeydown"
/>
<div
v-if="$slots['search-actions']"
class="flex shrink-0 items-center"
@keydown="handleSearchActionsKeydown"
>
<slot
name="search-actions"
:model-value="modelValue"
:selected-options="selectedOptions"
:clear-all="clearAll"
:is-open="isOpen"
></slot>
</div>
</div>
<div
v-if="hasFilteredOptions || shouldShowSelectAll"
class="flex flex-col bg-surface-4 border-0 border-solid border-b border-b-surface-5 empty:hidden"
>
<div v-if="shouldShowSelectAll" class="sticky top-0 z-10 bg-surface-4">
<span
class="flex w-full items-center gap-2.5 cursor-pointer px-4 py-3 text-left transition-all duration-150 text-contrast hover:brightness-[115%]"
:class="{ 'brightness-[115%]': focusedIndex === -2 }"
data-option-index="-2"
:data-focused="focusedIndex === -2"
role="option"
:aria-selected="isAllSelected"
tabindex="-1"
@click="toggleSelectAll"
@mouseenter="focusedIndex = -2"
>
<span
v-if="checkboxPosition === 'left'"
class="w-5 h-5 rounded-md flex items-center justify-center border-[1px] border-solid shrink-0 checkbox-shadow"
:class="[
isAllSelected
? 'bg-brand border-button-border text-brand-inverted'
: 'bg-surface-2 border-surface-5',
isIndeterminate ? 'text-primary' : '',
]"
>
<MinusIcon v-if="isIndeterminate" aria-hidden="true" stroke-width="3" />
<CheckIcon v-else-if="isAllSelected" aria-hidden="true" stroke-width="3" />
</span>
<span class="min-w-0 flex-1 font-semibold leading-tight text-primary">
{{ selectAllLabel }}
</span>
<span
v-if="checkboxPosition === 'right'"
class="flex items-center justify-center shrink-0 text-brand"
>
<MinusIcon v-if="isIndeterminate" aria-hidden="true" class="size-5" />
<CheckIcon v-else-if="isAllSelected" aria-hidden="true" class="size-5" />
</span>
</span>
</div>
</div>
<div v-if="$slots.top" class="border-0 border-b border-solid border-b-surface-5">
<slot
name="top"
:model-value="modelValue"
:selected-options="selectedOptions"
:clear-all="clearAll"
:is-open="isOpen"
></slot>
</div>
<div
v-if="shouldShowSelectionActions && hasFilteredOptions"
ref="selectionActionsRef"
class="flex items-center justify-between gap-3 border-0 border-b border-solid border-b-surface-5 bg-surface-4 px-4 py-2.5 text-sm"
>
<span class="font-semibold text-secondary">{{ selectionActionsLabel }}</span>
<button
type="button"
class="border-0 bg-transparent p-0 text-sm font-semibold text-secondary shadow-none transition-all"
:class="
hasSelectedOptions
? 'hover:bg-transparent hover:text-contrast'
: 'cursor-not-allowed opacity-50'
"
:disabled="!hasSelectedOptions"
@click="clearAll"
@keydown.enter.stop
@keydown.space.stop
>
{{ selectionActionsClearLabel }}
</button>
</div>
</div>
<div
v-if="hasFilteredOptions"
ref="optionsScrollbarRef"
class="multi-select-options-scrollbar bg-surface-4"
data-overlayscrollbars-initialize
>
<div
ref="optionsContainerRef"
class="overflow-y-auto overscroll-contain select-none"
:style="{ maxHeight: `${maxHeight}px` }"
data-overlayscrollbars-viewport
>
<div
ref="listContainer"
:class="shouldVirtualizeOptions ? 'relative' : 'flex flex-col'"
:style="optionsListStyle"
>
<template
v-for="{ item, index } in renderedVisibleOptions"
:key="getItemKey(item, index)"
>
<div
class="group/option-container focus-visible:outline-none"
:class="shouldVirtualizeOptions ? 'absolute left-0 right-0' : undefined"
:style="getOptionWrapperStyle(index)"
>
<div
v-if="isSectionHeader(item)"
class="flex items-center justify-between gap-3 text-sm font-semibold text-secondary border-t border-surface-5 border-solid border-0 group-first/option-container:border-t-0"
:class="[
item.class,
shouldVirtualizeOptions ? 'h-10 px-4' : 'h-10 px-4 pb-1 pt-2',
]"
role="presentation"
>
<span class="min-w-0 truncate">{{ item.label }}</span>
<button
v-if="hasSelectableSectionHeaderOptions(item)"
type="button"
class="shrink-0 border-0 bg-transparent p-0 text-sm font-semibold text-secondary shadow-none transition-all hover:bg-transparent hover:text-contrast"
@click.stop="toggleSectionHeaderOptions(item)"
@keydown.enter.stop
@keydown.space.stop
>
{{ areSectionHeaderOptionsSelected(item) ? 'Clear' : 'Select all' }}
</button>
</div>
<span
v-else
role="option"
:aria-selected="item.selected"
:aria-disabled="item.disabled || undefined"
:data-option-index="index"
:data-focused="focusedIndex === index"
class="flex w-full cursor-pointer items-center gap-2.5 px-4 py-3 outline-none focus-visible:outline-none text-left text-contrast transition-all duration-150 bg-surface-4 hover:brightness-[115%] focus-visible:brightness-[115%]"
:class="[
item.class,
shouldVirtualizeOptions ? 'h-12' : undefined,
{
'brightness-[115%]': item.selected,
'pointer-events-none cursor-not-allowed opacity-50': item.disabled,
},
]"
tabindex="-1"
@click="toggleOption(item, $event)"
@mouseenter="!item.disabled && (focusedIndex = index)"
>
<span
v-if="checkboxPosition === 'left'"
class="checkbox-shadow flex h-5 w-5 shrink-0 items-center justify-center rounded-md border-[1px] border-solid"
:class="
item.selected
? 'border-button-border bg-brand text-brand-inverted'
: 'border-surface-5 bg-surface-2'
"
>
<CheckIcon v-if="item.selected" aria-hidden="true" stroke-width="3" />
</span>
<slot name="option" :item="item" :selected="item.selected" :index="index">
<slot
:name="`option-${item.value}`"
:item="item"
:selected="item.selected"
:index="index"
>
<div class="flex min-w-0 flex-1 items-center justify-between gap-3">
<div class="flex min-w-0 items-center gap-2">
<component
:is="item.icon"
v-if="item.icon"
class="h-5 w-5 shrink-0"
/>
<span
class="min-w-0 truncate font-semibold leading-tight"
:class="item.selected ? 'text-contrast' : 'text-primary'"
>
{{ item.label }}
</span>
</div>
<slot
name="option-right"
:item="item"
:selected="item.selected"
:index="index"
></slot>
</div>
</slot>
</slot>
<span
v-if="checkboxPosition === 'right'"
class="flex shrink-0 items-center justify-center text-brand"
>
<CheckIcon v-if="item.selected" aria-hidden="true" class="size-5" />
</span>
</span>
</div>
</template>
</div>
</div>
</div>
<template v-else>
<div
v-if="shouldShowSelectionActions"
class="flex items-center justify-between gap-3 border-0 border-b border-solid border-b-surface-5 px-4 py-2.5 text-sm"
>
<span class="font-semibold text-secondary">{{ selectionActionsLabel }}</span>
<button
type="button"
class="border-0 bg-transparent p-0 text-sm font-semibold text-secondary shadow-none transition-all"
:class="
hasSelectedOptions
? 'hover:bg-transparent hover:text-contrast'
: 'cursor-not-allowed opacity-50'
"
:disabled="!hasSelectedOptions"
@click="clearAll"
@keydown.enter.stop
@keydown.space.stop
>
{{ selectionActionsClearLabel }}
</button>
</div>
<div
v-if="isNoOptionsState && noOptionsMessage"
class="p-4 mb-2 text-center text-sm text-secondary"
>
{{ noOptionsMessage }}
</div>
<div v-else-if="searchQuery" class="p-4 mb-2 text-center text-sm text-secondary">
{{ noResultsMessage }}
</div>
</template>
<div v-if="$slots.bottom" @keydown.stop>
<slot name="bottom"></slot>
</div>
<slot name="dropdown-footer"></slot>
</div>
</Transition>
</Teleport>
</div>
</template>
<script setup lang="ts" generic="T">
import 'overlayscrollbars/overlayscrollbars.css'
import { CheckIcon, ChevronLeftIcon, MinusIcon, SearchIcon, XIcon } from '@modrinth/assets'
import { onClickOutside } from '@vueuse/core'
import { Menu } from 'floating-vue'
import { OverlayScrollbars, type PartialOptions } from 'overlayscrollbars'
import {
type Component,
computed,
nextTick,
onMounted,
onUnmounted,
ref,
shallowRef,
useSlots,
watch,
} from 'vue'
import { useVirtualScroll } from '../../composables/virtual-scroll'
import StyledInput from './StyledInput.vue'
export interface MultiSelectOption<T> {
value: T
label: string
icon?: Component
disabled?: boolean
class?: string
searchTerms?: string[]
}
export interface MultiSelectSectionHeader {
type: 'section-header'
label: string
key?: string
class?: string
}
export type MultiSelectItem<T> = MultiSelectOption<T> | MultiSelectSectionHeader
type RenderedMultiSelectOption<T> = MultiSelectOption<T> & {
selected: boolean
}
type RenderedMultiSelectItem<T> = RenderedMultiSelectOption<T> | MultiSelectSectionHeader
type VisibleMultiSelectItem<T> = {
item: RenderedMultiSelectItem<T>
index: number
}
type OverlayScrollbarsInstance = NonNullable<ReturnType<typeof OverlayScrollbars>>
type ViewportRect = {
width: number
height: number
offsetTop: number
offsetLeft: number
}
const DROPDOWN_VIEWPORT_MARGIN = 8
const DROPDOWN_GAP = 8
const DEFAULT_MAX_HEIGHT = 300
const MULTI_SELECT_OPTION_ROW_HEIGHT = 48
const MULTI_SELECT_VIRTUALIZATION_THRESHOLD = 80
const MOBILE_SEARCH_AUTO_FOCUS_QUERY = '(pointer: coarse), (max-width: 800px)'
const OPTIONS_OVERLAY_SCROLLBARS_OPTIONS = Object.freeze<PartialOptions>({
overflow: {
x: 'hidden',
y: 'scroll',
},
scrollbars: {
theme: 'os-theme-modrinth',
autoHide: 'leave',
autoHideSuspend: true,
},
})
function isSectionHeader<T>(item: MultiSelectItem<T>): item is MultiSelectSectionHeader {
return 'type' in item && item.type === 'section-header'
}
function isOption<T>(item: MultiSelectItem<T>): item is MultiSelectOption<T> {
return !isSectionHeader(item)
}
const props = withDefaults(
defineProps<{
modelValue: T[]
options: MultiSelectItem<T>[]
placeholder?: string
disabled?: boolean
searchable?: boolean
searchPlaceholder?: string
showChevron?: boolean
clearable?: boolean
maxHeight?: number
triggerClass?: string
fitContent?: boolean
/** Width for the teleported dropdown; defaults to the trigger width */
dropdownWidth?: string | number
/** Minimum width for the teleported dropdown */
dropdownMinWidth?: string | number
forceDirection?: 'up' | 'down'
noOptionsMessage?: string
noResultsMessage?: string
disableSearchFilter?: boolean
includeSelectAllOption?: boolean
selectAllLabel?: string
showSelectionActions?: boolean
selectionActionsClearLabel?: string
maxTagRows?: number
checkboxPosition?: 'left' | 'right'
}>(),
{
placeholder: 'Select options',
disabled: false,
searchable: false,
searchPlaceholder: 'Search...',
showChevron: true,
clearable: true,
maxHeight: DEFAULT_MAX_HEIGHT,
fitContent: false,
noOptionsMessage: 'No options available',
noResultsMessage: 'No results found',
includeSelectAllOption: false,
selectAllLabel: 'Select all',
showSelectionActions: false,
selectionActionsClearLabel: 'Clear',
maxTagRows: 1,
checkboxPosition: 'left',
},
)
const emit = defineEmits<{
'update:modelValue': [value: T[]]
open: []
close: []
searchInput: [query: string]
}>()
const slots = useSlots()
const isOpen = ref(false)
const searchQuery = ref('')
const focusedIndex = ref(-1)
const containerRef = ref<HTMLElement>()
const triggerRef = ref<HTMLElement>()
const dropdownRef = ref<HTMLElement>()
const optionsScrollbarRef = ref<HTMLElement>()
const optionsContainerRef = ref<HTMLElement>()
const selectionActionsRef = ref<HTMLElement>()
const searchInputRef = ref<InstanceType<typeof StyledInput>>()
const rafId = ref<number | null>(null)
const tagsContainerRef = ref<HTMLElement>()
const optionsOverlayScrollbars = ref<OverlayScrollbarsInstance | null>(null)
const lastSelectionActionsHeight = ref(0)
const dropdownStyle = ref({
top: '0px',
left: '0px',
width: '0px',
minWidth: '0px',
})
const openDirection = ref<'down' | 'up'>('down')
const hasCustomInputContent = computed(() => Boolean(slots['input-content']))
const selectableOptions = computed(() => props.options.filter(isOption))
const enabledSelectableOptions = computed(() =>
selectableOptions.value.filter((opt) => !opt.disabled),
)
const selectedValueSet = computed(() => new Set(props.modelValue))
const selectedOptions = computed(() => {
const selectedValues = selectedValueSet.value
return selectableOptions.value.filter((opt) => selectedValues.has(opt.value))
})
const isAllSelected = computed(() => {
const selectableOptions = enabledSelectableOptions.value
const selectedValues = selectedValueSet.value
return (
selectableOptions.length > 0 && selectableOptions.every((opt) => selectedValues.has(opt.value))
)
})
const isIndeterminate = computed(() => {
const selectedValues = selectedValueSet.value
return (
!isAllSelected.value &&
selectableOptions.value.some((opt) => !opt.disabled && selectedValues.has(opt.value))
)
})
const visibleTagCount = ref(Infinity)
const visibleTags = computed(() => {
return selectedOptions.value.slice(0, visibleTagCount.value)
})
const overflowCount = computed(() => {
return Math.max(0, selectedOptions.value.length - visibleTagCount.value)
})
const overflowTags = computed(() => {
return selectedOptions.value.slice(visibleTagCount.value)
})
const popperOverflowTags = shallowRef<MultiSelectOption<T>[]>([])
const lastClickedValue = shallowRef<{ value: T } | null>(null)
const filteredOptions = computed(() => {
if (!searchQuery.value || !props.searchable || props.disableSearchFilter) {
return props.options
}
const query = searchQuery.value.toLowerCase()
const items: MultiSelectItem<T>[] = []
let pendingSectionHeader: MultiSelectSectionHeader | null = null
for (const opt of props.options) {
if (isSectionHeader(opt)) {
pendingSectionHeader = opt
continue
}
const matches =
opt.label.toLowerCase().includes(query) ||
opt.searchTerms?.some((term) => term.toLowerCase().includes(query))
if (!matches) {
continue
}
if (pendingSectionHeader) {
items.push(pendingSectionHeader)
pendingSectionHeader = null
}
items.push(opt)
}
return items
})
const renderedFilteredOptions = computed<RenderedMultiSelectItem<T>[]>(() => {
const selectedValues = selectedValueSet.value
return filteredOptions.value.map((item) => {
if (isSectionHeader(item)) {
return item
}
return {
...item,
selected: selectedValues.has(item.value),
}
})
})
const shouldVirtualizeOptions = computed(
() => renderedFilteredOptions.value.length > MULTI_SELECT_VIRTUALIZATION_THRESHOLD,
)
const { listContainer, totalHeight, visibleRange, visibleItems } = useVirtualScroll(
renderedFilteredOptions,
{
itemHeight: MULTI_SELECT_OPTION_ROW_HEIGHT,
bufferSize: 8,
initialItemCount: 12,
enabled: shouldVirtualizeOptions,
},
)
const renderedVisibleOptions = computed<VisibleMultiSelectItem<T>[]>(() =>
visibleItems.value.map((item, offset) => ({
item,
index: visibleRange.value.start + offset,
})),
)
const optionsListStyle = computed(() =>
shouldVirtualizeOptions.value ? { height: `${totalHeight.value}px` } : undefined,
)
const hasFilteredOptions = computed(() => filteredOptions.value.some(isOption))
const isNoOptionsState = computed(() => selectableOptions.value.length === 0 && !searchQuery.value)
const shouldShowSelectAll = computed(
() => props.includeSelectAllOption && enabledSelectableOptions.value.length > 1,
)
const selectedOptionCount = computed(() => selectedOptions.value.length)
const hasSelectedOptions = computed(() => selectedOptionCount.value > 0)
const shouldShowSelectionActions = computed(() => props.showSelectionActions)
const selectionActionsLabel = computed(() => {
return selectedOptionCount.value === 1 ? '1 selected' : `${selectedOptionCount.value} selected`
})
function isSelected(value: T) {
return selectedValueSet.value.has(value)
}
function getItemKey(item: MultiSelectItem<T>, index: number) {
if (isSectionHeader(item)) {
return item.key ?? `section-header-${item.label}-${index}`
}
return `option-${String(item.value)}`
}
function getSectionHeaderOptions(sectionHeader: MultiSelectSectionHeader) {
const sectionHeaderIndex = props.options.findIndex((item) => item === sectionHeader)
if (sectionHeaderIndex === -1) {
return []
}
const sectionHeaderOptions: MultiSelectOption<T>[] = []
for (let i = sectionHeaderIndex + 1; i < props.options.length; i++) {
const item = props.options[i]
if (!item || isSectionHeader(item)) {
break
}
if (!item.disabled) {
sectionHeaderOptions.push(item)
}
}
return sectionHeaderOptions
}
function hasSelectableSectionHeaderOptions(sectionHeader: MultiSelectSectionHeader) {
return getSectionHeaderOptions(sectionHeader).length > 1
}
function areSectionHeaderOptionsSelected(sectionHeader: MultiSelectSectionHeader) {
const sectionHeaderOptions = getSectionHeaderOptions(sectionHeader)
return (
sectionHeaderOptions.length > 0 &&
sectionHeaderOptions.every((option) => isSelected(option.value))
)
}
function toggleSectionHeaderOptions(sectionHeader: MultiSelectSectionHeader) {
const sectionHeaderOptions = getSectionHeaderOptions(sectionHeader)
if (sectionHeaderOptions.length === 0) {
return
}
let newValue: T[]
if (sectionHeaderOptions.every((option) => isSelected(option.value))) {
const sectionHeaderValues = new Set(sectionHeaderOptions.map((option) => option.value))
newValue = props.modelValue.filter((value) => !sectionHeaderValues.has(value))
} else {
newValue = [...props.modelValue]
for (const option of sectionHeaderOptions) {
if (!newValue.includes(option.value)) {
newValue.push(option.value)
}
}
}
emit('update:modelValue', newValue)
const lastSectionHeaderOption = sectionHeaderOptions[sectionHeaderOptions.length - 1]
if (lastSectionHeaderOption) {
lastClickedValue.value = { value: lastSectionHeaderOption.value }
}
}
function toggleOption(option: MultiSelectOption<T>, event?: MouseEvent | KeyboardEvent) {
if (option.disabled) return
if (event?.shiftKey && lastClickedValue.value) {
const anchorValue = lastClickedValue.value.value
const anchorIndex = filteredOptions.value.findIndex(
(opt) => isOption(opt) && opt.value === anchorValue,
)
const currentIndex = filteredOptions.value.findIndex(
(opt) => isOption(opt) && opt.value === option.value,
)
if (anchorIndex !== -1 && currentIndex !== -1 && anchorIndex !== currentIndex) {
const start = Math.min(anchorIndex, currentIndex)
const end = Math.max(anchorIndex, currentIndex)
const shouldSelect = !isSelected(option.value)
const newValue = [...props.modelValue]
for (let i = start; i <= end; i++) {
const opt = filteredOptions.value[i]
if (!opt || isSectionHeader(opt) || opt.disabled) continue
const idx = newValue.indexOf(opt.value)
if (shouldSelect && idx === -1) {
newValue.push(opt.value)
} else if (!shouldSelect && idx !== -1) {
newValue.splice(idx, 1)
}
}
emit('update:modelValue', newValue)
lastClickedValue.value = { value: option.value }
return
}
}
const newValue = isSelected(option.value)
? props.modelValue.filter((v) => v !== option.value)
: [...props.modelValue, option.value]
emit('update:modelValue', newValue)
lastClickedValue.value = { value: option.value }
}
function removeTag(value: T) {
emit(
'update:modelValue',
props.modelValue.filter((v) => v !== value),
)
}
function clearAll() {
emit('update:modelValue', [])
}
function toggleDropdown() {
if (isOpen.value) {
closeDropdown()
} else {
openDropdown()
}
}
function toggleSelectAll() {
if (isAllSelected.value) {
emit('update:modelValue', [])
} else {
const allValues = enabledSelectableOptions.value.map((opt) => opt.value)
emit('update:modelValue', allValues)
}
}
async function calculateVisibleTags() {
visibleTagCount.value = Infinity
await nextTick()
if (!tagsContainerRef.value || selectedOptions.value.length === 0) return
const container = tagsContainerRef.value
const maxH = container.offsetHeight
if (container.scrollHeight <= maxH) return
let count = selectedOptions.value.length
while (count > 0) {
count--
visibleTagCount.value = count
await nextTick()
if (container.scrollHeight <= container.offsetHeight) return
}
}
function determineOpenDirection(
triggerRect: DOMRect,
dropdownRect: DOMRect,
viewport: ViewportRect,
): 'up' | 'down' {
if (props.forceDirection) return props.forceDirection
const triggerTop = triggerRect.top + viewport.offsetTop
const triggerBottom = triggerRect.bottom + viewport.offsetTop
const viewportTop = viewport.offsetTop
const viewportBottom = viewport.offsetTop + viewport.height
const hasSpaceBelow =
triggerBottom + dropdownRect.height + DROPDOWN_GAP + DROPDOWN_VIEWPORT_MARGIN <= viewportBottom
const hasSpaceAbove =
triggerTop - dropdownRect.height - DROPDOWN_GAP - DROPDOWN_VIEWPORT_MARGIN > viewportTop
return !hasSpaceBelow && hasSpaceAbove ? 'up' : 'down'
}
function calculateVerticalPosition(
triggerRect: DOMRect,
dropdownRect: DOMRect,
direction: 'up' | 'down',
viewport: ViewportRect,
): number {
const top =
direction === 'up'
? triggerRect.top - dropdownRect.height - DROPDOWN_GAP
: triggerRect.bottom + DROPDOWN_GAP
return top + viewport.offsetTop
}
function calculateHorizontalPosition(
triggerRect: DOMRect,
dropdownRect: DOMRect,
viewport: ViewportRect,
): number {
const minLeft = viewport.offsetLeft + DROPDOWN_VIEWPORT_MARGIN
const maxRight = viewport.offsetLeft + viewport.width - DROPDOWN_VIEWPORT_MARGIN
let left = triggerRect.left + viewport.offsetLeft
if (left + dropdownRect.width > maxRight) {
left = Math.max(minLeft, maxRight - dropdownRect.width)
}
return left
}
function getViewportRect(): ViewportRect {
const visualViewport = window.visualViewport
return {
width: visualViewport?.width ?? window.innerWidth,
height: visualViewport?.height ?? window.innerHeight,
offsetTop: visualViewport?.offsetTop ?? 0,
offsetLeft: visualViewport?.offsetLeft ?? 0,
}
}
function resolveDropdownWidth(triggerWidth: number): string {
if (props.dropdownWidth === undefined) return `${triggerWidth}px`
if (typeof props.dropdownWidth === 'number') return `${props.dropdownWidth}px`
return props.dropdownWidth
}
function resolveCssSize(size: string | number | undefined): string | undefined {
if (size === undefined) return undefined
if (typeof size === 'number') return `${size}px`
return size
}
async function updateDropdownPosition() {
if (!triggerRef.value || !dropdownRef.value) return
await nextTick()
const triggerRect = triggerRef.value.getBoundingClientRect()
const width = resolveDropdownWidth(triggerRect.width)
const minWidth = resolveCssSize(props.dropdownMinWidth) ?? '0px'
dropdownStyle.value = {
...dropdownStyle.value,
width,
minWidth,
}
await nextTick()
const dropdownRect = dropdownRef.value.getBoundingClientRect()
const viewport = getViewportRect()
const direction = determineOpenDirection(triggerRect, dropdownRect, viewport)
const top = calculateVerticalPosition(triggerRect, dropdownRect, direction, viewport)
const left = calculateHorizontalPosition(triggerRect, dropdownRect, viewport)
dropdownStyle.value = {
top: `${top}px`,
left: `${left}px`,
width,
minWidth,
}
openDirection.value = direction
}
async function initializeOptionsOverlayScrollbars() {
await nextTick()
if (!isOpen.value || !hasFilteredOptions.value) {
destroyOptionsOverlayScrollbars()
return
}
if (!optionsScrollbarRef.value || !optionsContainerRef.value || !listContainer.value) {
return
}
if (optionsOverlayScrollbars.value) {
optionsOverlayScrollbars.value.update(true)
return
}
optionsOverlayScrollbars.value = OverlayScrollbars(
{
target: optionsScrollbarRef.value,
elements: {
viewport: optionsContainerRef.value,
content: listContainer.value,
},
},
OPTIONS_OVERLAY_SCROLLBARS_OPTIONS,
)
}
function getSelectionActionsHeight() {
return shouldShowSelectionActions.value ? (selectionActionsRef.value?.offsetHeight ?? 0) : 0
}
async function syncSelectionActionsHeight() {
await nextTick()
lastSelectionActionsHeight.value = getSelectionActionsHeight()
}
function updateOptionsOverlayScrollbars() {
nextTick(() => {
optionsOverlayScrollbars.value?.update(true)
})
}
function destroyOptionsOverlayScrollbars() {
optionsOverlayScrollbars.value?.destroy()
optionsOverlayScrollbars.value = null
}
function shouldAutoFocusSearch() {
return (
props.searchable &&
typeof window !== 'undefined' &&
!window.matchMedia(MOBILE_SEARCH_AUTO_FOCUS_QUERY).matches
)
}
async function openDropdown() {
if (props.disabled || isOpen.value) return
isOpen.value = true
emit('open')
await nextTick()
await updateDropdownPosition()
await initializeOptionsOverlayScrollbars()
await syncSelectionActionsHeight()
if (shouldAutoFocusSearch() && searchInputRef.value) {
;(searchInputRef.value as unknown as { focus: () => void }).focus()
}
focusedIndex.value = shouldShowSelectAll.value ? -2 : getFirstFocusableOptionIndex()
startPositionTracking()
}
function closeDropdown() {
if (!isOpen.value) return
stopPositionTracking()
destroyOptionsOverlayScrollbars()
isOpen.value = false
searchQuery.value = ''
focusedIndex.value = -1
emit('close')
nextTick(() => {
triggerRef.value?.focus()
})
}
function handleTriggerClick(event: MouseEvent) {
if (event.detail === 0) return
if (isOpen.value) {
closeDropdown()
} else {
openDropdown()
}
}
function handleTriggerKeydown(event: KeyboardEvent) {
if (isOpen.value) {
handleDropdownKeydown(event)
return
}
switch (event.key) {
case 'Enter':
case ' ':
case 'ArrowDown':
case 'ArrowUp':
event.preventDefault()
openDropdown()
break
}
}
function isFocusableOptionIndex(index: number) {
const option = filteredOptions.value[index]
return option !== undefined && isOption(option) && !option.disabled
}
function getFirstFocusableOptionIndex() {
return filteredOptions.value.findIndex((_, index) => isFocusableOptionIndex(index))
}
function findNextFocusableOption(currentIndex: number, direction: 'next' | 'previous') {
const length = filteredOptions.value.length
if (length === 0) return -1
let index = currentIndex
for (let i = 0; i < length; i++) {
index = direction === 'next' ? (index + 1) % length : (index - 1 + length) % length
if (isFocusableOptionIndex(index)) {
return index
}
}
return -1
}
function focusNextOption() {
const length = filteredOptions.value.length
if (length === 0) return
const nextIndex = findNextFocusableOption(
focusedIndex.value === -2 ? -1 : focusedIndex.value,
'next',
)
if (nextIndex === -1) return
focusedIndex.value = nextIndex
focusOptionIndex(focusedIndex.value)
}
function focusPreviousOption() {
const length = filteredOptions.value.length
if (length === 0) return
if (focusedIndex.value === getFirstFocusableOptionIndex() && shouldShowSelectAll.value) {
focusedIndex.value = -2
focusOptionIndex(focusedIndex.value)
return
}
const previousIndex = findNextFocusableOption(
focusedIndex.value === -1 ? 0 : focusedIndex.value,
'previous',
)
if (previousIndex === -1) return
focusedIndex.value = previousIndex
focusOptionIndex(focusedIndex.value)
}
function scrollOptionIndexIntoView(index: number) {
if (index < 0) {
return
}
const container = optionsContainerRef.value
if (!container) {
return
}
const optionElement = container.querySelector<HTMLElement>(`[data-option-index="${index}"]`)
if (optionElement) {
const containerRect = container.getBoundingClientRect()
const optionRect = optionElement.getBoundingClientRect()
if (optionRect.top < containerRect.top) {
container.scrollTop -= containerRect.top - optionRect.top
} else if (optionRect.bottom > containerRect.bottom) {
container.scrollTop += optionRect.bottom - containerRect.bottom
}
return
}
const optionTop = index * MULTI_SELECT_OPTION_ROW_HEIGHT
const optionBottom = optionTop + MULTI_SELECT_OPTION_ROW_HEIGHT
if (optionTop < container.scrollTop) {
container.scrollTop = optionTop
} else if (optionBottom > container.scrollTop + container.clientHeight) {
container.scrollTop = optionBottom - container.clientHeight
}
}
function focusOptionIndex(index: number) {
scrollOptionIndexIntoView(index)
nextTick(() => {
dropdownRef.value?.querySelector<HTMLElement>(`[data-option-index="${index}"]`)?.focus()
})
}
function getOptionWrapperStyle(index: number) {
if (!shouldVirtualizeOptions.value) {
return undefined
}
return {
transform: `translateY(${index * MULTI_SELECT_OPTION_ROW_HEIGHT}px)`,
}
}
function handleDropdownKeydown(event: KeyboardEvent) {
switch (event.key) {
case 'Escape':
event.preventDefault()
closeDropdown()
break
case 'ArrowDown':
event.preventDefault()
focusNextOption()
break
case 'ArrowUp':
event.preventDefault()
focusPreviousOption()
break
case 'Enter':
case ' ':
event.preventDefault()
if (focusedIndex.value === -2) {
toggleSelectAll()
} else if (focusedIndex.value >= 0) {
const option = filteredOptions.value[focusedIndex.value]
if (option && isOption(option)) toggleOption(option, event)
}
break
case 'Tab':
event.preventDefault()
if (event.shiftKey) {
focusPreviousOption()
} else {
focusNextOption()
}
break
}
}
function handleSearchKeydown(event: KeyboardEvent) {
event.stopPropagation()
if (event.key === 'Escape') {
event.preventDefault()
closeDropdown()
} else if (event.key === 'ArrowDown') {
event.preventDefault()
focusOptionFromSearch('next')
} else if (event.key === 'ArrowUp') {
event.preventDefault()
focusOptionFromSearch('previous')
} else if (event.key === 'Enter' || event.key === ' ') {
if (event.key === 'Enter') {
event.preventDefault()
if (focusedIndex.value === -2) {
toggleSelectAll()
} else if (focusedIndex.value >= 0) {
const option = filteredOptions.value[focusedIndex.value]
if (option && isOption(option)) toggleOption(option, event)
}
}
} else if (event.key === 'Tab' && isOpen.value) {
event.preventDefault()
if (event.shiftKey) {
focusPreviousOption()
} else {
focusNextOption()
}
}
}
function handleSearchActionsKeydown(event: KeyboardEvent) {
if (event.key !== 'Escape') {
event.stopPropagation()
}
}
function focusOptionFromSearch(direction: 'next' | 'previous') {
const activeElement = document.activeElement
if (activeElement instanceof HTMLElement) {
activeElement.blur()
}
if (direction === 'previous') {
focusPreviousOption()
return
}
const nextIndex =
focusedIndex.value === -1
? shouldShowSelectAll.value
? -2
: getFirstFocusableOptionIndex()
: focusedIndex.value
if (nextIndex === -1) {
return
}
focusedIndex.value = nextIndex
focusOptionIndex(nextIndex)
}
function handleSearchInput() {
emit('searchInput', searchQuery.value)
if (!isOpen.value) {
openDropdown()
}
if (optionsContainerRef.value) {
optionsContainerRef.value.scrollTop = 0
}
updateOptionsOverlayScrollbars()
focusedIndex.value = shouldShowSelectAll.value ? -2 : getFirstFocusableOptionIndex()
}
function handleWindowResize() {
if (isOpen.value) {
scheduleDropdownPositionUpdate()
}
}
function scheduleDropdownPositionUpdate() {
if (rafId.value !== null) return
rafId.value = requestAnimationFrame(() => {
rafId.value = null
updateDropdownPosition()
})
}
function handleViewportChange() {
if (isOpen.value) {
scheduleDropdownPositionUpdate()
}
}
function startPositionTracking() {
window.addEventListener('scroll', handleViewportChange, true)
window.visualViewport?.addEventListener('scroll', handleViewportChange)
window.visualViewport?.addEventListener('resize', handleViewportChange)
}
function stopPositionTracking() {
window.removeEventListener('scroll', handleViewportChange, true)
window.visualViewport?.removeEventListener('scroll', handleViewportChange)
window.visualViewport?.removeEventListener('resize', handleViewportChange)
if (rafId.value !== null) {
cancelAnimationFrame(rafId.value)
rafId.value = null
}
}
onClickOutside(
dropdownRef,
() => {
closeDropdown()
},
{ ignore: [triggerRef, containerRef, '.v-popper__popper'] },
)
onMounted(() => {
window.addEventListener('resize', handleWindowResize)
calculateVisibleTags()
})
onUnmounted(() => {
window.removeEventListener('resize', handleWindowResize)
stopPositionTracking()
destroyOptionsOverlayScrollbars()
})
watch(isOpen, (value) => {
if (value) {
updateDropdownPosition()
}
})
watch(filteredOptions, () => {
if (isOpen.value) {
updateDropdownPosition()
if (hasFilteredOptions.value) {
initializeOptionsOverlayScrollbars()
syncSelectionActionsHeight()
} else {
destroyOptionsOverlayScrollbars()
lastSelectionActionsHeight.value = 0
}
}
})
watch(shouldShowSelectionActions, async () => {
const container = optionsContainerRef.value
const previousHeight = lastSelectionActionsHeight.value
const previousScrollTop = container?.scrollTop ?? 0
await nextTick()
const nextHeight = hasFilteredOptions.value ? getSelectionActionsHeight() : 0
lastSelectionActionsHeight.value = nextHeight
if (!isOpen.value || !hasFilteredOptions.value || !container || previousHeight === nextHeight) {
if (isOpen.value) {
updateDropdownPosition()
}
updateOptionsOverlayScrollbars()
return
}
container.scrollTop = Math.max(0, previousScrollTop + nextHeight - previousHeight)
updateDropdownPosition()
updateOptionsOverlayScrollbars()
})
watch(
() => props.modelValue,
() => {
calculateVisibleTags()
if (isOpen.value) {
updateDropdownPosition()
updateOptionsOverlayScrollbars()
}
},
{ deep: true },
)
watch(
() => props.maxHeight,
() => {
if (isOpen.value) {
updateOptionsOverlayScrollbars()
}
},
)
</script>
<style scoped>
.checkbox-shadow {
box-shadow: 1px 1px 2px 0 rgba(0, 0, 0, 0.08);
}
.multi-select-options-scrollbar :deep(.os-theme-modrinth) {
--os-size: 8px;
--os-padding-perpendicular: 0px;
--os-padding-axis: 0px;
--os-track-bg: transparent;
--os-track-bg-hover: transparent;
--os-track-bg-active: transparent;
--os-handle-border-radius: 9999px;
--os-handle-bg: var(--color-scrollbar, var(--surface-5));
--os-handle-bg-hover: var(--color-scrollbar, var(--surface-5));
--os-handle-bg-active: var(--color-scrollbar, var(--surface-5));
}
</style>