Date Range Picker
Patterns for implementing date range selection with useTemporal.
Basic Date Range Selection
typescript
import { createTemporal, contains, isSame, period } from 'usetemporal'
interface DateRange {
start: Date | null
end: Date | null
}
class DateRangePicker {
private range: DateRange = { start: null, end: null }
private selecting = false
constructor(private temporal: Temporal) {}
// Handle date selection
selectDate(date: Date): DateRange | null {
if (!this.range.start || (this.range.start && this.range.end)) {
// Start new selection
this.range = { start: date, end: null }
this.selecting = true
} else if (this.selecting) {
// Complete selection
if (date < this.range.start) {
// Swap if end is before start
this.range = { start: date, end: this.range.start }
} else {
this.range.end = date
}
this.selecting = false
return this.getSelectedPeriod()
}
return null
}
// Get selected period
getSelectedPeriod(): Period | null {
if (this.range.start && this.range.end) {
return period(this.temporal, { start: this.range.start, end: this.range.end })
}
return null
}
// Check if date is in selection
isInRange(date: Date): boolean {
if (!this.range.start) return false
if (this.range.end) {
const period = this.getSelectedPeriod()!
return contains(period, date)
} else if (this.selecting) {
// Preview range while selecting
const start = this.range.start
const end = date
return date >= Math.min(start.getTime(), end.getTime()) &&
date <= Math.max(start.getTime(), end.getTime())
}
return isSame(
this.temporal,
toPeriod(this.temporal, date, 'day'),
toPeriod(this.temporal, this.range.start, 'day'),
'day'
)
}
// Clear selection
clear(): void {
this.range = { start: null, end: null }
this.selecting = false
}
}Vue Date Range Component
vue
<template>
<div class="date-range-picker">
<div class="range-inputs">
<input
type="text"
:value="formattedStart"
placeholder="Start date"
readonly
@click="showPicker = true"
/>
<span class="separator">→</span>
<input
type="text"
:value="formattedEnd"
placeholder="End date"
readonly
@click="showPicker = true"
/>
<button v-if="hasSelection" @click="clearSelection" class="clear-btn">
✕
</button>
</div>
<div v-if="showPicker" class="calendar-popup">
<div class="preset-ranges">
<button
v-for="preset in presets"
:key="preset.label"
@click="selectPreset(preset)"
>
{{ preset.label }}
</button>
</div>
<div class="calendars">
<MonthCalendar
:temporal="temporal"
:month="currentMonth"
:selectedRange="selectedRange"
:highlightDate="hoverDate"
@date-click="handleDateClick"
@date-hover="handleDateHover"
/>
<MonthCalendar
:temporal="temporal"
:month="nextMonth"
:selectedRange="selectedRange"
:highlightDate="hoverDate"
@date-click="handleDateClick"
@date-hover="handleDateHover"
/>
</div>
<div class="actions">
<button @click="cancel">Cancel</button>
<button @click="apply" :disabled="!isComplete">Apply</button>
</div>
</div>
</div>
</template>
<script setup>
import { ref, computed, watch } from 'vue'
import { createTemporal, toPeriod, next, previous, period } from 'usetemporal'
const props = defineProps({
modelValue: Object // { start: Date, end: Date }
})
const emit = defineEmits(['update:modelValue'])
const temporal = createTemporal({ date: new Date() })
const picker = ref(new DateRangePicker(temporal))
const showPicker = ref(false)
const hoverDate = ref(null)
const currentMonth = computed(() =>
usePeriod(temporal, 'month').value
)
const nextMonth = computed(() =>
next(temporal, currentMonth.value)
)
const selectedRange = computed(() => ({
start: picker.value.range.start,
end: picker.value.range.end
}))
const hasSelection = computed(() =>
selectedRange.value.start && selectedRange.value.end
)
const isComplete = computed(() =>
hasSelection.value
)
const formattedStart = computed(() =>
selectedRange.value.start?.toLocaleDateString() || ''
)
const formattedEnd = computed(() =>
selectedRange.value.end?.toLocaleDateString() || ''
)
// Preset ranges
const presets = [
{
label: 'Today',
getRange: () => {
const today = new Date()
return { start: today, end: today }
}
},
{
label: 'This Week',
getRange: () => {
const week = toPeriod(temporal, new Date(), 'week')
return { start: week.start, end: week.end }
}
},
{
label: 'This Month',
getRange: () => {
const month = toPeriod(temporal, new Date(), 'month')
return { start: month.start, end: month.end }
}
},
{
label: 'Last 7 Days',
getRange: () => {
const end = new Date()
const start = new Date()
start.setDate(start.getDate() - 6)
return { start, end }
}
},
{
label: 'Last 30 Days',
getRange: () => {
const end = new Date()
const start = new Date()
start.setDate(start.getDate() - 29)
return { start, end }
}
}
]
const handleDateClick = (date) => {
const result = picker.value.selectDate(date)
if (result) {
// Selection complete
hoverDate.value = null
}
}
const handleDateHover = (date) => {
if (picker.value.selecting) {
hoverDate.value = date
}
}
const selectPreset = (preset) => {
const range = preset.getRange()
picker.value.range = range
picker.value.selecting = false
}
const clearSelection = () => {
picker.value.clear()
emit('update:modelValue', null)
}
const apply = () => {
if (hasSelection.value) {
emit('update:modelValue', {
start: selectedRange.value.start,
end: selectedRange.value.end
})
showPicker.value = false
}
}
const cancel = () => {
showPicker.value = false
}
</script>
<style scoped>
.date-range-picker {
position: relative;
display: inline-block;
}
.range-inputs {
display: flex;
align-items: center;
gap: 0.5rem;
padding: 0.5rem;
border: 1px solid #ddd;
border-radius: 4px;
background: white;
}
.range-inputs input {
border: none;
outline: none;
width: 120px;
}
.separator {
color: #999;
}
.clear-btn {
background: none;
border: none;
cursor: pointer;
color: #999;
}
.calendar-popup {
position: absolute;
top: 100%;
left: 0;
margin-top: 0.5rem;
background: white;
border: 1px solid #ddd;
border-radius: 4px;
box-shadow: 0 2px 8px rgba(0,0,0,0.1);
z-index: 1000;
}
.preset-ranges {
display: flex;
gap: 0.5rem;
padding: 1rem;
border-bottom: 1px solid #eee;
}
.preset-ranges button {
padding: 0.25rem 0.75rem;
border: 1px solid #ddd;
background: white;
border-radius: 4px;
cursor: pointer;
}
.preset-ranges button:hover {
background: #f5f5f5;
}
.calendars {
display: flex;
gap: 1rem;
padding: 1rem;
}
.actions {
display: flex;
justify-content: flex-end;
gap: 0.5rem;
padding: 1rem;
border-top: 1px solid #eee;
}
</style>Advanced Range Validation
typescript
interface RangeConstraints {
minDate?: Date
maxDate?: Date
maxDays?: number
minDays?: number
disabledDates?: Date[]
disabledDaysOfWeek?: number[]
}
class ConstrainedDateRangePicker extends DateRangePicker {
constructor(
temporal: Temporal,
private constraints: RangeConstraints = {}
) {
super(temporal)
}
// Check if date is selectable
isDateSelectable(date: Date): boolean {
// Check min/max bounds
if (this.constraints.minDate && date < this.constraints.minDate) {
return false
}
if (this.constraints.maxDate && date > this.constraints.maxDate) {
return false
}
// Check disabled dates
if (this.constraints.disabledDates) {
const isDisabled = this.constraints.disabledDates.some(d =>
isSame(this.temporal,
toPeriod(this.temporal, d, 'day'),
toPeriod(this.temporal, date, 'day'),
'day'
)
)
if (isDisabled) return false
}
// Check disabled days of week
if (this.constraints.disabledDaysOfWeek) {
const dayOfWeek = date.getDay()
if (this.constraints.disabledDaysOfWeek.includes(dayOfWeek)) {
return false
}
}
return true
}
// Validate range selection
validateRange(start: Date, end: Date): string | null {
// Check individual dates
if (!this.isDateSelectable(start) || !this.isDateSelectable(end)) {
return 'Selected dates are not available'
}
// Check range length
const days = Math.ceil((end.getTime() - start.getTime()) / (1000 * 60 * 60 * 24)) + 1
if (this.constraints.minDays && days < this.constraints.minDays) {
return `Minimum ${this.constraints.minDays} days required`
}
if (this.constraints.maxDays && days > this.constraints.maxDays) {
return `Maximum ${this.constraints.maxDays} days allowed`
}
// Check for disabled dates within range
if (this.constraints.disabledDates) {
const hasDisabled = this.constraints.disabledDates.some(d =>
d >= start && d <= end
)
if (hasDisabled) {
return 'Range contains unavailable dates'
}
}
return null // Valid
}
}Business Days Range
typescript
// Select only business days
function createBusinessDayRangePicker(temporal: Temporal) {
const constraints: RangeConstraints = {
disabledDaysOfWeek: [0, 6], // Disable weekends
disabledDates: getFederalHolidays(new Date().getFullYear())
}
return new ConstrainedDateRangePicker(temporal, constraints)
}
// Count business days in range
function countBusinessDaysInRange(
temporal: Temporal,
range: Period
): number {
const days = divide(temporal, range, 'day')
return days.filter(day =>
!isWeekend(day) && !isHoliday(day.date)
).length
}
// Get business day range
function getBusinessDayRange(
temporal: Temporal,
startDate: Date,
businessDays: number
): Period {
let current = toPeriod(temporal, startDate, 'day')
let count = 0
let endDate = startDate
while (count < businessDays) {
if (!isWeekend(current) && !isHoliday(current.date)) {
count++
endDate = current.date
}
current = next(temporal, current)
}
return period(temporal, { start: startDate, end: endDate })
}React Date Range Hook
jsx
import { useState, useCallback, useMemo } from 'react'
import { createTemporal, period, contains, isSame } from 'usetemporal'
function useDateRange(constraints = {}) {
const [temporal] = useState(() => createTemporal({ date: new Date() }))
const [range, setRange] = useState({ start: null, end: null })
const [selecting, setSelecting] = useState(false)
const selectDate = useCallback((date) => {
if (!range.start || (range.start && range.end)) {
// Start new selection
setRange({ start: date, end: null })
setSelecting(true)
} else if (selecting) {
// Complete selection
const newRange = date < range.start
? { start: date, end: range.start }
: { start: range.start, end: date }
setRange(newRange)
setSelecting(false)
return period(temporal, { start: newRange.start, end: newRange.end })
}
return null
}, [range, selecting])
const isInRange = useCallback((date) => {
if (!range.start) return false
if (range.end) {
const customPeriod = period(temporal, { start: range.start, end: range.end })
return contains(customPeriod, date)
} else if (selecting) {
// Preview range
const start = range.start.getTime()
const dateTime = date.getTime()
return dateTime >= Math.min(start, dateTime) &&
dateTime <= Math.max(start, dateTime)
}
return isSame(
temporal,
toPeriod(temporal, date, 'day'),
toPeriod(temporal, range.start, 'day'),
'day'
)
}, [temporal, range, selecting])
const clear = useCallback(() => {
setRange({ start: null, end: null })
setSelecting(false)
}, [])
return {
range,
selecting,
selectDate,
isInRange,
clear,
temporal
}
}
// Usage
function DateRangePickerComponent() {
const {
range,
selecting,
selectDate,
isInRange,
clear
} = useDateRange()
return (
<div>
{/* Calendar UI */}
</div>
)
}Linked Date Inputs
typescript
// Manage start and end date inputs
class LinkedDateInputs {
constructor(
private temporal: Temporal,
private onRangeChange: (range: Period | null) => void
) {}
private start: Date | null = null
private end: Date | null = null
setStartDate(date: Date | null): void {
this.start = date
if (this.end && date && date > this.end) {
// Auto-adjust end date
this.end = null
}
this.updateRange()
}
setEndDate(date: Date | null): void {
this.end = date
if (this.start && date && date < this.start) {
// Auto-adjust start date
this.start = null
}
this.updateRange()
}
private updateRange(): void {
if (this.start && this.end) {
const range = period(this.temporal, { start: this.start, end: this.end })
this.onRangeChange(range)
} else {
this.onRangeChange(null)
}
}
// Get valid date range for end input
getEndDateConstraints(): { min?: Date; max?: Date } {
return {
min: this.start || undefined
}
}
// Get valid date range for start input
getStartDateConstraints(): { min?: Date; max?: Date } {
return {
max: this.end || undefined
}
}
}Usage Examples
typescript
const temporal = createTemporal({ date: new Date() })
// Basic range picker
const picker = new DateRangePicker(temporal)
// Select dates
picker.selectDate(new Date('2024-03-01'))
picker.selectDate(new Date('2024-03-15'))
const selectedPeriod = picker.getSelectedPeriod()
console.log(`Selected: ${selectedPeriod.start} to ${selectedPeriod.end}`)
// Constrained picker (max 7 days, no weekends)
const constrainedPicker = new ConstrainedDateRangePicker(temporal, {
maxDays: 7,
disabledDaysOfWeek: [0, 6]
})
// Business day range
const businessRange = getBusinessDayRange(
temporal,
new Date('2024-03-01'),
10 // 10 business days
)
console.log(`10 business days: ${businessRange.start} to ${businessRange.end}`)
// Count days in range
const days = divide(temporal, selectedPeriod, 'day').length
const businessDays = countBusinessDaysInRange(temporal, selectedPeriod)
console.log(`Total days: ${days}, Business days: ${businessDays}`)See Also
- Business Days - Working with business days
- Month Calendar - Calendar implementation
- contains - Check date containment
- period - Create custom periods