mirror of
https://github.com/modrinth/code.git
synced 2026-09-03 21:39:24 +00:00
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 d6dc8a99f1.
* 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>
This commit is contained in:
@@ -47,13 +47,13 @@
|
||||
ref="triggerRef"
|
||||
role="button"
|
||||
tabindex="0"
|
||||
class="relative flex min-h-5 w-full items-center justify-between overflow-hidden rounded-xl bg-surface-4 px-4 py-2.5 text-left transition-all duration-200 text-button-text"
|
||||
class="relative flex min-h-5 w-full items-center justify-between overflow-hidden rounded-xl bg-surface-4 px-4 py-2 text-left transition-all duration-200 text-button-text gap-2.5"
|
||||
:class="[
|
||||
props.triggerClass,
|
||||
{
|
||||
'z-[9999]': isOpen,
|
||||
'cursor-not-allowed opacity-50': disabled,
|
||||
'cursor-pointer hover:brightness-125 active:brightness-125': !disabled,
|
||||
'cursor-pointer hover:brightness-[115%] active:brightness-[115%]': !disabled,
|
||||
},
|
||||
]"
|
||||
:aria-expanded="isOpen"
|
||||
@@ -62,14 +62,14 @@
|
||||
@click="handleTriggerClick($event)"
|
||||
@keydown="handleTriggerKeydown"
|
||||
>
|
||||
<div class="flex items-center gap-2">
|
||||
<div class="flex min-w-0 items-center gap-2">
|
||||
<slot name="prefix"></slot>
|
||||
<component
|
||||
:is="selectedOption?.icon"
|
||||
v-if="showIconInSelected && selectedOption?.icon"
|
||||
class="h-5 w-5"
|
||||
class="h-5 w-5 shrink-0"
|
||||
/>
|
||||
<span class="text-primary font-semibold leading-tight">
|
||||
<span class="min-w-0 truncate text-primary font-semibold leading-tight">
|
||||
<slot name="selected">{{ triggerText }}</slot>
|
||||
</span>
|
||||
</div>
|
||||
@@ -95,6 +95,7 @@
|
||||
ref="dropdownRef"
|
||||
class="fixed z-[9999] flex flex-col overflow-hidden rounded-[14px] bg-surface-4 border border-solid border-surface-5"
|
||||
:class="[
|
||||
props.dropdownClass,
|
||||
openDirection === 'up' ? 'shadow-[0_-25px_50px_-12px_rgb(0,0,0,0.25)]' : 'shadow-2xl',
|
||||
]"
|
||||
:style="dropdownStyle"
|
||||
@@ -104,59 +105,73 @@
|
||||
>
|
||||
<div
|
||||
v-if="filteredOptions.length > 0"
|
||||
ref="optionsContainerRef"
|
||||
class="flex flex-col gap-2 overflow-y-auto p-3"
|
||||
:style="{ maxHeight: `${maxHeight}px` }"
|
||||
ref="optionsScrollbarRef"
|
||||
class="combobox-options-scrollbar bg-surface-4"
|
||||
data-overlayscrollbars-initialize
|
||||
>
|
||||
<template v-for="(item, index) in filteredOptions" :key="item.key">
|
||||
<div v-if="item.type === 'divider'" class="h-px bg-surface-5"></div>
|
||||
<component
|
||||
:is="item.type === 'link' ? 'a' : 'span'"
|
||||
v-else
|
||||
:ref="(el: HTMLElement) => setOptionRef(el as HTMLElement, index)"
|
||||
:href="item.type === 'link' && !item.disabled ? item.href : undefined"
|
||||
:target="item.type === 'link' && !item.disabled ? item.target : undefined"
|
||||
:role="listbox ? 'option' : 'menuitem'"
|
||||
:aria-selected="listbox && item.value === modelValue"
|
||||
:aria-disabled="item.disabled || undefined"
|
||||
:data-focused="focusedIndex === index"
|
||||
class="group/option flex items-center gap-2.5 cursor-pointer rounded-xl p-3 text-left transition-colors duration-150 text-contrast hover:bg-surface-5 focus:bg-surface-5"
|
||||
:class="getOptionClasses(item, index)"
|
||||
tabindex="-1"
|
||||
@mousedown.prevent
|
||||
@click="handleOptionClick(item, index)"
|
||||
@mouseenter="handleOptionMouseEnter(item, index)"
|
||||
>
|
||||
<slot
|
||||
name="option"
|
||||
:item="item"
|
||||
:index="index"
|
||||
:is-selected="!!(listbox && item.value === modelValue)"
|
||||
>
|
||||
<div class="flex w-full items-center justify-between gap-2">
|
||||
<div class="flex items-center gap-2">
|
||||
<component :is="item.icon" v-if="item.icon" class="h-5 w-5" />
|
||||
<div class="flex flex-col gap-1.5">
|
||||
<span
|
||||
class="font-semibold leading-tight"
|
||||
:class="item.value === modelValue ? 'text-contrast' : 'text-primary'"
|
||||
>
|
||||
{{ item.label }}
|
||||
</span>
|
||||
<span
|
||||
v-if="item.subLabel"
|
||||
class="text-sm"
|
||||
:class="item.value === modelValue ? 'text-contrast' : 'text-secondary'"
|
||||
>
|
||||
{{ item.subLabel }}
|
||||
</span>
|
||||
<div
|
||||
ref="optionsContainerRef"
|
||||
class="overflow-y-auto"
|
||||
:style="{ maxHeight: `${maxHeight}px` }"
|
||||
data-overlayscrollbars-viewport
|
||||
>
|
||||
<div ref="optionsListRef" class="flex flex-col">
|
||||
<template v-for="(item, index) in filteredOptions" :key="item.key">
|
||||
<div v-if="item.type === 'divider'" class="h-px bg-surface-5"></div>
|
||||
<component
|
||||
:is="item.type === 'link' ? 'a' : 'span'"
|
||||
v-else
|
||||
:ref="(el: HTMLElement) => setOptionRef(el as HTMLElement, index)"
|
||||
:href="item.type === 'link' && !item.disabled ? item.href : undefined"
|
||||
:target="item.type === 'link' && !item.disabled ? item.target : undefined"
|
||||
:role="listbox ? 'option' : 'menuitem'"
|
||||
:aria-selected="listbox && item.value === modelValue"
|
||||
:aria-disabled="item.disabled || undefined"
|
||||
:data-focused="focusedIndex === index"
|
||||
class="group/option flex items-center gap-2.5 cursor-pointer px-4 py-3 text-left transition-all duration-150"
|
||||
:class="getOptionClasses(item, index)"
|
||||
tabindex="-1"
|
||||
@mousedown.prevent
|
||||
@click="handleOptionClick(item, index)"
|
||||
@mouseenter="handleOptionMouseEnter(item, index)"
|
||||
>
|
||||
<slot
|
||||
name="option"
|
||||
:item="item"
|
||||
:index="index"
|
||||
:is-selected="!!(listbox && item.value === modelValue)"
|
||||
>
|
||||
<div class="flex w-full items-center justify-between gap-2">
|
||||
<div class="flex items-center gap-2">
|
||||
<component
|
||||
:is="item.icon"
|
||||
v-if="item.icon"
|
||||
class="h-5 w-5"
|
||||
:class="item.value === modelValue ? 'text-green' : 'text-primary'"
|
||||
/>
|
||||
<div class="flex flex-col gap-1.5">
|
||||
<span
|
||||
class="font-semibold leading-tight"
|
||||
:class="item.value === modelValue ? 'text-green' : 'text-primary'"
|
||||
>
|
||||
{{ item.label }}
|
||||
</span>
|
||||
<span
|
||||
v-if="item.subLabel"
|
||||
class="text-sm"
|
||||
:class="item.value === modelValue ? 'text-green' : 'text-secondary'"
|
||||
>
|
||||
{{ item.subLabel }}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<slot name="option-suffix" :item="item"></slot>
|
||||
</div>
|
||||
</div>
|
||||
<slot name="option-suffix" :item="item"></slot>
|
||||
</div>
|
||||
</slot>
|
||||
</component>
|
||||
</template>
|
||||
</slot>
|
||||
</component>
|
||||
</template>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-else-if="searchQuery" class="p-4 mb-2 text-center text-sm text-secondary">
|
||||
@@ -171,8 +186,11 @@
|
||||
</template>
|
||||
|
||||
<script setup lang="ts" generic="T">
|
||||
import 'overlayscrollbars/overlayscrollbars.css'
|
||||
|
||||
import { ChevronLeftIcon, SearchIcon } from '@modrinth/assets'
|
||||
import { onClickOutside } from '@vueuse/core'
|
||||
import { OverlayScrollbars, type PartialOptions } from 'overlayscrollbars'
|
||||
import {
|
||||
type Component,
|
||||
computed,
|
||||
@@ -200,9 +218,28 @@ export interface ComboboxOption<T> {
|
||||
searchTerms?: string[]
|
||||
}
|
||||
|
||||
type OverlayScrollbarsInstance = NonNullable<ReturnType<typeof OverlayScrollbars>>
|
||||
type ViewportRect = {
|
||||
width: number
|
||||
height: number
|
||||
offsetTop: number
|
||||
offsetLeft: number
|
||||
}
|
||||
|
||||
const DROPDOWN_VIEWPORT_MARGIN = 8
|
||||
const DROPDOWN_GAP = 12
|
||||
const DROPDOWN_GAP = 8
|
||||
const DEFAULT_MAX_HEIGHT = 300
|
||||
const OPTIONS_OVERLAY_SCROLLBARS_OPTIONS = Object.freeze<PartialOptions>({
|
||||
overflow: {
|
||||
x: 'hidden',
|
||||
y: 'scroll',
|
||||
},
|
||||
scrollbars: {
|
||||
theme: 'os-theme-modrinth',
|
||||
autoHide: 'leave',
|
||||
autoHideSuspend: true,
|
||||
},
|
||||
})
|
||||
|
||||
function isDropdownOption<T>(
|
||||
opt: ComboboxOption<T> | { type: 'divider' },
|
||||
@@ -229,6 +266,13 @@ const props = withDefaults(
|
||||
displayValue?: string
|
||||
searchValue?: string
|
||||
triggerClass?: string
|
||||
dropdownClass?: string
|
||||
/** Additional selectors to ignore when detecting outside clicks */
|
||||
outsideClickIgnore?: string[]
|
||||
/** Width for the teleported dropdown; defaults to the trigger/input width */
|
||||
dropdownWidth?: string | number
|
||||
/** Minimum width for the teleported dropdown */
|
||||
dropdownMinWidth?: string | number
|
||||
forceDirection?: 'up' | 'down'
|
||||
noOptionsMessage?: string
|
||||
disableSearchFilter?: boolean
|
||||
@@ -252,6 +296,7 @@ const props = withDefaults(
|
||||
syncWithSelection: true,
|
||||
selectSearchTextOnFocus: false,
|
||||
showSearchIcon: false,
|
||||
outsideClickIgnore: () => [],
|
||||
},
|
||||
)
|
||||
|
||||
@@ -275,9 +320,12 @@ const containerRef = ref<HTMLElement>()
|
||||
const triggerRef = ref<HTMLElement>()
|
||||
const searchTriggerRef = ref<InstanceType<typeof StyledInput>>()
|
||||
const dropdownRef = ref<HTMLElement>()
|
||||
const optionsScrollbarRef = ref<HTMLElement>()
|
||||
const optionsContainerRef = ref<HTMLElement>()
|
||||
const optionsListRef = ref<HTMLElement>()
|
||||
const optionRefs = ref<(HTMLElement | null)[]>([])
|
||||
const rafId = ref<number | null>(null)
|
||||
const optionsOverlayScrollbars = ref<OverlayScrollbarsInstance | null>(null)
|
||||
|
||||
const effectiveTriggerEl = computed(() => {
|
||||
if (props.searchable && searchTriggerRef.value) {
|
||||
@@ -285,11 +333,17 @@ const effectiveTriggerEl = computed(() => {
|
||||
}
|
||||
return triggerRef.value
|
||||
})
|
||||
const outsideClickIgnoreTargets = computed(() => [
|
||||
triggerRef,
|
||||
containerRef,
|
||||
...props.outsideClickIgnore,
|
||||
])
|
||||
|
||||
const dropdownStyle = ref({
|
||||
top: '0px',
|
||||
left: '0px',
|
||||
width: '0px',
|
||||
minWidth: '0px',
|
||||
})
|
||||
|
||||
const openDirection = ref<'down' | 'up'>('down')
|
||||
@@ -352,13 +406,15 @@ const shouldRenderDropdown = computed(() => {
|
||||
return isOpen.value && hasDropdownContent.value
|
||||
})
|
||||
|
||||
function getOptionClasses(item: ComboboxOption<T> & { key: string }, index: number) {
|
||||
function getOptionClasses(item: ComboboxOption<T> & { key: string }, _index: number) {
|
||||
const isSelected = props.listbox && item.value === props.modelValue
|
||||
|
||||
return [
|
||||
item.class,
|
||||
{
|
||||
'bg-surface-5':
|
||||
(props.listbox && item.value === props.modelValue) ||
|
||||
(focusedIndex.value === index && !(props.listbox && item.value === props.modelValue)),
|
||||
'bg-surface-4 text-contrast hover:brightness-[115%] focus:brightness-[115%]': !isSelected,
|
||||
'bg-highlight-green text-green !cursor-default hover:bg-highlight-green focus:bg-highlight-green':
|
||||
isSelected,
|
||||
'cursor-not-allowed opacity-50 pointer-events-none': item.disabled,
|
||||
},
|
||||
]
|
||||
@@ -381,17 +437,20 @@ function setInitialFocus() {
|
||||
function determineOpenDirection(
|
||||
triggerRect: DOMRect,
|
||||
dropdownRect: DOMRect,
|
||||
viewportHeight: number,
|
||||
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 =
|
||||
triggerRect.bottom + dropdownRect.height + DROPDOWN_GAP + DROPDOWN_VIEWPORT_MARGIN <=
|
||||
viewportHeight
|
||||
triggerBottom + dropdownRect.height + DROPDOWN_GAP + DROPDOWN_VIEWPORT_MARGIN <= viewportBottom
|
||||
const hasSpaceAbove =
|
||||
triggerRect.top - dropdownRect.height - DROPDOWN_GAP - DROPDOWN_VIEWPORT_MARGIN > 0
|
||||
triggerTop - dropdownRect.height - DROPDOWN_GAP - DROPDOWN_VIEWPORT_MARGIN > viewportTop
|
||||
|
||||
return !hasSpaceBelow && hasSpaceAbove ? 'up' : 'down'
|
||||
}
|
||||
@@ -400,52 +459,129 @@ function calculateVerticalPosition(
|
||||
triggerRect: DOMRect,
|
||||
dropdownRect: DOMRect,
|
||||
direction: 'up' | 'down',
|
||||
viewport: ViewportRect,
|
||||
): number {
|
||||
return direction === 'up'
|
||||
? triggerRect.top - dropdownRect.height - DROPDOWN_GAP
|
||||
: triggerRect.bottom + DROPDOWN_GAP
|
||||
const top =
|
||||
direction === 'up'
|
||||
? triggerRect.top - dropdownRect.height - DROPDOWN_GAP
|
||||
: triggerRect.bottom + DROPDOWN_GAP
|
||||
|
||||
return top + viewport.offsetTop
|
||||
}
|
||||
|
||||
function calculateHorizontalPosition(
|
||||
triggerRect: DOMRect,
|
||||
dropdownRect: DOMRect,
|
||||
viewportWidth: number,
|
||||
viewport: ViewportRect,
|
||||
): number {
|
||||
let left = triggerRect.left
|
||||
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 > viewportWidth - DROPDOWN_VIEWPORT_MARGIN) {
|
||||
left = Math.max(
|
||||
DROPDOWN_VIEWPORT_MARGIN,
|
||||
viewportWidth - dropdownRect.width - DROPDOWN_VIEWPORT_MARGIN,
|
||||
)
|
||||
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 (!effectiveTriggerEl.value || !dropdownRef.value) return
|
||||
|
||||
await nextTick()
|
||||
|
||||
const triggerRect = effectiveTriggerEl.value.getBoundingClientRect()
|
||||
const dropdownRect = dropdownRef.value.getBoundingClientRect()
|
||||
const viewportHeight = window.innerHeight
|
||||
const viewportWidth = window.innerWidth
|
||||
const width = resolveDropdownWidth(triggerRect.width)
|
||||
const minWidth = resolveCssSize(props.dropdownMinWidth) ?? '0px'
|
||||
|
||||
const direction = determineOpenDirection(triggerRect, dropdownRect, viewportHeight)
|
||||
const top = calculateVerticalPosition(triggerRect, dropdownRect, direction)
|
||||
const left = calculateHorizontalPosition(triggerRect, dropdownRect, viewportWidth)
|
||||
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: `${triggerRect.width}px`,
|
||||
width,
|
||||
minWidth,
|
||||
}
|
||||
|
||||
openDirection.value = direction
|
||||
}
|
||||
|
||||
async function initializeOptionsOverlayScrollbars() {
|
||||
await nextTick()
|
||||
|
||||
if (!isOpen.value || filteredOptions.value.length === 0) {
|
||||
destroyOptionsOverlayScrollbars()
|
||||
return
|
||||
}
|
||||
|
||||
if (!optionsScrollbarRef.value || !optionsContainerRef.value || !optionsListRef.value) {
|
||||
return
|
||||
}
|
||||
|
||||
if (optionsOverlayScrollbars.value) {
|
||||
optionsOverlayScrollbars.value.update(true)
|
||||
return
|
||||
}
|
||||
|
||||
optionsOverlayScrollbars.value = OverlayScrollbars(
|
||||
{
|
||||
target: optionsScrollbarRef.value,
|
||||
elements: {
|
||||
viewport: optionsContainerRef.value,
|
||||
content: optionsListRef.value,
|
||||
},
|
||||
},
|
||||
OPTIONS_OVERLAY_SCROLLBARS_OPTIONS,
|
||||
)
|
||||
}
|
||||
|
||||
function updateOptionsOverlayScrollbars() {
|
||||
nextTick(() => {
|
||||
optionsOverlayScrollbars.value?.update(true)
|
||||
})
|
||||
}
|
||||
|
||||
function destroyOptionsOverlayScrollbars() {
|
||||
optionsOverlayScrollbars.value?.destroy()
|
||||
optionsOverlayScrollbars.value = null
|
||||
}
|
||||
|
||||
async function openDropdown() {
|
||||
if (props.disabled || isOpen.value || !hasDropdownContent.value) return
|
||||
|
||||
@@ -454,6 +590,7 @@ async function openDropdown() {
|
||||
|
||||
await nextTick()
|
||||
await updateDropdownPosition()
|
||||
await initializeOptionsOverlayScrollbars()
|
||||
|
||||
setInitialFocus()
|
||||
startPositionTracking()
|
||||
@@ -463,6 +600,7 @@ function closeDropdown() {
|
||||
if (!isOpen.value) return
|
||||
|
||||
stopPositionTracking()
|
||||
destroyOptionsOverlayScrollbars()
|
||||
isOpen.value = false
|
||||
userHasTyped.value = false
|
||||
focusedIndex.value = -1
|
||||
@@ -489,6 +627,8 @@ function handleTriggerClick(event: MouseEvent) {
|
||||
|
||||
function handleOptionClick(option: ComboboxOption<T>, index: number) {
|
||||
if (option.disabled || option.type === 'divider') return
|
||||
const isSelected = props.listbox && option.value === props.modelValue
|
||||
if (isSelected) return
|
||||
|
||||
focusedIndex.value = index
|
||||
|
||||
@@ -682,19 +822,36 @@ function handleSearchClick() {
|
||||
|
||||
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() {
|
||||
function track() {
|
||||
updateDropdownPosition()
|
||||
rafId.value = requestAnimationFrame(track)
|
||||
}
|
||||
rafId.value = requestAnimationFrame(track)
|
||||
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
|
||||
@@ -706,7 +863,7 @@ onClickOutside(
|
||||
() => {
|
||||
closeDropdown()
|
||||
},
|
||||
{ ignore: [triggerRef, containerRef] },
|
||||
{ ignore: outsideClickIgnoreTargets },
|
||||
)
|
||||
|
||||
onMounted(() => {
|
||||
@@ -716,6 +873,7 @@ onMounted(() => {
|
||||
onUnmounted(() => {
|
||||
window.removeEventListener('resize', handleWindowResize)
|
||||
stopPositionTracking()
|
||||
destroyOptionsOverlayScrollbars()
|
||||
})
|
||||
|
||||
watch(isOpen, (value) => {
|
||||
@@ -727,12 +885,18 @@ watch(isOpen, (value) => {
|
||||
watch(shouldRenderDropdown, (value) => {
|
||||
if (value) {
|
||||
updateDropdownPosition()
|
||||
initializeOptionsOverlayScrollbars()
|
||||
}
|
||||
})
|
||||
|
||||
watch(filteredOptions, () => {
|
||||
if (isOpen.value) {
|
||||
updateDropdownPosition()
|
||||
if (filteredOptions.value.length > 0) {
|
||||
initializeOptionsOverlayScrollbars()
|
||||
} else {
|
||||
destroyOptionsOverlayScrollbars()
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
@@ -749,7 +913,37 @@ watch(
|
||||
const opt = props.options.find((o) => isDropdownOption(o) && o.value === val)
|
||||
searchQuery.value = opt && isDropdownOption(opt) ? opt.label : ''
|
||||
}
|
||||
if (isOpen.value) {
|
||||
updateOptionsOverlayScrollbars()
|
||||
}
|
||||
},
|
||||
{ immediate: true },
|
||||
)
|
||||
|
||||
watch(
|
||||
() => props.maxHeight,
|
||||
() => {
|
||||
if (isOpen.value) {
|
||||
updateOptionsOverlayScrollbars()
|
||||
}
|
||||
},
|
||||
)
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.combobox-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-border: 2px solid var(--color-surface-4);
|
||||
--os-handle-border-hover: 2px solid var(--color-surface-4);
|
||||
--os-handle-border-active: 2px solid var(--color-surface-4);
|
||||
--os-handle-bg: var(--color-scrollbar, var(--color-surface-5));
|
||||
--os-handle-bg-hover: var(--color-scrollbar, var(--color-surface-5));
|
||||
--os-handle-bg-active: var(--color-scrollbar, var(--color-surface-5));
|
||||
}
|
||||
</style>
|
||||
|
||||
Reference in New Issue
Block a user