Date Range Picker
Date Range Picker handles one decision with two date-only boundaries. It submits both dates as stable YYYY-MM-DD values and keeps the contiguous range visible in Calendar.
Installation
The registry resolves Input, Popover, and Calendar before installing the framework-native range composition:
Run one command from a Boring Stack application. Klean detects the framework and conventional destination, then adds the framework-native source and its direct dependencies.
npx klean-ui add date-range-picker- No initializer or configuration file
- No framework, alias, or theme questions
- No Klean runtime dependency
When to use
Use Date Range Picker for reporting periods, stays, booking windows, availability, campaigns, and filters where start and end form one decision.
When not to use
Use two Date Pickers when the fields have different business meanings or asymmetric rules, such as invoice issue and due dates. Use Schedule Picker when either boundary needs a time and timezone. Use Calendar when the date surface should remain visible.
Usage
Vue
<script setup>
import { ref } from 'vue'
import DateRangePicker from '~/components/ui/date-range-picker/DateRangePicker.vue'
const period = ref({ start: '', end: '' })
</script>
<template>
<DateRangePicker
v-model="period"
name="period"
label="Reporting period"
start-label="From"
end-label="To"
required
/>
</template>
React
import { useState } from 'react'
import DateRangePicker from '@/components/ui/date-range-picker/DateRangePicker.jsx'
export default function ReportingPeriod() {
const [period, setPeriod] = useState({ start: '', end: '' })
return (
<DateRangePicker
value={period}
onValueChange={setPeriod}
name="period"
label="Reporting period"
startLabel="From"
endLabel="To"
required
/>
)
}
Svelte
<script>
import DateRangePicker from '~/components/ui/date-range-picker/DateRangePicker.svelte'
let period = $state({ start: '', end: '' })
</script>
<DateRangePicker
bind:value={period}
name="period"
label="Reporting period"
startLabel="From"
endLabel="To"
required
/>
Value and form contract
The value is { start, end }. A picker named period submits two native form entries: period[start] and period[end]. Committed state is always empty, start-only, or a complete ordered range. It never contains a partial date, an end without a start, or a timezone-dependent Date.
Ranges are inclusive: the start and end may be the same day. That is useful for one-day reports and stays. If a product requires at least one day between the boundaries, express that product rule with availability or compose two Date Pickers with derived min and max values.
Choosing a second date earlier than the first orders the result. The component does not leave the application with an inverted range.
Range rules
- The calendar anchors to the field that opened it. It flips or shifts before leaving the viewport and follows the field through scroll, resize, and layout changes.
- Start and end are inclusive; selecting the same date twice creates a valid one-day range.
- Reverse calendar selection is ordered automatically. An inverted typed draft is reported as invalid without changing committed application state.
- Clearing the start clears both boundaries. Typing an end without a start remains an invalid draft.
minandmaxconstrain both boundaries. Anunavailabledate cannot be selected or crossed, so the result remains one contiguous range.- Arrow Down opens from either field. Escape and explicit dismissal return focus to that field; outside dismissal leaves focus at the new target.
- Required, disabled, readonly, controlled, uncontrolled, locale, direction, and native form behavior need no additional configuration.
API
| Purpose | Vue | React | Svelte |
|---|---|---|---|
| Current range | v-model | value, onValueChange | bind:value |
| Initial range | default-value | defaultValue | defaultValue |
| Native form names | name | name | name |
| Group and field labels | label, start-label, end-label | label, startLabel, endLabel | label, startLabel, endLabel |
| Bounds | min, max | min, max | min, max |
| Product availability | unavailable | unavailable | unavailable |
| Open state | v-model:open | open, onOpenChange | bind:open |
| Native states | required, disabled, readonly | required, disabled, readOnly | required, disabled, readonly |
The root is a semantic fieldset with one legend and two labelled editable fields. Arrow Down opens Calendar from either field. Selecting the start keeps the surface open and advances to the end; selecting the end closes it and returns focus reliably.
Related components
Calendar, Date Picker, and Date Range Picker use date-only YYYY-MM-DD values. Choose Schedule Picker when time and timezone must resolve to an exact ISO instant.
- Calendar — one always-visible date-only
YYYY-MM-DDsurface. - Date Picker — independent date-only
YYYY-MM-DDvalues and asymmetric rules. - Schedule Picker — date, time, and IANA timezone stored as an exact ISO instant.
- Popover — the floating surface used by the compact range.
Complete framework source
Vue
<script setup>
import { computed, nextTick, ref, useAttrs, useId, watch } from 'vue'
import { twMerge } from 'tailwind-merge'
import Calendar from '../calendar/Calendar.vue'
import {
addDays,
compareDates,
dateIsUnavailable,
dateLabel,
parseIsoDate,
resolveLocale
} from '../calendar/date.js'
import Input from '../input/Input.vue'
import Popover from '../popover/Popover.vue'
defineOptions({ inheritAttrs: false })
function normalizeRange(value) {
return {
start: parseIsoDate(value?.start) ? value.start : '',
end: parseIsoDate(value?.end) ? value.end : ''
}
}
function rangeContainsUnavailable(start, end, unavailable) {
if (!unavailable || !parseIsoDate(start) || !parseIsoDate(end)) return false
const [first, last] =
compareDates(start, end) <= 0 ? [start, end] : [end, start]
for (
let date = first;
date && compareDates(date, last) <= 0;
date = addDays(date, 1)
) {
if (unavailable(date)) return true
}
return false
}
const props = defineProps({
/** Date-only ISO boundaries: { start: YYYY-MM-DD, end: YYYY-MM-DD }. */
modelValue: { type: Object, default: undefined },
defaultValue: { type: Object, default: () => ({}) },
id: { type: String, default: undefined },
/** Produces name[start] and name[end] native form entries. */
name: { type: String, default: undefined },
label: { type: String, default: 'Date range' },
startLabel: { type: String, default: 'Start date' },
endLabel: { type: String, default: 'End date' },
min: { type: String, default: undefined },
max: { type: String, default: undefined },
unavailable: { type: Function, default: undefined },
locale: { type: String, default: undefined },
dir: { type: String, default: undefined },
open: { type: Boolean, default: undefined },
defaultOpen: { type: Boolean, default: false },
required: { type: Boolean, default: false },
disabled: { type: Boolean, default: false },
readonly: { type: Boolean, default: false }
})
const emit = defineEmits(['update:modelValue', 'change', 'update:open'])
const attrs = useAttrs()
const generatedId = useId().replace(/[^a-zA-Z0-9_-]/g, '')
const baseId = computed(() => props.id ?? `klean-date-range-${generatedId}`)
const startId = computed(() => `${baseId.value}-start`)
const endId = computed(() => `${baseId.value}-end`)
const popoverId = computed(() => `${baseId.value}-calendar`)
const statusId = computed(() => `${baseId.value}-status`)
const internalValue = ref(normalizeRange(props.defaultValue))
const value = computed(() =>
props.modelValue === undefined
? internalValue.value
: normalizeRange(props.modelValue)
)
const startDraft = ref(value.value.start)
const endDraft = ref(value.value.end)
const activePart = ref('start')
const preview = ref('')
const internalPopoverOpen = ref(props.defaultOpen)
const startInput = ref()
const endInput = ref()
const popover = ref()
const locale = computed(() => resolveLocale(props.locale))
const limits = computed(() => ({
min: props.min,
max: props.max,
unavailable: props.unavailable
}))
const startInvalid = computed(
() =>
Boolean(startDraft.value) &&
(!parseIsoDate(startDraft.value) ||
dateIsUnavailable(startDraft.value, limits.value))
)
const endInvalid = computed(
() =>
Boolean(endDraft.value) &&
(!parseIsoDate(endDraft.value) ||
dateIsUnavailable(endDraft.value, limits.value))
)
const endWithoutStartInvalid = computed(
() => Boolean(endDraft.value) && !Boolean(startDraft.value)
)
const orderInvalid = computed(
() =>
parseIsoDate(startDraft.value) &&
parseIsoDate(endDraft.value) &&
compareDates(endDraft.value, startDraft.value) < 0
)
const rangeUnavailable = computed(
() =>
!orderInvalid.value &&
rangeContainsUnavailable(
startDraft.value,
endDraft.value,
props.unavailable
)
)
const popoverOpen = computed(() =>
props.open === undefined ? internalPopoverOpen.value : props.open
)
const calendarUnavailable = computed(
() => (date) =>
dateIsUnavailable(date, limits.value) ||
(activePart.value === 'end' &&
value.value.start &&
rangeContainsUnavailable(value.value.start, date, props.unavailable))
)
const statusText = computed(() => {
if (endWithoutStartInvalid.value)
return 'Choose a start date before the end date.'
if (orderInvalid.value)
return 'The end date must be on or after the start date.'
if (startInvalid.value || endInvalid.value) {
return 'Enter available dates as YYYY-MM-DD.'
}
if (rangeUnavailable.value) {
return 'The range cannot include an unavailable date.'
}
if (value.value.start && value.value.end) {
return `${dateLabel(value.value.start, locale.value)} through ${dateLabel(value.value.end, locale.value)}.`
}
if (value.value.start) return 'Choose an end date.'
return 'Choose a start date, then an end date.'
})
const orderedDecoration = computed(() => {
const start = value.value.start
const end = value.value.end || preview.value
if (!start || !end) return { start, end }
return compareDates(start, end) <= 0
? { start, end }
: { start: end, end: start }
})
const calendarValue = computed(() =>
activePart.value === 'end'
? value.value.end || preview.value || value.value.start
: value.value.start
)
const fieldsetAttrs = computed(() => {
const { class: _class, 'data-slot': _dataSlot, ...rest } = attrs
return rest
})
const rootClasses = computed(() =>
twMerge(
'grid w-full gap-3 **:data-[slot=date-range-field]:relative **:data-[slot=date-range-field]:flex **:data-[slot=date-range-field]:items-stretch **:data-[slot=input]:pe-12',
attrs.class
)
)
function updateRange(nextRange) {
const normalized = normalizeRange(nextRange)
if (props.modelValue === undefined) internalValue.value = normalized
emit('update:modelValue', normalized)
emit('change', normalized)
}
function openPart(part, source) {
if (props.disabled || props.readonly) return
activePart.value = part
preview.value = ''
const input =
part === 'end' ? endInput.value?.element : startInput.value?.element
popover.value?.open(source ?? input)
}
function handleDraft(part, event) {
const next = event.target.value.trim()
if (part === 'start') startDraft.value = next
else endDraft.value = next
if (part === 'start' && !next) {
endDraft.value = ''
updateRange({ start: '', end: '' })
return
}
if (part === 'end' && next && !value.value.start) return
if (next && (!parseIsoDate(next) || dateIsUnavailable(next, limits.value)))
return
const nextRange = {
start: part === 'start' ? next : value.value.start,
end: part === 'end' ? next : value.value.end
}
if (
nextRange.start &&
nextRange.end &&
compareDates(nextRange.end, nextRange.start) < 0
) {
return
}
if (
nextRange.start &&
nextRange.end &&
rangeContainsUnavailable(nextRange.start, nextRange.end, props.unavailable)
) {
return
}
updateRange(nextRange)
}
function chooseDate(date) {
if (props.disabled || props.readonly || calendarUnavailable.value(date))
return
if (activePart.value === 'start' || !value.value.start) {
updateRange({ start: date, end: '' })
startDraft.value = date
endDraft.value = ''
activePart.value = 'end'
preview.value = date
return
}
const range =
compareDates(date, value.value.start) < 0
? { start: date, end: value.value.start }
: { start: value.value.start, end: date }
updateRange(range)
startDraft.value = range.start
endDraft.value = range.end
preview.value = ''
popover.value?.close()
}
function handleCalendarFocus(date) {
if (
activePart.value === 'end' &&
value.value.start &&
!calendarUnavailable.value(date)
) {
preview.value = date
}
}
function handleInputKeydown(event, part) {
if (event.key !== 'ArrowDown' || props.disabled || props.readonly) return
event.preventDefault()
openPart(part, event.currentTarget)
}
function handleOpenChange(nextOpen) {
if (props.open === undefined) internalPopoverOpen.value = nextOpen
emit('update:open', nextOpen)
}
watch(value, (nextValue) => {
startDraft.value = nextValue.start
endDraft.value = nextValue.end
})
watch(
() => [
startInvalid.value,
endInvalid.value,
endWithoutStartInvalid.value,
orderInvalid.value,
rangeUnavailable.value,
props.required,
value.value.start,
value.value.end
],
async () => {
await nextTick()
const startElement = startInput.value?.element
const endElement = endInput.value?.element
if (startElement) {
startElement.setCustomValidity(
startInvalid.value || orderInvalid.value || rangeUnavailable.value
? statusText.value
: props.required && !value.value.start
? 'Choose a start date.'
: ''
)
}
if (endElement) {
endElement.setCustomValidity(
endInvalid.value ||
endWithoutStartInvalid.value ||
orderInvalid.value ||
rangeUnavailable.value
? statusText.value
: props.required && !value.value.end
? 'Choose an end date.'
: ''
)
}
},
{ immediate: true }
)
defineExpose({
focus: (part = 'start', options) =>
(part === 'end' ? endInput.value : startInput.value)?.focus(options),
open: (part = 'start') => openPart(part),
close: () => popover.value?.close()
})
</script>
<template>
<fieldset
v-bind="fieldsetAttrs"
data-slot="date-range-picker"
:disabled="disabled"
:class="rootClasses"
>
<legend data-slot="date-range-legend" class="text-sm font-medium">
{{ label }}
</legend>
<div class="grid gap-3 sm:grid-cols-2">
<div class="grid gap-2">
<label :for="startId" class="text-sm text-gray-600 dark:text-gray-400">
{{ startLabel }}
</label>
<div data-slot="date-range-field">
<Input
ref="startInput"
:id="startId"
type="text"
inputmode="numeric"
autocomplete="off"
:name="name ? `${name}[start]` : undefined"
:value="startDraft"
placeholder="YYYY-MM-DD"
:required="required"
:readonly="readonly"
:aria-invalid="
startInvalid || orderInvalid || rangeUnavailable || undefined
"
:aria-describedby="statusId"
:aria-controls="popoverId"
:aria-expanded="popoverOpen && activePart === 'start'"
aria-haspopup="grid"
data-slot="date-range-start"
@input="handleDraft('start', $event)"
@click="openPart('start', $event.currentTarget)"
@keydown="handleInputKeydown($event, 'start')"
/>
<button
type="button"
:popovertarget="popoverId"
popovertargetaction="show"
data-slot="date-range-start-button"
class="absolute inset-y-0 inset-e-0 grid min-w-11 place-items-center rounded-e-md text-gray-500 hover:bg-gray-100 hover:text-gray-950 focus-visible:z-10 focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-gray-950 disabled:cursor-not-allowed disabled:opacity-40 dark:text-gray-400 dark:hover:bg-gray-800 dark:hover:text-white dark:focus-visible:outline-white"
:disabled="disabled || readonly"
:aria-label="`Choose ${startLabel.toLowerCase()}`"
aria-haspopup="grid"
@click="activePart = 'start'"
>
<svg
aria-hidden="true"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="1.8"
class="size-5"
>
<path
d="M7 3v3M17 3v3M4 9h16M5 5h14a1 1 0 0 1 1 1v14H4V6a1 1 0 0 1 1-1Z"
/>
</svg>
</button>
</div>
</div>
<div class="grid gap-2">
<label :for="endId" class="text-sm text-gray-600 dark:text-gray-400">
{{ endLabel }}
</label>
<div data-slot="date-range-field">
<Input
ref="endInput"
:id="endId"
type="text"
inputmode="numeric"
autocomplete="off"
:name="name ? `${name}[end]` : undefined"
:value="endDraft"
placeholder="YYYY-MM-DD"
:required="required"
:readonly="readonly"
:aria-invalid="
endWithoutStartInvalid ||
endInvalid ||
orderInvalid ||
rangeUnavailable ||
undefined
"
:aria-describedby="statusId"
:aria-controls="popoverId"
:aria-expanded="popoverOpen && activePart === 'end'"
aria-haspopup="grid"
data-slot="date-range-end"
@input="handleDraft('end', $event)"
@click="openPart('end', $event.currentTarget)"
@keydown="handleInputKeydown($event, 'end')"
/>
<button
type="button"
:popovertarget="popoverId"
popovertargetaction="show"
data-slot="date-range-end-button"
class="absolute inset-y-0 inset-e-0 grid min-w-11 place-items-center rounded-e-md text-gray-500 hover:bg-gray-100 hover:text-gray-950 focus-visible:z-10 focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-gray-950 disabled:cursor-not-allowed disabled:opacity-40 dark:text-gray-400 dark:hover:bg-gray-800 dark:hover:text-white dark:focus-visible:outline-white"
:disabled="disabled || readonly"
:aria-label="`Choose ${endLabel.toLowerCase()}`"
aria-haspopup="grid"
@click="activePart = 'end'"
>
<svg
aria-hidden="true"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="1.8"
class="size-5"
>
<path
d="M7 3v3M17 3v3M4 9h16M5 5h14a1 1 0 0 1 1 1v14H4V6a1 1 0 0 1 1-1Z"
/>
</svg>
</button>
</div>
</div>
</div>
<p
:id="statusId"
data-slot="date-range-status"
class="text-sm text-gray-600 dark:text-gray-400"
:class="{
'text-red-700 dark:text-red-400':
startInvalid ||
endInvalid ||
endWithoutStartInvalid ||
orderInvalid ||
rangeUnavailable
}"
aria-live="polite"
>
{{ statusText }}
</p>
<Popover
ref="popover"
:id="popoverId"
:open="open"
:default-open="defaultOpen"
placement="bottom-start"
data-slot="date-range-popover"
class="w-[min(22rem,calc(100vw-1rem))] p-0"
@update:open="handleOpenChange"
>
<Calendar
:model-value="calendarValue"
:default-value="value.start"
:min="min"
:max="max"
:unavailable="calendarUnavailable"
:range-start="orderedDecoration.start"
:range-end="value.end ? orderedDecoration.end : undefined"
:range-preview="!value.end ? orderedDecoration.end : undefined"
:locale="locale"
:dir="dir"
:disabled="disabled"
:readonly="readonly"
@focus-change="handleCalendarFocus"
@update:model-value="chooseDate"
/>
</Popover>
</fieldset>
</template>
React
import {
forwardRef,
useEffect,
useId,
useImperativeHandle,
useRef,
useState
} from 'react'
import { twMerge } from 'tailwind-merge'
import Calendar from '../calendar/Calendar.jsx'
import {
addDays,
compareDates,
dateIsUnavailable,
dateLabel,
parseIsoDate,
resolveLocale
} from '../calendar/date.js'
import Input from '../input/Input.jsx'
import Popover from '../popover/Popover.jsx'
function normalizeRange(value) {
return {
start: parseIsoDate(value?.start) ? value.start : '',
end: parseIsoDate(value?.end) ? value.end : ''
}
}
function rangeContainsUnavailable(start, end, unavailable) {
if (!unavailable || !parseIsoDate(start) || !parseIsoDate(end)) return false
const [first, last] =
compareDates(start, end) <= 0 ? [start, end] : [end, start]
for (
let date = first;
date && compareDates(date, last) <= 0;
date = addDays(date, 1)
) {
if (unavailable(date)) return true
}
return false
}
const DateRangePicker = forwardRef(function DateRangePicker(
{
value,
defaultValue = {},
onValueChange,
id,
name,
label = 'Date range',
startLabel = 'Start date',
endLabel = 'End date',
min,
max,
unavailable,
locale: localeProp,
dir,
open,
defaultOpen = false,
onOpenChange,
required = false,
disabled = false,
readOnly = false,
className,
...fieldsetProps
},
forwardedRef
) {
const generatedId = useId().replace(/[^a-zA-Z0-9_-]/g, '')
const baseId = id ?? `klean-date-range-${generatedId}`
const startId = `${baseId}-start`
const endId = `${baseId}-end`
const popoverId = `${baseId}-calendar`
const statusId = `${baseId}-status`
const [internalValue, setInternalValue] = useState(() =>
normalizeRange(defaultValue)
)
const range = value === undefined ? internalValue : normalizeRange(value)
const [startDraft, setStartDraft] = useState(range.start)
const [endDraft, setEndDraft] = useState(range.end)
const [activePart, setActivePart] = useState('start')
const [preview, setPreview] = useState('')
const [internalPopoverOpen, setInternalPopoverOpen] = useState(defaultOpen)
const startInput = useRef(null)
const endInput = useRef(null)
const popoverRef = useRef(null)
const locale = resolveLocale(localeProp)
const limits = { min, max, unavailable }
const startInvalid = Boolean(
startDraft &&
(!parseIsoDate(startDraft) || dateIsUnavailable(startDraft, limits))
)
const endInvalid = Boolean(
endDraft && (!parseIsoDate(endDraft) || dateIsUnavailable(endDraft, limits))
)
const endWithoutStartInvalid = Boolean(endDraft && !startDraft)
const orderInvalid = Boolean(
parseIsoDate(startDraft) &&
parseIsoDate(endDraft) &&
compareDates(endDraft, startDraft) < 0
)
const rangeUnavailable = Boolean(
!orderInvalid && rangeContainsUnavailable(startDraft, endDraft, unavailable)
)
const popoverOpen = open === undefined ? internalPopoverOpen : open
let statusText
if (endWithoutStartInvalid)
statusText = 'Choose a start date before the end date.'
else if (orderInvalid)
statusText = 'The end date must be on or after the start date.'
else if (startInvalid || endInvalid)
statusText = 'Enter available dates as YYYY-MM-DD.'
else if (rangeUnavailable)
statusText = 'The range cannot include an unavailable date.'
else if (range.start && range.end)
statusText = `${dateLabel(range.start, locale)} through ${dateLabel(range.end, locale)}.`
else if (range.start) statusText = 'Choose an end date.'
else statusText = 'Choose a start date, then an end date.'
const decoratedEnd = range.end || preview
const decoration =
range.start && decoratedEnd && compareDates(range.start, decoratedEnd) > 0
? { start: decoratedEnd, end: range.start }
: { start: range.start, end: decoratedEnd }
const calendarValue =
activePart === 'end' ? range.end || preview || range.start : range.start
function updateRange(nextRange) {
const normalized = normalizeRange(nextRange)
if (value === undefined) setInternalValue(normalized)
onValueChange?.(normalized)
}
function calendarUnavailable(date) {
return (
dateIsUnavailable(date, limits) ||
(activePart === 'end' &&
range.start &&
rangeContainsUnavailable(range.start, date, unavailable))
)
}
function openPart(part, source) {
if (disabled || readOnly) return
setActivePart(part)
setPreview('')
const input = part === 'end' ? endInput.current : startInput.current
popoverRef.current?.open(source ?? input)
}
function handleDraft(part, event) {
const next = event.target.value.trim()
if (part === 'start') setStartDraft(next)
else setEndDraft(next)
if (part === 'start' && !next) {
setEndDraft('')
updateRange({ start: '', end: '' })
return
}
if (part === 'end' && next && !range.start) return
if (next && (!parseIsoDate(next) || dateIsUnavailable(next, limits))) return
const nextRange = {
start: part === 'start' ? next : range.start,
end: part === 'end' ? next : range.end
}
if (
nextRange.start &&
nextRange.end &&
compareDates(nextRange.end, nextRange.start) < 0
) {
return
}
if (
nextRange.start &&
nextRange.end &&
rangeContainsUnavailable(nextRange.start, nextRange.end, unavailable)
) {
return
}
updateRange(nextRange)
}
function chooseDate(date) {
if (disabled || readOnly || calendarUnavailable(date)) return
if (activePart === 'start' || !range.start) {
updateRange({ start: date, end: '' })
setStartDraft(date)
setEndDraft('')
setActivePart('end')
setPreview(date)
return
}
const nextRange =
compareDates(date, range.start) < 0
? { start: date, end: range.start }
: { start: range.start, end: date }
updateRange(nextRange)
setStartDraft(nextRange.start)
setEndDraft(nextRange.end)
setPreview('')
popoverRef.current?.close()
}
function handleOpenChange(nextOpen) {
if (open === undefined) setInternalPopoverOpen(nextOpen)
onOpenChange?.(nextOpen)
}
useEffect(() => {
setStartDraft(range.start)
setEndDraft(range.end)
}, [range.end, range.start])
useEffect(() => {
if (startInput.current) {
startInput.current.setCustomValidity(
startInvalid || orderInvalid || rangeUnavailable
? statusText
: required && !range.start
? 'Choose a start date.'
: ''
)
}
if (endInput.current) {
endInput.current.setCustomValidity(
endInvalid || endWithoutStartInvalid || orderInvalid || rangeUnavailable
? statusText
: required && !range.end
? 'Choose an end date.'
: ''
)
}
}, [
endInvalid,
endWithoutStartInvalid,
orderInvalid,
rangeUnavailable,
range.end,
range.start,
required,
startInvalid,
statusText
])
useImperativeHandle(forwardedRef, () => ({
focus: (part = 'start', options) =>
(part === 'end' ? endInput.current : startInput.current)?.focus(options),
open: (part = 'start') => openPart(part),
close: () => popoverRef.current?.close()
}))
const calendarButtonClasses =
'absolute inset-y-0 inset-e-0 grid min-w-11 place-items-center rounded-e-md text-gray-500 hover:bg-gray-100 hover:text-gray-950 focus-visible:z-10 focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-gray-950 disabled:cursor-not-allowed disabled:opacity-40 dark:text-gray-400 dark:hover:bg-gray-800 dark:hover:text-white dark:focus-visible:outline-white'
return (
<fieldset
{...fieldsetProps}
data-slot="date-range-picker"
disabled={disabled}
className={twMerge(
'grid w-full gap-3 **:data-[slot=date-range-field]:relative **:data-[slot=date-range-field]:flex **:data-[slot=date-range-field]:items-stretch **:data-[slot=input]:pe-12',
className
)}
>
<legend data-slot="date-range-legend" className="text-sm font-medium">
{label}
</legend>
<div className="grid gap-3 sm:grid-cols-2">
<div className="grid gap-2">
<label
htmlFor={startId}
className="text-sm text-gray-600 dark:text-gray-400"
>
{startLabel}
</label>
<div data-slot="date-range-field">
<Input
ref={startInput}
id={startId}
type="text"
inputMode="numeric"
autoComplete="off"
name={name ? `${name}[start]` : undefined}
value={startDraft}
placeholder="YYYY-MM-DD"
required={required}
readOnly={readOnly}
aria-invalid={
startInvalid || orderInvalid || rangeUnavailable || undefined
}
aria-describedby={statusId}
aria-controls={popoverId}
aria-expanded={popoverOpen && activePart === 'start'}
aria-haspopup="grid"
data-slot="date-range-start"
onChange={(event) => handleDraft('start', event)}
onClick={(event) => openPart('start', event.currentTarget)}
onKeyDown={(event) => {
if (event.key === 'ArrowDown' && !disabled && !readOnly) {
event.preventDefault()
openPart('start', event.currentTarget)
}
}}
/>
<button
type="button"
popoverTarget={popoverId}
popoverTargetAction="show"
data-slot="date-range-start-button"
className={calendarButtonClasses}
disabled={disabled || readOnly}
aria-label={`Choose ${startLabel.toLowerCase()}`}
aria-haspopup="grid"
onClick={() => setActivePart('start')}
>
<svg
aria-hidden="true"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="1.8"
className="size-5"
>
<path d="M7 3v3M17 3v3M4 9h16M5 5h14a1 1 0 0 1 1 1v14H4V6a1 1 0 0 1 1-1Z" />
</svg>
</button>
</div>
</div>
<div className="grid gap-2">
<label
htmlFor={endId}
className="text-sm text-gray-600 dark:text-gray-400"
>
{endLabel}
</label>
<div data-slot="date-range-field">
<Input
ref={endInput}
id={endId}
type="text"
inputMode="numeric"
autoComplete="off"
name={name ? `${name}[end]` : undefined}
value={endDraft}
placeholder="YYYY-MM-DD"
required={required}
readOnly={readOnly}
aria-invalid={
endInvalid ||
endWithoutStartInvalid ||
orderInvalid ||
rangeUnavailable ||
undefined
}
aria-describedby={statusId}
aria-controls={popoverId}
aria-expanded={popoverOpen && activePart === 'end'}
aria-haspopup="grid"
data-slot="date-range-end"
onChange={(event) => handleDraft('end', event)}
onClick={(event) => openPart('end', event.currentTarget)}
onKeyDown={(event) => {
if (event.key === 'ArrowDown' && !disabled && !readOnly) {
event.preventDefault()
openPart('end', event.currentTarget)
}
}}
/>
<button
type="button"
popoverTarget={popoverId}
popoverTargetAction="show"
data-slot="date-range-end-button"
className={calendarButtonClasses}
disabled={disabled || readOnly}
aria-label={`Choose ${endLabel.toLowerCase()}`}
aria-haspopup="grid"
onClick={() => setActivePart('end')}
>
<svg
aria-hidden="true"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="1.8"
className="size-5"
>
<path d="M7 3v3M17 3v3M4 9h16M5 5h14a1 1 0 0 1 1 1v14H4V6a1 1 0 0 1 1-1Z" />
</svg>
</button>
</div>
</div>
</div>
<p
id={statusId}
data-slot="date-range-status"
className={twMerge(
'text-sm text-gray-600 dark:text-gray-400',
(startInvalid ||
endInvalid ||
endWithoutStartInvalid ||
orderInvalid ||
rangeUnavailable) &&
'text-red-700 dark:text-red-400'
)}
aria-live="polite"
>
{statusText}
</p>
<Popover
ref={popoverRef}
id={popoverId}
open={open}
defaultOpen={defaultOpen}
onOpenChange={handleOpenChange}
placement="bottom-start"
data-slot="date-range-popover"
className="w-[min(22rem,calc(100vw-1rem))] p-0"
>
<Calendar
value={calendarValue}
defaultValue={range.start}
min={min}
max={max}
unavailable={calendarUnavailable}
rangeStart={decoration.start}
rangeEnd={range.end ? decoration.end : undefined}
rangePreview={!range.end ? decoration.end : undefined}
locale={locale}
dir={dir}
disabled={disabled}
readOnly={readOnly}
onFocusChange={(date) => {
if (
activePart === 'end' &&
range.start &&
!calendarUnavailable(date)
) {
setPreview(date)
}
}}
onValueChange={chooseDate}
/>
</Popover>
</fieldset>
)
})
export default DateRangePicker
Svelte
<script>
import { untrack } from "svelte";
import { twMerge } from "tailwind-merge";
import Calendar from "../calendar/Calendar.svelte";
import {
addDays,
compareDates,
dateIsUnavailable,
dateLabel,
parseIsoDate,
resolveLocale,
} from "../calendar/date.js";
import Input from "../input/Input.svelte";
import Popover from "../popover/Popover.svelte";
function normalizeRange(candidate) {
return {
start: parseIsoDate(candidate?.start) ? candidate.start : "",
end: parseIsoDate(candidate?.end) ? candidate.end : "",
};
}
function rangeContainsUnavailable(start, end, unavailable) {
if (!unavailable || !parseIsoDate(start) || !parseIsoDate(end))
return false;
const [first, last] =
compareDates(start, end) <= 0 ? [start, end] : [end, start];
for (
let date = first;
date && compareDates(date, last) <= 0;
date = addDays(date, 1)
) {
if (unavailable(date)) return true;
}
return false;
}
let {
value = $bindable(),
defaultValue = {},
onchange,
id,
name,
label = "Date range",
startLabel = "Start date",
endLabel = "End date",
min,
max,
unavailable,
locale: localeProp,
dir,
open = $bindable(),
defaultOpen = false,
onopenchange,
required = false,
disabled = false,
readonly = false,
class: className,
...fieldsetProps
} = $props();
const initialRange = normalizeRange(
untrack(() => (value === undefined ? defaultValue : value)),
);
if (untrack(() => value) === undefined) value = initialRange;
const componentId = $props.id();
const generatedId = componentId.replace(/[^a-zA-Z0-9_-]/g, "");
let baseId = $derived(id ?? `klean-date-range-${generatedId}`);
let startId = $derived(`${baseId}-start`);
let endId = $derived(`${baseId}-end`);
let popoverId = $derived(`${baseId}-calendar`);
let statusId = $derived(`${baseId}-status`);
let range = $derived(normalizeRange(value));
let startDraft = $state(initialRange.start);
let endDraft = $state(initialRange.end);
let activePart = $state("start");
let preview = $state("");
let internalPopoverOpen = $state(untrack(() => defaultOpen));
let startInput;
let endInput;
let popover;
let locale = $derived(resolveLocale(localeProp));
let startInvalid = $derived(
Boolean(
startDraft &&
(!parseIsoDate(startDraft) ||
dateIsUnavailable(startDraft, { min, max, unavailable })),
),
);
let endInvalid = $derived(
Boolean(
endDraft &&
(!parseIsoDate(endDraft) ||
dateIsUnavailable(endDraft, { min, max, unavailable })),
),
);
let endWithoutStartInvalid = $derived(Boolean(endDraft && !startDraft));
let orderInvalid = $derived(
Boolean(
parseIsoDate(startDraft) &&
parseIsoDate(endDraft) &&
compareDates(endDraft, startDraft) < 0,
),
);
let rangeUnavailable = $derived(
!orderInvalid &&
rangeContainsUnavailable(startDraft, endDraft, unavailable),
);
let popoverOpen = $derived(open ?? internalPopoverOpen);
let statusText = $derived.by(() => {
if (endWithoutStartInvalid)
return "Choose a start date before the end date.";
if (orderInvalid) return "The end date must be on or after the start date.";
if (startInvalid || endInvalid)
return "Enter available dates as YYYY-MM-DD.";
if (rangeUnavailable)
return "The range cannot include an unavailable date.";
if (range.start && range.end)
return `${dateLabel(range.start, locale)} through ${dateLabel(range.end, locale)}.`;
if (range.start) return "Choose an end date.";
return "Choose a start date, then an end date.";
});
let decoration = $derived.by(() => {
const decoratedEnd = range.end || preview;
return range.start &&
decoratedEnd &&
compareDates(range.start, decoratedEnd) > 0
? { start: decoratedEnd, end: range.start }
: { start: range.start, end: decoratedEnd };
});
let calendarValue = $derived(
activePart === "end" ? range.end || preview || range.start : range.start,
);
const calendarButtonClasses =
"absolute inset-y-0 inset-e-0 grid min-w-11 place-items-center rounded-e-md text-gray-500 hover:bg-gray-100 hover:text-gray-950 focus-visible:z-10 focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-gray-950 disabled:cursor-not-allowed disabled:opacity-40 dark:text-gray-400 dark:hover:bg-gray-800 dark:hover:text-white dark:focus-visible:outline-white";
function updateRange(nextRange) {
value = normalizeRange(nextRange);
onchange?.(value);
}
function calendarUnavailable(date) {
return (
dateIsUnavailable(date, { min, max, unavailable }) ||
(activePart === "end" &&
range.start &&
rangeContainsUnavailable(range.start, date, unavailable))
);
}
function openPart(part, source) {
if (disabled || readonly) return;
activePart = part;
preview = "";
const input =
part === "end" ? endInput?.getElement() : startInput?.getElement();
popover?.show(source ?? input);
}
function handleDraft(part, event) {
const next = event.target.value.trim();
if (part === "start") startDraft = next;
else endDraft = next;
if (part === "start" && !next) {
endDraft = "";
updateRange({ start: "", end: "" });
return;
}
if (part === "end" && next && !range.start) return;
if (
next &&
(!parseIsoDate(next) ||
dateIsUnavailable(next, { min, max, unavailable }))
)
return;
const nextRange = {
start: part === "start" ? next : range.start,
end: part === "end" ? next : range.end,
};
if (
nextRange.start &&
nextRange.end &&
compareDates(nextRange.end, nextRange.start) < 0
) {
return;
}
if (
nextRange.start &&
nextRange.end &&
rangeContainsUnavailable(nextRange.start, nextRange.end, unavailable)
) {
return;
}
updateRange(nextRange);
}
function chooseDate(date) {
if (disabled || readonly || calendarUnavailable(date)) return;
if (activePart === "start" || !range.start) {
updateRange({ start: date, end: "" });
startDraft = date;
endDraft = "";
activePart = "end";
preview = date;
return;
}
const nextRange =
compareDates(date, range.start) < 0
? { start: date, end: range.start }
: { start: range.start, end: date };
updateRange(nextRange);
startDraft = nextRange.start;
endDraft = nextRange.end;
preview = "";
popover?.close();
}
function handleKeydown(event, part) {
if (event.key !== "ArrowDown" || disabled || readonly) return;
event.preventDefault();
openPart(part, event.currentTarget);
}
function handleOpenChange(nextOpen) {
if (open === undefined) internalPopoverOpen = nextOpen;
onopenchange?.(nextOpen);
}
$effect(() => {
startDraft = range.start;
endDraft = range.end;
});
$effect(() => {
const startElement = startInput?.getElement();
const endElement = endInput?.getElement();
if (startElement) {
startElement.setCustomValidity(
startInvalid || orderInvalid || rangeUnavailable
? statusText
: required && !range.start
? "Choose a start date."
: "",
);
}
if (endElement) {
endElement.setCustomValidity(
endInvalid || endWithoutStartInvalid || orderInvalid || rangeUnavailable
? statusText
: required && !range.end
? "Choose an end date."
: "",
);
}
});
export function focus(part = "start", options) {
(part === "end" ? endInput : startInput)?.focus(options);
}
export function show(part = "start") {
openPart(part);
}
export function close() {
popover?.close();
}
</script>
<fieldset
{...fieldsetProps}
data-slot="date-range-picker"
{disabled}
class={twMerge(
"grid w-full gap-3 **:data-[slot=date-range-field]:relative **:data-[slot=date-range-field]:flex **:data-[slot=date-range-field]:items-stretch **:data-[slot=input]:pe-12",
className,
)}
>
<legend data-slot="date-range-legend" class="text-sm font-medium">
{label}
</legend>
<div class="grid gap-3 sm:grid-cols-2">
<div class="grid gap-2">
<label for={startId} class="text-sm text-gray-600 dark:text-gray-400">
{startLabel}
</label>
<div data-slot="date-range-field">
<Input
bind:this={startInput}
id={startId}
type="text"
inputmode="numeric"
autocomplete="off"
name={name ? `${name}[start]` : undefined}
value={startDraft}
placeholder="YYYY-MM-DD"
{required}
{readonly}
aria-invalid={startInvalid ||
orderInvalid ||
rangeUnavailable ||
undefined}
aria-describedby={statusId}
aria-controls={popoverId}
aria-expanded={popoverOpen && activePart === "start"}
aria-haspopup="grid"
data-slot="date-range-start"
oninput={(event) => handleDraft("start", event)}
onclick={(event) => openPart("start", event.currentTarget)}
onkeydown={(event) => handleKeydown(event, "start")}
/>
<button
type="button"
popovertarget={popoverId}
popovertargetaction="show"
data-slot="date-range-start-button"
class={calendarButtonClasses}
disabled={disabled || readonly}
aria-label={`Choose ${startLabel.toLowerCase()}`}
aria-haspopup="grid"
onclick={() => (activePart = "start")}
>
<svg
aria-hidden="true"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="1.8"
class="size-5"
>
<path
d="M7 3v3M17 3v3M4 9h16M5 5h14a1 1 0 0 1 1 1v14H4V6a1 1 0 0 1 1-1Z"
/>
</svg>
</button>
</div>
</div>
<div class="grid gap-2">
<label for={endId} class="text-sm text-gray-600 dark:text-gray-400">
{endLabel}
</label>
<div data-slot="date-range-field">
<Input
bind:this={endInput}
id={endId}
type="text"
inputmode="numeric"
autocomplete="off"
name={name ? `${name}[end]` : undefined}
value={endDraft}
placeholder="YYYY-MM-DD"
{required}
{readonly}
aria-invalid={endInvalid ||
endWithoutStartInvalid ||
orderInvalid ||
rangeUnavailable ||
undefined}
aria-describedby={statusId}
aria-controls={popoverId}
aria-expanded={popoverOpen && activePart === "end"}
aria-haspopup="grid"
data-slot="date-range-end"
oninput={(event) => handleDraft("end", event)}
onclick={(event) => openPart("end", event.currentTarget)}
onkeydown={(event) => handleKeydown(event, "end")}
/>
<button
type="button"
popovertarget={popoverId}
popovertargetaction="show"
data-slot="date-range-end-button"
class={calendarButtonClasses}
disabled={disabled || readonly}
aria-label={`Choose ${endLabel.toLowerCase()}`}
aria-haspopup="grid"
onclick={() => (activePart = "end")}
>
<svg
aria-hidden="true"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="1.8"
class="size-5"
>
<path
d="M7 3v3M17 3v3M4 9h16M5 5h14a1 1 0 0 1 1 1v14H4V6a1 1 0 0 1 1-1Z"
/>
</svg>
</button>
</div>
</div>
</div>
<p
id={statusId}
data-slot="date-range-status"
class={twMerge(
"text-sm text-gray-600 dark:text-gray-400",
(startInvalid ||
endInvalid ||
endWithoutStartInvalid ||
orderInvalid ||
rangeUnavailable) &&
"text-red-700 dark:text-red-400",
)}
aria-live="polite"
>
{statusText}
</p>
<Popover
bind:this={popover}
bind:open
id={popoverId}
{defaultOpen}
onOpenChange={handleOpenChange}
placement="bottom-start"
data-slot="date-range-popover"
class="w-[min(22rem,calc(100vw-1rem))] p-0"
>
<Calendar
value={calendarValue}
defaultValue={range.start}
{min}
{max}
unavailable={calendarUnavailable}
rangeStart={decoration.start}
rangeEnd={range.end ? decoration.end : undefined}
rangePreview={!range.end ? decoration.end : undefined}
{locale}
{dir}
{disabled}
{readonly}
onfocuschange={(date) => {
if (activePart === "end" && range.start && !calendarUnavailable(date)) {
preview = date;
}
}}
onchange={chooseDate}
/>
</Popover>
</fieldset>