feat(ui): add Calendar and DateRangePicker components
This commit is contained in:
parent
b382ff640c
commit
d586e9474c
2 changed files with 316 additions and 0 deletions
158
web/src/components/ui/Calendar.vue
Normal file
158
web/src/components/ui/Calendar.vue
Normal 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>
|
||||
158
web/src/components/ui/DateRangePicker.vue
Normal file
158
web/src/components/ui/DateRangePicker.vue
Normal 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>
|
||||
Loading…
Add table
Reference in a new issue