Skip to content

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.

Calendar.vue
Try Arrow keys, Home, End, Page Up, Page Down, and Shift with Page Up or Page Down.

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.

Terminal
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

AvailabilityCalendar.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

AvailabilityCalendar.jsx
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

AvailabilityCalendar.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

PurposeVueReactSvelte
Current valuev-modelvalue, onValueChangebind:value
Initial valuedefault-valuedefaultValuedefaultValue
Boundsmin, maxmin, maxmin, max
Product availabilityunavailableunavailableunavailable
Localelocale, dirlocale, dirlocale, dir
Interactiondisabled, readonlydisabled, readOnlydisabled, readonly
StylingclassclassNameclass

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.

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-DD field with an optional calendar.
  • Date Range Picker — two ordered date-only YYYY-MM-DD boundaries.
  • 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

Calendar.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>

date.js
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

Calendar.jsx
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

date.js
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

Calendar.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>

date.js
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)
}

All open source projects are released under the MIT License.