feat(ui): add Calendar and DateRangePicker components

This commit is contained in:
Vadym Samoilenko 2026-05-13 11:01:51 +01:00
parent b382ff640c
commit d586e9474c
2 changed files with 316 additions and 0 deletions

View file

@ -0,0 +1,158 @@
<template>
<div class="p-3 select-none">
<!-- Month header -->
<div class="mb-3 flex items-center justify-between">
<button
type="button"
class="flex h-8 w-8 items-center justify-center rounded-md hover:bg-accent focus-visible:ring-2 focus-visible:ring-ring focus-visible:outline-none"
@click="prevMonth"
aria-label="Previous month"
>
<ChevronLeft class="h-4 w-4" />
</button>
<span class="text-sm font-medium">{{ monthLabel }}</span>
<button
type="button"
class="flex h-8 w-8 items-center justify-center rounded-md hover:bg-accent focus-visible:ring-2 focus-visible:ring-ring focus-visible:outline-none"
@click="nextMonth"
aria-label="Next month"
>
<ChevronRight class="h-4 w-4" />
</button>
</div>
<!-- Day-of-week header -->
<div class="mb-1 grid grid-cols-7 gap-1">
<div
v-for="d in dayNames"
:key="d"
class="flex h-8 items-center justify-center text-[11px] font-medium uppercase tracking-wide text-muted-foreground"
>{{ d }}</div>
</div>
<!-- Day grid -->
<div class="grid grid-cols-7 gap-1">
<div v-for="i in leadingBlanks" :key="'blank-' + i" />
<button
v-for="day in daysInMonth"
:key="day.toISOString()"
type="button"
:class="cn(
'flex h-9 w-9 items-center justify-center rounded-md text-sm transition-colors',
'focus-visible:ring-2 focus-visible:ring-ring focus-visible:outline-none',
isOutOfMonth(day) && 'text-muted-foreground/40',
isToday(day) && !isSelected(day) && 'ring-1 ring-primary',
isRangeStart(day) && 'rounded-l-md bg-primary text-primary-foreground',
isRangeEnd(day) && 'rounded-r-md bg-primary text-primary-foreground',
isInRange(day) && !isRangeStart(day) && !isRangeEnd(day) && 'bg-primary/15 rounded-none',
isPreviewInRange(day) && !isInRange(day) && !isSelected(day) && 'bg-primary/10',
!isSelected(day) && !isInRange(day) && !isPreviewInRange(day) && 'hover:bg-accent'
)"
@click="selectDay(day)"
@mouseenter="hoverDay = day"
@mouseleave="hoverDay = null"
:aria-label="format(day, 'EEEE, MMMM d, yyyy')"
:aria-pressed="isSelected(day)"
>
{{ format(day, 'd') }}
</button>
</div>
</div>
</template>
<script setup lang="ts">
import { ref, computed } from 'vue'
import {
startOfMonth, endOfMonth, eachDayOfInterval, addMonths, subMonths,
isSameDay, isToday as dateFnsIsToday, isWithinInterval, format,
getDay, isBefore
} from 'date-fns'
import { ChevronLeft, ChevronRight } from 'lucide-vue-next'
import { cn } from '@/lib/utils'
interface DateRange { from: Date | null; to: Date | null }
const props = withDefaults(defineProps<{
modelValue?: DateRange
mode?: 'single' | 'range'
initialMonth?: Date
}>(), {
mode: 'range',
modelValue: () => ({ from: null, to: null })
})
const emit = defineEmits<{
'update:modelValue': [value: DateRange]
}>()
const currentMonth = ref(props.initialMonth ?? new Date())
const hoverDay = ref<Date | null>(null)
const dayNames = ['Mo', 'Tu', 'We', 'Th', 'Fr', 'Sa', 'Su']
const monthLabel = computed(() => format(currentMonth.value, 'MMMM yyyy'))
const daysInMonth = computed(() => {
const start = startOfMonth(currentMonth.value)
const end = endOfMonth(currentMonth.value)
return eachDayOfInterval({ start, end })
})
// How many blank cells before the first day (Mon=0, Sun=6)
const leadingBlanks = computed(() => {
const day = getDay(startOfMonth(currentMonth.value))
return day === 0 ? 6 : day - 1
})
function prevMonth() { currentMonth.value = subMonths(currentMonth.value, 1) }
function nextMonth() { currentMonth.value = addMonths(currentMonth.value, 1) }
function isOutOfMonth(day: Date) {
return day.getMonth() !== currentMonth.value.getMonth()
}
function isToday(day: Date) { return dateFnsIsToday(day) }
function isSelected(day: Date) {
return isRangeStart(day) || isRangeEnd(day)
}
function isRangeStart(day: Date) {
return props.modelValue?.from ? isSameDay(day, props.modelValue.from) : false
}
function isRangeEnd(day: Date) {
return props.modelValue?.to ? isSameDay(day, props.modelValue.to) : false
}
function isInRange(day: Date) {
const { from, to } = props.modelValue ?? {}
if (!from || !to) return false
return isWithinInterval(day, { start: from, end: to }) && !isSameDay(day, from) && !isSameDay(day, to)
}
function isPreviewInRange(day: Date) {
const from = props.modelValue?.from
if (!from || props.modelValue?.to || !hoverDay.value) return false
const hover = hoverDay.value
if (isBefore(hover, from)) return false
return isWithinInterval(day, { start: from, end: hover })
}
function selectDay(day: Date) {
if (props.mode === 'single') {
emit('update:modelValue', { from: day, to: day })
return
}
const { from, to } = props.modelValue ?? {}
if (!from || (from && to)) {
emit('update:modelValue', { from: day, to: null })
} else {
if (isBefore(day, from)) {
emit('update:modelValue', { from: day, to: from })
} else {
emit('update:modelValue', { from, to: day })
}
}
}
</script>

