You've already forked AstralRinth
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>
This commit is contained in:
@@ -188,7 +188,7 @@ const colorVariables = computed(() => {
|
||||
}
|
||||
const hoverColors = JSON.parse(JSON.stringify(colors))
|
||||
const boxShadow =
|
||||
props.type === 'chip' && colorVar.value ? `0 0 0 2px ${colorVar.value}` : defaultShadow
|
||||
props.type === 'chip' && colorVar.value ? `0 0 0 1px ${colorVar.value}` : defaultShadow
|
||||
return `--_bg: ${colors.bg}; --_text: ${colors.text}; --_icon: ${colors.icon}; --_hover-bg: ${hoverColors.bg}; --_hover-text: ${hoverColors.text}; --_hover-icon: ${hoverColors.icon}; --_box-shadow: ${boxShadow};`
|
||||
}
|
||||
|
||||
@@ -226,7 +226,7 @@ const colorVariables = computed(() => {
|
||||
}
|
||||
|
||||
const boxShadow =
|
||||
props.type === 'chip' && colorVar.value ? `0 0 0 2px ${colorVar.value}` : defaultShadow
|
||||
props.type === 'chip' && colorVar.value ? `0 0 0 1px ${colorVar.value}` : defaultShadow
|
||||
return `--_bg: ${colors.bg}; --_text: ${colors.text}; --_hover-bg: ${hoverColors.bg}; --_hover-text: ${hoverColors.text}; --_box-shadow: ${boxShadow};`
|
||||
})
|
||||
|
||||
@@ -266,7 +266,7 @@ const fontSize = computed(() => {
|
||||
> *:first-child
|
||||
> *:first-child
|
||||
> :is(button, a, .button-like):first-child {
|
||||
@apply flex cursor-pointer flex-row items-center justify-center border-solid border-2 border-transparent bg-[--_bg] text-[--_text] h-[--_height] min-w-[--_width] rounded-[--_radius] px-[--_padding-x] py-[--_padding-y] gap-[--_gap] font-[--_font-weight] whitespace-nowrap;
|
||||
@apply flex touch-manipulation cursor-pointer flex-row items-center justify-center border-solid border border-transparent bg-[--_bg] text-[--_text] h-[--_height] min-w-[--_width] rounded-[--_radius] px-[--_padding-x] py-[--_padding-y] gap-[--_gap] font-[--_font-weight] whitespace-nowrap;
|
||||
box-shadow: var(--_box-shadow, inset 0 0 0 transparent);
|
||||
transition:
|
||||
scale 0.125s ease-in-out,
|
||||
|
||||
@@ -35,7 +35,7 @@ import { CheckIcon, MinusIcon } from '@modrinth/assets'
|
||||
import type { HTMLAttributes } from 'vue'
|
||||
|
||||
const emit = defineEmits<{
|
||||
'update:modelValue': [boolean]
|
||||
'update:modelValue': [modelValue: boolean, event?: MouseEvent]
|
||||
}>()
|
||||
|
||||
const props = withDefaults(
|
||||
@@ -59,9 +59,9 @@ const props = withDefaults(
|
||||
},
|
||||
)
|
||||
|
||||
function toggle() {
|
||||
function toggle(event: MouseEvent) {
|
||||
if (!props.disabled) {
|
||||
emit('update:modelValue', !props.modelValue)
|
||||
emit('update:modelValue', !props.modelValue, event)
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
@@ -75,6 +75,7 @@ function toggleItem(item: T) {
|
||||
flex-wrap: wrap;
|
||||
|
||||
.btn {
|
||||
border: 1px solid var(--surface-5);
|
||||
&.capitalize {
|
||||
text-transform: capitalize;
|
||||
}
|
||||
@@ -91,11 +92,9 @@ function toggleItem(item: T) {
|
||||
}
|
||||
|
||||
.selected {
|
||||
color: var(--color-contrast);
|
||||
color: var(--color-brand);
|
||||
background-color: var(--color-brand-highlight);
|
||||
box-shadow:
|
||||
inset 0 0 0 transparent,
|
||||
0 0 0 1px var(--color-brand);
|
||||
border: 1px solid var(--color-brand);
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -32,13 +32,22 @@
|
||||
:aria-hidden="calendarOnly ? 'true' : undefined"
|
||||
type="text"
|
||||
/>
|
||||
<button
|
||||
v-if="hasClearButton"
|
||||
type="button"
|
||||
class="absolute right-0.5 z-[1] touch-manipulation cursor-pointer select-none border-none bg-transparent p-2 text-secondary transition-colors hover:text-contrast"
|
||||
aria-label="Clear date"
|
||||
@click.stop="clearValue"
|
||||
>
|
||||
<XIcon class="h-5 w-5" aria-hidden="true" />
|
||||
</button>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import 'flatpickr/dist/flatpickr.css'
|
||||
|
||||
import { CalendarIcon } from '@modrinth/assets'
|
||||
import { CalendarIcon, XIcon } from '@modrinth/assets'
|
||||
import chevronLeftIcon from '@modrinth/assets/icons/chevron-left.svg?raw'
|
||||
import chevronRightIcon from '@modrinth/assets/icons/chevron-right.svg?raw'
|
||||
import flatpickr from 'flatpickr'
|
||||
@@ -127,6 +136,7 @@ const props = withDefaults(
|
||||
showIcon?: boolean
|
||||
showToday?: boolean
|
||||
calendarOnly?: boolean
|
||||
closeOnSelect?: boolean
|
||||
/**
|
||||
* Controls where the calendar opens relative to the input. Use `above`
|
||||
* to force the calendar to open above the input.
|
||||
@@ -146,7 +156,7 @@ const props = withDefaults(
|
||||
}>(),
|
||||
{
|
||||
disabled: false,
|
||||
readonly: false,
|
||||
readonly: true,
|
||||
enableTime: false,
|
||||
mode: 'single',
|
||||
showMonths: 1,
|
||||
@@ -156,6 +166,7 @@ const props = withDefaults(
|
||||
showIcon: true,
|
||||
showToday: false,
|
||||
calendarOnly: false,
|
||||
closeOnSelect: false,
|
||||
position: 'auto',
|
||||
preserveDay: false,
|
||||
viewDateAlignment: 'left',
|
||||
@@ -176,13 +187,18 @@ const intendedViewMonth = ref<CalendarViewMonth | null>(null)
|
||||
const preserveViewOnNextModelSync = ref(false)
|
||||
const intendedDay = ref<number | null>(null)
|
||||
const isPreservingDay = ref(false)
|
||||
const timeInputDigits = ref(new WeakMap<HTMLInputElement, string>())
|
||||
const rangeDragState = ref<RangeDragState | null>(null)
|
||||
const rangeEndpointMoveState = ref<RangeEndpointMoveState | null>(null)
|
||||
const suppressNextRangeClick = ref(false)
|
||||
let rangeClickSuppressionTimeout: number | null = null
|
||||
let monthSelectSyncFrame: number | null = null
|
||||
let calendarPortal: HTMLElement | null = null
|
||||
let inputFocusScrollSuppressionTarget: HTMLInputElement | null = null
|
||||
let originalInputFocus: HTMLInputElement['focus'] | null = null
|
||||
let suppressNextInputFocusScroll = false
|
||||
const calendarBaseClass = 'modrinth-date-picker-calendar'
|
||||
const twoCalendarClass = 'has-two-calendars'
|
||||
const calendarStateClasses = [
|
||||
'calendar-only',
|
||||
'show-today',
|
||||
@@ -299,6 +315,7 @@ function syncCalendarClasses(instance?: Instance) {
|
||||
if (!container) return
|
||||
|
||||
container.classList.add(calendarBaseClass)
|
||||
container.classList.toggle(twoCalendarClass, resolvedShowMonths.value === 2)
|
||||
|
||||
for (const cls of appliedCalendarClasses) {
|
||||
container.classList.remove(cls)
|
||||
@@ -868,7 +885,7 @@ function getTimeInputDigits(value: string) {
|
||||
|
||||
function sanitizeTimeInputValue(input: HTMLInputElement) {
|
||||
const nextValue = getTimeInputDigits(input.value)
|
||||
if (input.value === nextValue) return
|
||||
if (input.value === nextValue) return nextValue
|
||||
|
||||
const cursorPosition = input.selectionStart ?? nextValue.length
|
||||
const removedBeforeCursor =
|
||||
@@ -878,6 +895,7 @@ function sanitizeTimeInputValue(input: HTMLInputElement) {
|
||||
|
||||
input.value = nextValue
|
||||
input.setSelectionRange(nextCursorPosition, nextCursorPosition)
|
||||
return nextValue
|
||||
}
|
||||
|
||||
function normalizeTimeInputValue(input: HTMLInputElement) {
|
||||
@@ -917,14 +935,81 @@ function preventNonNumericTimeKeydown(event: KeyboardEvent) {
|
||||
if (event.key.length === 1 && /\D/.test(event.key)) event.preventDefault()
|
||||
}
|
||||
|
||||
function sanitizeNumericTimeInput(event: Event) {
|
||||
if (isTimeInput(event.target)) sanitizeTimeInputValue(event.target)
|
||||
function getTimeInputs(instance: Instance) {
|
||||
return [instance.hourElement, instance.minuteElement, instance.secondElement].filter(
|
||||
(input): input is HTMLInputElement => Boolean(input),
|
||||
)
|
||||
}
|
||||
|
||||
function getTimeInputDraft(input: HTMLInputElement) {
|
||||
return document.activeElement === input
|
||||
? (timeInputDigits.value.get(input) ?? getTimeInputDigits(input.value))
|
||||
: getTimeInputDigits(input.value)
|
||||
}
|
||||
|
||||
function getNormalizedTimeInputDraft(input: HTMLInputElement) {
|
||||
const draft = getTimeInputDraft(input)
|
||||
if (!draft) return null
|
||||
if (draft.length === 2) return draft
|
||||
|
||||
const minValue = Number.parseInt(input.min, 10)
|
||||
const maxValue = Number.parseInt(input.max, 10)
|
||||
let nextValue = Number.parseInt(draft, 10)
|
||||
|
||||
if (Number.isFinite(minValue)) nextValue = Math.max(nextValue, minValue)
|
||||
if (Number.isFinite(maxValue)) nextValue = Math.min(nextValue, maxValue)
|
||||
|
||||
return String(nextValue).padStart(2, '0')
|
||||
}
|
||||
|
||||
function prepareTimeInputValuesForCommit(instance: Instance) {
|
||||
for (const input of getTimeInputs(instance)) {
|
||||
const nextValue = getNormalizedTimeInputDraft(input)
|
||||
if (nextValue === null) return false
|
||||
|
||||
input.value = nextValue
|
||||
}
|
||||
|
||||
return true
|
||||
}
|
||||
|
||||
function syncTimeInputDrafts(instance: Instance) {
|
||||
for (const input of getTimeInputs(instance)) {
|
||||
timeInputDigits.value.set(input, getTimeInputDigits(input.value))
|
||||
}
|
||||
}
|
||||
|
||||
function commitTimeInputForInput(event: Event) {
|
||||
if (!isTimeInput(event.target)) return
|
||||
|
||||
const instance = picker.value
|
||||
if (!instance) return
|
||||
|
||||
const previousValue = instance.input.value
|
||||
const nextDigits = sanitizeTimeInputValue(event.target)
|
||||
timeInputDigits.value.set(event.target, nextDigits)
|
||||
if (!prepareTimeInputValuesForCommit(instance)) return
|
||||
|
||||
event.target.dispatchEvent(new Event('increment', { bubbles: true }))
|
||||
if (nextDigits.length === 1 && document.activeElement === event.target) {
|
||||
event.target.value = nextDigits
|
||||
event.target.setSelectionRange(nextDigits.length, nextDigits.length)
|
||||
}
|
||||
syncTimeInputDrafts(instance)
|
||||
if (instance.input.value === previousValue) return
|
||||
|
||||
const nextValue =
|
||||
props.mode === 'single'
|
||||
? instance.input.value || null
|
||||
: instance.selectedDates.map((date) => instance.formatDate(date, resolvedDateFormat.value))
|
||||
model.value = nextValue
|
||||
emit('change', nextValue)
|
||||
}
|
||||
|
||||
function syncTimeInputTypes(instance: Instance) {
|
||||
const timeInputs = [instance.hourElement, instance.minuteElement, instance.secondElement].filter(
|
||||
(input): input is HTMLInputElement => Boolean(input),
|
||||
)
|
||||
const timeInputs = getTimeInputs(instance)
|
||||
const activeTimeInput = timeInputs.find((input) => document.activeElement === input)
|
||||
const activeDraft = activeTimeInput ? timeInputDigits.value.get(activeTimeInput) : undefined
|
||||
|
||||
for (const input of timeInputs) {
|
||||
input.type = 'text'
|
||||
@@ -932,6 +1017,71 @@ function syncTimeInputTypes(instance: Instance) {
|
||||
input.pattern = '[0-9]*'
|
||||
normalizeTimeInputValue(input)
|
||||
}
|
||||
|
||||
if (activeTimeInput && activeDraft !== undefined && activeDraft.length < 2) {
|
||||
activeTimeInput.value = activeDraft
|
||||
activeTimeInput.setSelectionRange(activeDraft.length, activeDraft.length)
|
||||
}
|
||||
|
||||
syncTimeInputDrafts(instance)
|
||||
}
|
||||
|
||||
function openPickerWithoutInputFocus(event: PointerEvent) {
|
||||
if (!props.readonly || props.disabled || props.calendarOnly) return
|
||||
if (event.button !== 0) return
|
||||
if (event.pointerType === 'mouse') return
|
||||
|
||||
event.preventDefault()
|
||||
suppressNextInputFocusScroll = true
|
||||
picker.value?.open()
|
||||
}
|
||||
|
||||
function suppressInputFocusScrollForCalendarPointer(event: PointerEvent) {
|
||||
if (!props.readonly || props.disabled || props.calendarOnly || !props.closeOnSelect) return
|
||||
if (event.button !== 0) return
|
||||
if (event.pointerType === 'mouse') return
|
||||
|
||||
const dayElem = getRangeDayElement(event.target)
|
||||
if (!dayElem || !isSelectableDay(dayElem)) return
|
||||
|
||||
suppressNextInputFocusScroll = true
|
||||
}
|
||||
|
||||
function patchInputFocusScrollSuppression(target: HTMLInputElement) {
|
||||
originalInputFocus = target.focus
|
||||
target.focus = ((options?: FocusOptions) => {
|
||||
if (suppressNextInputFocusScroll) {
|
||||
originalInputFocus?.call(target, { preventScroll: true })
|
||||
return
|
||||
}
|
||||
|
||||
originalInputFocus?.call(target, options)
|
||||
}) as HTMLInputElement['focus']
|
||||
}
|
||||
|
||||
function teardownInputFocusScrollSuppression() {
|
||||
inputFocusScrollSuppressionTarget?.removeEventListener('pointerdown', openPickerWithoutInputFocus)
|
||||
if (inputFocusScrollSuppressionTarget && originalInputFocus) {
|
||||
inputFocusScrollSuppressionTarget.focus = originalInputFocus
|
||||
}
|
||||
inputFocusScrollSuppressionTarget = null
|
||||
originalInputFocus = null
|
||||
suppressNextInputFocusScroll = false
|
||||
}
|
||||
|
||||
function syncInputFocusScrollSuppression() {
|
||||
const target = picker.value?.altInput ?? inputRef.value ?? null
|
||||
if (!target || !props.readonly || props.disabled || props.calendarOnly) {
|
||||
teardownInputFocusScrollSuppression()
|
||||
return
|
||||
}
|
||||
|
||||
if (inputFocusScrollSuppressionTarget === target) return
|
||||
|
||||
teardownInputFocusScrollSuppression()
|
||||
target.addEventListener('pointerdown', openPickerWithoutInputFocus)
|
||||
patchInputFocusScrollSuppression(target)
|
||||
inputFocusScrollSuppressionTarget = target
|
||||
}
|
||||
|
||||
const resolvedDateFormat = computed(
|
||||
@@ -944,19 +1094,6 @@ const resolvedShowMonths = computed(() =>
|
||||
Number.isFinite(props.showMonths) ? Math.max(1, Math.floor(props.showMonths)) : 1,
|
||||
)
|
||||
|
||||
const inputClasses = computed(() => [
|
||||
props.calendarOnly
|
||||
? 'sr-only pointer-events-none absolute h-0 w-0 opacity-0'
|
||||
: 'w-full text-primary placeholder:text-secondary focus:text-contrast font-medium transition-[shadow,color] appearance-none shadow-none focus:ring-4 focus:ring-brand-shadow !outline-0',
|
||||
!props.calendarOnly && props.showIcon ? 'pl-10' : '',
|
||||
!props.calendarOnly && !props.showIcon ? 'pl-3' : '',
|
||||
!props.calendarOnly
|
||||
? 'pr-3 h-9 py-2 text-base outline-none bg-surface-4 border-none rounded-xl'
|
||||
: '',
|
||||
props.disabled && !props.calendarOnly ? 'cursor-not-allowed' : '',
|
||||
props.inputClass,
|
||||
])
|
||||
|
||||
const selectedDates = computed(() => {
|
||||
const value = model.value
|
||||
if (Array.isArray(value)) {
|
||||
@@ -966,6 +1103,28 @@ const selectedDates = computed(() => {
|
||||
return value ? [value] : []
|
||||
})
|
||||
|
||||
const hasClearButton = computed(
|
||||
() =>
|
||||
!props.calendarOnly &&
|
||||
props.clearable &&
|
||||
!props.disabled &&
|
||||
!props.readonly &&
|
||||
selectedDates.value.length > 0,
|
||||
)
|
||||
|
||||
const inputClasses = computed(() => [
|
||||
props.calendarOnly
|
||||
? 'sr-only pointer-events-none absolute h-0 w-0 opacity-0'
|
||||
: 'w-full touch-manipulation text-primary placeholder:text-secondary focus:text-contrast font-medium transition-[shadow,color] appearance-none shadow-none focus:ring-4 focus:ring-brand-shadow !outline-0',
|
||||
!props.calendarOnly && props.showIcon ? 'pl-10' : '',
|
||||
!props.calendarOnly && !props.showIcon ? 'pl-3' : '',
|
||||
!props.calendarOnly
|
||||
? `${hasClearButton.value ? 'pr-10' : 'pr-3'} h-9 py-2 text-base outline-none bg-surface-4 border-none rounded-xl`
|
||||
: '',
|
||||
props.disabled && !props.calendarOnly ? 'cursor-not-allowed' : '',
|
||||
props.inputClass,
|
||||
])
|
||||
|
||||
watch(
|
||||
() => [
|
||||
model.value,
|
||||
@@ -982,6 +1141,7 @@ watch(
|
||||
props.altFormat,
|
||||
props.time24hr,
|
||||
props.calendarOnly,
|
||||
props.closeOnSelect,
|
||||
props.position,
|
||||
],
|
||||
() => {
|
||||
@@ -1042,6 +1202,11 @@ onMounted(async () => {
|
||||
onReady: (_selectedDates, _dateStr, instance) => {
|
||||
syncCalendarView(instance)
|
||||
|
||||
instance.calendarContainer.addEventListener(
|
||||
'pointerdown',
|
||||
suppressInputFocusScrollForCalendarPointer,
|
||||
true,
|
||||
)
|
||||
instance.calendarContainer.addEventListener('pointerdown', startRangeDrag, true)
|
||||
instance.calendarContainer.addEventListener('mousedown', stopRangeEndpointMouseEvent, true)
|
||||
instance.calendarContainer.addEventListener('mouseup', stopRangeEndpointMouseEvent, true)
|
||||
@@ -1061,7 +1226,7 @@ onMounted(async () => {
|
||||
)
|
||||
instance.calendarContainer.addEventListener('beforeinput', preventNonNumericTimeInput, true)
|
||||
instance.calendarContainer.addEventListener('keydown', preventNonNumericTimeKeydown, true)
|
||||
instance.calendarContainer.addEventListener('input', sanitizeNumericTimeInput, true)
|
||||
instance.calendarContainer.addEventListener('input', commitTimeInputForInput, true)
|
||||
|
||||
instance.calendarContainer.addEventListener('mousedown', (event) => {
|
||||
if (props.mode !== 'range') return
|
||||
@@ -1119,6 +1284,7 @@ onMounted(async () => {
|
||||
syncCalendarStateClasses(instance)
|
||||
syncRangeEndpointMoveState(instance)
|
||||
syncMultiMonthSelects(instance)
|
||||
syncInputFocusScrollSuppression()
|
||||
},
|
||||
onChange: (_selectedDates, dateStr, instance) => {
|
||||
if (isSyncingFromModel.value) return
|
||||
@@ -1145,6 +1311,12 @@ onMounted(async () => {
|
||||
},
|
||||
onClose: (_selectedDates, dateStr, instance) => {
|
||||
cancelRangeEndpointMovePreview()
|
||||
if (suppressNextInputFocusScroll) {
|
||||
const focusTarget = instance.altInput ?? inputRef.value
|
||||
focusTarget?.blur()
|
||||
suppressNextInputFocusScroll = false
|
||||
}
|
||||
|
||||
if (hasCompleteModelRange()) {
|
||||
syncPickerFromModel()
|
||||
return
|
||||
@@ -1192,6 +1364,7 @@ onMounted(async () => {
|
||||
}
|
||||
|
||||
syncAltInputState()
|
||||
syncInputFocusScrollSuppression()
|
||||
syncPickerFromModel()
|
||||
syncHeaderControlState(picker.value)
|
||||
syncRangeEndpointMoveState(picker.value)
|
||||
@@ -1204,6 +1377,7 @@ onBeforeUnmount(() => {
|
||||
document.removeEventListener('pointermove', updateRangeDrag, true)
|
||||
document.removeEventListener('pointerup', stopRangeDrag, true)
|
||||
document.removeEventListener('pointercancel', stopRangeDrag, true)
|
||||
teardownInputFocusScrollSuppression()
|
||||
picker.value?.destroy()
|
||||
destroyCalendarPortal()
|
||||
})
|
||||
@@ -1215,7 +1389,7 @@ function flatpickrOptions(): Options {
|
||||
altInputClass: props.calendarOnly ? undefined : inputClasses.value.filter(Boolean).join(' '),
|
||||
altFormat: resolvedAltFormat.value,
|
||||
appendTo: ensureCalendarPortal(),
|
||||
closeOnSelect: false,
|
||||
closeOnSelect: props.closeOnSelect,
|
||||
dateFormat: resolvedDateFormat.value,
|
||||
disableMobile: true,
|
||||
enableTime: props.enableTime,
|
||||
@@ -1267,25 +1441,31 @@ function syncPickerFromModel() {
|
||||
}
|
||||
|
||||
function syncAltInputState() {
|
||||
if (!picker.value?.altInput) return
|
||||
if (!picker.value?.altInput) {
|
||||
syncInputFocusScrollSuppression()
|
||||
return
|
||||
}
|
||||
|
||||
picker.value.altInput.disabled = props.disabled
|
||||
picker.value.altInput.readOnly = props.readonly
|
||||
syncInputFocusScrollSuppression()
|
||||
}
|
||||
|
||||
function clearValue() {
|
||||
const nextValue = props.mode === 'single' ? null : []
|
||||
model.value = nextValue
|
||||
picker.value?.clear(false)
|
||||
setRangeEndpointMoveState(null)
|
||||
syncMissingRangeEndState()
|
||||
emit('clear')
|
||||
emit('change', nextValue)
|
||||
}
|
||||
|
||||
defineExpose({
|
||||
focus: () => picker.value?.altInput?.focus() ?? inputRef.value?.focus(),
|
||||
open: () => picker.value?.open(),
|
||||
close: () => picker.value?.close(),
|
||||
clear: () => {
|
||||
const nextValue = props.mode === 'single' ? null : []
|
||||
model.value = nextValue
|
||||
picker.value?.clear(false)
|
||||
setRangeEndpointMoveState(null)
|
||||
syncMissingRangeEndState()
|
||||
emit('clear')
|
||||
emit('change', nextValue)
|
||||
},
|
||||
clear: clearValue,
|
||||
})
|
||||
</script>
|
||||
|
||||
@@ -1295,7 +1475,7 @@ defineExpose({
|
||||
}
|
||||
|
||||
.modrinth-date-picker :deep(.flatpickr-calendar) {
|
||||
@apply mt-2 rounded-2xl border border-solid border-surface-5 bg-surface-3 shadow-none p-3 text-primary select-none;
|
||||
@apply mt-2 touch-manipulation rounded-2xl border border-solid border-surface-5 bg-surface-3 shadow-none p-3 text-primary select-none;
|
||||
box-sizing: content-box;
|
||||
}
|
||||
|
||||
@@ -1333,6 +1513,27 @@ defineExpose({
|
||||
box-shadow: none;
|
||||
}
|
||||
|
||||
.modrinth-date-picker :deep(.flatpickr-calendar.has-two-calendars) {
|
||||
width: calc(615.75px + 0.75rem) !important;
|
||||
}
|
||||
|
||||
.modrinth-date-picker :deep(.flatpickr-calendar.has-two-calendars .flatpickr-months),
|
||||
.modrinth-date-picker :deep(.flatpickr-calendar.has-two-calendars .flatpickr-weekdays),
|
||||
.modrinth-date-picker :deep(.flatpickr-calendar.has-two-calendars .flatpickr-days) {
|
||||
@apply gap-3;
|
||||
}
|
||||
|
||||
.modrinth-date-picker :deep(.flatpickr-calendar.has-two-calendars .flatpickr-rContainer),
|
||||
.modrinth-date-picker :deep(.flatpickr-calendar.has-two-calendars .flatpickr-weekdays),
|
||||
.modrinth-date-picker :deep(.flatpickr-calendar.has-two-calendars .flatpickr-days) {
|
||||
width: calc(615.75px + 0.75rem) !important;
|
||||
}
|
||||
|
||||
.modrinth-date-picker :deep(.flatpickr-calendar.has-two-calendars .flatpickr-month),
|
||||
.modrinth-date-picker :deep(.flatpickr-calendar.has-two-calendars .flatpickr-weekdaycontainer) {
|
||||
@apply max-w-[307.875px] min-w-[307.875px] flex-none;
|
||||
}
|
||||
|
||||
.modrinth-date-picker :deep(.flatpickr-calendar::before),
|
||||
.modrinth-date-picker :deep(.flatpickr-calendar::after) {
|
||||
display: none;
|
||||
@@ -1356,7 +1557,7 @@ defineExpose({
|
||||
|
||||
.modrinth-date-picker :deep(.flatpickr-current-month input.cur-year),
|
||||
.modrinth-date-picker :deep(.flatpickr-current-month .flatpickr-monthDropdown-months) {
|
||||
@apply rounded-xl bg-surface-4 py-1 font-semibold text-contrast hover:bg-surface-5 min-h-10;
|
||||
@apply touch-manipulation rounded-xl bg-surface-4 py-1 font-semibold text-contrast hover:bg-surface-5 min-h-10;
|
||||
}
|
||||
|
||||
.modrinth-date-picker :deep(.flatpickr-current-month .flatpickr-monthDropdown-months) {
|
||||
@@ -1414,7 +1615,7 @@ defineExpose({
|
||||
|
||||
.modrinth-date-picker :deep(.flatpickr-prev-month),
|
||||
.modrinth-date-picker :deep(.flatpickr-next-month) {
|
||||
@apply top-2.5 mx-3.5 flex h-10 w-10 items-center justify-center rounded-full p-0 text-secondary hover:bg-surface-4 hover:text-contrast;
|
||||
@apply top-2.5 mx-3.5 flex h-10 w-10 touch-manipulation items-center justify-center rounded-full p-0 text-secondary hover:bg-surface-4 hover:text-contrast;
|
||||
}
|
||||
|
||||
.modrinth-date-picker :deep(.flatpickr-prev-month.flatpickr-disabled),
|
||||
@@ -1439,7 +1640,7 @@ defineExpose({
|
||||
}
|
||||
|
||||
.modrinth-date-picker :deep(.flatpickr-day) {
|
||||
@apply relative z-0 m-0 max-w-none rounded-full border border-solid border-transparent text-primary hover:bg-surface-4 hover:text-contrast font-semibold aspect-square h-auto;
|
||||
@apply relative z-0 m-0 max-w-none touch-manipulation rounded-full border border-solid border-transparent text-primary hover:bg-surface-4 hover:text-contrast font-semibold aspect-square h-auto;
|
||||
}
|
||||
.modrinth-date-picker
|
||||
:deep(
|
||||
@@ -1625,7 +1826,7 @@ defineExpose({
|
||||
}
|
||||
|
||||
.modrinth-date-picker :deep(.flatpickr-time) {
|
||||
@apply mt-2 flex h-11 max-h-none items-center gap-2 border-0 border-t border-solid border-surface-5 px-1 pt-2 leading-none;
|
||||
@apply mt-2 flex h-11 max-h-none items-center gap-2 border-0 border-t border-solid border-surface-5 px-1 pt-2 overflow-visible leading-none;
|
||||
}
|
||||
|
||||
.modrinth-date-picker :deep(.flatpickr-time .numInputWrapper) {
|
||||
@@ -1634,7 +1835,7 @@ defineExpose({
|
||||
|
||||
.modrinth-date-picker :deep(.flatpickr-time input),
|
||||
.modrinth-date-picker :deep(.flatpickr-time .flatpickr-am-pm) {
|
||||
@apply h-full rounded-xl bg-transparent px-2 text-center font-semibold text-primary hover:bg-surface-5 focus:bg-surface-5;
|
||||
@apply h-full touch-manipulation rounded-xl bg-transparent px-2 text-center font-semibold text-primary hover:bg-surface-5 focus:bg-surface-5;
|
||||
}
|
||||
|
||||
.modrinth-date-picker :deep(.flatpickr-time .flatpickr-time-separator) {
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -30,7 +30,7 @@
|
||||
:autocomplete="autocomplete"
|
||||
:maxlength="maxlength"
|
||||
:rows="rows"
|
||||
class="w-full text-primary placeholder:text-secondary focus:text-contrast font-medium transition-[shadow,color] appearance-none shadow-none focus:ring-4 focus:ring-brand-shadow bg-surface-4 border-none rounded-xl"
|
||||
class="w-full touch-manipulation text-primary placeholder:text-secondary focus:text-contrast font-medium transition-[shadow,color] appearance-none shadow-none focus:ring-4 focus:ring-brand-shadow bg-surface-4 border-none rounded-xl"
|
||||
:class="[
|
||||
inputClass,
|
||||
'pl-3 pr-3 py-2 text-base',
|
||||
@@ -60,7 +60,7 @@
|
||||
:min="min"
|
||||
:max="max"
|
||||
:step="step"
|
||||
class="w-full text-primary placeholder:text-secondary focus:text-contrast font-medium transition-[shadow,color] appearance-none shadow-none focus:ring-4 focus:ring-brand-shadow"
|
||||
class="w-full touch-manipulation text-primary placeholder:text-secondary focus:text-contrast font-medium transition-[shadow,color] appearance-none shadow-none focus:ring-4 focus:ring-brand-shadow"
|
||||
:class="[
|
||||
inputClass,
|
||||
variant === 'filled' && icon ? 'pl-10' : 'pl-3',
|
||||
@@ -84,7 +84,7 @@
|
||||
<button
|
||||
v-if="!multiline && clearable && model && !disabled && !readonly && variant === 'filled'"
|
||||
type="button"
|
||||
class="absolute right-0.5 z-[1] p-2 bg-transparent border-none text-secondary hover:text-contrast transition-colors cursor-pointer select-none"
|
||||
class="absolute right-0.5 z-[1] p-2 touch-manipulation bg-transparent border-none text-secondary hover:text-contrast transition-colors cursor-pointer select-none"
|
||||
aria-label="Clear input"
|
||||
@click="clear"
|
||||
>
|
||||
@@ -95,7 +95,7 @@
|
||||
<button
|
||||
v-if="!multiline && variant === 'outlined'"
|
||||
type="button"
|
||||
class="flex items-center justify-center px-2 bg-transparent border border-solid border-button-bg rounded-r-xl text-secondary hover:text-contrast transition-colors shrink-0"
|
||||
class="flex touch-manipulation items-center justify-center px-2 bg-transparent border border-solid border-button-bg rounded-r-xl text-secondary hover:text-contrast transition-colors shrink-0"
|
||||
:aria-label="clearable && model ? 'Clear input' : 'Search'"
|
||||
:tabindex="clearable && model ? undefined : -1"
|
||||
@click="clearable && model ? clear() : undefined"
|
||||
|
||||
@@ -6,114 +6,123 @@
|
||||
>
|
||||
<slot name="header" />
|
||||
</div>
|
||||
<table class="w-full table-fixed border-separate border-spacing-0 border-surface-5">
|
||||
<colgroup>
|
||||
<col v-if="showSelection" class="w-10" />
|
||||
<col
|
||||
v-for="column in columns"
|
||||
:key="column.key"
|
||||
:style="column.width ? { width: column.width } : undefined"
|
||||
/>
|
||||
</colgroup>
|
||||
<thead class="">
|
||||
<tr class="bg-surface-3">
|
||||
<th v-if="showSelection" class="w-10 pl-4">
|
||||
<Checkbox
|
||||
:model-value="allSelected"
|
||||
:indeterminate="someSelected"
|
||||
class="shrink-0 py-4"
|
||||
@update:model-value="toggleSelectAll"
|
||||
/>
|
||||
</th>
|
||||
<th
|
||||
<div class="overflow-x-auto overflow-y-hidden">
|
||||
<table
|
||||
class="w-full table-fixed border-separate border-spacing-0 border-surface-5"
|
||||
:style="tableMinWidth ? { minWidth: tableMinWidth } : undefined"
|
||||
>
|
||||
<colgroup>
|
||||
<col v-if="showSelection" class="w-12" />
|
||||
<col
|
||||
v-for="column in columns"
|
||||
:key="column.key"
|
||||
class="h-14 first:pl-4 last:pr-4"
|
||||
:class="[
|
||||
`text-${column.align ?? 'left'}`,
|
||||
column.enableSorting ? 'cursor-pointer select-none' : '',
|
||||
]"
|
||||
@click="column.enableSorting ? handleSort(column.key) : undefined"
|
||||
>
|
||||
<slot :name="`header-${column.key}`" :column="column">
|
||||
<span
|
||||
v-if="column.label || column.enableSorting"
|
||||
class="inline-flex min-w-0 max-w-full items-center gap-1 font-semibold"
|
||||
:class="`${sortColumn === column.key ? 'text-contrast' : ''}`"
|
||||
>
|
||||
<span class="min-w-0 truncate">{{ column.label ?? '' }}</span>
|
||||
<template v-if="column.enableSorting">
|
||||
<ChevronUpIcon
|
||||
v-if="sortColumn === column.key && sortDirection === 'asc'"
|
||||
class="size-4 shrink-0"
|
||||
/>
|
||||
<ChevronDownIcon
|
||||
v-else-if="sortColumn === column.key && sortDirection === 'desc'"
|
||||
class="size-4 shrink-0"
|
||||
/>
|
||||
</template>
|
||||
</span>
|
||||
</slot>
|
||||
</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody :ref="setListContainer">
|
||||
<tr v-if="data.length === 0" class="bg-surface-2">
|
||||
<td :colspan="columnSpan" class="border-solid border-0 border-t border-surface-5 p-0">
|
||||
<slot name="empty-state">
|
||||
<div class="text-secondary flex h-64 items-center justify-center">
|
||||
No data available.
|
||||
</div>
|
||||
</slot>
|
||||
</td>
|
||||
</tr>
|
||||
<template v-else>
|
||||
<tr v-if="virtualized && topSpacerHeight > 0" aria-hidden="true">
|
||||
<td
|
||||
:colspan="columnSpan"
|
||||
class="border-0 p-0"
|
||||
:style="{ height: `${topSpacerHeight}px` }"
|
||||
></td>
|
||||
</tr>
|
||||
<tr
|
||||
v-for="(row, rowIndex) in renderedRows"
|
||||
:key="getRowRenderKey(row, getAbsoluteRowIndex(rowIndex))"
|
||||
:class="getAbsoluteRowIndex(rowIndex) % 2 === 0 ? 'bg-surface-2' : 'bg-surface-1.5'"
|
||||
>
|
||||
<td v-if="showSelection" class="w-10 border-solid border-0 border-t border-surface-5">
|
||||
:style="column.width ? { width: column.width } : undefined"
|
||||
/>
|
||||
</colgroup>
|
||||
<thead class="">
|
||||
<tr class="bg-surface-3">
|
||||
<th v-if="showSelection" class="w-12">
|
||||
<Checkbox
|
||||
:model-value="isSelected(row)"
|
||||
class="shrink-0 p-4"
|
||||
@update:model-value="toggleSelection(row)"
|
||||
:model-value="allSelected"
|
||||
:indeterminate="someSelected"
|
||||
class="shrink-0 p-4 focus-visible:!outline-none"
|
||||
@update:model-value="toggleSelectAll"
|
||||
/>
|
||||
</td>
|
||||
<td
|
||||
</th>
|
||||
<th
|
||||
v-for="column in columns"
|
||||
:key="column.key"
|
||||
class="text-secondary h-14 overflow-hidden first:pl-4 last:pr-4 border-solid border-0 border-t border-surface-5"
|
||||
:class="`text-${column.align ?? 'left'}`"
|
||||
class="h-14 first:pl-4 last:pr-4"
|
||||
:class="[
|
||||
`text-${column.align ?? 'left'}`,
|
||||
column.enableSorting ? 'cursor-pointer select-none' : '',
|
||||
]"
|
||||
:style="column.width ? { width: column.width } : undefined"
|
||||
@click="column.enableSorting ? handleSort(column.key) : undefined"
|
||||
>
|
||||
<slot
|
||||
:name="`cell-${column.key}`"
|
||||
:row="row"
|
||||
:value="row[column.key]"
|
||||
:column="column"
|
||||
:index="getAbsoluteRowIndex(rowIndex)"
|
||||
>
|
||||
{{ row[column.key] ?? '' }}
|
||||
<slot :name="`header-${column.key}`" :column="column">
|
||||
<span
|
||||
v-if="column.label || column.enableSorting"
|
||||
class="inline-flex min-w-0 max-w-full items-center gap-1 font-semibold"
|
||||
:class="`${sortColumn === column.key ? 'text-contrast' : ''}`"
|
||||
>
|
||||
<span class="min-w-0 truncate">{{ column.label ?? '' }}</span>
|
||||
<template v-if="column.enableSorting">
|
||||
<ChevronUpIcon
|
||||
v-if="sortColumn === column.key && sortDirection === 'asc'"
|
||||
class="size-4 shrink-0"
|
||||
/>
|
||||
<ChevronDownIcon
|
||||
v-else-if="sortColumn === column.key && sortDirection === 'desc'"
|
||||
class="size-4 shrink-0"
|
||||
/>
|
||||
</template>
|
||||
</span>
|
||||
</slot>
|
||||
</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody :ref="setListContainer">
|
||||
<tr v-if="data.length === 0" class="bg-surface-2">
|
||||
<td :colspan="columnSpan" class="border-solid border-0 border-t border-surface-5 p-0">
|
||||
<slot name="empty-state">
|
||||
<div class="text-secondary flex h-64 items-center justify-center">
|
||||
No data available.
|
||||
</div>
|
||||
</slot>
|
||||
</td>
|
||||
</tr>
|
||||
<tr v-if="virtualized && bottomSpacerHeight > 0" aria-hidden="true">
|
||||
<td
|
||||
:colspan="columnSpan"
|
||||
class="border-0 p-0"
|
||||
:style="{ height: `${bottomSpacerHeight}px` }"
|
||||
></td>
|
||||
</tr>
|
||||
</template>
|
||||
</tbody>
|
||||
</table>
|
||||
<template v-else>
|
||||
<tr v-if="virtualized && topSpacerHeight > 0" aria-hidden="true">
|
||||
<td
|
||||
:colspan="columnSpan"
|
||||
class="border-0 p-0"
|
||||
:style="{ height: `${topSpacerHeight}px` }"
|
||||
></td>
|
||||
</tr>
|
||||
<tr
|
||||
v-for="(row, rowIndex) in renderedRows"
|
||||
:key="getRowRenderKey(row, getAbsoluteRowIndex(rowIndex))"
|
||||
:class="getAbsoluteRowIndex(rowIndex) % 2 === 0 ? 'bg-surface-2' : 'bg-surface-1.5'"
|
||||
>
|
||||
<td
|
||||
v-if="showSelection"
|
||||
class="w-12 border-solid border-0 border-t border-surface-5 focus:outline-none"
|
||||
>
|
||||
<Checkbox
|
||||
:model-value="isSelected(row)"
|
||||
class="shrink-0 p-4 -outline-offset-[14px] outline rounded-2xl"
|
||||
@update:model-value="(selectRow, event) => toggleSelection(row, selectRow, event)"
|
||||
/>
|
||||
</td>
|
||||
<td
|
||||
v-for="column in columns"
|
||||
:key="column.key"
|
||||
class="text-secondary h-14 overflow-hidden first:pl-4 last:pr-4 border-solid border-0 border-t border-surface-5"
|
||||
:class="`text-${column.align ?? 'left'}`"
|
||||
>
|
||||
<slot
|
||||
:name="`cell-${column.key}`"
|
||||
:row="row"
|
||||
:value="row[column.key]"
|
||||
:column="column"
|
||||
:index="getAbsoluteRowIndex(rowIndex)"
|
||||
>
|
||||
{{ row[column.key] ?? '' }}
|
||||
</slot>
|
||||
</td>
|
||||
</tr>
|
||||
<tr v-if="virtualized && bottomSpacerHeight > 0" aria-hidden="true">
|
||||
<td
|
||||
:colspan="columnSpan"
|
||||
class="border-0 p-0"
|
||||
:style="{ height: `${bottomSpacerHeight}px` }"
|
||||
></td>
|
||||
</tr>
|
||||
</template>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@@ -123,7 +132,7 @@
|
||||
generic="K extends string = string, T extends Record<string, unknown> = Record<K, unknown>"
|
||||
>
|
||||
import { ChevronDownIcon, ChevronUpIcon } from '@modrinth/assets'
|
||||
import { computed, toRef, useSlots } from 'vue'
|
||||
import { computed, ref, toRef, useSlots } from 'vue'
|
||||
|
||||
import { useVirtualScroll } from '../../composables/virtual-scroll'
|
||||
import Checkbox from './Checkbox.vue'
|
||||
@@ -140,6 +149,7 @@ export interface TableColumn<K extends string = string> {
|
||||
label?: string
|
||||
align?: TableColumnAlign
|
||||
enableSorting?: boolean
|
||||
defaultSortDirection?: SortDirection
|
||||
/**
|
||||
* CSS width value for the column.
|
||||
* Accepts any valid CSS width (e.g., '200px', '20%', '10rem', 'auto', 'fit-content').
|
||||
@@ -153,9 +163,16 @@ const props = withDefaults(
|
||||
data: T[] /* Row data for table */
|
||||
showSelection?: boolean
|
||||
rowKey?: keyof T /* The key used to uniquely identify each row */
|
||||
selectionKey?: keyof T /* The key used to identify selectable rows */
|
||||
selectionData?: T[] /* The complete selectable data set when data is paginated */
|
||||
selectionIds?: unknown[] /* Complete selectable IDs when callers do not want to retain row objects */
|
||||
virtualized?: boolean
|
||||
virtualRowHeight?: number
|
||||
virtualBufferSize?: number /* The number of extra rows rendered above and below the visible viewport */
|
||||
/**
|
||||
* Sets a minimum width for the table content, allowing horizontal overflow below that width.
|
||||
*/
|
||||
tableMinWidth?: string
|
||||
}>(),
|
||||
{
|
||||
showSelection: false,
|
||||
@@ -170,6 +187,7 @@ const selectedIds = defineModel<unknown[]>('selectedIds', { default: () => [] })
|
||||
const sortColumn = defineModel<string | undefined>('sortColumn')
|
||||
const sortDirection = defineModel<SortDirection>('sortDirection', { default: 'asc' })
|
||||
const slots = useSlots()
|
||||
const selectionAnchorId = ref<unknown>()
|
||||
const hasHeaderSlot = computed(() => Boolean(slots.header))
|
||||
const columnSpan = computed(() => Math.max(props.columns.length + (props.showSelection ? 1 : 0), 1))
|
||||
|
||||
@@ -201,17 +219,39 @@ const emit = defineEmits<{
|
||||
sort: [column: string, direction: SortDirection]
|
||||
}>()
|
||||
|
||||
const selectableRows = computed(() => props.selectionData ?? props.data)
|
||||
const selectableRowIds = computed(
|
||||
() => props.selectionIds ?? selectableRows.value.map((row) => getSelectionId(row)),
|
||||
)
|
||||
const selectedIdSet = computed(() => new Set(selectedIds.value))
|
||||
const selectedSelectableIdCount = computed(() => {
|
||||
let count = 0
|
||||
for (const id of selectableRowIds.value) {
|
||||
if (selectedIdSet.value.has(id)) {
|
||||
count++
|
||||
}
|
||||
}
|
||||
return count
|
||||
})
|
||||
const allSelected = computed(
|
||||
() => props.data.length > 0 && selectedIds.value.length === props.data.length,
|
||||
() =>
|
||||
selectableRowIds.value.length > 0 &&
|
||||
selectedSelectableIdCount.value === selectableRowIds.value.length,
|
||||
)
|
||||
const someSelected = computed(
|
||||
() => selectedIds.value.length > 0 && selectedIds.value.length < props.data.length,
|
||||
() =>
|
||||
selectedSelectableIdCount.value > 0 &&
|
||||
selectedSelectableIdCount.value < selectableRowIds.value.length,
|
||||
)
|
||||
|
||||
function getRowId(row: T): unknown {
|
||||
return row[props.rowKey as keyof T]
|
||||
}
|
||||
|
||||
function getSelectionId(row: T): unknown {
|
||||
return row[(props.selectionKey ?? props.rowKey) as keyof T]
|
||||
}
|
||||
|
||||
function setListContainer(element: unknown) {
|
||||
listContainer.value = props.virtualized ? (element as HTMLElement | null) : null
|
||||
}
|
||||
@@ -230,31 +270,66 @@ function getRowRenderKey(row: T, rowIndex: number): PropertyKey {
|
||||
}
|
||||
|
||||
function isSelected(row: T): boolean {
|
||||
return selectedIds.value.includes(getRowId(row))
|
||||
return selectedIdSet.value.has(getSelectionId(row))
|
||||
}
|
||||
|
||||
function toggleSelection(row: T) {
|
||||
const id = getRowId(row)
|
||||
if (isSelected(row)) {
|
||||
selectedIds.value = selectedIds.value.filter((selectedId) => selectedId !== id)
|
||||
function toggleSelection(row: T, selectRow: boolean, event?: MouseEvent) {
|
||||
const id = getSelectionId(row)
|
||||
const rowIndex = selectableRowIds.value.findIndex((selectableId) => selectableId === id)
|
||||
const anchorIndex = selectableRowIds.value.findIndex(
|
||||
(selectableId) => selectableId === selectionAnchorId.value,
|
||||
)
|
||||
|
||||
if (event?.shiftKey && rowIndex !== -1 && anchorIndex !== -1) {
|
||||
const startIndex = Math.min(rowIndex, anchorIndex)
|
||||
const endIndex = Math.max(rowIndex, anchorIndex)
|
||||
const rangeIds = selectableRowIds.value.slice(startIndex, endIndex + 1)
|
||||
|
||||
if (selectRow) {
|
||||
const nextSelectedIds = [...selectedIds.value]
|
||||
const nextSelectedIdSet = new Set(nextSelectedIds)
|
||||
for (const rangeId of rangeIds) {
|
||||
if (!nextSelectedIdSet.has(rangeId)) {
|
||||
nextSelectedIds.push(rangeId)
|
||||
nextSelectedIdSet.add(rangeId)
|
||||
}
|
||||
}
|
||||
selectedIds.value = nextSelectedIds
|
||||
} else {
|
||||
const rangeIdSet = new Set(rangeIds)
|
||||
selectedIds.value = selectedIds.value.filter((selectedId) => !rangeIdSet.has(selectedId))
|
||||
}
|
||||
} else {
|
||||
selectedIds.value = [...selectedIds.value, id]
|
||||
selectedIds.value = selectRow
|
||||
? [...selectedIds.value, id]
|
||||
: selectedIds.value.filter((selectedId) => selectedId !== id)
|
||||
}
|
||||
|
||||
selectionAnchorId.value = id
|
||||
}
|
||||
|
||||
function toggleSelectAll(selectAll: boolean) {
|
||||
selectionAnchorId.value = undefined
|
||||
if (selectAll) {
|
||||
selectedIds.value = props.data.map((row) => getRowId(row))
|
||||
selectedIds.value = [...selectableRowIds.value]
|
||||
} else {
|
||||
selectedIds.value = []
|
||||
}
|
||||
}
|
||||
|
||||
function handleSort(columnKey: string) {
|
||||
const column = props.columns.find((column) => column.key === columnKey)
|
||||
const defaultDirection = column?.defaultSortDirection ?? 'asc'
|
||||
const newDirection: SortDirection =
|
||||
sortColumn.value === columnKey && sortDirection.value === 'asc' ? 'desc' : 'asc'
|
||||
sortColumn.value === columnKey && sortDirection.value === defaultDirection
|
||||
? getOppositeSortDirection(defaultDirection)
|
||||
: defaultDirection
|
||||
sortColumn.value = columnKey
|
||||
sortDirection.value = newDirection
|
||||
emit('sort', columnKey, newDirection)
|
||||
}
|
||||
|
||||
function getOppositeSortDirection(direction: SortDirection): SortDirection {
|
||||
return direction === 'asc' ? 'desc' : 'asc'
|
||||
}
|
||||
</script>
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
<template>
|
||||
<div
|
||||
v-if="tabs.length > 0"
|
||||
class="inline-flex w-fit items-center overflow-x-auto rounded-xl border border-solid border-surface-5 p-0.5 shadow-sm gap-1"
|
||||
class="inline-flex w-fit items-center overflow-x-auto rounded-xl border border-solid border-surface-5 p-0.5 shadow-sm gap-1 h-[38px]"
|
||||
role="tablist"
|
||||
>
|
||||
<button
|
||||
@@ -9,7 +9,7 @@
|
||||
:key="tab.value"
|
||||
ref="tabButtons"
|
||||
type="button"
|
||||
class="flex min-h-6 shrink-0 cursor-pointer items-center justify-center gap-2 rounded-lg border border-solid px-2.5 py-1 text-sm font-medium outline-none transition-all active:scale-[0.97] focus-visible:ring-4 focus-visible:ring-brand-shadow"
|
||||
class="flex min-h-6 shrink-0 cursor-pointer items-center justify-center gap-2 rounded-lg border border-solid px-2.5 h-full text-sm font-medium outline-none transition-all active:scale-[0.97] focus-visible:ring-4 focus-visible:ring-brand-shadow"
|
||||
:class="
|
||||
tab.value === value
|
||||
? 'border-green bg-highlight-green text-green'
|
||||
@@ -27,7 +27,7 @@
|
||||
class="size-5 shrink-0"
|
||||
:class="tab.value === value ? 'text-green' : 'text-secondary'"
|
||||
/>
|
||||
<span class="text-nowrap">{{ tab.label }}</span>
|
||||
<span v-if="tab.label" class="text-nowrap">{{ tab.label }}</span>
|
||||
</button>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -5,7 +5,7 @@
|
||||
role="switch"
|
||||
:aria-checked="modelValue"
|
||||
:disabled="disabled"
|
||||
class="group inline-flex shrink-0 items-center rounded-full m-0 p-1 transition-all duration-200 cursor-pointer border-none"
|
||||
class="group inline-flex shrink-0 touch-manipulation items-center rounded-full m-0 p-1 transition-all duration-200 cursor-pointer border-none"
|
||||
:class="[
|
||||
small ? 'h-5 !w-[40px]' : 'h-6 !w-[48px]',
|
||||
modelValue ? 'bg-brand' : 'bg-button-bg',
|
||||
|
||||
@@ -50,7 +50,11 @@ export { default as LoadingBar } from './LoadingBar.vue'
|
||||
export { default as LoadingIndicator } from './LoadingIndicator.vue'
|
||||
export { default as ManySelect } from './ManySelect.vue'
|
||||
export { default as MarkdownEditor } from './MarkdownEditor.vue'
|
||||
export type { MultiSelectOption } from './MultiSelect.vue'
|
||||
export type {
|
||||
MultiSelectItem,
|
||||
MultiSelectOption,
|
||||
MultiSelectSectionHeader,
|
||||
} from './MultiSelect.vue'
|
||||
export { default as MultiSelect } from './MultiSelect.vue'
|
||||
export type { MaybeCtxFn, StageButtonConfig, StageConfigInput } from './MultiStageModal.vue'
|
||||
export { default as MultiStageModal, resolveCtxFn } from './MultiStageModal.vue'
|
||||
@@ -77,12 +81,20 @@ export type { StackedAdmonitionItem, StackedAdmonitionType } from './StackedAdmo
|
||||
export { default as StackedAdmonitions } from './StackedAdmonitions.vue'
|
||||
export { default as StatItem } from './StatItem.vue'
|
||||
export { default as StyledInput } from './StyledInput.vue'
|
||||
export type { TableColumn } from './Table.vue'
|
||||
export type { SortDirection, TableColumn } from './Table.vue'
|
||||
export { default as Table } from './Table.vue'
|
||||
export type { TabsTab, TabsValue } from './Tabs.vue'
|
||||
export { default as Tabs } from './Tabs.vue'
|
||||
export { default as TagItem } from './TagItem.vue'
|
||||
export { default as TagTagItem } from './TagTagItem.vue'
|
||||
export type {
|
||||
TimeFrameLastUnit,
|
||||
TimeFrameLastUnitOption,
|
||||
TimeFrameMode,
|
||||
TimeFramePickerSelection,
|
||||
TimeFramePreset,
|
||||
} from './TimeFramePicker.vue'
|
||||
export { default as TimeFramePicker } from './TimeFramePicker.vue'
|
||||
export { default as Timeline } from './Timeline.vue'
|
||||
export { default as Toggle } from './Toggle.vue'
|
||||
export { default as UnsavedChangesPopup } from './UnsavedChangesPopup.vue'
|
||||
|
||||
Reference in New Issue
Block a user