Files
modrinth/packages/ui/src/components/base/DatePicker.vue
T
Truman Gao 72a4e86c26 fix: analytics events page not in admin dropdown (#6352)
* fix: analytics events page not in admin dropdown

* pnpm prepr

* fix: add clearing date picker

* fix: date picker positioning not using rendered height
2026-06-09 21:01:42 +00:00

1932 lines
60 KiB
Vue

<template>
<div
ref="wrapperRef"
class="modrinth-date-picker relative inline-flex items-center"
:class="[
wrapperClass,
calendarOnly ? 'calendar-only' : '',
disabled ? 'cursor-not-allowed opacity-50' : '',
showToday ? 'show-today' : '',
props.mode === 'range' && selectedDates.length < 2 ? 'can-select-range' : '',
props.mode === 'range' && selectedDates.length === 1 ? 'is-selecting-range' : '',
props.mode === 'range' && selectedDates.length === 2 ? 'can-drag-range' : '',
rangeDragState ? 'is-dragging-range' : '',
rangeEndpointMoveState ? 'is-moving-range-end' : '',
]"
>
<CalendarIcon
v-if="showIcon && !calendarOnly"
class="pointer-events-none absolute left-3 z-[1] h-5 w-5 text-secondary opacity-60 transition-colors"
aria-hidden="true"
/>
<input
:id="id"
ref="inputRef"
:name="name"
:placeholder="placeholder"
:disabled="disabled"
:readonly="readonly"
:autocomplete="autocomplete"
:class="inputClasses"
:tabindex="calendarOnly ? -1 : undefined"
:aria-hidden="calendarOnly ? 'true' : undefined"
type="text"
/>
<button
v-if="hasClearButton"
type="button"
class="absolute right-0.5 top-px 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, 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'
import type { Instance } from 'flatpickr/dist/types/instance'
import type { Options } from 'flatpickr/dist/types/options'
import { computed, nextTick, onBeforeUnmount, onMounted, ref, watch } from 'vue'
type DatePickerValue = string | Date | null | undefined
type RangeEdge = 'start' | 'end'
type ViewDateAlignment = 'left' | 'right'
type DatePickerPosition =
| 'auto'
| 'above'
| 'below'
| 'auto left'
| 'auto center'
| 'auto right'
| 'above left'
| 'above center'
| 'above right'
| 'below left'
| 'below center'
| 'below right'
type CalendarViewMonth = { month: number; year: number }
type RangeDayElement = HTMLElement & { dateObj?: Date }
type RangeDragState = {
edge: RangeEdge
anchorDate: Date
draggedEndpointDate: Date
pointerId: number
lastDateTime: number
hasMoved: boolean
}
type RangeEndpointMoveState = {
edge: RangeEdge
anchorDate: Date
endpointDate: Date
lastPreviewDateTime: number
}
const model = defineModel<DatePickerValue | DatePickerValue[]>()
const props = withDefaults(
defineProps<{
id?: string
name?: string
placeholder?: string
autocomplete?: string
disabled?: boolean
readonly?: boolean
enableTime?: boolean
mode?: 'single' | 'multiple' | 'range'
showMonths?: number
minDate?: string | Date
maxDate?: string | Date
/**
* The date the calendar opens to when no value is selected. Does not set
* the value — only controls which month/year is shown when the picker
* first opens (and on subsequent opens while the value is empty).
*/
defaultViewDate?: string | Date
/**
* Controls which visible calendar contains the opening view date when
* multiple months are shown. In range mode, a complete range opens to
* the end date.
*/
viewDateAlignment?: ViewDateAlignment
/**
* The stored value format emitted by v-model. See https://flatpickr.js.org/formatting/
*
* Examples:
* - `Y-m-d` renders as `2026-04-27`
* - `Y-m-d H:i` renders as `2026-04-27 14:30`
*/
dateFormat?: string
/**
* The human-friendly format shown in the visible input. See https://flatpickr.js.org/formatting/
*
* Examples:
* - `F j, Y` renders as `April 27, 2026`
* - `F j, Y at h:i K` renders as `April 27, 2026 at 02:30 PM`
*/
altFormat?: string
time24hr?: boolean
clearable?: boolean
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.
*/
position?: DatePickerPosition
/**
* When true (single mode only), navigating between months/years preserves the
* originally selected day number. If the target month has fewer days, the day
* is clamped to the last valid day, and a `clamp` event is emitted with the
* intended and resolved days. Navigating back to a month that supports the
* original day snaps the selection back.
*/
preserveDay?: boolean
wrapperClass?: string
inputClass?: string
calendarClass?: string
}>(),
{
disabled: false,
readonly: true,
enableTime: false,
mode: 'single',
showMonths: 1,
time24hr: false,
clearable: false,
placeholder: 'Enter date',
showIcon: true,
showToday: false,
calendarOnly: false,
closeOnSelect: false,
position: 'auto',
preserveDay: false,
viewDateAlignment: 'left',
},
)
const emit = defineEmits<{
change: [value: DatePickerValue | DatePickerValue[]]
clear: []
clamp: [intendedDay: number, resolvedDay: number]
}>()
const inputRef = ref<HTMLInputElement>()
const wrapperRef = ref<HTMLElement>()
const picker = ref<Instance>()
const isSyncingFromModel = ref(false)
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 calendarPositionGap = 2
const calendarStateClasses = [
'calendar-only',
'show-today',
'can-select-range',
'is-selecting-range',
'can-drag-range',
'is-dragging-range',
'is-moving-range-end',
]
function getCalendarStateClasses() {
return [
props.calendarOnly ? 'calendar-only' : '',
props.showToday ? 'show-today' : '',
props.mode === 'range' && selectedDates.value.length < 2 ? 'can-select-range' : '',
props.mode === 'range' && selectedDates.value.length === 1 ? 'is-selecting-range' : '',
props.mode === 'range' && selectedDates.value.length === 2 ? 'can-drag-range' : '',
rangeDragState.value ? 'is-dragging-range' : '',
rangeEndpointMoveState.value ? 'is-moving-range-end' : '',
].filter(Boolean)
}
function syncCalendarStateClasses(instance?: Instance) {
const container = (instance ?? picker.value)?.calendarContainer
const targets = [container, calendarPortal].filter((target): target is HTMLElement =>
Boolean(target),
)
if (targets.length === 0) return
for (const target of targets) {
for (const cls of calendarStateClasses) {
target.classList.remove(cls)
}
for (const cls of getCalendarStateClasses()) {
target.classList.add(cls)
}
}
}
function ensureCalendarPortal() {
if (props.calendarOnly || typeof document === 'undefined') return undefined
if (!calendarPortal) {
calendarPortal = document.createElement('div')
calendarPortal.classList.add('modrinth-date-picker', 'modrinth-date-picker-portal')
for (const attr of wrapperRef.value?.getAttributeNames() ?? []) {
if (attr.startsWith('data-v-')) {
calendarPortal.setAttribute(attr, '')
}
}
document.body.appendChild(calendarPortal)
}
syncCalendarStateClasses()
return calendarPortal
}
function destroyCalendarPortal() {
calendarPortal?.remove()
calendarPortal = null
}
function getDayFromDateStr(dateStr: string): number | null {
const parts = dateStr.split('-')
if (parts.length < 3) return null
const day = Number.parseInt(parts[2], 10)
return Number.isInteger(day) ? day : null
}
function daysInMonth(month: number, year: number): number {
return new Date(year, month + 1, 0).getDate()
}
function applyPreserveDay(instance: Instance) {
if (!props.preserveDay) return
if (props.mode !== 'single') return
if (intendedDay.value === null) return
const selected = instance.selectedDates[0]
if (!selected) return
if (
selected.getMonth() === instance.currentMonth &&
selected.getFullYear() === instance.currentYear
) {
return
}
const maxDay = daysInMonth(instance.currentMonth, instance.currentYear)
const resolvedDay = Math.min(intendedDay.value, maxDay)
const newDate = new Date(instance.currentYear, instance.currentMonth, resolvedDay)
emit('clamp', intendedDay.value, resolvedDay)
isPreservingDay.value = true
instance.setDate(newDate, true)
nextTick(() => {
isPreservingDay.value = false
})
}
let appliedCalendarClasses: string[] = []
function parseClassString(value: string | undefined): string[] {
if (!value) return []
return value.split(/\s+/).filter(Boolean)
}
function syncCalendarClasses(instance?: Instance) {
const container = (instance ?? picker.value)?.calendarContainer
if (!container) return
container.classList.add(calendarBaseClass)
container.classList.toggle(twoCalendarClass, resolvedShowMonths.value === 2)
for (const cls of appliedCalendarClasses) {
container.classList.remove(cls)
}
const nextClasses = parseClassString(props.calendarClass)
for (const cls of nextClasses) {
container.classList.add(cls)
}
appliedCalendarClasses = nextClasses
syncCalendarStateClasses(instance)
}
function syncHeaderControlState(instance: Instance) {
const prevDisabled = instance.prevMonthNav.classList.contains('flatpickr-disabled')
const nextDisabled = instance.nextMonthNav.classList.contains('flatpickr-disabled')
instance.prevMonthNav.setAttribute('aria-disabled', String(prevDisabled))
instance.nextMonthNav.setAttribute('aria-disabled', String(nextDisabled))
if (instance.monthsDropdownContainer) {
const monthSelectDisabled = instance.monthsDropdownContainer.options.length <= 1
instance.monthsDropdownContainer.disabled = monthSelectDisabled
instance.monthsDropdownContainer.setAttribute('aria-disabled', String(monthSelectDisabled))
}
}
function getDisplayedMonthDate(instance: Instance, index: number) {
return new Date(instance.currentYear, instance.currentMonth + index, 1)
}
function getMonthName(instance: Instance, month: number) {
const monthNames = instance.config.shorthandCurrentMonth
? instance.l10n.months.shorthand
: instance.l10n.months.longhand
return monthNames[month] ?? ''
}
function syncMultiMonthSelects(instance = picker.value) {
if (!instance) return
for (const select of instance.monthNav.querySelectorAll('.modrinth-monthDropdown-months')) {
select.remove()
}
for (const monthElement of instance.monthNav.querySelectorAll('.modrinth-hidden-cur-month')) {
monthElement.classList.remove('modrinth-hidden-cur-month')
}
const showMultiMonthSelects =
resolvedShowMonths.value >= 1 && !instance.isMobile && !instance.config.noCalendar
instance.calendarContainer.classList.toggle('has-multi-month-selects', showMultiMonthSelects)
if (!showMultiMonthSelects) return
instance.monthElements.forEach((monthElement, index) => {
const currentMonth = monthElement.closest('.flatpickr-current-month')
if (!currentMonth) return
const displayedMonthDate = getDisplayedMonthDate(instance, index)
const displayedYear = displayedMonthDate.getFullYear()
const displayedMonth = displayedMonthDate.getMonth()
const monthSelect = document.createElement('select')
monthSelect.className = 'flatpickr-monthDropdown-months modrinth-monthDropdown-months'
monthSelect.setAttribute('aria-label', instance.l10n.monthAriaLabel)
monthSelect.tabIndex = -1
for (let month = 0; month < 12; month++) {
const option = document.createElement('option')
option.className = 'flatpickr-monthDropdown-month'
option.value = String(month)
option.textContent = getMonthName(instance, month)
option.selected = month === displayedMonth
monthSelect.appendChild(option)
}
monthSelect.addEventListener('change', () => {
const selectedMonth = Number.parseInt(monthSelect.value, 10)
const selectedYear = Number.parseInt(
instance.yearElements[index]?.value ?? String(displayedYear),
10,
)
if (!Number.isFinite(selectedMonth) || !Number.isFinite(selectedYear)) return
instance.jumpToDate(new Date(selectedYear, selectedMonth - index, 1), true)
syncHeaderControlState(instance)
syncMissingRangeEndState()
syncRangeEndpointMoveState(instance)
syncMultiMonthSelects(instance)
})
monthElement.classList.add('modrinth-hidden-cur-month')
currentMonth.insertBefore(monthSelect, monthElement)
})
}
function changeYearInput(instance: Instance, input: HTMLInputElement, delta: number) {
const index = instance.yearElements.indexOf(input)
if (index < 0) return
const displayedMonthDate = getDisplayedMonthDate(instance, index)
const displayedYear = displayedMonthDate.getFullYear()
const selectedYear = Number.parseInt(input.value, 10)
const nextYear = (Number.isFinite(selectedYear) ? selectedYear : displayedYear) + delta
instance.jumpToDate(new Date(nextYear, displayedMonthDate.getMonth() - index, 1), true)
syncHeaderControlState(instance)
syncMissingRangeEndState()
syncRangeEndpointMoveState(instance)
syncMultiMonthSelects(instance)
}
function queueMultiMonthSelectSync(instance: Instance) {
if (monthSelectSyncFrame !== null) window.cancelAnimationFrame(monthSelectSyncFrame)
monthSelectSyncFrame = window.requestAnimationFrame(() => {
monthSelectSyncFrame = null
syncMultiMonthSelects(instance)
})
}
function parseViewDate(value: DatePickerValue, instance: Instance): Date | null {
if (!value) return null
if (value instanceof Date) return Number.isNaN(value.getTime()) ? null : value
const parsedDate =
instance.parseDate(value, resolvedDateFormat.value) ?? instance.parseDate(value)
if (parsedDate && !Number.isNaN(parsedDate.getTime())) return parsedDate
const nativeDate = new Date(value)
return Number.isNaN(nativeDate.getTime()) ? null : nativeDate
}
function getAlignedViewDate(date: Date) {
if (props.viewDateAlignment !== 'right') return date
const calendarOffset = resolvedShowMonths.value - 1
if (calendarOffset <= 0) return date
return new Date(date.getFullYear(), date.getMonth() - calendarOffset, 1)
}
function getOpeningViewDate() {
if (props.mode === 'range' && selectedDates.value.length >= 2) {
return selectedDates.value[selectedDates.value.length - 1] ?? null
}
return selectedDates.value[0] ?? props.defaultViewDate ?? null
}
function hasCompleteRange(instance: Instance) {
return props.mode === 'range' && instance.selectedDates.length === 2
}
function hasCompleteModelRange() {
return props.mode === 'range' && selectedDates.value.length === 2
}
function getCompleteRangeInputValue(instance: Instance, format: string) {
if (!hasCompleteRange(instance)) return null
const rangeValues = instance.selectedDates.map((date) => instance.formatDate(date, format))
if (rangeValues[0] === rangeValues[1]) return rangeValues[0] ?? null
return rangeValues.join(instance.l10n.rangeSeparator)
}
function syncInputDisplayValue(instance = picker.value) {
if (!instance) return
const inputValue = getCompleteRangeInputValue(instance, resolvedDateFormat.value)
if (inputValue) {
instance.input.value = inputValue
}
const altInputValue = getCompleteRangeInputValue(instance, resolvedAltFormat.value)
if (instance.altInput && altInputValue) {
instance.altInput.value = altInputValue
}
}
function syncCalendarView(instance: Instance) {
const viewDate = parseViewDate(getOpeningViewDate(), instance)
if (!viewDate) return
instance.jumpToDate(getAlignedViewDate(viewDate))
}
function getRangeDayElement(target: EventTarget | null): RangeDayElement | null {
if (!(target instanceof Element)) return null
return target.closest<RangeDayElement>('.flatpickr-day')
}
function isSelectableDay(dayElem: RangeDayElement) {
return (
Boolean(dayElem.dateObj) &&
!dayElem.classList.contains('flatpickr-disabled') &&
!dayElem.classList.contains('hidden')
)
}
function getRangeEdge(dayElem: RangeDayElement): RangeEdge | null {
if (props.mode !== 'range') return null
if (picker.value?.selectedDates.length !== 2) return null
if (!isSelectableDay(dayElem)) return null
if (dayElem.classList.contains('startRange') && !dayElem.classList.contains('endRange')) {
return 'start'
}
if (dayElem.classList.contains('endRange') && !dayElem.classList.contains('startRange')) {
return 'end'
}
return null
}
function dateTime(date: Date) {
return date.getTime()
}
function dateOnlyTime(date: Date) {
return new Date(date.getFullYear(), date.getMonth(), date.getDate()).getTime()
}
function jumpToViewMonth(instance: Instance, viewMonth: CalendarViewMonth) {
if (instance.currentMonth === viewMonth.month && instance.currentYear === viewMonth.year) return
instance.jumpToDate(new Date(viewMonth.year, viewMonth.month, 1))
}
function withEndpointTime(date: Date, endpoint: Date) {
return new Date(
date.getFullYear(),
date.getMonth(),
date.getDate(),
endpoint.getHours(),
endpoint.getMinutes(),
endpoint.getSeconds(),
endpoint.getMilliseconds(),
)
}
function resolveRangeEndpointMove(targetDate: Date, endpointDate: Date, anchorDate: Date) {
const movedDate = withEndpointTime(targetDate, endpointDate)
const [nextStartDate, nextEndDate] =
dateOnlyTime(movedDate) <= dateOnlyTime(anchorDate)
? [movedDate, anchorDate]
: [anchorDate, movedDate]
return {
nextStartDate,
nextEndDate,
nextEndpointDate: movedDate,
}
}
function withPreservedCalendarView(instance: Instance, callback: () => void) {
const currentViewMonth = {
month: instance.currentMonth,
year: instance.currentYear,
}
callback()
jumpToViewMonth(instance, currentViewMonth)
}
function syncRangeEndpointMoveState(instance = picker.value) {
if (!instance) return
for (const dayElem of instance.calendarContainer.querySelectorAll('.is-moving-range-end')) {
dayElem.classList.remove('is-moving-range-end')
}
const state = rangeEndpointMoveState.value
if (!state || props.mode !== 'range' || instance.selectedDates.length !== 2) return
const selector =
state.edge === 'start'
? '.flatpickr-day.startRange:not(.endRange)'
: '.flatpickr-day.endRange:not(.startRange)'
instance.calendarContainer.querySelector(selector)?.classList.add('is-moving-range-end')
}
function setRangeEndpointMoveState(state: RangeEndpointMoveState | null) {
rangeEndpointMoveState.value = state
syncCalendarStateClasses()
syncRangeEndpointMoveState()
}
function selectRangeEndpointForMove(edge: RangeEdge, startDate: Date, endDate: Date) {
setRangeEndpointMoveState({
edge,
anchorDate: new Date(edge === 'start' ? endDate : startDate),
endpointDate: new Date(edge === 'start' ? startDate : endDate),
lastPreviewDateTime: dateTime(edge === 'start' ? startDate : endDate),
})
}
function cancelRangeEndpointMovePreview() {
if (!rangeEndpointMoveState.value) return
setRangeEndpointMoveState(null)
syncPickerFromModel()
}
function previewSelectedRangeEndpoint(event: MouseEvent) {
const state = rangeEndpointMoveState.value
const instance = picker.value
if (!state || !instance) return
const dayElem = getRangeDayElement(event.target)
if (!dayElem || !instance.calendarContainer.contains(dayElem) || !isSelectableDay(dayElem)) return
const { nextStartDate, nextEndDate, nextEndpointDate } = resolveRangeEndpointMove(
dayElem.dateObj!,
state.endpointDate,
state.anchorDate,
)
const nextPreviewDateTime = dateTime(nextEndpointDate)
if (nextPreviewDateTime === state.lastPreviewDateTime) return
state.lastPreviewDateTime = nextPreviewDateTime
withPreservedCalendarView(instance, () => {
instance.setDate([nextStartDate, nextEndDate], false)
})
syncHeaderControlState(instance)
syncMissingRangeEndState()
syncRangeEndpointMoveState(instance)
}
function moveSelectedRangeEndpoint(dayElem: RangeDayElement) {
const state = rangeEndpointMoveState.value
const instance = picker.value
if (!state || !instance || !dayElem.dateObj) return false
const { nextStartDate, nextEndDate, nextEndpointDate } = resolveRangeEndpointMove(
dayElem.dateObj,
state.endpointDate,
state.anchorDate,
)
setRangeEndpointMoveState(null)
if (dateTime(nextEndpointDate) === dateTime(state.endpointDate)) return true
intendedViewMonth.value = {
month: instance.currentMonth,
year: instance.currentYear,
}
instance.setDate([nextStartDate, nextEndDate], true)
syncHeaderControlState(instance)
return true
}
function clearRangeClickSuppressionTimeout() {
if (rangeClickSuppressionTimeout === null) return
window.clearTimeout(rangeClickSuppressionTimeout)
rangeClickSuppressionTimeout = null
}
function clearRangeClickSuppressionSoon() {
clearRangeClickSuppressionTimeout()
rangeClickSuppressionTimeout = window.setTimeout(() => {
suppressNextRangeClick.value = false
rangeClickSuppressionTimeout = null
}, 50)
}
function startRangeDrag(event: PointerEvent) {
if (event.button !== 0) return
if (rangeEndpointMoveState.value) return
const instance = picker.value
const dayElem = getRangeDayElement(event.target)
if (!instance || !dayElem) return
const edge = getRangeEdge(dayElem)
if (!edge) return
const [startDate, endDate] = instance.selectedDates
if (!startDate || !endDate) return
const draggedEndpointDate = new Date(edge === 'start' ? startDate : endDate)
rangeDragState.value = {
edge,
anchorDate: new Date(edge === 'start' ? endDate : startDate),
draggedEndpointDate,
pointerId: event.pointerId,
lastDateTime: dateTime(draggedEndpointDate),
hasMoved: false,
}
syncCalendarStateClasses(instance)
clearRangeClickSuppressionTimeout()
suppressNextRangeClick.value = true
document.addEventListener('pointermove', updateRangeDrag, true)
document.addEventListener('pointerup', stopRangeDrag, true)
document.addEventListener('pointercancel', stopRangeDrag, true)
event.preventDefault()
event.stopImmediatePropagation()
}
function updateRangeDrag(event: PointerEvent) {
const state = rangeDragState.value
const instance = picker.value
if (!state || !instance || event.pointerId !== state.pointerId) return
const target = document.elementFromPoint(event.clientX, event.clientY)
const dayElem = getRangeDayElement(target)
if (!dayElem || !instance.calendarContainer.contains(dayElem) || !isSelectableDay(dayElem)) return
const { nextStartDate, nextEndDate, nextEndpointDate } = resolveRangeEndpointMove(
dayElem.dateObj!,
state.draggedEndpointDate,
state.anchorDate,
)
const nextDraggedTime = dateTime(nextEndpointDate)
if (nextDraggedTime === state.lastDateTime) return
intendedViewMonth.value = {
month: instance.currentMonth,
year: instance.currentYear,
}
state.lastDateTime = nextDraggedTime
state.hasMoved = true
instance.setDate([nextStartDate, nextEndDate], true)
syncHeaderControlState(instance)
event.preventDefault()
event.stopImmediatePropagation()
}
function stopRangeDrag(event: PointerEvent) {
const state = rangeDragState.value
if (!state || event.pointerId !== state.pointerId) return
const instance = picker.value
rangeDragState.value = null
syncCalendarStateClasses(instance)
document.removeEventListener('pointermove', updateRangeDrag, true)
document.removeEventListener('pointerup', stopRangeDrag, true)
document.removeEventListener('pointercancel', stopRangeDrag, true)
clearRangeClickSuppressionSoon()
if (state.hasMoved) {
setRangeEndpointMoveState(null)
} else {
selectRangeEndpointForMove(
state.edge,
state.edge === 'start' ? state.draggedEndpointDate : state.anchorDate,
state.edge === 'end' ? state.draggedEndpointDate : state.anchorDate,
)
}
if (
document.activeElement instanceof HTMLElement &&
instance?.calendarContainer.contains(document.activeElement)
) {
document.activeElement.blur()
}
event.preventDefault()
event.stopImmediatePropagation()
}
function stopRangeEndpointMouseEvent(event: MouseEvent) {
if (
rangeDragState.value ||
suppressNextRangeClick.value ||
(rangeEndpointMoveState.value && getRangeDayElement(event.target))
) {
event.preventDefault()
event.stopImmediatePropagation()
}
}
function suppressRangeDragClick(event: MouseEvent) {
if (!suppressNextRangeClick.value) return
clearRangeClickSuppressionTimeout()
suppressNextRangeClick.value = false
event.preventDefault()
event.stopImmediatePropagation()
}
function handleRangeEndpointMoveClick(event: MouseEvent) {
if (suppressNextRangeClick.value) {
suppressRangeDragClick(event)
return
}
const instance = picker.value
const dayElem = getRangeDayElement(event.target)
if (!instance || !dayElem || props.mode !== 'range') return
const moveState = rangeEndpointMoveState.value
if (moveState) {
if (!isSelectableDay(dayElem)) return
moveSelectedRangeEndpoint(dayElem)
event.preventDefault()
event.stopImmediatePropagation()
return
}
const edge = getRangeEdge(dayElem)
if (!edge) return
const [startDate, endDate] = instance.selectedDates
if (!startDate || !endDate) return
selectRangeEndpointForMove(edge, startDate, endDate)
event.preventDefault()
event.stopImmediatePropagation()
}
function hasRangeEnd(instance: Instance) {
return (
instance.selectedDates.length === 2 ||
Boolean(instance.calendarContainer.querySelector('.flatpickr-day.endRange:not(.startRange)'))
)
}
function shouldSuppressMissingRangeEndBackground(instance?: Instance) {
if (props.mode !== 'range' || !instance) return false
if (instance.selectedDates.length !== 1) return false
const hasRangeStart = Boolean(
instance.calendarContainer.querySelector('.flatpickr-day.startRange:not(.endRange)'),
)
const hasRangePreview = Boolean(
instance.calendarContainer.querySelector('.flatpickr-day.inRange'),
)
return hasRangeStart && !hasRangeEnd(instance) && !hasRangePreview
}
function syncMissingRangeEndState() {
const instance = picker.value
instance?.calendarContainer.classList.toggle(
'is-missing-range-end',
shouldSuppressMissingRangeEndBackground(instance),
)
}
function isTimeInput(target: EventTarget | null): target is HTMLInputElement {
return (
target instanceof HTMLInputElement &&
target.matches('input.flatpickr-hour, input.flatpickr-minute, input.flatpickr-second')
)
}
function getTimeInputFromArrow(target: EventTarget | null): HTMLInputElement | null {
if (!(target instanceof Element)) return null
if (!target.classList.contains('arrowUp') && !target.classList.contains('arrowDown')) return null
return target.closest('.numInputWrapper')?.querySelector<HTMLInputElement>('input') ?? null
}
function getTimeInputDigits(value: string) {
return value.replace(/\D/g, '').slice(0, 2)
}
function sanitizeTimeInputValue(input: HTMLInputElement) {
const nextValue = getTimeInputDigits(input.value)
if (input.value === nextValue) return nextValue
const cursorPosition = input.selectionStart ?? nextValue.length
const removedBeforeCursor =
input.value.slice(0, cursorPosition).length -
getTimeInputDigits(input.value.slice(0, cursorPosition)).length
const nextCursorPosition = Math.max(0, cursorPosition - removedBeforeCursor)
input.value = nextValue
input.setSelectionRange(nextCursorPosition, nextCursorPosition)
return nextValue
}
function normalizeTimeInputValue(input: HTMLInputElement) {
sanitizeTimeInputValue(input)
if (input.value.length === 2) return
const minValue = Number.parseInt(input.min, 10)
const maxValue = Number.parseInt(input.max, 10)
const parsedValue = Number.parseInt(input.value, 10)
const fallbackValue = Number.isFinite(minValue) ? minValue : 0
let nextValue = Number.isFinite(parsedValue) ? parsedValue : fallbackValue
if (Number.isFinite(minValue)) nextValue = Math.max(nextValue, minValue)
if (Number.isFinite(maxValue)) nextValue = Math.min(nextValue, maxValue)
input.value = String(nextValue).padStart(2, '0')
}
function normalizeTimeInputForArrowIncrement(event: MouseEvent) {
const input = getTimeInputFromArrow(event.target)
if (input && isTimeInput(input)) normalizeTimeInputValue(input)
}
function normalizeTimeInputForKeyboardIncrement(event: KeyboardEvent) {
if (event.key !== 'ArrowUp' && event.key !== 'ArrowDown') return
if (isTimeInput(event.target)) normalizeTimeInputValue(event.target)
}
function preventNonNumericTimeInput(event: InputEvent) {
if (!isTimeInput(event.target)) return
if (event.data && /\D/.test(event.data)) event.preventDefault()
}
function preventNonNumericTimeKeydown(event: KeyboardEvent) {
if (!isTimeInput(event.target)) return
if (event.metaKey || event.ctrlKey || event.altKey) return
if (event.key.length === 1 && /\D/.test(event.key)) event.preventDefault()
}
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 = 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'
input.inputMode = 'numeric'
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
}
function setCalendarPositionClass(container: HTMLElement, className: string, isEnabled: boolean) {
container.classList.toggle(className, isEnabled)
}
function getCalendarPositionParts() {
const parts = props.position.split(' ')
return {
vertical: parts[0] ?? 'auto',
horizontal: parts[1] ?? null,
}
}
function getCalendarHeight(container: HTMLElement) {
const height = container.getBoundingClientRect().height
if (height > 0) return height
return Array.from(container.children).reduce(
(total, child) => total + (child instanceof HTMLElement ? child.offsetHeight : 0),
0,
)
}
function positionCalendar(instance: Instance, customPositionElement?: HTMLElement) {
const container = instance.calendarContainer
const positionElement = customPositionElement ?? instance._positionElement
if (!container || !positionElement) return
const calendarHeight = getCalendarHeight(container)
const calendarWidth = container.offsetWidth
const { vertical, horizontal } = getCalendarPositionParts()
const inputBounds = positionElement.getBoundingClientRect()
const distanceFromBottom = window.innerHeight - inputBounds.bottom
const showOnTop =
vertical === 'above' ||
(vertical !== 'below' &&
distanceFromBottom < calendarHeight &&
inputBounds.top > calendarHeight)
const top =
window.pageYOffset +
inputBounds.top +
(showOnTop
? -calendarHeight - calendarPositionGap
: positionElement.offsetHeight + calendarPositionGap)
let left = window.pageXOffset + inputBounds.left
let isCenter = false
let isRight = false
if (horizontal === 'center') {
left -= (calendarWidth - inputBounds.width) / 2
isCenter = true
} else if (horizontal === 'right') {
left -= calendarWidth - inputBounds.width
isRight = true
}
const viewportLeft = window.pageXOffset
const viewportRight = viewportLeft + document.documentElement.clientWidth
const isOverflowingRight = left + calendarWidth > viewportRight
const clampedLeft = Math.min(
Math.max(viewportLeft, left),
Math.max(viewportLeft, viewportRight - calendarWidth),
)
setCalendarPositionClass(container, 'arrowTop', !showOnTop)
setCalendarPositionClass(container, 'arrowBottom', showOnTop)
setCalendarPositionClass(container, 'arrowLeft', !isCenter && !isRight)
setCalendarPositionClass(container, 'arrowCenter', isCenter)
setCalendarPositionClass(container, 'arrowRight', isRight)
setCalendarPositionClass(container, 'rightMost', isOverflowingRight)
setCalendarPositionClass(container, 'centerMost', false)
container.style.top = `${top}px`
container.style.left = `${clampedLeft}px`
container.style.right = 'auto'
}
const resolvedDateFormat = computed(
() => props.dateFormat ?? (props.enableTime ? 'Y-m-d H:i' : 'Y-m-d'),
)
const resolvedAltFormat = computed(
() => props.altFormat ?? (props.enableTime ? 'F j, Y at h:i K' : 'F j, Y'),
)
const resolvedShowMonths = computed(() =>
Number.isFinite(props.showMonths) ? Math.max(1, Math.floor(props.showMonths)) : 1,
)
const selectedDates = computed(() => {
const value = model.value
if (Array.isArray(value)) {
return value.filter(Boolean) as Array<string | Date>
}
return value ? [value] : []
})
const hasClearButton = computed(
() => !props.calendarOnly && props.clearable && !props.disabled && 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,
props.disabled,
props.readonly,
props.minDate,
props.maxDate,
props.enableTime,
props.mode,
props.showMonths,
props.defaultViewDate,
props.viewDateAlignment,
props.dateFormat,
props.altFormat,
props.time24hr,
props.calendarOnly,
props.closeOnSelect,
props.position,
props.clearable,
],
() => {
if (!picker.value) return
picker.value.set({
...flatpickrOptions(),
onChange: picker.value.config.onChange,
onClose: picker.value.config.onClose,
onReady: picker.value.config.onReady,
onMonthChange: picker.value.config.onMonthChange,
onYearChange: picker.value.config.onYearChange,
onOpen: picker.value.config.onOpen,
})
if (!isPreservingDay.value && props.mode === 'single') {
const value = model.value
if (typeof value === 'string' && value) {
const day = getDayFromDateStr(value)
if (day !== null) intendedDay.value = day
} else {
intendedDay.value = null
}
}
syncAltInputState()
syncTimeInputTypes(picker.value)
if (props.mode !== 'range' || selectedDates.value.length !== 2) cancelRangeEndpointMovePreview()
syncPickerFromModel()
syncCalendarClasses()
},
{ deep: true },
)
watch(
() => props.calendarClass,
() => syncCalendarClasses(),
)
watch(
() => [
props.showToday,
props.calendarOnly,
props.mode,
selectedDates.value.length,
Boolean(rangeDragState.value),
Boolean(rangeEndpointMoveState.value),
],
() => syncCalendarStateClasses(),
)
onMounted(async () => {
await nextTick()
if (!inputRef.value) return
picker.value = flatpickr(inputRef.value, {
...flatpickrOptions(),
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)
instance.calendarContainer.addEventListener('click', handleRangeEndpointMoveClick, true)
instance.calendarContainer.addEventListener('mouseover', previewSelectedRangeEndpoint, true)
instance.calendarContainer.addEventListener('mouseover', syncMissingRangeEndState)
instance.calendarContainer.addEventListener('mouseleave', syncMissingRangeEndState)
instance.calendarContainer.addEventListener(
'click',
normalizeTimeInputForArrowIncrement,
true,
)
instance.calendarContainer.addEventListener(
'keydown',
normalizeTimeInputForKeyboardIncrement,
true,
)
instance.calendarContainer.addEventListener('beforeinput', preventNonNumericTimeInput, true)
instance.calendarContainer.addEventListener('keydown', preventNonNumericTimeKeydown, true)
instance.calendarContainer.addEventListener('input', commitTimeInputForInput, true)
instance.calendarContainer.addEventListener('mousedown', (event) => {
if (props.mode !== 'range') return
const target = event.target as HTMLElement | null
const dayElem = target?.closest<RangeDayElement>('.flatpickr-day')
if (!dayElem) return
if (resolvedShowMonths.value > 1) {
if (!isSelectableDay(dayElem)) return
} else if (
!dayElem.classList.contains('prevMonthDay') &&
!dayElem.classList.contains('nextMonthDay')
) {
return
}
intendedViewMonth.value = {
month: instance.currentMonth,
year: instance.currentYear,
}
})
instance.calendarContainer.addEventListener(
'keydown',
(event) => {
const target = event.target as HTMLElement | null
if (!target?.matches('input.cur-year')) return
if (event.key === 'ArrowUp' || event.key === 'ArrowDown') {
event.preventDefault()
event.stopImmediatePropagation()
changeYearInput(instance, target as HTMLInputElement, event.key === 'ArrowUp' ? 1 : -1)
return
}
if (
event.key === 'ArrowLeft' ||
event.key === 'ArrowRight' ||
event.key === 'Home' ||
event.key === 'End'
) {
event.stopPropagation()
}
},
true,
)
instance.calendarContainer.addEventListener('keyup', (event) => {
const target = event.target as HTMLElement | null
if (!target?.matches('input.cur-year') || event.key !== 'Enter') return
window.requestAnimationFrame(() => target.blur())
})
syncTimeInputTypes(instance)
syncHeaderControlState(instance)
syncCalendarClasses(instance)
syncCalendarStateClasses(instance)
syncRangeEndpointMoveState(instance)
syncMultiMonthSelects(instance)
syncInputFocusScrollSuppression()
},
onChange: (_selectedDates, dateStr, instance) => {
if (isSyncingFromModel.value) return
const nextValue =
props.mode === 'single'
? dateStr || null
: _selectedDates.map((date) => instance.formatDate(date, resolvedDateFormat.value))
if (intendedViewMonth.value !== null) {
preserveViewOnNextModelSync.value = true
}
model.value = nextValue
emit('change', nextValue)
if (intendedViewMonth.value !== null) {
jumpToViewMonth(instance, intendedViewMonth.value)
}
syncHeaderControlState(instance)
syncMissingRangeEndState()
syncCalendarStateClasses(instance)
syncRangeEndpointMoveState(instance)
syncInputDisplayValue(instance)
},
onClose: (_selectedDates, dateStr, instance) => {
cancelRangeEndpointMovePreview()
if (suppressNextInputFocusScroll) {
const focusTarget = instance.altInput ?? inputRef.value
focusTarget?.blur()
suppressNextInputFocusScroll = false
}
if (hasCompleteModelRange()) {
syncPickerFromModel()
return
}
syncInputDisplayValue(instance)
if (!props.clearable || dateStr || hasCompleteRange(instance)) return
const nextValue = props.mode === 'single' ? null : []
model.value = nextValue
instance.clear(false)
emit('clear')
emit('change', nextValue)
},
onMonthChange: (_selectedDates, _dateStr, instance) => {
applyPreserveDay(instance)
syncHeaderControlState(instance)
syncMissingRangeEndState()
syncCalendarStateClasses(instance)
syncRangeEndpointMoveState(instance)
queueMultiMonthSelectSync(instance)
},
onYearChange: (_selectedDates, _dateStr, instance) => {
applyPreserveDay(instance)
syncHeaderControlState(instance)
syncMissingRangeEndState()
syncCalendarStateClasses(instance)
syncRangeEndpointMoveState(instance)
queueMultiMonthSelectSync(instance)
},
onOpen: (_selectedDates, _dateStr, instance) => {
syncCalendarView(instance)
syncTimeInputTypes(instance)
syncHeaderControlState(instance)
syncMissingRangeEndState()
syncCalendarStateClasses(instance)
syncRangeEndpointMoveState(instance)
syncMultiMonthSelects(instance)
},
})
if (props.mode === 'single' && typeof model.value === 'string' && model.value) {
const day = getDayFromDateStr(model.value)
if (day !== null) intendedDay.value = day
}
syncAltInputState()
syncInputFocusScrollSuppression()
syncPickerFromModel()
syncHeaderControlState(picker.value)
syncRangeEndpointMoveState(picker.value)
syncMultiMonthSelects(picker.value)
})
onBeforeUnmount(() => {
clearRangeClickSuppressionTimeout()
if (monthSelectSyncFrame !== null) window.cancelAnimationFrame(monthSelectSyncFrame)
document.removeEventListener('pointermove', updateRangeDrag, true)
document.removeEventListener('pointerup', stopRangeDrag, true)
document.removeEventListener('pointercancel', stopRangeDrag, true)
teardownInputFocusScrollSuppression()
picker.value?.destroy()
destroyCalendarPortal()
})
function flatpickrOptions(): Options {
return {
allowInput: !props.calendarOnly,
altInput: !props.calendarOnly,
altInputClass: props.calendarOnly ? undefined : inputClasses.value.filter(Boolean).join(' '),
altFormat: resolvedAltFormat.value,
appendTo: ensureCalendarPortal(),
closeOnSelect: props.closeOnSelect,
dateFormat: resolvedDateFormat.value,
disableMobile: true,
enableTime: props.enableTime,
inline: props.calendarOnly,
maxDate: props.maxDate,
minDate: props.minDate,
mode: props.mode,
noCalendar: false,
nextArrow: chevronRightIcon,
position: positionCalendar,
prevArrow: chevronLeftIcon,
showMonths: resolvedShowMonths.value,
static: false,
time_24hr: props.time24hr,
}
}
function syncPickerFromModel() {
if (!picker.value) return
const preservedViewMonth =
intendedViewMonth.value ??
(preserveViewOnNextModelSync.value
? {
month: picker.value.currentMonth,
year: picker.value.currentYear,
}
: null)
isSyncingFromModel.value = true
picker.value.setDate(selectedDates.value, false, resolvedDateFormat.value)
syncTimeInputTypes(picker.value)
if (preservedViewMonth !== null) {
jumpToViewMonth(picker.value, preservedViewMonth)
intendedViewMonth.value = null
preserveViewOnNextModelSync.value = false
} else {
syncCalendarView(picker.value)
}
isSyncingFromModel.value = false
syncHeaderControlState(picker.value)
syncInputDisplayValue()
syncMissingRangeEndState()
syncCalendarStateClasses()
syncRangeEndpointMoveState()
syncMultiMonthSelects()
}
function syncAltInputState() {
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: clearValue,
})
</script>
<style scoped>
.modrinth-date-picker :deep(.flatpickr-wrapper) {
@apply w-full;
}
.modrinth-date-picker :deep(.flatpickr-calendar) {
@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;
}
.modrinth-date-picker :deep(.flatpickr-calendar.arrowBottom) {
margin-top: -0.5rem;
}
.modrinth-date-picker.calendar-only {
@apply block;
}
.modrinth-date-picker.calendar-only :deep(.flatpickr-wrapper) {
@apply block w-full;
}
.modrinth-date-picker.calendar-only :deep(.flatpickr-calendar) {
@apply m-0;
box-sizing: content-box;
}
.modrinth-date-picker :deep(.dayContainer) {
@apply max-w-[307.875px] min-w-[307.875px] w-[307.875px];
}
.modrinth-date-picker :deep(.flatpickr-calendar.multiMonth .flatpickr-rContainer),
.modrinth-date-picker :deep(.flatpickr-calendar.multiMonth .flatpickr-days) {
@apply max-w-none;
}
.modrinth-date-picker :deep(.flatpickr-calendar.multiMonth .flatpickr-days) {
overflow: visible;
}
.modrinth-date-picker :deep(.flatpickr-calendar.multiMonth .dayContainer + .dayContainer) {
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;
}
.modrinth-date-picker :deep(.flatpickr-months),
.modrinth-date-picker :deep(.flatpickr-month) {
@apply items-center h-10 flex mb-1 overflow-visible;
}
.modrinth-date-picker :deep(.flatpickr-month),
.modrinth-date-picker :deep(.flatpickr-current-month),
.modrinth-date-picker :deep(.flatpickr-weekdays),
.modrinth-date-picker :deep(.flatpickr-weekdaycontainer) {
@apply text-contrast shadow-none;
}
.modrinth-date-picker :deep(.flatpickr-current-month) {
@apply flex h-full items-center justify-center gap-2 py-0 text-base font-semibold;
}
.modrinth-date-picker :deep(.flatpickr-current-month input.cur-year),
.modrinth-date-picker :deep(.flatpickr-current-month .flatpickr-monthDropdown-months) {
@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) {
@apply min-w-28 pl-3 pr-8;
appearance: none;
background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='none' stroke='%23ffffff' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpath d='m6 9 6 6 6-6'/%3E%3C/svg%3E");
background-position: calc(100% - 8px) calc(50%);
background-repeat: no-repeat;
background-size: 16px 16px;
}
.modrinth-date-picker :deep(.flatpickr-current-month .modrinth-hidden-cur-month) {
display: none;
}
.modrinth-date-picker :deep(.flatpickr-current-month .flatpickr-monthDropdown-months:disabled) {
@apply cursor-not-allowed opacity-40 hover:bg-surface-4;
}
.modrinth-date-picker :deep(.flatpickr-current-month .numInputWrapper:has(input.cur-year)) {
@apply w-[76px];
}
.modrinth-date-picker :deep(.flatpickr-current-month input.cur-year) {
@apply min-w-[76px] px-2 text-center;
}
.modrinth-date-picker :deep(input[type='number']) {
-moz-appearance: textfield;
appearance: textfield;
}
.modrinth-date-picker :deep(input[type='number']::-webkit-inner-spin-button),
.modrinth-date-picker :deep(input[type='number']::-webkit-outer-spin-button) {
margin: 0;
-webkit-appearance: none;
}
.modrinth-date-picker
:deep(.flatpickr-current-month .numInputWrapper:has(input.cur-year:disabled)) {
@apply cursor-not-allowed;
background: none;
}
.modrinth-date-picker :deep(.flatpickr-current-month input.cur-year:disabled) {
@apply opacity-40 bg-surface-4 hover:bg-surface-4;
}
.modrinth-date-picker
:deep(.flatpickr-current-month .numInputWrapper:has(input.cur-year:disabled) span) {
@apply pointer-events-none opacity-0;
}
.modrinth-date-picker :deep(.flatpickr-current-month input.cur-year:focus),
.modrinth-date-picker :deep(.flatpickr-current-month .flatpickr-monthDropdown-months:focus) {
@apply outline-none ring-4 ring-brand-shadow;
}
.modrinth-date-picker :deep(.numInputWrapper span.arrowUp) {
@apply border-0 rounded-tr-xl;
}
.modrinth-date-picker :deep(.numInputWrapper span.arrowDown) {
@apply border-0 rounded-br-xl;
}
.modrinth-date-picker :deep(.numInputWrapper span.arrowUp::after) {
border-bottom-color: var(--color-text-secondary);
}
.modrinth-date-picker :deep(.numInputWrapper span.arrowDown::after) {
border-top-color: var(--color-text-secondary);
}
.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 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),
.modrinth-date-picker :deep(.flatpickr-next-month.flatpickr-disabled) {
@apply cursor-not-allowed opacity-40 hover:bg-transparent hover:text-secondary;
}
.modrinth-date-picker :deep(.flatpickr-prev-month svg),
.modrinth-date-picker :deep(.flatpickr-next-month svg) {
@apply h-5 w-5 stroke-current text-secondary;
fill: none;
stroke-width: 3;
}
.modrinth-date-picker :deep(.flatpickr-prev-month:hover svg),
.modrinth-date-picker :deep(.flatpickr-next-month:hover svg) {
fill: none;
}
.modrinth-date-picker :deep(.flatpickr-weekday) {
@apply text-xs font-semibold text-secondary;
}
.modrinth-date-picker :deep(.flatpickr-day) {
@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(
.flatpickr-day:focus:not(:focus-visible):not(.selected):not(.startRange):not(.endRange):not(
.inRange
)
) {
@apply border-transparent bg-transparent text-primary outline-none;
}
.modrinth-date-picker
:deep(.flatpickr-day:focus-visible:not(.selected):not(.startRange):not(.endRange):not(.inRange)) {
@apply border-transparent bg-surface-4 text-contrast outline-none;
}
.modrinth-date-picker :deep(.flatpickr-day.flatpickr-disabled) {
@apply hover:bg-transparent;
}
.modrinth-date-picker :deep(.flatpickr-day::before) {
content: '';
position: absolute;
inset: 0 0;
z-index: -1;
background: transparent;
}
.modrinth-date-picker :deep(.flatpickr-day.today) {
@apply border-transparent;
}
.modrinth-date-picker.show-today :deep(.flatpickr-day.today::after) {
content: '';
position: absolute;
bottom: 6px;
left: 50%;
width: 4px;
height: 4px;
border-radius: 9999px;
transform: translateX(-50%);
@apply bg-brand;
}
.modrinth-date-picker.show-today :deep(.flatpickr-day.today.selected::after),
.modrinth-date-picker.show-today :deep(.flatpickr-day.today.startRange::after),
.modrinth-date-picker.show-today :deep(.flatpickr-day.today.endRange::after) {
@apply bg-brand-inverted;
}
.modrinth-date-picker :deep(.flatpickr-day.selected),
.modrinth-date-picker :deep(.flatpickr-day.startRange),
.modrinth-date-picker :deep(.flatpickr-day.endRange) {
@apply z-[1] border-brand bg-brand text-brand-inverted !shadow-none hover:border-brand hover:bg-brand hover:text-brand-inverted hover:shadow-none;
}
.modrinth-date-picker :deep(.flatpickr-day.inRange) {
@apply rounded-none border-transparent bg-transparent text-brand shadow-none hover:rounded-none hover:border-transparent hover:bg-transparent;
}
.modrinth-date-picker :deep(.flatpickr-calendar.multiMonth .flatpickr-day.inRange) {
box-shadow: none !important;
}
.modrinth-date-picker :deep(.flatpickr-day.inRange::before),
.modrinth-date-picker :deep(.flatpickr-day.startRange:not(.endRange)::before),
.modrinth-date-picker :deep(.flatpickr-day.endRange:not(.startRange)::before) {
background: var(--color-brand-highlight);
}
.modrinth-date-picker :deep(.flatpickr-day.inRange::before) {
left: -1px;
right: -1px;
}
.modrinth-date-picker :deep(.flatpickr-day.inRange:nth-child(7n + 1)::before) {
@apply rounded-l-xl;
left: 0;
}
.modrinth-date-picker :deep(.flatpickr-day.inRange:nth-child(7n)::before) {
@apply rounded-r-xl;
right: 0;
}
.modrinth-date-picker :deep(.flatpickr-day.startRange:not(.endRange)) {
@apply rounded-full border-brand;
}
.modrinth-date-picker :deep(.flatpickr-day.startRange:not(.endRange)::before) {
@apply rounded-l-xl;
left: 50%;
right: -1px;
}
.modrinth-date-picker
:deep(.flatpickr-calendar.is-missing-range-end .flatpickr-day.startRange:not(.endRange)::before),
.modrinth-date-picker :deep(.flatpickr-calendar.is-missing-range-end .flatpickr-day:hover::before) {
background: transparent;
}
.modrinth-date-picker :deep(.flatpickr-day.startRange:nth-child(7n):not(.endRange)::before) {
@apply rounded-r-xl;
right: 0;
}
.modrinth-date-picker :deep(.flatpickr-day.endRange:not(.startRange)) {
@apply rounded-full border-brand;
}
.modrinth-date-picker :deep(.flatpickr-day.endRange:not(.startRange)::before) {
@apply rounded-r-xl;
left: -1px;
right: 50%;
}
.modrinth-date-picker :deep(.flatpickr-day.endRange:nth-child(7n + 1):not(.startRange)::before) {
@apply rounded-l-xl;
left: 0;
}
.modrinth-date-picker :deep(.flatpickr-calendar.multiMonth .flatpickr-day.hidden::before) {
display: none;
}
.modrinth-date-picker
:deep(.flatpickr-calendar.multiMonth .flatpickr-day.hidden + .flatpickr-day.inRange::before),
.modrinth-date-picker
:deep(
.flatpickr-calendar.multiMonth
.flatpickr-day.hidden
+ .flatpickr-day.endRange:not(.startRange)::before
) {
@apply rounded-l-xl;
left: 0;
}
.modrinth-date-picker
:deep(.flatpickr-calendar.multiMonth .flatpickr-day.inRange:has(+ .hidden)::before),
.modrinth-date-picker
:deep(
.flatpickr-calendar.multiMonth .flatpickr-day.startRange:not(.endRange):has(+ .hidden)::before
) {
@apply rounded-r-xl;
right: 0;
}
.modrinth-date-picker.can-select-range :deep(.flatpickr-day:not(.flatpickr-disabled):not(.hidden)) {
cursor: pointer;
}
.modrinth-date-picker.is-selecting-range
:deep(.flatpickr-day:not(.flatpickr-disabled):not(.hidden)) {
cursor: grabbing;
}
.modrinth-date-picker.can-drag-range
:deep(.flatpickr-day.startRange:not(.endRange):not(.flatpickr-disabled)),
.modrinth-date-picker.can-drag-range
:deep(.flatpickr-day.endRange:not(.startRange):not(.flatpickr-disabled)) {
cursor: grab;
}
.modrinth-date-picker.is-moving-range-end
:deep(.flatpickr-day:not(.flatpickr-disabled):not(.hidden)) {
cursor: grabbing !important;
}
.modrinth-date-picker.is-dragging-range :deep(.flatpickr-day) {
cursor: grabbing !important;
}
.modrinth-date-picker.is-dragging-range
:deep(.flatpickr-day:not(.selected):not(.startRange):not(.endRange):not(.inRange):hover),
.modrinth-date-picker.is-dragging-range
:deep(.flatpickr-day:not(.selected):not(.startRange):not(.endRange):not(.inRange):focus) {
@apply border-transparent bg-transparent text-primary;
}
.modrinth-date-picker
:deep(.flatpickr-day.prevMonthDay:not(.inRange):not(.startRange):not(.endRange)),
.modrinth-date-picker
:deep(.flatpickr-day.nextMonthDay:not(.inRange):not(.startRange):not(.endRange)),
.modrinth-date-picker :deep(.flatpickr-day.flatpickr-disabled) {
@apply text-secondary opacity-40;
}
.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 overflow-visible leading-none;
}
.modrinth-date-picker :deep(.flatpickr-time .numInputWrapper) {
@apply h-full flex-1 rounded-xl bg-surface-4;
}
.modrinth-date-picker :deep(.flatpickr-time input),
.modrinth-date-picker :deep(.flatpickr-time .flatpickr-am-pm) {
@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) {
@apply flex h-full items-center justify-center text-secondary text-center;
}
.modrinth-date-picker :deep(.flatpickr-time .flatpickr-am-pm) {
@apply flex w-14 items-center justify-center bg-surface-4 px-3;
}
</style>