You've already forked AstralRinth
11b2b6e6c0
* 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>
1430 lines
40 KiB
Vue
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>
|