Files
AstralRinth/packages/ui/src/components/base/MultiSelect.vue
T
Truman Gao 11b2b6e6c0 feat: improve analytics dashboard (#5897)
* feat: implement cancel/apply for custom timeframe range picker

* feat: implement dot for showing todays date

* feat: add max date to be today and show todays date

* feat: if ratio mode, dont show total

* feat: implement show more batching excess lines into "Other" bucket

* refactor: pnpm prepr

* feat: add pick and plop for date range start/end dates

* feat: implement reset query button

* feat: clear button to clear breakdown

* feat: more aggressively trim allowed minimum group by option

* fix: dont show project status filter when from project settings/analytics

* fix: clear selected X above number when appropriate

* feat: graph style updates and dont show year in x axis unless more than 2 year timeframe

* fix: loading state to include legend in blur

* feat: add project icon to project select

* feat: filter out draft projects from analytics

* feat: implement multiselect sections headers, project select org sections, and project options icons

* feat: implement click and drag to select date range

* feat: implement windows history for query builder

* revert: no longer switch breakdown/filter option if same category

* feat: implement showing project for project version breakdown/filter when there are multiple projects

* feat: implement modrinth sided events

* fix: border radius

* feat: implement analytics range highlight

* fix: loading state showing empty state text

* refactor: pnpm prepr

* feat: improve dropdown filter bar and multiselect performance

* fix: multiselect keyboard use

* fix: graph overflow issues

* fix: loading state text on table

* feat: implement tooltip scroll

* fix: adjust charts event tooltip

* feat: shorten time to not repeat am/pm

* feat: implement query params for graph component settings

* fix: qa

* feat: add reset timeframe button

* fix: legend colors moving between metric by determining color based on only downloads metric index

* feat: implement auto switching temporarily to group by day for renvenue metric and disable revenue metric for time range < 2 days

* fix: change to > 1 day

* fix: custom timeframe picker

* feat: implement big performance improvement for table

* feat: implement hover on legend to highlight graph

* fix: defer commit in query builder/filter and style fixes

* feat: more performance optimization to analytics dashboard state, chart, and table

* feat: add tooltip for other item

* feat: improve custom time frame range select

* feat: implement analytics events admin page

* fix: switch column order

* pnpm prepr

* feat: implement mock analytics events

* feat: improve analytics events admin page

* feat: focus title input on analytics create event modal

* fix: remove labels annoying

* feat: hook up analytics events backend

* fix: type error

* feat: reduce combobox padding

* feat: reduce padding on multiselect

* feat: add overlay scrollbar for combobox

* feat: a bunch of style fixes to combobox, multiselect and dropdown filter bar

* feat: MORE PADDING fixes

* feat: use user_agent for download source

* Revert "feat: use user_agent for download source"

This reverts commit d6dc8a99f11f94660872427796cdcf6fc93bb21d.

* fix: query filter project version lag and borked virtualization

* feat: rename breakdown "none" to "project"

* feat: implement right side checkmark for multiselect

* feat: keep crossed out legend items still shown in tooltip but also crossed out

* fix: focus styles

* fix: focus styles pt2

* feat: implement filter by top 8

* fix: preview is incorrect when selecting same date in range date picker

* feat (playtest): cross out legend items in tooltip and allow hide/show in tooltip

* feat (playtest): table component controls what graph shows

* feat: change download source to use user_agent

* feat: fix click to cross out in legend

* feat: add hover legend item to highlight line in tooltip

* fix: export csv to always be dropdown

* feat: implement breakdown = none

* performance: frontend memory reduction

* performance: reduce memory usage from project versions query by keeping only whats necessary

* fix: table checked items not in graph if 0

* feat: add shift click to select a range in table

* performance: add caching for metric types so switching between them is snappy

* performance: batch analytics requests by 15 project ids, with 150 ms delay between, so backend is happy

* feat: add analytics table search

* refactor: pnpm prepr

* fix: query filter options not coming in from analytics fetch

* feat: remove breakdown = none when there are multiple projects

* feat: improve table sorting

* feat: sort projects in project dropdown

* fix: getting project name for project versions

* fix: add loading state for filter and parallel fetch

* performance: use precomputed map for project version options to remove first hover lag

* feat: dropdown filter always open on one side and improve styles

* fix: custom time range picker being weird

* refactor: pnpm prepr

* fix: add back in batch with 300ms interval for projects to prevent backend rate limiting

* performance: only do queries to populate graph first before other analytics queries

* fix: QA polish issues around style and copy

* feat: dont show select all when its just one item in section

* fix: bugs with ratio mode and hiding chart lines

* fix: adjust padding in combobox and multiselect and fix not unfocusing when deselect

* fix: small styles

* fix: polish admin analytic events

* fix: keep scroll position with selection action row appearing when selecting one

* feat: add subheading in graph for showing N items from table

* feat: add unmonetized explaination tooltip

* performance: implement limit on how many lines can be shown in graph

* feat: mobile pass

* refactor: pnpm prepr

* add clear button

* feat: add time in analytics event and normalize date/time so its correct to timezones

* fix: padding

* feat: implement show prev period toggle

* feat: extract TimeFramePicker to packages/ui

* fix: adjust style

* feat: keep table selected persisted in query parameter

* fix: style on prev item value in legend

* fix: when breakdown switches, reset selected series

* fix: tooltip styles

* feat: change project selection to reset to show top 8 only if reconciled down to 0 items

* feat: implement show top 8 button in graph subheading

* fix: rename download type to download reason

* fix: formatting label for table

* feat: persist table sort by and sort direction

* fix: show top 8 button in graph not defaulting to top 8 for other metrics

* feat: implement prev period analytics fetch into the same current period fetch by shifting start date

* refactor: pnpm prepr

* fix: remove number if its just top 1

* fix: brief select items empty state when switch breakdown

* feat: implement format table playtime column

* feat: update export csv filename

* feat: change playtime column to display in hours

* refactor: pnpm prepr

* fix: still download type in filter

* feat: update analytics tooltip

* fix: wrong all projects icon

* feat: force legend order and graph colour for monetization

* refactor: pnpm prepr

* fix: multiselect and combobox sizes

* fix: chart icon add hover delay

* feat: (to playtest) implement multiple breakdowns

* fix: couple UX things for multiple breakdown

* fix: cannot unpin on page click

* fix: multiple breakdown legend and tooltip labels

* feat: add right side checkmark for dropdown filtr bar

* feat: enabling prev period will cross out prev for current ones already crossed out

* feat-mobile: remove drag to select time frame in graph

* feat-mobile: dropdown filter to replace dropdown for submenus on small screen

* feat-mobile: time frame picker to use different start and end date pickers for mobile

* fix-mobile: fix multiselect scroll on mobile

* feat: consolidate is mobile ref into context

* fix-mobile: combobox and multiselect scroll bug when mobile search bar open, fix timeframe picker mobile pick date, and dropdown filter bar click outside to close

* fix-mobile: smaller metric card font

* fix: dropdown filter bar scroll while search

* feat: implement project side events

* feat: implement better mobile view design for query builder

* feat: handle events overflow

* small: add select none

* feat: remove clear project and breakdown

* fix: event icon hover color

* feat: default hide project events if there are multiple projects, and default show if only 1 project

* feat: implement analytics performance updates, including facets, and v3 user projects

* feat: grey out dimmed lined on legend item hover

* feat-mobile: style fixes

* add close on select prop

* feat: add close on select for time frame picker mobile

* feat: date picker default read only

* refactor: pnpm prepr

* feat: default to projects breakdown instead of no breakdown with multiple projects

* fix-mobile: improve graph touch interactions

* small: 2 sig figs on playtime

* feat: deduplicate version uploads that have same version number and are uploaded on same day

* fix: analytics events grouping causing overflow

* feat: improve performance on analytics events grouping

* fix: tooltip expanding page width briefly

* fix: prevent double tap to zoom on inputs

* feat: add click to show chart event for mobile

* fix: toggle not having touch manipulation

* fix: chart tooltip scroll in mobile

* fix: remove project breakdownoption as it is default breakdown when none are selected

* fix: dropdown filter bar briefly empty when switching pages in mobile

* feat: keep tooltip open after drag in mobile

* fix: using plural instead of single for project breakdown

* fix: date picker scrolling page after picking date in mobile by suppressing focus

* fix: callback to Organization instead of org id

* feat: improve chart tooltip date range label formatter to be much more consistent

* feat: tap to toggle event tooltip

* fix: add user select none on graph and fix zoom into download threshold input

* fix: frontend still filtering after backend already filters

* feat: fix emptys state height content shift

* fix: qa issues

* fix: a number of qa issues

- Hide project events based on visible project legend/table selection
- Filter project status events by end status and add explicit copy for approved, private, and unlisted
- Style Modrinth analytics events with blue icon, marker, guide, and range borders
- Add scroll fade shadows to analytics chart and event tooltips
- Show previous-period date range in the chart tooltip
- Make project breakdown conditional on multiple selected projects and allow no breakdown when none are selected
- Add breakdown selection actions and fix “Group by day” copy

* feat: implement graph controls dropdown

* fix date picker typing into time input

* fix: styles in events table

* small: style

* feat: implement using new backend facets route

* feat: implement user get all projects

* performance: deter non-critical fetches to after analytics is in

* fix: refreshing causes multiple projects to do breakdown=none

* performance: cache project version options to fix lag on open sub menu

* refactor: remove chart event height being controlled by parent

* feat: update controls dropdown to have fainter border

* fix: loading bar not fading away

* fix: cannot click in graph

* feat: dont conditionally show multiselect selection actions

* fix: z-index and padding issues

* fix: project events incorrectly toggling on for first page load

* feat: remove show more and show less in legend, always show all

* fix: playtime y axis labels

* feat: improve y axis formatting for playtime and others

* feat: use tabs for game version select, and remove prev period when change breakdown or project selection

* refactor: pnpm prepr

* feat: change hidden legend items to not contribute to ratio percentages

* feat: event icon consume scroll for tooltip panel

* feat: remove gap inside chart tooltip

* feat: add gap for date picker 2 calendar view

* feat: improve analytics events grouping logic for modrinth events to be close to target

* pnpm prepr

* fix: cant click in gap in toggle

* fix: bugs around selected series from table not persisting with timeframe or filter changes

* refactor: kabab case

* refactor: split up large analytics chart and table component files into smaller components and ts modules

* fix: legend is stale after resetting query

* refactor: split up giant analytics provider with utils

* i18n pass

* revert: format number composable change

* fix: playtime was choosing y axis ticks in seconds instead of hours

* refactor: rename folder that with components to match main component name

* refactor: same rename for analytics table for consistency

* refactor: name main components to index.vue and keep folder name as component name

* refactor: pnpm prepr

* refactor: rename types

* refactor: move query builder types into types file and move components into components/analytics-dashboard

* refactor: colocate query builder url with analytics-dashboard component

* refactor: pnpm prepr:frontend

* fix: download threshold not width fit

* fix: no option to see release/all game versions in selected filter dropdown

* fix: game version dropdown width

---------

Signed-off-by: Truman Gao <106889354+tdgao@users.noreply.github.com>
Co-authored-by: Calum H. (IMB11) <contact@cal.engineer>
2026-05-29 19:39:55 +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: 10px;
--os-padding-perpendicular: 2px;
--os-padding-axis: 2px;
--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>