Files
modrinth/packages/ui/src/components/base/TimeFramePicker.vue
T
Calum H.andProspector bb2193b6f5 refactor: Button components (#6929)
* feat: refactor button components

* fix: qa

* fix: storybook

* a11y: pass

* fix: build

* fix: rename default ->md

* fix: lint

* docs: buttons.md

* refactor part 1

* refactor part 2

* fix: undo refactor for fresh restart

* refactor

* Revert "refactor"

This reverts commit 96d65902d7.

* refactor: part 1

* fix: qa

* fix: qa

* fix: qa

* fix: qa

* fix: qa

* fix: qa

* fix: qa

* fix: qa

* fix: remove text-contrast

* fix: qa

* fix: v-tooltip

* fix: lint

* fix: split broken

* fix: passkey qa

* fix: qa

* fix: qa

* fix: prepr

* fix: splitbutton

* improve button group seam

---------

Signed-off-by: Calum H. <calum@modrinth.com>
Co-authored-by: Prospector <6166773+Prospector@users.noreply.github.com>
2026-08-04 13:54:04 -07:00

1108 lines
34 KiB
Vue

<template>
<Combobox
:model-value="highlightedTimeframePreset"
:options="timeframeDropdownOptions"
:display-value="selectedTimeframeLabel"
:max-height="maxHeight"
:trigger-class="triggerClass"
:trigger-type="triggerType"
:trigger-size="triggerSize"
:trigger-interaction="triggerInteraction"
:dropdown-min-width="timeframeDropdownMinWidth"
:outside-click-ignore="timeframeDropdownOutsideClickIgnore"
:dropdown-class="
activeTimeframePanel === 'custom_range'
? 'bg-transparent border-0 -mt-1 pb-2 shadow-none'
: ''
"
@update:model-value="handleTimeframeModelUpdate"
@open="handleTimeframeSelectOpen"
@close="handleTimeframeSelectClose"
@select="handleTimeframePresetSelect"
>
<template #prefix>
<slot name="prefix"></slot>
</template>
<template #dropdown-footer>
<template v-if="activeTimeframePanel === 'custom_range'">
<div
class="flex flex-col gap-0 rounded-2xl border border-solid border-surface-5 bg-surface-3 p-0 pt-1"
>
<DatePicker
v-if="!isMobileCustomRangePicker"
v-model="pickerRange"
mode="range"
:show-months="2"
:clearable="false"
:default-view-date="todayInputValue"
view-date-alignment="right"
:min-date="minDate"
:max-date="customRangeMaxDate"
show-today
calendar-only
wrapper-class="w-full"
calendar-class="!border-none"
/>
<div v-else class="grid grid-cols-1 gap-3 p-3">
<div class="flex flex-col gap-1">
<span class="px-1 text-sm font-semibold text-secondary">
{{ formatMessage(messages.startDate) }}
</span>
<DatePicker
v-model="mobileStartDate"
mode="single"
:show-months="1"
:clearable="false"
:default-view-date="mobileStartDefaultViewDate"
:min-date="minDate"
:max-date="customRangeMaxDate"
show-today
wrapper-class="w-full"
close-on-select
/>
</div>
<div class="flex flex-col gap-1">
<span class="px-1 text-sm font-semibold text-secondary">
{{ formatMessage(messages.endDate) }}
</span>
<DatePicker
v-model="mobileEndDate"
mode="single"
:show-months="1"
:clearable="false"
:default-view-date="mobileEndDefaultViewDate"
:min-date="minDate"
:max-date="customRangeMaxDate"
show-today
wrapper-class="w-full"
close-on-select
/>
</div>
</div>
<div
class="flex items-center gap-3 p-4 pt-1"
:class="isMobileCustomRangePicker ? 'justify-end' : 'justify-between'"
>
<div v-if="!isMobileCustomRangePicker" class="text-base">
<template v-if="formattedRange">
<div class="flex items-center gap-1.5">
<span class="font-normal text-primary">{{ rangeLabel }}:</span>
<span class="font-medium text-contrast">{{ formattedRange }}</span>
<button
v-if="selectedDraftDates.length !== 1"
type="button"
class="ml-1 border-0 bg-transparent p-0 font-normal text-primary underline hover:text-primary"
@click.stop="clearRange"
>
{{ formatMessage(messages.clearRange) }}
</button>
</div>
</template>
<template v-else>
<span class="font-normal text-primary">
{{ formatMessage(messages.emptyRange) }}
</span>
</template>
</div>
<div class="flex items-center gap-2">
<Button type="outlined" native-type="button" @click="handleCustomRangeCancel">
{{ formatMessage(messages.cancel) }}
</Button>
<Button
type="colored"
color="brand"
native-type="button"
:disabled="!hasCompleteRange"
@click="handleCustomRangeApply"
>
{{ formatMessage(messages.apply) }}
</Button>
</div>
</div>
</div>
</template>
<div
v-else
class="flex flex-col border-0 border-t border-solid border-surface-5 bg-surface-4"
>
<div
class="px-3 py-2"
:class="draftSelectedTimeframeMode === 'last' ? 'bg-highlight-green' : ''"
>
<div class="flex items-center gap-2.5 py-0.5 transition-colors">
<span
class="shrink-0 text-sm font-semibold"
:class="draftSelectedTimeframeMode === 'last' ? 'text-green' : 'text-primary'"
>
{{ formatMessage(messages.lastTimeframePrefix) }}
</span>
<div
class="flex h-8 shrink-0 items-center overflow-hidden rounded-lg border border-solid border-surface-5 bg-surface-3"
>
<button
type="button"
class="flex h-8 w-8 touch-manipulation cursor-pointer items-center justify-center border-0 border-r border-solid border-surface-5 bg-transparent p-0 text-secondary transition-colors hover:text-contrast"
:aria-label="formatMessage(messages.decreaseAmount)"
@click.stop="decrementAmount"
>
<MinusIcon class="size-4" />
</button>
<input
v-model="amountInput"
type="number"
min="1"
step="1"
class="h-8 w-12 touch-manipulation border-0 bg-transparent px-1 text-center text-sm font-semibold text-primary outline-none ring-0 focus:outline-none focus-visible:shadow-none max-sm:text-base"
:aria-label="formatMessage(messages.timeframeAmount)"
@focus="activateLastTimeframe"
@input="handleAmountInput"
@blur="commitAmountInput"
@keydown.enter.prevent.stop="submitAmountInput"
/>
<button
type="button"
class="flex h-8 w-8 touch-manipulation cursor-pointer items-center justify-center border-0 border-l border-solid border-surface-5 bg-transparent p-0 text-secondary transition-colors hover:text-contrast"
:aria-label="formatMessage(messages.increaseAmount)"
@click.stop="incrementAmount"
>
<PlusIcon class="size-4" />
</button>
</div>
<select
v-model="draftSelectedLastTimeframeUnit"
class="h-8 touch-manipulation rounded-lg border border-solid border-surface-5 bg-surface-3 px-2 text-sm font-semibold text-primary outline-none transition-[box-shadow,color] focus:text-contrast focus:ring-4 focus:ring-brand-shadow"
:aria-label="formatMessage(messages.timeframeUnit)"
@change="handleLastTimeframeUnitChange"
>
<option
v-for="option in lastTimeframeUnitOptions"
:key="option.value"
:value="option.value"
>
{{ option.label }}
</option>
</select>
</div>
</div>
<button
type="button"
class="flex cursor-pointer items-center border-0 border-t border-solid border-surface-5 bg-transparent px-3 py-3 text-left text-sm font-semibold text-primary transition-colors hover:bg-surface-5"
@click.stop="switchDraftToCustomDateRange"
>
{{ formatMessage(messages.customRange) }}
</button>
</div>
</template>
</Combobox>
</template>
<script setup lang="ts">
import { MinusIcon, PlusIcon } from '@modrinth/assets'
import { computed, nextTick, onBeforeUnmount, onMounted, ref, watch } from 'vue'
import {
Button,
type ButtonInteraction,
type ButtonSize,
type ButtonType,
} from '#ui/components/base/buttons'
import { defineMessages, useVIntl } from '../../composables/i18n'
import Combobox, { type ComboboxOption } from './Combobox.vue'
import DatePicker from './DatePicker.vue'
export type TimeFramePreset =
| 'today'
| 'yesterday'
| 'last_7_days'
| 'last_14_days'
| 'last_30_days'
| 'last_90_days'
| 'last_180_days'
| 'year_to_date'
| 'all_time'
export type TimeFrameMode = 'preset' | 'last' | 'custom_range' | 'custom_datetime_range'
export type TimeFrameLastUnit = 'hours' | 'days' | 'weeks' | 'months'
export type TimeFrameLastUnitOption = {
value: TimeFrameLastUnit
label: string
}
export type TimeFramePickerSelection = {
mode: TimeFrameMode
preset: TimeFramePreset
lastAmount: number
lastUnit: TimeFrameLastUnit
customStartDate: string
customEndDate: string
}
type DatePickerValue = string | Date | null | undefined
type TimeFramePanel = 'preset' | 'custom_range'
type LastTimeframeValue = {
amount: number
unit: TimeFrameLastUnit
}
const TIMEFRAME_DROPDOWN_MAX_HEIGHT = 500
const TIMEFRAME_DROPDOWN_MIN_WIDTH = '20rem'
const CUSTOM_RANGE_DROPDOWN_MIN_WIDTH = '41.25rem'
const MOBILE_CUSTOM_RANGE_DROPDOWN_MIN_WIDTH = 'min(calc(100vw - 1rem), 20rem)'
const MOBILE_CUSTOM_RANGE_PICKER_QUERY = '(pointer: coarse), (max-width: 800px)'
const DATE_PICKER_PORTAL_SELECTOR = '.modrinth-date-picker-portal'
const DEFAULT_LAST_TIMEFRAME_VALUE_BY_PRESET: Partial<Record<TimeFramePreset, LastTimeframeValue>> =
{
today: { amount: 1, unit: 'days' },
yesterday: { amount: 1, unit: 'days' },
last_7_days: { amount: 7, unit: 'days' },
last_14_days: { amount: 14, unit: 'days' },
last_30_days: { amount: 30, unit: 'days' },
last_90_days: { amount: 90, unit: 'days' },
last_180_days: { amount: 180, unit: 'days' },
}
const messages = defineMessages({
today: {
id: 'time-frame-picker.option.today',
defaultMessage: 'Today',
},
yesterday: {
id: 'time-frame-picker.option.yesterday',
defaultMessage: 'Yesterday',
},
last7Days: {
id: 'time-frame-picker.option.last-7-days',
defaultMessage: 'Last 7 days',
},
last14Days: {
id: 'time-frame-picker.option.last-14-days',
defaultMessage: 'Last 14 days',
},
last30Days: {
id: 'time-frame-picker.option.last-30-days',
defaultMessage: 'Last 30 days',
},
last90Days: {
id: 'time-frame-picker.option.last-90-days',
defaultMessage: 'Last 90 days',
},
last180Days: {
id: 'time-frame-picker.option.last-180-days',
defaultMessage: 'Last 180 days',
},
yearToDate: {
id: 'time-frame-picker.option.year-to-date',
defaultMessage: 'Year to date',
},
allTime: {
id: 'time-frame-picker.option.all-time',
defaultMessage: 'All time',
},
hours: {
id: 'time-frame-picker.unit.hours',
defaultMessage: 'hours',
},
days: {
id: 'time-frame-picker.unit.days',
defaultMessage: 'days',
},
weeks: {
id: 'time-frame-picker.unit.weeks',
defaultMessage: 'weeks',
},
months: {
id: 'time-frame-picker.unit.months',
defaultMessage: 'months',
},
lastTimeframe: {
id: 'time-frame-picker.last-timeframe',
defaultMessage:
'In the last {amount} {unit, select, hours {{amount, plural, one {hour} other {hours}}} days {{amount, plural, one {day} other {days}}} weeks {{amount, plural, one {week} other {weeks}}} months {{amount, plural, one {month} other {months}}} other {days}}',
},
lastTimeframePrefix: {
id: 'time-frame-picker.last-timeframe-prefix',
defaultMessage: 'In the last',
},
customRange: {
id: 'time-frame-picker.custom-range',
defaultMessage: 'Custom fixed date range...',
},
clearRange: {
id: 'time-frame-picker.clear-range',
defaultMessage: 'Clear',
},
cancel: {
id: 'time-frame-picker.cancel',
defaultMessage: 'Cancel',
},
apply: {
id: 'time-frame-picker.apply',
defaultMessage: 'Apply',
},
emptyRange: {
id: 'time-frame-picker.empty-range',
defaultMessage: 'No date range selected.',
},
selectingRange: {
id: 'time-frame-picker.selecting-range',
defaultMessage: 'Selecting',
},
selectedRange: {
id: 'time-frame-picker.selected-range',
defaultMessage: 'Selected',
},
startDate: {
id: 'time-frame-picker.start-date',
defaultMessage: 'Start date',
},
endDate: {
id: 'time-frame-picker.end-date',
defaultMessage: 'End date',
},
selectTimeframe: {
id: 'time-frame-picker.select-timeframe',
defaultMessage: 'Select timeframe',
},
decreaseAmount: {
id: 'time-frame-picker.decrease-amount',
defaultMessage: 'Decrease timeframe amount',
},
increaseAmount: {
id: 'time-frame-picker.increase-amount',
defaultMessage: 'Increase timeframe amount',
},
timeframeAmount: {
id: 'time-frame-picker.timeframe-amount',
defaultMessage: 'Timeframe amount',
},
timeframeUnit: {
id: 'time-frame-picker.timeframe-unit',
defaultMessage: 'Timeframe unit',
},
})
const mode = defineModel<TimeFrameMode>('mode', { required: true })
const preset = defineModel<TimeFramePreset>('preset', { required: true })
const lastAmount = defineModel<number>('lastAmount', { required: true })
const lastUnit = defineModel<TimeFrameLastUnit>('lastUnit', { required: true })
const customStartDate = defineModel<string>('customStartDate', { required: true })
const customEndDate = defineModel<string>('customEndDate', { required: true })
const props = withDefaults(
defineProps<{
timeframeOptions?: ComboboxOption<TimeFramePreset>[]
lastTimeframeUnitOptions?: TimeFrameLastUnitOption[]
lastTimeframeValueByPreset?: Partial<Record<TimeFramePreset, LastTimeframeValue>>
minDate?: string
maxDate?: string
nowTimestamp?: number
maxHeight?: number
triggerClass?: string
triggerType?: ButtonType
triggerSize?: ButtonSize
triggerInteraction?: ButtonInteraction
dropdownMinWidth?: string | number
customRangeDropdownMinWidth?: string | number
}>(),
{
maxHeight: TIMEFRAME_DROPDOWN_MAX_HEIGHT,
triggerType: 'base',
triggerSize: 'md',
triggerInteraction: 'surface',
dropdownMinWidth: TIMEFRAME_DROPDOWN_MIN_WIDTH,
customRangeDropdownMinWidth: CUSTOM_RANGE_DROPDOWN_MIN_WIDTH,
},
)
const { formatMessage, locale } = useVIntl()
const emit = defineEmits<{
open: []
close: []
cancel: []
commit: [selection: TimeFramePickerSelection]
apply: [selection: TimeFramePickerSelection]
'draft-change': [selection: TimeFramePickerSelection]
'preset-select': [option: ComboboxOption<TimeFramePreset>, selection: TimeFramePickerSelection]
}>()
const isTimeframeSelectOpen = ref(false)
const activeTimeframePanel = ref<TimeFramePanel>('preset')
const draftSelectedTimeframeMode = ref<TimeFrameMode>(mode.value)
const draftSelectedTimeframe = ref<TimeFramePreset>(preset.value)
const draftSelectedLastTimeframeAmount = ref(lastAmount.value)
const draftSelectedLastTimeframeUnit = ref<TimeFrameLastUnit>(lastUnit.value)
const draftSelectedCustomTimeframeStartDate = ref(customStartDate.value)
const draftSelectedCustomTimeframeEndDate = ref(customEndDate.value)
const amountInput = ref(String(lastAmount.value))
const pickerRange = ref<DatePickerValue[]>([customStartDate.value, customEndDate.value])
const mobileStartDate = ref<DatePickerValue>(customStartDate.value)
const mobileEndDate = ref<DatePickerValue>(customEndDate.value)
const isMobileCustomRangePicker = ref(false)
let mobileCustomRangePickerMedia: MediaQueryList | null = null
const timeframeOptions = computed<ComboboxOption<TimeFramePreset>[]>(
() =>
props.timeframeOptions ?? [
{ value: 'today', label: formatMessage(messages.today) },
{ value: 'yesterday', label: formatMessage(messages.yesterday) },
{ value: 'last_7_days', label: formatMessage(messages.last7Days) },
{ value: 'last_14_days', label: formatMessage(messages.last14Days) },
{ value: 'last_30_days', label: formatMessage(messages.last30Days) },
{ value: 'last_90_days', label: formatMessage(messages.last90Days) },
{ value: 'last_180_days', label: formatMessage(messages.last180Days) },
{ value: 'year_to_date', label: formatMessage(messages.yearToDate) },
{ value: 'all_time', label: formatMessage(messages.allTime) },
],
)
const lastTimeframeUnitOptions = computed<TimeFrameLastUnitOption[]>(
() =>
props.lastTimeframeUnitOptions ?? [
{ value: 'hours', label: formatMessage(messages.hours) },
{ value: 'days', label: formatMessage(messages.days) },
{ value: 'weeks', label: formatMessage(messages.weeks) },
{ value: 'months', label: formatMessage(messages.months) },
],
)
const lastTimeframeValueByPreset = computed(
() => props.lastTimeframeValueByPreset ?? DEFAULT_LAST_TIMEFRAME_VALUE_BY_PRESET,
)
const timeframeDropdownOptions = computed<ComboboxOption<TimeFramePreset>[]>(() =>
activeTimeframePanel.value === 'custom_range' ? [] : timeframeOptions.value,
)
const timeframeDropdownMinWidth = computed(() =>
activeTimeframePanel.value === 'custom_range'
? isMobileCustomRangePicker.value
? MOBILE_CUSTOM_RANGE_DROPDOWN_MIN_WIDTH
: props.customRangeDropdownMinWidth
: props.dropdownMinWidth,
)
const timeframeDropdownOutsideClickIgnore = computed(() =>
activeTimeframePanel.value === 'custom_range' && isMobileCustomRangePicker.value
? [DATE_PICKER_PORTAL_SELECTOR]
: [],
)
const highlightedTimeframePreset = computed<TimeFramePreset | undefined>(() =>
draftSelectedTimeframeMode.value === 'preset' ? draftSelectedTimeframe.value : undefined,
)
const selectedTimeframeLabel = computed(() => {
const useDraftTimeframeLabel =
isTimeframeSelectOpen.value && activeTimeframePanel.value !== 'custom_range'
return getTimeframeLabel(
useDraftTimeframeLabel ? draftSelectedTimeframeMode.value : mode.value,
useDraftTimeframeLabel ? draftSelectedTimeframe.value : preset.value,
useDraftTimeframeLabel ? draftSelectedLastTimeframeAmount.value : lastAmount.value,
useDraftTimeframeLabel ? draftSelectedLastTimeframeUnit.value : lastUnit.value,
useDraftTimeframeLabel ? draftSelectedCustomTimeframeStartDate.value : customStartDate.value,
useDraftTimeframeLabel ? draftSelectedCustomTimeframeEndDate.value : customEndDate.value,
)
})
const todayInputValue = computed(() => getDateInputValue(new Date()))
const customRangeMaxDate = computed(() => props.maxDate ?? todayInputValue.value)
const mobileStartDefaultViewDate = computed(
() => draftSelectedCustomTimeframeStartDate.value || todayInputValue.value,
)
const mobileEndDefaultViewDate = computed(
() => draftSelectedCustomTimeframeEndDate.value || todayInputValue.value,
)
const selectedDraftDates = computed(() =>
pickerRange.value
.map(getDatePickerValueString)
.filter((value): value is string => Boolean(value)),
)
const rangeLabel = computed(() =>
formatMessage(
selectedDraftDates.value.length === 1 ? messages.selectingRange : messages.selectedRange,
),
)
const hasCompleteRange = computed(() => Boolean(getOrderedRange(pickerRange.value)))
const formattedRange = computed(() => {
if (selectedDraftDates.value.length === 1) {
return `${formatDateString(selectedDraftDates.value[0])} -`
}
const orderedRange =
getOrderedRange(pickerRange.value) ??
getOrderedRange([
draftSelectedCustomTimeframeStartDate.value,
draftSelectedCustomTimeframeEndDate.value,
])
if (!orderedRange) return ''
const [nextStartDate, nextEndDate] = orderedRange
if (nextStartDate === nextEndDate) return formatDateString(nextStartDate)
return `${formatDateString(nextStartDate)} - ${formatDateString(nextEndDate)}`
})
function getTimeframeLabel(
selectedMode: TimeFrameMode,
selectedPreset: TimeFramePreset,
selectedLastAmount: number,
selectedLastUnit: TimeFrameLastUnit,
selectedCustomStartDate: string,
selectedCustomEndDate: string,
): string {
if (selectedMode === 'last') {
return formatMessage(messages.lastTimeframe, {
amount: selectedLastAmount,
unit: selectedLastUnit,
})
}
if (selectedMode === 'custom_range') {
return formatCustomTimeframeRangeLabel(selectedCustomStartDate, selectedCustomEndDate)
}
if (selectedMode === 'custom_datetime_range') {
return formatCustomDateTimeRangeLabel(selectedCustomStartDate, selectedCustomEndDate)
}
return (
timeframeOptions.value.find((option) => option.value === selectedPreset)?.label ??
formatMessage(messages.selectTimeframe)
)
}
function getDraftSelection(): TimeFramePickerSelection {
return {
mode: draftSelectedTimeframeMode.value,
preset: draftSelectedTimeframe.value,
lastAmount: draftSelectedLastTimeframeAmount.value,
lastUnit: draftSelectedLastTimeframeUnit.value,
customStartDate: draftSelectedCustomTimeframeStartDate.value,
customEndDate: draftSelectedCustomTimeframeEndDate.value,
}
}
function emitDraftChange() {
emit('draft-change', getDraftSelection())
}
function syncMobileCustomRangePickerState() {
isMobileCustomRangePicker.value = mobileCustomRangePickerMedia?.matches ?? false
}
function setupMobileCustomRangePickerMedia() {
if (typeof window === 'undefined') {
return
}
mobileCustomRangePickerMedia = window.matchMedia(MOBILE_CUSTOM_RANGE_PICKER_QUERY)
syncMobileCustomRangePickerState()
mobileCustomRangePickerMedia.addEventListener('change', syncMobileCustomRangePickerState)
}
function teardownMobileCustomRangePickerMedia() {
mobileCustomRangePickerMedia?.removeEventListener('change', syncMobileCustomRangePickerState)
mobileCustomRangePickerMedia = null
}
function getDateInputValue(date: Date): string {
const year = date.getFullYear()
const month = String(date.getMonth() + 1).padStart(2, '0')
const day = String(date.getDate()).padStart(2, '0')
return `${year}-${month}-${day}`
}
function getDateFromInputValue(value: string): Date | undefined {
const date = new Date(`${value}T00:00:00`)
if (Number.isNaN(date.getTime()) || getDateInputValue(date) !== value) {
return undefined
}
return date
}
function getDateTimeFromInputValue(value: string): Date | undefined {
const date = new Date(value)
if (Number.isNaN(date.getTime())) {
return undefined
}
return date
}
function formatDateString(value: string): string {
const parsed = new Date(`${value}T00:00:00`)
if (Number.isNaN(parsed.getTime())) return value
return formatDate(parsed, {
month: 'short',
day: 'numeric',
year: 'numeric',
})
}
function isValidDateInputValue(value: string): boolean {
if (!/^\d{4}-\d{2}-\d{2}$/.test(value)) {
return false
}
const parsedDate = new Date(`${value}T00:00:00`)
return !Number.isNaN(parsedDate.getTime()) && getDateInputValue(parsedDate) === value
}
function getDatePickerValueString(value: DatePickerValue): string | null {
if (typeof value === 'string') {
return isValidDateInputValue(value) ? value : null
}
if (value instanceof Date && !Number.isNaN(value.getTime())) {
return getDateInputValue(value)
}
return null
}
function getOrderedRange(values: DatePickerValue[]): [string, string] | null {
const dates = values
.map(getDatePickerValueString)
.filter((value): value is string => Boolean(value))
if (dates.length < 2) {
return null
}
const firstDate = dates[0]
const secondDate = dates[1]
if (!firstDate || !secondDate) {
return null
}
return firstDate <= secondDate ? [firstDate, secondDate] : [secondDate, firstDate]
}
function formatCustomTimeframeRangeLabel(startDateValue: string, endDateValue: string): string {
const startDate = getDateFromInputValue(startDateValue)
const endDate = getDateFromInputValue(endDateValue)
if (!startDate || !endDate) {
return `${startDateValue} - ${endDateValue}`
}
if (startDateValue === endDateValue) {
return formatDate(startDate, {
month: 'long',
day: 'numeric',
year: 'numeric',
})
}
const sameYear = startDate.getFullYear() === endDate.getFullYear()
if (sameYear) {
const startLabel = formatDate(startDate, { month: 'long', day: 'numeric' })
const endLabel = formatDate(endDate, { month: 'long', day: 'numeric' })
return `${startLabel} - ${endLabel}, ${startDate.getFullYear()}`
}
const startLabel = formatDate(startDate, {
month: 'long',
day: 'numeric',
year: 'numeric',
})
const endLabel = formatDate(endDate, {
month: 'long',
day: 'numeric',
year: 'numeric',
})
return `${startLabel} - ${endLabel}`
}
function formatCustomDateTimeRangeLabel(startDateValue: string, endDateValue: string): string {
const startDate = getDateTimeFromInputValue(startDateValue)
const endDate = getDateTimeFromInputValue(endDateValue)
if (!startDate || !endDate) {
return `${startDateValue} - ${endDateValue}`
}
if (startDate.getTime() === endDate.getTime()) {
return formatDate(startDate, {
month: 'short',
day: 'numeric',
year: 'numeric',
hour: 'numeric',
minute: '2-digit',
})
}
const sameYear = startDate.getFullYear() === endDate.getFullYear()
if (sameYear) {
const startLabel = formatDate(startDate, {
month: 'short',
day: 'numeric',
hour: 'numeric',
minute: '2-digit',
})
const endLabel = formatDate(endDate, {
month: 'short',
day: 'numeric',
hour: 'numeric',
minute: '2-digit',
})
return `${startLabel} - ${endLabel}, ${startDate.getFullYear()}`
}
const startLabel = formatDate(startDate, {
month: 'short',
day: 'numeric',
year: 'numeric',
hour: 'numeric',
minute: '2-digit',
})
const endLabel = formatDate(endDate, {
month: 'short',
day: 'numeric',
year: 'numeric',
hour: 'numeric',
minute: '2-digit',
})
return `${startLabel} - ${endLabel}`
}
function formatDate(date: Date, options: Intl.DateTimeFormatOptions): string {
return new Intl.DateTimeFormat(locale.value, options).format(date)
}
function getRoundedNow(timestamp: number): Date {
const roundedTimestamp = Math.floor(timestamp / 60000) * 60000
return new Date(roundedTimestamp)
}
function addDays(date: Date, days: number): Date {
const nextDate = new Date(date)
nextDate.setDate(nextDate.getDate() + days)
return nextDate
}
function isStartOfDay(date: Date): boolean {
return (
date.getHours() === 0 &&
date.getMinutes() === 0 &&
date.getSeconds() === 0 &&
date.getMilliseconds() === 0
)
}
function getInclusiveEndDateInputValue(end: Date): string {
return getDateInputValue(isStartOfDay(end) ? addDays(end, -1) : end)
}
function subtractCalendarMonths(date: Date, months: number): Date {
const nextDate = new Date(date)
const day = nextDate.getDate()
nextDate.setDate(1)
nextDate.setMonth(nextDate.getMonth() - months)
const daysInMonth = new Date(nextDate.getFullYear(), nextDate.getMonth() + 1, 0).getDate()
nextDate.setDate(Math.min(day, daysInMonth))
return nextDate
}
function getTimeRangeForLastTimeframe() {
const end = getRoundedNow(props.nowTimestamp ?? Date.now())
const amount = Math.max(1, Math.floor(draftSelectedLastTimeframeAmount.value))
switch (draftSelectedLastTimeframeUnit.value) {
case 'hours':
return { start: new Date(end.getTime() - amount * 60 * 60 * 1000), end }
case 'days':
return { start: new Date(end.getTime() - amount * 24 * 60 * 60 * 1000), end }
case 'weeks':
return { start: new Date(end.getTime() - amount * 7 * 24 * 60 * 60 * 1000), end }
case 'months':
return { start: subtractCalendarMonths(end, amount), end }
}
}
function getDraftTimeRange() {
if (draftSelectedTimeframeMode.value === 'last') {
return getTimeRangeForLastTimeframe()
}
const startDate =
draftSelectedTimeframeMode.value === 'custom_datetime_range'
? getDateTimeFromInputValue(draftSelectedCustomTimeframeStartDate.value)
: getDateFromInputValue(draftSelectedCustomTimeframeStartDate.value)
const endDate =
draftSelectedTimeframeMode.value === 'custom_datetime_range'
? getDateTimeFromInputValue(draftSelectedCustomTimeframeEndDate.value)
: getDateFromInputValue(draftSelectedCustomTimeframeEndDate.value)
if (!startDate || !endDate) {
return null
}
return {
start: startDate,
end: draftSelectedTimeframeMode.value === 'custom_range' ? addDays(endDate, 1) : endDate,
}
}
function resetTimeframeDraft() {
draftSelectedTimeframeMode.value = mode.value
draftSelectedTimeframe.value = preset.value
draftSelectedLastTimeframeAmount.value = lastAmount.value
draftSelectedLastTimeframeUnit.value = lastUnit.value
draftSelectedCustomTimeframeStartDate.value = customStartDate.value
draftSelectedCustomTimeframeEndDate.value = customEndDate.value
amountInput.value = String(lastAmount.value)
syncPickerRangeFromDraft()
}
function commitTimeframeDraft() {
const selection = getDraftSelection()
mode.value = selection.mode
preset.value = selection.preset
lastAmount.value = selection.lastAmount
lastUnit.value = selection.lastUnit
customStartDate.value = selection.customStartDate
customEndDate.value = selection.customEndDate
emit('commit', selection)
}
function handleTimeframeSelectOpen() {
resetTimeframeDraft()
activeTimeframePanel.value = 'preset'
isTimeframeSelectOpen.value = true
emit('open')
}
function handleTimeframeSelectClose() {
if (activeTimeframePanel.value !== 'custom_range') {
commitTimeframeDraft()
}
isTimeframeSelectOpen.value = false
emit('close')
}
function closeTimeframeSelectDropdown(event: Event) {
const eventTarget = event.target
if (!(eventTarget instanceof HTMLElement)) {
isTimeframeSelectOpen.value = false
return
}
const dropdown = eventTarget.closest('[role="listbox"], [role="menu"]')
if (!dropdown) {
isTimeframeSelectOpen.value = false
return
}
dropdown.dispatchEvent(new KeyboardEvent('keydown', { key: 'Escape', bubbles: true }))
}
async function applyTimeframeDraft(event: Event) {
commitTimeframeDraft()
closeTimeframeSelectDropdown(event)
await nextTick()
emit('apply', getDraftSelection())
}
function handleCustomRangeCancel() {
resetTimeframeDraft()
activeTimeframePanel.value = 'preset'
emit('cancel')
emitDraftChange()
}
async function handleCustomRangeApply(event: MouseEvent) {
if (!hasCompleteDraftCustomDateRange()) {
return
}
const orderedRange = getOrderedRange([
draftSelectedCustomTimeframeStartDate.value,
draftSelectedCustomTimeframeEndDate.value,
])
if (orderedRange) {
const [nextStartDate, nextEndDate] = orderedRange
draftSelectedCustomTimeframeStartDate.value = nextStartDate
draftSelectedCustomTimeframeEndDate.value = nextEndDate
}
draftSelectedTimeframeMode.value = 'custom_range'
await applyTimeframeDraft(event)
}
function handleTimeframeModelUpdate(value: TimeFramePreset) {
draftSelectedTimeframe.value = value
emitDraftChange()
}
function handleTimeframePresetSelect(option: ComboboxOption<TimeFramePreset>) {
draftSelectedTimeframeMode.value = 'preset'
const lastTimeframeValue = lastTimeframeValueByPreset.value[option.value]
if (lastTimeframeValue) {
draftSelectedLastTimeframeAmount.value = lastTimeframeValue.amount
draftSelectedLastTimeframeUnit.value = lastTimeframeValue.unit
amountInput.value = String(lastTimeframeValue.amount)
}
const selection = getDraftSelection()
emit('preset-select', option, selection)
emit('draft-change', selection)
}
function hasCompleteDraftCustomDateRange() {
return Boolean(
getDateFromInputValue(draftSelectedCustomTimeframeStartDate.value) &&
getDateFromInputValue(draftSelectedCustomTimeframeEndDate.value),
)
}
function switchDraftToCustomDateRange() {
if (draftSelectedTimeframeMode.value === 'preset') {
draftSelectedCustomTimeframeStartDate.value = ''
draftSelectedCustomTimeframeEndDate.value = ''
} else {
const rawRange = getDraftTimeRange()
draftSelectedCustomTimeframeStartDate.value = rawRange ? getDateInputValue(rawRange.start) : ''
draftSelectedCustomTimeframeEndDate.value = rawRange
? getInclusiveEndDateInputValue(rawRange.end)
: ''
}
draftSelectedTimeframeMode.value = 'custom_range'
activeTimeframePanel.value = 'custom_range'
syncPickerRangeFromDraft()
emitDraftChange()
}
function activateLastTimeframe() {
draftSelectedTimeframeMode.value = 'last'
emitDraftChange()
}
function parseAmountInput() {
const nextAmount = Number(amountInput.value)
return Number.isFinite(nextAmount) ? Math.max(1, Math.floor(nextAmount)) : null
}
function handleAmountInput() {
const nextAmount = parseAmountInput()
if (nextAmount !== null && String(nextAmount) === amountInput.value) {
draftSelectedLastTimeframeAmount.value = nextAmount
}
activateLastTimeframe()
}
function commitAmountInput() {
const nextAmount = parseAmountInput() ?? 1
draftSelectedLastTimeframeAmount.value = nextAmount
amountInput.value = String(nextAmount)
activateLastTimeframe()
}
function submitAmountInput(event: KeyboardEvent) {
commitAmountInput()
void applyTimeframeDraft(event)
}
function incrementAmount() {
commitAmountInput()
draftSelectedLastTimeframeAmount.value += 1
amountInput.value = String(draftSelectedLastTimeframeAmount.value)
activateLastTimeframe()
}
function decrementAmount() {
commitAmountInput()
draftSelectedLastTimeframeAmount.value = Math.max(1, draftSelectedLastTimeframeAmount.value - 1)
amountInput.value = String(draftSelectedLastTimeframeAmount.value)
activateLastTimeframe()
}
function handleLastTimeframeUnitChange() {
activateLastTimeframe()
}
function clearRange() {
draftSelectedCustomTimeframeStartDate.value = ''
draftSelectedCustomTimeframeEndDate.value = ''
pickerRange.value = []
mobileStartDate.value = ''
mobileEndDate.value = ''
emitDraftChange()
}
function syncPickerRangeFromDraft() {
if (mobileStartDate.value !== draftSelectedCustomTimeframeStartDate.value) {
mobileStartDate.value = draftSelectedCustomTimeframeStartDate.value
}
if (mobileEndDate.value !== draftSelectedCustomTimeframeEndDate.value) {
mobileEndDate.value = draftSelectedCustomTimeframeEndDate.value
}
if (
pickerRange.value.length === 2 &&
pickerRange.value[0] === draftSelectedCustomTimeframeStartDate.value &&
pickerRange.value[1] === draftSelectedCustomTimeframeEndDate.value
) {
return
}
pickerRange.value = [
draftSelectedCustomTimeframeStartDate.value,
draftSelectedCustomTimeframeEndDate.value,
]
}
watch([mode, preset, lastAmount, lastUnit, customStartDate, customEndDate], () => {
if (isTimeframeSelectOpen.value) {
return
}
resetTimeframeDraft()
})
watch(
[draftSelectedCustomTimeframeStartDate, draftSelectedCustomTimeframeEndDate],
syncPickerRangeFromDraft,
)
watch(mobileStartDate, (nextDate) => {
const nextStartDate = getDatePickerValueString(nextDate)
if (!nextStartDate || nextStartDate === draftSelectedCustomTimeframeStartDate.value) {
return
}
draftSelectedCustomTimeframeStartDate.value = nextStartDate
draftSelectedTimeframeMode.value = 'custom_range'
emitDraftChange()
})
watch(mobileEndDate, (nextDate) => {
const nextEndDate = getDatePickerValueString(nextDate)
if (!nextEndDate || nextEndDate === draftSelectedCustomTimeframeEndDate.value) {
return
}
draftSelectedCustomTimeframeEndDate.value = nextEndDate
draftSelectedTimeframeMode.value = 'custom_range'
emitDraftChange()
})
watch(pickerRange, (nextRange) => {
if (isMobileCustomRangePicker.value) {
return
}
const orderedRange = getOrderedRange(nextRange)
if (!orderedRange) {
return
}
const [nextStartDate, nextEndDate] = orderedRange
draftSelectedCustomTimeframeStartDate.value = nextStartDate
draftSelectedCustomTimeframeEndDate.value = nextEndDate
emitDraftChange()
})
onMounted(setupMobileCustomRangePickerMedia)
onBeforeUnmount(teardownMobileCustomRangePickerMedia)
</script>