View file

@ -0,0 +1,158 @@
<template>
<PopoverRoot v-model:open="open">
<PopoverTrigger as-child>
<Button variant="outline" :class="cn('w-full justify-start gap-2 text-left font-normal', !modelValue?.from && 'text-muted-foreground')">
<CalendarIcon class="h-4 w-4 shrink-0" />
<span>{{ rangeLabel }}</span>
</Button>
</PopoverTrigger>
<PopoverPortal>
<PopoverContent
:side-offset="8"
class="z-50 w-auto rounded-xl border border-border bg-popover p-0 shadow-xl animate-in fade-in-0 zoom-in-95 data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=closed]:zoom-out-95"
>
<div class="flex">
<!-- Preset sidebar -->
<div class="flex w-36 flex-col border-r border-border p-2 gap-1">
<button
v-for="preset in availablePresets"
:key="preset.value"
type="button"
:class="cn(
'rounded-md px-3 py-2 text-left text-sm transition-colors',
'hover:bg-accent focus-visible:ring-2 focus-visible:ring-ring focus-visible:outline-none',
activePreset === preset.value && 'bg-primary/10 font-medium text-primary'
)"
@click="applyPreset(preset.value)"
>{{ preset.label }}</button>
</div>
<!-- Calendar(s) -->
<div class="flex flex-col">
<div class="flex">
<Calendar
v-model="internalRange"
:initial-month="calMonth1"
mode="range"
/>
<div class="border-l border-border">
<Calendar
v-model="internalRange"
:initial-month="calMonth2"
mode="range"
/>
</div>
</div>
<!-- Validation error -->
<p v-if="validationError" class="px-4 pb-2 text-xs text-destructive">{{ validationError }}</p>
<!-- Footer -->
<div class="flex items-center justify-end gap-2 border-t border-border px-4 py-3">
<Button variant="ghost" size="sm" @click="handleCancel">Cancel</Button>
<Button size="sm" :disabled="!canApply" @click="handleApply">Apply</Button>
</div>
</div>
</div>
</PopoverContent>
</PopoverPortal>
</PopoverRoot>
</template>
<script setup lang="ts">
import { ref, computed, watch } from 'vue'
import { format, subDays, startOfMonth } from 'date-fns'
import { Calendar as CalendarIcon } from 'lucide-vue-next'
import { PopoverRoot, PopoverTrigger, PopoverPortal, PopoverContent } from 'radix-vue'
import { cn } from '@/lib/utils'
import Button from './Button.vue'
import Calendar from './Calendar.vue'
interface DateRange { from: Date | null; to: Date | null }
const props = withDefaults(defineProps<{
modelValue?: DateRange
}>(), {
modelValue: () => ({ from: null, to: null })
})
const emit = defineEmits<{
'update:modelValue': [value: DateRange]
}>()
const open = ref(false)
const internalRange = ref<DateRange>({ from: props.modelValue?.from ?? null, to: props.modelValue?.to ?? null })
const today = new Date()
const calMonth1 = computed(() => startOfMonth(internalRange.value.from ?? today))
const calMonth2 = computed(() => {
const m = new Date(calMonth1.value)
m.setMonth(m.getMonth() + 1)
return m
})
const PRESETS = [
{ value: 'today', label: 'Today' },
{ value: 'last7', label: 'Last 7 days' },
{ value: 'last30', label: 'Last 30 days' },
{ value: 'thisMonth', label: 'This month' },
{ value: 'custom', label: 'Custom' },
]
const availablePresets = PRESETS
const activePreset = ref<string>('custom')
function applyPreset(value: string) {
activePreset.value = value
if (value === 'custom') return
const now = new Date()
let from: Date
let to: Date
if (value === 'today') {
from = now; to = now
} else if (value === 'last7') {
from = subDays(now, 6); to = now
} else if (value === 'last30') {
from = subDays(now, 29); to = now
} else if (value === 'thisMonth') {
from = startOfMonth(now); to = now
} else return
internalRange.value = { from, to }
}
const validationError = computed(() => {
if (internalRange.value.from && internalRange.value.to) {
if (internalRange.value.from > internalRange.value.to) return 'End date must be after start date'
}
return null
})
const canApply = computed(() =>
internalRange.value.from !== null &&
internalRange.value.to !== null &&
!validationError.value
)
const rangeLabel = computed(() => {
const { from, to } = props.modelValue ?? {}
if (!from) return 'Pick a date range'
if (!to || from.getTime() === to.getTime()) return format(from, 'MMM d, yyyy')
return `${format(from, 'MMM d')} ${format(to, 'MMM d, yyyy')}`
})
function handleApply() {
if (!canApply.value) return
emit('update:modelValue', { from: internalRange.value.from, to: internalRange.value.to })
open.value = false
}
function handleCancel() {
internalRange.value = { from: props.modelValue?.from ?? null, to: props.modelValue?.to ?? null }
open.value = false
}
watch(() => props.modelValue, (v) => {
internalRange.value = { from: v?.from ?? null, to: v?.to ?? null }
}, { deep: true })
</script>