Calendar
Calendar is an always-visible, locale-aware surface for choosing one date. Its value is YYYY-MM-DD: a date on a calendar, not midnight in an accidental timezone.
Installation
One command detects Vue, React, or Svelte and installs the framework-native calendar plus its small date-only helper:
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 calendar- No initializer or configuration file
- No framework, alias, or theme questions
- No Klean runtime dependency
When to use
Use Calendar when choosing dates is the main task: availability, booking, capacity, or a scheduling workspace where the month should remain visible.
When not to use
Use Date Picker for one compact form field, Date Range Picker for one related period, and Schedule Picker when date, time, and timezone must become an exact instant.
Usage
Vue
<script setup>
import { ref } from 'vue'
import Calendar from '~/components/ui/calendar/Calendar.vue'
const date = ref('2026-08-12')
</script>
<template>
<Calendar v-model="date" :min="'2026-08-01'" />
</template>
React
import { useState } from 'react'
import Calendar from '@/components/ui/calendar/Calendar.jsx'
export default function AvailabilityCalendar() {
const [date, setDate] = useState('2026-08-12')
return <Calendar value={date} onValueChange={setDate} min="2026-08-01" />
}
Svelte
<script>
import Calendar from '~/components/ui/calendar/Calendar.svelte'
let date = $state('2026-08-12')
</script>
<Calendar bind:value={date} min="2026-08-01" />
API
| Purpose | Vue | React | Svelte |
|---|---|---|---|
| Current value | v-model | value, onValueChange | bind:value |
| Initial value | default-value | defaultValue | defaultValue |
| Bounds | min, max | min, max | min, max |
| Product availability | unavailable | unavailable | unavailable |
| Locale | locale, dir | locale, dir | locale, dir |
| Interaction | disabled, readonly | disabled, readOnly | disabled, readonly |
| Styling | class | className | class |
unavailable(date) receives a YYYY-MM-DD string. Return true for dates the product cannot accept. The component owns date navigation and selection; the application owns business availability and ordinary Tailwind.
There are no variants, tones, week-start settings, or translation tables. Intl derives month names, weekday names, reading direction, and locale week conventions. A supplied dir overrides direction when the application needs to do so explicitly.
Keyboard and accessibility
- Arrow Left and Right move by day; Arrow Up and Down move by week.
- Home and End move to the first and last day of the locale week.
- Page Up and Page Down move by month; Shift moves by year.
- Enter and Space select an available date.
- Every date is a native button inside a semantic grid.
- Focus follows the active date without creating 42 Tab stops.
- Disabled dates remain understandable and cannot be committed.
Calendar state is durable only when the application persists the selected date. The viewed month and focused day are interaction state and are never written to storage or the URL by Klean.
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.
- Date Picker — one editable date-only
YYYY-MM-DDfield with an optional calendar. - Date Range Picker — two ordered date-only
YYYY-MM-DDboundaries. - Schedule Picker — date, time, and IANA timezone stored as an exact ISO instant.
- Popover — the floating surface used by compact date components.
Complete framework source
Vue
<script setup>
import { computed, nextTick, onMounted, ref, useAttrs, useId, watch } from 'vue'
import { twMerge } from 'tailwind-merge'
import {
addDays,
addMonths,
calendarGrid,
clampDate,
compareDates,
dateIsUnavailable,
dateLabel,
endOfMonth,
firstDayOfWeek,
monthLabel,
monthValue,
parseIsoDate,
resolveDirection,
resolveLocale,
startOfMonth,
todayIso,
weekEdge,
weekdayLabels
} from './date.js'
defineOptions({ inheritAttrs: false })
const props = defineProps({
/** A date-only ISO value: YYYY-MM-DD. */
modelValue: { type: String, default: undefined },
/** Initial value when modelValue is not controlled. */
defaultValue: { type: String, default: undefined },
/** Earliest selectable YYYY-MM-DD date. */
min: { type: String, default: undefined },
/** Latest selectable YYYY-MM-DD date. */
max: { type: String, default: undefined },
/** Product rule for unavailable dates. Receives YYYY-MM-DD. */
unavailable: { type: Function, default: undefined },
/** Range boundary decoration used by DateRangePicker. */
rangeStart: { type: String, default: undefined },
/** Committed range boundary decoration used by DateRangePicker. */
rangeEnd: { type: String, default: undefined },
/** Pending range boundary decoration used by DateRangePicker. */
rangePreview: { type: String, default: undefined },
/** BCP 47 locale. Defaults to the document or browser locale. */
locale: { type: String, default: undefined },
/** Reading direction. Inferred when omitted. */
dir: {
type: String,
default: undefined,
validator: (value) => value === undefined || ['ltr', 'rtl'].includes(value)
},
/** Accessible name for the calendar region. */
label: { type: String, default: 'Choose a date' },
disabled: { type: Boolean, default: false },
readonly: { type: Boolean, default: false }
})
const emit = defineEmits(['update:modelValue', 'change', 'focus-change'])
const attrs = useAttrs()
const generatedId = useId().replace(/[^a-zA-Z0-9_-]/g, '')
const root = ref()
const internalValue = ref(
parseIsoDate(props.defaultValue) ? props.defaultValue : undefined
)
const locale = computed(() => resolveLocale(props.locale))
const direction = computed(() => resolveDirection(locale.value, props.dir))
const today = computed(() => todayIso())
const selected = computed(() =>
props.modelValue === undefined ? internalValue.value : props.modelValue
)
const limits = computed(() => ({
min: parseIsoDate(props.min) ? props.min : undefined,
max: parseIsoDate(props.max) ? props.max : undefined,
unavailable: props.unavailable
}))
function available(value) {
return !props.disabled && !dateIsUnavailable(value, limits.value)
}
function nearestAvailable(value, direction = 1) {
let candidate = clampDate(value, limits.value) ?? today.value
for (let count = 0; count < 732; count += 1) {
if (available(candidate)) return candidate
const next = addDays(candidate, direction)
if (!next) break
if (props.min && compareDates(next, props.min) < 0) break
if (props.max && compareDates(next, props.max) > 0) break
candidate = next
}
return clampDate(value, limits.value) ?? today.value
}
const initialFocus =
(parseIsoDate(selected.value) && selected.value) ||
(parseIsoDate(props.defaultValue) && props.defaultValue) ||
today.value
const focusedDate = ref(nearestAvailable(initialFocus))
const viewMonth = ref(monthValue(focusedDate.value))
const weekStart = computed(() => firstDayOfWeek(locale.value))
const weekdays = computed(() => weekdayLabels(locale.value, weekStart.value))
const days = computed(() =>
calendarGrid(`${viewMonth.value}-01`, weekStart.value).map((value) => {
const parsed = parseIsoDate(value)
const rangeEnd = props.rangeEnd || props.rangePreview
const inRange =
props.rangeStart &&
rangeEnd &&
compareDates(value, props.rangeStart) >= 0 &&
compareDates(value, rangeEnd) <= 0
return {
value,
day: parsed.day,
label: dateLabel(value, locale.value, { weekday: 'long' }),
outside: monthValue(value) !== viewMonth.value,
selected: value === selected.value,
today: value === today.value,
unavailable: !available(value),
rangeStart: value === props.rangeStart,
rangeEnd: value === rangeEnd,
inRange
}
})
)
const weeks = computed(() =>
Array.from({ length: 6 }, (_, index) =>
days.value.slice(index * 7, index * 7 + 7)
)
)
const visibleMonthLabel = computed(() =>
monthLabel(`${viewMonth.value}-01`, locale.value)
)
const previousDisabled = computed(() => {
if (props.disabled) return true
const previousEnd = endOfMonth(addMonths(`${viewMonth.value}-01`, -1))
return Boolean(props.min && compareDates(previousEnd, props.min) < 0)
})
const nextDisabled = computed(() => {
if (props.disabled) return true
const nextStart = startOfMonth(addMonths(`${viewMonth.value}-01`, 1))
return Boolean(props.max && compareDates(nextStart, props.max) > 0)
})
const rootAttrs = computed(() => {
const { class: _class, 'data-slot': _dataSlot, ...rest } = attrs
return rest
})
const rootClasses = computed(() =>
twMerge(
'w-full max-w-88 rounded-lg bg-white p-3 text-gray-950 dark:bg-gray-950 dark:text-white',
attrs.class
)
)
function commit(value) {
if (!available(value) || props.readonly) return
if (props.modelValue === undefined) internalValue.value = value
emit('update:modelValue', value)
emit('change', value)
}
async function focus(value, { move = true } = {}) {
const next = nearestAvailable(
value,
compareDates(value, focusedDate.value) < 0 ? -1 : 1
)
focusedDate.value = next
if (move) viewMonth.value = monthValue(next)
emit('focus-change', next)
await nextTick()
root.value
?.querySelector(`[data-date="${next}"]`)
?.focus({ preventScroll: true })
}
function moveByMonth(amount) {
if (
(amount < 0 && previousDisabled.value) ||
(amount > 0 && nextDisabled.value)
) {
return
}
focus(addMonths(focusedDate.value, amount))
}
function handleDayFocus(value) {
focusedDate.value = value
emit('focus-change', value)
}
function handleDayKeydown(event, value) {
let next
const horizontal = direction.value === 'rtl' ? -1 : 1
if (event.key === 'ArrowLeft') next = addDays(value, -horizontal)
else if (event.key === 'ArrowRight') next = addDays(value, horizontal)
else if (event.key === 'ArrowUp') next = addDays(value, -7)
else if (event.key === 'ArrowDown') next = addDays(value, 7)
else if (event.key === 'Home')
next = weekEdge(value, weekStart.value, 'start')
else if (event.key === 'End') next = weekEdge(value, weekStart.value, 'end')
else if (event.key === 'PageUp')
next = addMonths(value, event.shiftKey ? -12 : -1)
else if (event.key === 'PageDown')
next = addMonths(value, event.shiftKey ? 12 : 1)
else return
event.preventDefault()
focus(next)
}
watch(
() => props.modelValue,
(value) => {
if (!parseIsoDate(value)) return
focusedDate.value = nearestAvailable(value)
viewMonth.value = monthValue(focusedDate.value)
}
)
onMounted(() => {
if (!root.value?.querySelector('[data-slot="calendar-day"]:focus')) return
focus(focusedDate.value)
})
defineExpose({
root,
focus: () => focus(focusedDate.value),
focusedDate
})
</script>
<template>
<section
ref="root"
v-bind="rootAttrs"
:dir="direction"
data-slot="calendar"
:aria-label="label"
:class="rootClasses"
>
<header
data-slot="calendar-header"
class="grid grid-cols-[2.75rem_1fr_2.75rem] items-center gap-1"
>
<button
type="button"
data-slot="calendar-previous"
class="grid min-h-11 min-w-11 place-items-center rounded-md text-xl hover:bg-gray-100 focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-gray-950 disabled:cursor-not-allowed disabled:opacity-40 dark:hover:bg-gray-800 dark:focus-visible:outline-white"
:aria-label="`Previous month, ${visibleMonthLabel}`"
:disabled="previousDisabled"
@click="moveByMonth(-1)"
>
<span aria-hidden="true">{{ direction === 'rtl' ? '→' : '←' }}</span>
</button>
<h2
:id="`klean-calendar-${generatedId}-heading`"
data-slot="calendar-heading"
class="text-center text-base font-semibold"
aria-live="polite"
aria-atomic="true"
>
{{ visibleMonthLabel }}
</h2>
<button
type="button"
data-slot="calendar-next"
class="grid min-h-11 min-w-11 place-items-center rounded-md text-xl hover:bg-gray-100 focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-gray-950 disabled:cursor-not-allowed disabled:opacity-40 dark:hover:bg-gray-800 dark:focus-visible:outline-white"
:aria-label="`Next month, ${visibleMonthLabel}`"
:disabled="nextDisabled"
@click="moveByMonth(1)"
>
<span aria-hidden="true">{{ direction === 'rtl' ? '←' : '→' }}</span>
</button>
</header>
<table
role="grid"
data-slot="calendar-grid"
class="mt-2 w-full table-fixed border-collapse"
:aria-labelledby="`klean-calendar-${generatedId}-heading`"
:aria-readonly="readonly || undefined"
:aria-disabled="disabled || undefined"
>
<thead>
<tr>
<th
v-for="weekday in weekdays"
:key="weekday"
scope="col"
data-slot="calendar-weekday"
class="h-9 text-center text-xs font-medium text-gray-500 dark:text-gray-400"
>
{{ weekday }}
</th>
</tr>
</thead>
<tbody>
<tr v-for="(week, weekIndex) in weeks" :key="weekIndex">
<td
v-for="day in week"
:key="day.value"
role="gridcell"
data-slot="calendar-cell"
class="p-0 text-center"
:aria-selected="day.selected"
>
<button
type="button"
data-slot="calendar-day"
:data-date="day.value"
:data-outside-month="day.outside || undefined"
:data-selected="day.selected || undefined"
:data-today="day.today || undefined"
:data-unavailable="day.unavailable || undefined"
:data-range-start="day.rangeStart || undefined"
:data-range-end="day.rangeEnd || undefined"
:data-in-range="day.inRange || undefined"
:aria-label="day.label"
:aria-current="day.today ? 'date' : undefined"
:aria-disabled="day.unavailable || undefined"
:disabled="day.unavailable"
:tabindex="day.value === focusedDate ? 0 : -1"
class="mx-auto grid min-h-11 min-w-11 place-items-center rounded-md text-sm tabular-nums hover:bg-gray-100 focus-visible:z-10 focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-gray-950 disabled:cursor-not-allowed data-in-range:bg-gray-100 data-outside-month:text-gray-400 data-range-end:bg-gray-950 data-range-end:font-semibold data-range-end:text-white data-range-start:bg-gray-950 data-range-start:font-semibold data-range-start:text-white data-selected:bg-gray-950 data-selected:font-semibold data-selected:text-white data-today:ring-1 data-today:ring-inset data-today:ring-gray-400 data-unavailable:text-gray-300 dark:hover:bg-gray-800 dark:focus-visible:outline-white dark:data-in-range:bg-gray-800 dark:data-outside-month:text-gray-600 dark:data-range-end:bg-white dark:data-range-end:text-gray-950 dark:data-range-start:bg-white dark:data-range-start:text-gray-950 dark:data-selected:bg-white dark:data-selected:text-gray-950 dark:data-today:ring-gray-600 dark:data-unavailable:text-gray-700"
@focus="handleDayFocus(day.value)"
@keydown="handleDayKeydown($event, day.value)"
@click="commit(day.value)"
>
{{ day.day }}
</button>
</td>
</tr>
</tbody>
</table>
</section>
</template>
const ISO_DATE = /^(\d{4})-(\d{2})-(\d{2})$/
function pad(value) {
return String(value).padStart(2, '0')
}
function fromUtcDate(date) {
return formatIsoDate({
year: date.getUTCFullYear(),
month: date.getUTCMonth() + 1,
day: date.getUTCDate()
})
}
function toUtcDate(value) {
const date = typeof value === 'string' ? parseIsoDate(value) : value
if (!date) return undefined
return new Date(Date.UTC(date.year, date.month - 1, date.day))
}
export function daysInMonth(year, month) {
return new Date(Date.UTC(year, month, 0)).getUTCDate()
}
export function formatIsoDate({ year, month, day }) {
return `${String(year).padStart(4, '0')}-${pad(month)}-${pad(day)}`
}
export function parseIsoDate(value) {
if (typeof value !== 'string') return undefined
const match = ISO_DATE.exec(value.trim())
if (!match) return undefined
const date = {
year: Number(match[1]),
month: Number(match[2]),
day: Number(match[3])
}
if (
date.month < 1 ||
date.month > 12 ||
date.day < 1 ||
date.day > daysInMonth(date.year, date.month)
) {
return undefined
}
return date
}
export function compareDates(left, right) {
return String(left).localeCompare(String(right))
}
export function addDays(value, amount) {
const date = toUtcDate(value)
if (!date) return undefined
date.setUTCDate(date.getUTCDate() + amount)
return fromUtcDate(date)
}
export function addMonths(value, amount) {
const date = typeof value === 'string' ? parseIsoDate(value) : value
if (!date) return undefined
const monthIndex = date.year * 12 + date.month - 1 + amount
const year = Math.floor(monthIndex / 12)
const month = (((monthIndex % 12) + 12) % 12) + 1
return formatIsoDate({
year,
month,
day: Math.min(date.day, daysInMonth(year, month))
})
}
export function monthValue(value) {
const date = typeof value === 'string' ? parseIsoDate(value) : value
return date ? `${String(date.year).padStart(4, '0')}-${pad(date.month)}` : ''
}
export function startOfMonth(value) {
const date = typeof value === 'string' ? parseIsoDate(value) : value
return date ? formatIsoDate({ ...date, day: 1 }) : undefined
}
export function endOfMonth(value) {
const date = typeof value === 'string' ? parseIsoDate(value) : value
return date
? formatIsoDate({
...date,
day: daysInMonth(date.year, date.month)
})
: undefined
}
export function resolveLocale(locale) {
return (
locale ||
(typeof document !== 'undefined' && document.documentElement.lang) ||
(typeof navigator !== 'undefined' && navigator.language) ||
'en'
)
}
export function resolveDirection(locale, direction) {
if (direction === 'ltr' || direction === 'rtl') return direction
if (typeof document !== 'undefined') {
const documentDirection = document.documentElement.dir
if (documentDirection === 'ltr' || documentDirection === 'rtl') {
return documentDirection
}
}
try {
return new Intl.Locale(resolveLocale(locale)).textInfo.direction
} catch {
return 'ltr'
}
}
export function firstDayOfWeek(locale) {
try {
const firstDay = new Intl.Locale(resolveLocale(locale)).getWeekInfo()
.firstDay
return firstDay === 7 ? 0 : firstDay
} catch {
const region = resolveLocale(locale).split('-')[1]?.toUpperCase()
return ['CA', 'JP', 'PH', 'US'].includes(region) ? 0 : 1
}
}
export function todayIso(timeZone) {
const parts = new Intl.DateTimeFormat('en-CA', {
...(timeZone ? { timeZone } : {}),
year: 'numeric',
month: '2-digit',
day: '2-digit'
}).formatToParts(new Date())
const value = Object.fromEntries(parts.map((part) => [part.type, part.value]))
return `${value.year}-${value.month}-${value.day}`
}
export function dateLabel(value, locale, options = {}) {
const date = toUtcDate(value)
if (!date) return ''
return new Intl.DateTimeFormat(resolveLocale(locale), {
timeZone: 'UTC',
year: 'numeric',
month: 'long',
day: 'numeric',
...options
}).format(date)
}
export function monthLabel(value, locale) {
const date = toUtcDate(startOfMonth(value))
if (!date) return ''
return new Intl.DateTimeFormat(resolveLocale(locale), {
timeZone: 'UTC',
year: 'numeric',
month: 'long'
}).format(date)
}
export function weekdayLabels(locale, firstDay = firstDayOfWeek(locale)) {
const formatter = new Intl.DateTimeFormat(resolveLocale(locale), {
timeZone: 'UTC',
weekday: 'short'
})
const sunday = new Date(Date.UTC(2020, 5, 7))
return Array.from({ length: 7 }, (_, index) => {
const weekday = (firstDay + index) % 7
const date = new Date(sunday)
date.setUTCDate(sunday.getUTCDate() + weekday)
return formatter.format(date)
})
}
export function calendarGrid(view, firstDay = 1) {
const first = startOfMonth(view)
const date = toUtcDate(first)
if (!date) return []
const leadingDays = (date.getUTCDay() - firstDay + 7) % 7
const gridStart = addDays(first, -leadingDays)
return Array.from({ length: 42 }, (_, index) => addDays(gridStart, index))
}
export function dateIsUnavailable(value, { min, max, unavailable } = {}) {
if (!parseIsoDate(value)) return true
if (min && compareDates(value, min) < 0) return true
if (max && compareDates(value, max) > 0) return true
return Boolean(unavailable?.(value))
}
export function clampDate(value, { min, max } = {}) {
if (!parseIsoDate(value)) return undefined
if (min && compareDates(value, min) < 0) return min
if (max && compareDates(value, max) > 0) return max
return value
}
export function weekEdge(value, firstDay, edge) {
const date = toUtcDate(value)
if (!date) return undefined
const offset = (date.getUTCDay() - firstDay + 7) % 7
return addDays(value, edge === 'end' ? 6 - offset : -offset)
}
React
import {
forwardRef,
useEffect,
useId,
useImperativeHandle,
useMemo,
useRef,
useState
} from 'react'
import { twMerge } from 'tailwind-merge'
import {
addDays,
addMonths,
calendarGrid,
clampDate,
compareDates,
dateIsUnavailable,
dateLabel,
endOfMonth,
firstDayOfWeek,
monthLabel,
monthValue,
parseIsoDate,
resolveDirection,
resolveLocale,
startOfMonth,
todayIso,
weekEdge,
weekdayLabels
} from './date.js'
const Calendar = forwardRef(function Calendar(
{
value,
defaultValue,
onValueChange,
onFocusChange,
min,
max,
unavailable,
rangeStart,
rangeEnd,
rangePreview,
locale: localeProp,
dir: directionProp,
label = 'Choose a date',
disabled = false,
readOnly = false,
className,
...sectionProps
},
forwardedRef
) {
const root = useRef(null)
const headingId = `klean-calendar-${useId().replace(/[^a-zA-Z0-9_-]/g, '')}-heading`
const locale = resolveLocale(localeProp)
const direction = resolveDirection(locale, directionProp)
const today = todayIso()
const [internalValue, setInternalValue] = useState(
parseIsoDate(defaultValue) ? defaultValue : undefined
)
const selected = value === undefined ? internalValue : value
const limits = useMemo(
() => ({ min, max, unavailable }),
[max, min, unavailable]
)
function available(candidate) {
return !disabled && !dateIsUnavailable(candidate, limits)
}
function nearestAvailable(candidate, movement = 1) {
let next = clampDate(candidate, limits) ?? today
for (let count = 0; count < 732; count += 1) {
if (available(next)) return next
const following = addDays(next, movement)
if (!following) break
if (min && compareDates(following, min) < 0) break
if (max && compareDates(following, max) > 0) break
next = following
}
return clampDate(candidate, limits) ?? today
}
const initialFocus =
(parseIsoDate(selected) && selected) ||
(parseIsoDate(defaultValue) && defaultValue) ||
today
const [focusedDate, setFocusedDate] = useState(() =>
nearestAvailable(initialFocus)
)
const [viewMonth, setViewMonth] = useState(() => monthValue(focusedDate))
const weekStart = firstDayOfWeek(locale)
const weekdays = weekdayLabels(locale, weekStart)
const days = calendarGrid(`${viewMonth}-01`, weekStart).map((candidate) => {
const parsed = parseIsoDate(candidate)
const decoratedEnd = rangeEnd || rangePreview
const inRange =
rangeStart &&
decoratedEnd &&
compareDates(candidate, rangeStart) >= 0 &&
compareDates(candidate, decoratedEnd) <= 0
return {
value: candidate,
day: parsed.day,
label: dateLabel(candidate, locale, { weekday: 'long' }),
outside: monthValue(candidate) !== viewMonth,
selected: candidate === selected,
today: candidate === today,
unavailable: !available(candidate),
rangeStart: candidate === rangeStart,
rangeEnd: candidate === decoratedEnd,
inRange
}
})
const weeks = Array.from({ length: 6 }, (_, index) =>
days.slice(index * 7, index * 7 + 7)
)
const visibleMonthLabel = monthLabel(`${viewMonth}-01`, locale)
const previousDisabled =
disabled ||
Boolean(
min && compareDates(endOfMonth(addMonths(`${viewMonth}-01`, -1)), min) < 0
)
const nextDisabled =
disabled ||
Boolean(
max &&
compareDates(startOfMonth(addMonths(`${viewMonth}-01`, 1)), max) > 0
)
function commit(nextValue) {
if (!available(nextValue) || readOnly) return
if (value === undefined) setInternalValue(nextValue)
onValueChange?.(nextValue)
}
function focusDate(nextValue, { move = true } = {}) {
const next = nearestAvailable(
nextValue,
compareDates(nextValue, focusedDate) < 0 ? -1 : 1
)
setFocusedDate(next)
if (move) setViewMonth(monthValue(next))
onFocusChange?.(next)
queueMicrotask(() => {
root.current
?.querySelector(`[data-date="${next}"]`)
?.focus({ preventScroll: true })
})
}
function moveByMonth(amount) {
if ((amount < 0 && previousDisabled) || (amount > 0 && nextDisabled)) return
focusDate(addMonths(focusedDate, amount))
}
function handleDayKeyDown(event, candidate) {
let next
const horizontal = direction === 'rtl' ? -1 : 1
if (event.key === 'ArrowLeft') next = addDays(candidate, -horizontal)
else if (event.key === 'ArrowRight') next = addDays(candidate, horizontal)
else if (event.key === 'ArrowUp') next = addDays(candidate, -7)
else if (event.key === 'ArrowDown') next = addDays(candidate, 7)
else if (event.key === 'Home')
next = weekEdge(candidate, weekStart, 'start')
else if (event.key === 'End') next = weekEdge(candidate, weekStart, 'end')
else if (event.key === 'PageUp')
next = addMonths(candidate, event.shiftKey ? -12 : -1)
else if (event.key === 'PageDown')
next = addMonths(candidate, event.shiftKey ? 12 : 1)
else return
event.preventDefault()
focusDate(next)
}
useEffect(() => {
if (!parseIsoDate(value)) return
const next = nearestAvailable(value)
setFocusedDate(next)
setViewMonth(monthValue(next))
// A controlled value is the synchronization boundary.
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [value])
useImperativeHandle(forwardedRef, () => ({
element: root.current,
focus: () => focusDate(focusedDate),
focusedDate
}))
return (
<section
{...sectionProps}
ref={root}
dir={direction}
data-slot="calendar"
aria-label={label}
className={twMerge(
'w-full max-w-88 rounded-lg bg-white p-3 text-gray-950 dark:bg-gray-950 dark:text-white',
className
)}
>
<header
data-slot="calendar-header"
className="grid grid-cols-[2.75rem_1fr_2.75rem] items-center gap-1"
>
<button
type="button"
data-slot="calendar-previous"
className="grid min-h-11 min-w-11 place-items-center rounded-md text-xl hover:bg-gray-100 focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-gray-950 disabled:cursor-not-allowed disabled:opacity-40 dark:hover:bg-gray-800 dark:focus-visible:outline-white"
aria-label={`Previous month, ${visibleMonthLabel}`}
disabled={previousDisabled}
onClick={() => moveByMonth(-1)}
>
<span aria-hidden="true">{direction === 'rtl' ? '→' : '←'}</span>
</button>
<h2
id={headingId}
data-slot="calendar-heading"
className="text-center text-base font-semibold"
aria-live="polite"
aria-atomic="true"
>
{visibleMonthLabel}
</h2>
<button
type="button"
data-slot="calendar-next"
className="grid min-h-11 min-w-11 place-items-center rounded-md text-xl hover:bg-gray-100 focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-gray-950 disabled:cursor-not-allowed disabled:opacity-40 dark:hover:bg-gray-800 dark:focus-visible:outline-white"
aria-label={`Next month, ${visibleMonthLabel}`}
disabled={nextDisabled}
onClick={() => moveByMonth(1)}
>
<span aria-hidden="true">{direction === 'rtl' ? '←' : '→'}</span>
</button>
</header>
<table
role="grid"
data-slot="calendar-grid"
className="mt-2 w-full table-fixed border-collapse"
aria-labelledby={headingId}
aria-readonly={readOnly || undefined}
aria-disabled={disabled || undefined}
>
<thead>
<tr>
{weekdays.map((weekday) => (
<th
key={weekday}
scope="col"
data-slot="calendar-weekday"
className="h-9 text-center text-xs font-medium text-gray-500 dark:text-gray-400"
>
{weekday}
</th>
))}
</tr>
</thead>
<tbody>
{weeks.map((week, weekIndex) => (
<tr key={weekIndex}>
{week.map((day) => (
<td
key={day.value}
role="gridcell"
data-slot="calendar-cell"
className="p-0 text-center"
aria-selected={day.selected}
>
<button
type="button"
data-slot="calendar-day"
data-date={day.value}
data-outside-month={day.outside || undefined}
data-selected={day.selected || undefined}
data-today={day.today || undefined}
data-unavailable={day.unavailable || undefined}
data-range-start={day.rangeStart || undefined}
data-range-end={day.rangeEnd || undefined}
data-in-range={day.inRange || undefined}
aria-label={day.label}
aria-current={day.today ? 'date' : undefined}
aria-disabled={day.unavailable || undefined}
disabled={day.unavailable}
tabIndex={day.value === focusedDate ? 0 : -1}
className="mx-auto grid min-h-11 min-w-11 place-items-center rounded-md text-sm tabular-nums hover:bg-gray-100 focus-visible:z-10 focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-gray-950 disabled:cursor-not-allowed data-in-range:bg-gray-100 data-outside-month:text-gray-400 data-range-end:bg-gray-950 data-range-end:font-semibold data-range-end:text-white data-range-start:bg-gray-950 data-range-start:font-semibold data-range-start:text-white data-selected:bg-gray-950 data-selected:font-semibold data-selected:text-white data-today:ring-1 data-today:ring-inset data-today:ring-gray-400 data-unavailable:text-gray-300 dark:hover:bg-gray-800 dark:focus-visible:outline-white dark:data-in-range:bg-gray-800 dark:data-outside-month:text-gray-600 dark:data-range-end:bg-white dark:data-range-end:text-gray-950 dark:data-range-start:bg-white dark:data-range-start:text-gray-950 dark:data-selected:bg-white dark:data-selected:text-gray-950 dark:data-today:ring-gray-600 dark:data-unavailable:text-gray-700"
onFocus={() => {
setFocusedDate(day.value)
onFocusChange?.(day.value)
}}
onKeyDown={(event) => handleDayKeyDown(event, day.value)}
onClick={() => commit(day.value)}
>
{day.day}
</button>
</td>
))}
</tr>
))}
</tbody>
</table>
</section>
)
})
export default Calendar
const ISO_DATE = /^(\d{4})-(\d{2})-(\d{2})$/
function pad(value) {
return String(value).padStart(2, '0')
}
function fromUtcDate(date) {
return formatIsoDate({
year: date.getUTCFullYear(),
month: date.getUTCMonth() + 1,
day: date.getUTCDate()
})
}
function toUtcDate(value) {
const date = typeof value === 'string' ? parseIsoDate(value) : value
if (!date) return undefined
return new Date(Date.UTC(date.year, date.month - 1, date.day))
}
export function daysInMonth(year, month) {
return new Date(Date.UTC(year, month, 0)).getUTCDate()
}
export function formatIsoDate({ year, month, day }) {
return `${String(year).padStart(4, '0')}-${pad(month)}-${pad(day)}`
}
export function parseIsoDate(value) {
if (typeof value !== 'string') return undefined
const match = ISO_DATE.exec(value.trim())
if (!match) return undefined
const date = {
year: Number(match[1]),
month: Number(match[2]),
day: Number(match[3])
}
if (
date.month < 1 ||
date.month > 12 ||
date.day < 1 ||
date.day > daysInMonth(date.year, date.month)
) {
return undefined
}
return date
}
export function compareDates(left, right) {
return String(left).localeCompare(String(right))
}
export function addDays(value, amount) {
const date = toUtcDate(value)
if (!date) return undefined
date.setUTCDate(date.getUTCDate() + amount)
return fromUtcDate(date)
}
export function addMonths(value, amount) {
const date = typeof value === 'string' ? parseIsoDate(value) : value
if (!date) return undefined
const monthIndex = date.year * 12 + date.month - 1 + amount
const year = Math.floor(monthIndex / 12)
const month = (((monthIndex % 12) + 12) % 12) + 1
return formatIsoDate({
year,
month,
day: Math.min(date.day, daysInMonth(year, month))
})
}
export function monthValue(value) {
const date = typeof value === 'string' ? parseIsoDate(value) : value
return date ? `${String(date.year).padStart(4, '0')}-${pad(date.month)}` : ''
}
export function startOfMonth(value) {
const date = typeof value === 'string' ? parseIsoDate(value) : value
return date ? formatIsoDate({ ...date, day: 1 }) : undefined
}
export function endOfMonth(value) {
const date = typeof value === 'string' ? parseIsoDate(value) : value
return date
? formatIsoDate({
...date,
day: daysInMonth(date.year, date.month)
})
: undefined
}
export function resolveLocale(locale) {
return (
locale ||
(typeof document !== 'undefined' && document.documentElement.lang) ||
(typeof navigator !== 'undefined' && navigator.language) ||
'en'
)
}
export function resolveDirection(locale, direction) {
if (direction === 'ltr' || direction === 'rtl') return direction
if (typeof document !== 'undefined') {
const documentDirection = document.documentElement.dir
if (documentDirection === 'ltr' || documentDirection === 'rtl') {
return documentDirection
}
}
try {
return new Intl.Locale(resolveLocale(locale)).textInfo.direction
} catch {
return 'ltr'
}
}
export function firstDayOfWeek(locale) {
try {
const firstDay = new Intl.Locale(resolveLocale(locale)).getWeekInfo()
.firstDay
return firstDay === 7 ? 0 : firstDay
} catch {
const region = resolveLocale(locale).split('-')[1]?.toUpperCase()
return ['CA', 'JP', 'PH', 'US'].includes(region) ? 0 : 1
}
}
export function todayIso(timeZone) {
const parts = new Intl.DateTimeFormat('en-CA', {
...(timeZone ? { timeZone } : {}),
year: 'numeric',
month: '2-digit',
day: '2-digit'
}).formatToParts(new Date())
const value = Object.fromEntries(parts.map((part) => [part.type, part.value]))
return `${value.year}-${value.month}-${value.day}`
}
export function dateLabel(value, locale, options = {}) {
const date = toUtcDate(value)
if (!date) return ''
return new Intl.DateTimeFormat(resolveLocale(locale), {
timeZone: 'UTC',
year: 'numeric',
month: 'long',
day: 'numeric',
...options
}).format(date)
}
export function monthLabel(value, locale) {
const date = toUtcDate(startOfMonth(value))
if (!date) return ''
return new Intl.DateTimeFormat(resolveLocale(locale), {
timeZone: 'UTC',
year: 'numeric',
month: 'long'
}).format(date)
}
export function weekdayLabels(locale, firstDay = firstDayOfWeek(locale)) {
const formatter = new Intl.DateTimeFormat(resolveLocale(locale), {
timeZone: 'UTC',
weekday: 'short'
})
const sunday = new Date(Date.UTC(2020, 5, 7))
return Array.from({ length: 7 }, (_, index) => {
const weekday = (firstDay + index) % 7
const date = new Date(sunday)
date.setUTCDate(sunday.getUTCDate() + weekday)
return formatter.format(date)
})
}
export function calendarGrid(view, firstDay = 1) {
const first = startOfMonth(view)
const date = toUtcDate(first)
if (!date) return []
const leadingDays = (date.getUTCDay() - firstDay + 7) % 7
const gridStart = addDays(first, -leadingDays)
return Array.from({ length: 42 }, (_, index) => addDays(gridStart, index))
}
export function dateIsUnavailable(value, { min, max, unavailable } = {}) {
if (!parseIsoDate(value)) return true
if (min && compareDates(value, min) < 0) return true
if (max && compareDates(value, max) > 0) return true
return Boolean(unavailable?.(value))
}
export function clampDate(value, { min, max } = {}) {
if (!parseIsoDate(value)) return undefined
if (min && compareDates(value, min) < 0) return min
if (max && compareDates(value, max) > 0) return max
return value
}
export function weekEdge(value, firstDay, edge) {
const date = toUtcDate(value)
if (!date) return undefined
const offset = (date.getUTCDay() - firstDay + 7) % 7
return addDays(value, edge === 'end' ? 6 - offset : -offset)
}
Svelte
<script>
import { untrack } from "svelte";
import { twMerge } from "tailwind-merge";
import {
addDays,
addMonths,
calendarGrid,
clampDate,
compareDates,
dateIsUnavailable,
dateLabel,
endOfMonth,
firstDayOfWeek,
monthLabel,
monthValue,
parseIsoDate,
resolveDirection,
resolveLocale,
startOfMonth,
todayIso,
weekEdge,
weekdayLabels,
} from "./date.js";
let {
value = $bindable(),
defaultValue,
onchange,
onfocuschange,
min,
max,
unavailable,
rangeStart,
rangeEnd,
rangePreview,
locale: localeProp,
dir: directionProp,
label = "Choose a date",
disabled = false,
readonly = false,
class: className,
...sectionProps
} = $props();
const initialValue = untrack(() =>
parseIsoDate(value)
? value
: parseIsoDate(defaultValue)
? defaultValue
: undefined,
);
let root;
let locale = $derived(resolveLocale(localeProp));
let direction = $derived(resolveDirection(locale, directionProp));
let today = $derived(todayIso());
function available(candidate) {
return (
!disabled && !dateIsUnavailable(candidate, { min, max, unavailable })
);
}
function nearestAvailable(candidate, movement = 1) {
let next = clampDate(candidate, { min, max }) ?? todayIso();
for (let count = 0; count < 732; count += 1) {
if (available(next)) return next;
const following = addDays(next, movement);
if (!following) break;
if (min && compareDates(following, min) < 0) break;
if (max && compareDates(following, max) > 0) break;
next = following;
}
return clampDate(candidate, { min, max }) ?? todayIso();
}
let focusedDate = $state(
nearestAvailable(initialValue || untrack(() => today)),
);
let viewMonth = $state(monthValue(untrack(() => focusedDate)));
let weekStart = $derived(firstDayOfWeek(locale));
let weekdays = $derived(weekdayLabels(locale, weekStart));
let days = $derived.by(() =>
calendarGrid(`${viewMonth}-01`, weekStart).map((candidate) => {
const parsed = parseIsoDate(candidate);
const decoratedEnd = rangeEnd || rangePreview;
const inRange =
rangeStart &&
decoratedEnd &&
compareDates(candidate, rangeStart) >= 0 &&
compareDates(candidate, decoratedEnd) <= 0;
return {
value: candidate,
day: parsed.day,
label: dateLabel(candidate, locale, { weekday: "long" }),
outside: monthValue(candidate) !== viewMonth,
selected: candidate === value,
today: candidate === today,
unavailable: !available(candidate),
rangeStart: candidate === rangeStart,
rangeEnd: candidate === decoratedEnd,
inRange,
};
}),
);
let weeks = $derived(
Array.from({ length: 6 }, (_, index) =>
days.slice(index * 7, index * 7 + 7),
),
);
let visibleMonthLabel = $derived(monthLabel(`${viewMonth}-01`, locale));
let previousDisabled = $derived(
disabled ||
Boolean(
min &&
compareDates(endOfMonth(addMonths(`${viewMonth}-01`, -1)), min) < 0,
),
);
let nextDisabled = $derived(
disabled ||
Boolean(
max &&
compareDates(startOfMonth(addMonths(`${viewMonth}-01`, 1)), max) > 0,
),
);
const componentId = $props.id();
const headingId = `klean-calendar-${componentId.replace(/[^a-zA-Z0-9_-]/g, "")}-heading`;
function commit(nextValue) {
if (!available(nextValue) || readonly) return;
value = nextValue;
onchange?.(nextValue);
}
function focusDate(nextValue, move = true) {
const next = nearestAvailable(
nextValue,
compareDates(nextValue, focusedDate) < 0 ? -1 : 1,
);
focusedDate = next;
if (move) viewMonth = monthValue(next);
onfocuschange?.(next);
queueMicrotask(() => {
root
?.querySelector(`[data-date="${next}"]`)
?.focus({ preventScroll: true });
});
}
function moveByMonth(amount) {
if ((amount < 0 && previousDisabled) || (amount > 0 && nextDisabled))
return;
focusDate(addMonths(focusedDate, amount));
}
function handleDayKeydown(event, candidate) {
let next;
const horizontal = direction === "rtl" ? -1 : 1;
if (event.key === "ArrowLeft") next = addDays(candidate, -horizontal);
else if (event.key === "ArrowRight") next = addDays(candidate, horizontal);
else if (event.key === "ArrowUp") next = addDays(candidate, -7);
else if (event.key === "ArrowDown") next = addDays(candidate, 7);
else if (event.key === "Home")
next = weekEdge(candidate, weekStart, "start");
else if (event.key === "End") next = weekEdge(candidate, weekStart, "end");
else if (event.key === "PageUp")
next = addMonths(candidate, event.shiftKey ? -12 : -1);
else if (event.key === "PageDown")
next = addMonths(candidate, event.shiftKey ? 12 : 1);
else return;
event.preventDefault();
focusDate(next);
}
$effect(() => {
if (!parseIsoDate(value)) return;
const next = nearestAvailable(value);
focusedDate = next;
viewMonth = monthValue(next);
});
export function focus() {
focusDate(focusedDate);
}
</script>
<section
{...sectionProps}
bind:this={root}
dir={direction}
data-slot="calendar"
aria-label={label}
class={twMerge(
"w-full max-w-88 rounded-lg bg-white p-3 text-gray-950 dark:bg-gray-950 dark:text-white",
className,
)}
>
<header
data-slot="calendar-header"
class="grid grid-cols-[2.75rem_1fr_2.75rem] items-center gap-1"
>
<button
type="button"
data-slot="calendar-previous"
class="grid min-h-11 min-w-11 place-items-center rounded-md text-xl hover:bg-gray-100 focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-gray-950 disabled:cursor-not-allowed disabled:opacity-40 dark:hover:bg-gray-800 dark:focus-visible:outline-white"
aria-label={`Previous month, ${visibleMonthLabel}`}
disabled={previousDisabled}
onclick={() => moveByMonth(-1)}
>
<span aria-hidden="true">{direction === "rtl" ? "→" : "←"}</span>
</button>
<h2
id={headingId}
data-slot="calendar-heading"
class="text-center text-base font-semibold"
aria-live="polite"
aria-atomic="true"
>
{visibleMonthLabel}
</h2>
<button
type="button"
data-slot="calendar-next"
class="grid min-h-11 min-w-11 place-items-center rounded-md text-xl hover:bg-gray-100 focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-gray-950 disabled:cursor-not-allowed disabled:opacity-40 dark:hover:bg-gray-800 dark:focus-visible:outline-white"
aria-label={`Next month, ${visibleMonthLabel}`}
disabled={nextDisabled}
onclick={() => moveByMonth(1)}
>
<span aria-hidden="true">{direction === "rtl" ? "←" : "→"}</span>
</button>
</header>
<table
role="grid"
data-slot="calendar-grid"
class="mt-2 w-full table-fixed border-collapse"
aria-labelledby={headingId}
aria-readonly={readonly || undefined}
aria-disabled={disabled || undefined}
>
<thead>
<tr>
{#each weekdays as weekday (weekday)}
<th
scope="col"
data-slot="calendar-weekday"
class="h-9 text-center text-xs font-medium text-gray-500 dark:text-gray-400"
>
{weekday}
</th>
{/each}
</tr>
</thead>
<tbody>
{#each weeks as week, weekIndex (weekIndex)}
<tr>
{#each week as day (day.value)}
<td
role="gridcell"
data-slot="calendar-cell"
class="p-0 text-center"
aria-selected={day.selected}
>
<button
type="button"
data-slot="calendar-day"
data-date={day.value}
data-outside-month={day.outside || undefined}
data-selected={day.selected || undefined}
data-today={day.today || undefined}
data-unavailable={day.unavailable || undefined}
data-range-start={day.rangeStart || undefined}
data-range-end={day.rangeEnd || undefined}
data-in-range={day.inRange || undefined}
aria-label={day.label}
aria-current={day.today ? "date" : undefined}
aria-disabled={day.unavailable || undefined}
disabled={day.unavailable}
tabindex={day.value === focusedDate ? 0 : -1}
class="mx-auto grid min-h-11 min-w-11 place-items-center rounded-md text-sm tabular-nums hover:bg-gray-100 focus-visible:z-10 focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-gray-950 disabled:cursor-not-allowed data-in-range:bg-gray-100 data-outside-month:text-gray-400 data-range-end:bg-gray-950 data-range-end:font-semibold data-range-end:text-white data-range-start:bg-gray-950 data-range-start:font-semibold data-range-start:text-white data-selected:bg-gray-950 data-selected:font-semibold data-selected:text-white data-today:ring-1 data-today:ring-inset data-today:ring-gray-400 data-unavailable:text-gray-300 dark:hover:bg-gray-800 dark:focus-visible:outline-white dark:data-in-range:bg-gray-800 dark:data-outside-month:text-gray-600 dark:data-range-end:bg-white dark:data-range-end:text-gray-950 dark:data-range-start:bg-white dark:data-range-start:text-gray-950 dark:data-selected:bg-white dark:data-selected:text-gray-950 dark:data-today:ring-gray-600 dark:data-unavailable:text-gray-700"
onfocus={() => {
focusedDate = day.value;
onfocuschange?.(day.value);
}}
onkeydown={(event) => handleDayKeydown(event, day.value)}
onclick={() => commit(day.value)}
>
{day.day}
</button>
</td>
{/each}
</tr>
{/each}
</tbody>
</table>
</section>
const ISO_DATE = /^(\d{4})-(\d{2})-(\d{2})$/
function pad(value) {
return String(value).padStart(2, '0')
}
function fromUtcDate(date) {
return formatIsoDate({
year: date.getUTCFullYear(),
month: date.getUTCMonth() + 1,
day: date.getUTCDate()
})
}
function toUtcDate(value) {
const date = typeof value === 'string' ? parseIsoDate(value) : value
if (!date) return undefined
return new Date(Date.UTC(date.year, date.month - 1, date.day))
}
export function daysInMonth(year, month) {
return new Date(Date.UTC(year, month, 0)).getUTCDate()
}
export function formatIsoDate({ year, month, day }) {
return `${String(year).padStart(4, '0')}-${pad(month)}-${pad(day)}`
}
export function parseIsoDate(value) {
if (typeof value !== 'string') return undefined
const match = ISO_DATE.exec(value.trim())
if (!match) return undefined
const date = {
year: Number(match[1]),
month: Number(match[2]),
day: Number(match[3])
}
if (
date.month < 1 ||
date.month > 12 ||
date.day < 1 ||
date.day > daysInMonth(date.year, date.month)
) {
return undefined
}
return date
}
export function compareDates(left, right) {
return String(left).localeCompare(String(right))
}
export function addDays(value, amount) {
const date = toUtcDate(value)
if (!date) return undefined
date.setUTCDate(date.getUTCDate() + amount)
return fromUtcDate(date)
}
export function addMonths(value, amount) {
const date = typeof value === 'string' ? parseIsoDate(value) : value
if (!date) return undefined
const monthIndex = date.year * 12 + date.month - 1 + amount
const year = Math.floor(monthIndex / 12)
const month = (((monthIndex % 12) + 12) % 12) + 1
return formatIsoDate({
year,
month,
day: Math.min(date.day, daysInMonth(year, month))
})
}
export function monthValue(value) {
const date = typeof value === 'string' ? parseIsoDate(value) : value
return date ? `${String(date.year).padStart(4, '0')}-${pad(date.month)}` : ''
}
export function startOfMonth(value) {
const date = typeof value === 'string' ? parseIsoDate(value) : value
return date ? formatIsoDate({ ...date, day: 1 }) : undefined
}
export function endOfMonth(value) {
const date = typeof value === 'string' ? parseIsoDate(value) : value
return date
? formatIsoDate({
...date,
day: daysInMonth(date.year, date.month)
})
: undefined
}
export function resolveLocale(locale) {
return (
locale ||
(typeof document !== 'undefined' && document.documentElement.lang) ||
(typeof navigator !== 'undefined' && navigator.language) ||
'en'
)
}
export function resolveDirection(locale, direction) {
if (direction === 'ltr' || direction === 'rtl') return direction
if (typeof document !== 'undefined') {
const documentDirection = document.documentElement.dir
if (documentDirection === 'ltr' || documentDirection === 'rtl') {
return documentDirection
}
}
try {
return new Intl.Locale(resolveLocale(locale)).textInfo.direction
} catch {
return 'ltr'
}
}
export function firstDayOfWeek(locale) {
try {
const firstDay = new Intl.Locale(resolveLocale(locale)).getWeekInfo()
.firstDay
return firstDay === 7 ? 0 : firstDay
} catch {
const region = resolveLocale(locale).split('-')[1]?.toUpperCase()
return ['CA', 'JP', 'PH', 'US'].includes(region) ? 0 : 1
}
}
export function todayIso(timeZone) {
const parts = new Intl.DateTimeFormat('en-CA', {
...(timeZone ? { timeZone } : {}),
year: 'numeric',
month: '2-digit',
day: '2-digit'
}).formatToParts(new Date())
const value = Object.fromEntries(parts.map((part) => [part.type, part.value]))
return `${value.year}-${value.month}-${value.day}`
}
export function dateLabel(value, locale, options = {}) {
const date = toUtcDate(value)
if (!date) return ''
return new Intl.DateTimeFormat(resolveLocale(locale), {
timeZone: 'UTC',
year: 'numeric',
month: 'long',
day: 'numeric',
...options
}).format(date)
}
export function monthLabel(value, locale) {
const date = toUtcDate(startOfMonth(value))
if (!date) return ''
return new Intl.DateTimeFormat(resolveLocale(locale), {
timeZone: 'UTC',
year: 'numeric',
month: 'long'
}).format(date)
}
export function weekdayLabels(locale, firstDay = firstDayOfWeek(locale)) {
const formatter = new Intl.DateTimeFormat(resolveLocale(locale), {
timeZone: 'UTC',
weekday: 'short'
})
const sunday = new Date(Date.UTC(2020, 5, 7))
return Array.from({ length: 7 }, (_, index) => {
const weekday = (firstDay + index) % 7
const date = new Date(sunday)
date.setUTCDate(sunday.getUTCDate() + weekday)
return formatter.format(date)
})
}
export function calendarGrid(view, firstDay = 1) {
const first = startOfMonth(view)
const date = toUtcDate(first)
if (!date) return []
const leadingDays = (date.getUTCDay() - firstDay + 7) % 7
const gridStart = addDays(first, -leadingDays)
return Array.from({ length: 42 }, (_, index) => addDays(gridStart, index))
}
export function dateIsUnavailable(value, { min, max, unavailable } = {}) {
if (!parseIsoDate(value)) return true
if (min && compareDates(value, min) < 0) return true
if (max && compareDates(value, max) > 0) return true
return Boolean(unavailable?.(value))
}
export function clampDate(value, { min, max } = {}) {
if (!parseIsoDate(value)) return undefined
if (min && compareDates(value, min) < 0) return min
if (max && compareDates(value, max) > 0) return max
return value
}
export function weekEdge(value, firstDay, edge) {
const date = toUtcDate(value)
if (!date) return undefined
const offset = (date.getUTCDay() - firstDay + 7) % 7
return addDays(value, edge === 'end' ? 6 - offset : -offset)
}