# Calendar

## Usage

`Calendar` shows one or two months and selects a day or a start and end. It opens on the requested month, or on the selected day, or on today when that day still falls inside the earliest and latest dates.

A day is unavailable when it is outside those bounds, when a predicate returns true, or when any constraint returns false. A range can also require a minimum and maximum length, counting both ends. Week numbers can sit beside each row. Days from the neighboring months can fill the grid, and they cannot be picked. The grid can shrink to the weeks the month needs, or stay six rows tall. The week can start on Sunday or on any other day, given as a number or a short name. A handle can move the visible month to a date and focus that day.

Arrow keys move by a day or a week, cross into the next month, and skip days that cannot be picked. Home and End move to the first and last available day of the week. Page Up and Page Down move by a month. Enter or Space picks the focused day. Escape cancels a range that has a start but no end yet. Each month is a grid named with that month. Selection, today, and unavailable days are exposed to assistive technology.

Bind a date:

```vue
<script setup lang="ts">
import { Calendar } from '@astryx-vue/core/Calendar'
import { ref } from 'vue'

const date = ref<Date | null>(new Date(2026, 9, 11))
</script>

<template>
  <Calendar v-model="date" label="Select a date" />
</template>
```

The value is a local `Date`. The month title and weekday names follow the active locale. Built-in labels are English unless the app calls `provideAstryxLocale('zh-CN')`.

### Anatomy

- **Calendar root**: The container that holds the month header and the day grid.
- **Month header**: The month name and year with navigation arrows to move between months. The arrows mirror automatically under dir="rtl".
- **Day grid**: A 7-column grid of days with column headers for the day names.
- **Day button**: One day in the grid; the control a date is picked with.
- **Selected day**: The currently selected date, highlighted. In range mode, the start and end dates plus the days between them.
- **Today marker**: A subtle indicator on the current date for orientation.

## Best practices

| Guideline | Practice |
| --- | --- |
| Do | Limit the range with `min` and `max` when the product only accepts some dates. |
| Do | Use `isDateDisabled` for dates that fall inside the range but still cannot be chosen. |
| Do | Construct dates as local calendar days, not as timestamps that shift across time zones. |
| Don't | Cover the day grid with extra decoration. The month should stay easy to scan. |

## Examples

### Constraints

Only weekdays from January 10 through March 20, 2026 can be selected.

```vue
<script setup lang="ts">
import { Calendar } from '@astryx-vue/core/Calendar'
import { VStack } from '@astryx-vue/core/Stack'
import { Text } from '@astryx-vue/core/Text'
import { ref } from 'vue'

const value = ref<Date | null>(null)
const focusDate = new Date(2026, 0, 1)
const min = new Date(2026, 0, 10)
const max = new Date(2026, 2, 20)

function isWeekend(date: Date): boolean {
  const day = date.getDay()
  return day === 0 || day === 6
}
</script>

<template>
  <VStack :gap="4" h-align="center">
    <Text type="supporting" color="secondary">
      Jan 10 – Mar 20, weekdays only
    </Text>
    <Calendar
      v-model="value"
      mode="single"
      :min="min"
      :max="max"
      :is-date-disabled="isWeekend"
      :focus-date="focusDate"
    />
  </VStack>
</template>
```

### Range

Pick a start and end date. The days between them stay highlighted.

```vue
<script setup lang="ts">
import type { CalendarRangeValue } from '@astryx-vue/core/Calendar'
import { Calendar } from '@astryx-vue/core/Calendar'
import { VStack } from '@astryx-vue/core/Stack'
import { Text } from '@astryx-vue/core/Text'
import { ref } from 'vue'

const range = ref<CalendarRangeValue>({
  start: new Date(2026, 0, 10),
  end: new Date(2026, 0, 20),
})
const focusDate = new Date(2026, 0, 1)

function iso(date: Date | null): string {
  if (!date)
    return ''
  const month = String(date.getMonth() + 1).padStart(2, '0')
  const day = String(date.getDate()).padStart(2, '0')
  return `${date.getFullYear()}-${month}-${day}`
}
</script>

<template>
  <VStack :gap="4" h-align="center">
    <Text type="supporting" color="secondary">
      {{ range.start && range.end ? `${iso(range.start)} → ${iso(range.end)}` : 'Pick a start and end date' }}
    </Text>
    <Calendar v-model:range="range" mode="range" :focus-date="focusDate" />
  </VStack>
</template>
```

### Single date

Pick one date from the month.

```vue
<script setup lang="ts">
import { Calendar } from '@astryx-vue/core/Calendar'
import { VStack } from '@astryx-vue/core/Stack'
import { Text } from '@astryx-vue/core/Text'
import { ref } from 'vue'

const value = ref<Date | null>(new Date(2026, 0, 15))
const focusDate = new Date(2026, 0, 1)

function iso(date: Date | null): string {
  if (!date)
    return ''
  const month = String(date.getMonth() + 1).padStart(2, '0')
  const day = String(date.getDate()).padStart(2, '0')
  return `${date.getFullYear()}-${month}-${day}`
}
</script>

<template>
  <VStack :gap="4" h-align="center">
    <Text type="supporting" color="secondary">
      {{ value ? `Selected: ${iso(value)}` : 'Pick a date' }}
    </Text>
    <Calendar v-model="value" mode="single" :focus-date="focusDate" />
  </VStack>
</template>
```

### Two months

Two months side by side, for a range that crosses a month boundary.

```vue
<script setup lang="ts">
import type { CalendarRangeValue } from '@astryx-vue/core/Calendar'
import { Calendar } from '@astryx-vue/core/Calendar'
import { VStack } from '@astryx-vue/core/Stack'
import { Text } from '@astryx-vue/core/Text'
import { ref } from 'vue'

const range = ref<CalendarRangeValue>({
  start: new Date(2026, 0, 25),
  end: new Date(2026, 1, 5),
})
const focusDate = new Date(2026, 0, 1)

function iso(date: Date | null): string {
  if (!date)
    return ''
  const month = String(date.getMonth() + 1).padStart(2, '0')
  const day = String(date.getDate()).padStart(2, '0')
  return `${date.getFullYear()}-${month}-${day}`
}
</script>

<template>
  <VStack :gap="4" h-align="center">
    <Text type="supporting" color="secondary">
      {{ range.start && range.end ? `${iso(range.start)} → ${iso(range.end)}` : 'Pick a date range' }}
    </Text>
    <Calendar v-model:range="range" mode="range" :number-of-months="2" :focus-date="focusDate" />
  </VStack>
</template>
```

### Weeks

Turn week numbers, outside days, and a shorter month on or off, and start the week on Monday.

```vue
<script setup lang="ts">
import { Button } from '@astryx-vue/core/Button'
import { Calendar } from '@astryx-vue/core/Calendar'
import { HStack, VStack } from '@astryx-vue/core/Stack'
import { Text } from '@astryx-vue/core/Text'
import { ref } from 'vue'

const value = ref<Date | null>(new Date(2026, 1, 12))
const weekNumbers = ref(true)
const outside = ref(true)
const variable = ref(true)
const monday = ref(true)
</script>

<template>
  <VStack :gap="3">
    <HStack :gap="2" wrap="wrap">
      <Button size="sm" :label="weekNumbers ? 'Week numbers on' : 'Week numbers off'" :variant="weekNumbers ? 'primary' : 'secondary'" @click="weekNumbers = !weekNumbers" />
      <Button size="sm" :label="outside ? 'Outside days on' : 'Outside days off'" :variant="outside ? 'primary' : 'secondary'" @click="outside = !outside" />
      <Button size="sm" :label="variable ? 'Short month' : 'Six rows'" :variant="variable ? 'primary' : 'secondary'" @click="variable = !variable" />
      <Button size="sm" :label="monday ? 'Starts Monday' : 'Starts Sunday'" :variant="monday ? 'primary' : 'secondary'" @click="monday = !monday" />
    </HStack>
    <Text type="supporting" color="secondary">
      February 2026. Adjacent days stay visible but cannot be selected.
    </Text>
    <Calendar
      v-model="value"
      :focus-date="new Date(2026, 1, 1)"
      :has-week-numbers="weekNumbers"
      :has-outside-days="outside"
      :has-variable-row-count="variable"
      :week-starts-on="monday ? 'mon' : 'sun'"
    />
  </VStack>
</template>
```

### Span and navigation

The range is at most three days, and Sundays cannot be picked. June moves the month from outside the calendar.

```vue
<script setup lang="ts">
import type { CalendarHandle } from '@astryx-vue/core/Calendar'
import { Button } from '@astryx-vue/core/Button'
import { Calendar } from '@astryx-vue/core/Calendar'
import { HStack, VStack } from '@astryx-vue/core/Stack'
import { Text } from '@astryx-vue/core/Text'
import { ref } from 'vue'

const range = ref({ start: new Date(2026, 0, 12), end: new Date(2026, 0, 14) })
const calendar = ref<CalendarHandle | null>(null)

function bindCalendar(handle: CalendarHandle | null): void {
  calendar.value = handle
}

function jump(): void {
  calendar.value?.navigateTo(new Date(2026, 5, 8))
}
</script>

<template>
  <VStack :gap="3">
    <HStack :gap="2">
      <Button size="sm" variant="secondary" label="June" @click="jump" />
      <Text type="supporting" color="secondary">
        At most three days, Sundays excluded. Escape cancels a range that is still open.
      </Text>
    </HStack>
    <Calendar
      v-model:range="range"
      mode="range"
      :handle-ref="bindCalendar"
      :focus-date="new Date(2026, 0, 1)"
      :max-range-span="3"
      :date-constraints="[(date: Date) => date.getDay() !== 0]"
    />
  </VStack>
</template>
```

## Calendar Props

| Name | Type | Default | Description |
| --- | --- | --- | --- |
| `modelValue` | `Date \| null` | `null` | The selected date. |
| `mode` | `'single' \| 'range'` | `'single'` | Select one day, or a start and end. |
| `range` | `{ start: Date \| null, end: Date \| null } \| null` | `null` | Range value. Bind with `v-model:range`. |
| `numberOfMonths` | `1 \| 2` | `1` | How many months to show side by side. |
| `focusDate` | `Date` | — | Month to show first. |
| `min` | `Date` | — | Earliest selectable date. |
| `max` | `Date` | — | Latest selectable date. |
| `isDateDisabled` | `(date: Date) => boolean` | — | Return true to disable a date. |
| `dateConstraints` | `Array<(date: Date) => boolean>` | — | A day is disabled when any function returns false. This applies together with the earliest date, the latest date, and the disable predicate. |
| `minRangeSpan` | `number` | `1` | Shortest range length, counting both ends. Above 1, clicking the start again cancels the selection. |
| `maxRangeSpan` | `number` | — | Longest range length, counting both ends. It applies only after a start is picked. |
| `hasOutsideDays` | `boolean` | `true` | Show days from the neighboring months. Those days cannot be selected. |
| `hasWeekNumbers` | `boolean` | `false` | Show the week number at the start of each row. |
| `hasVariableRowCount` | `boolean` | `false` | Shrink the grid to the weeks the month needs. Otherwise the grid stays six rows tall. |
| `weekStartsOn` | `0-6 \| 'sun' \| 'mon' \| 'tue' \| 'wed' \| 'thu' \| 'fri' \| 'sat'` | `0` | First day of the week. 0 is Sunday. |
| `handleRef` | `Ref \| (handle) => void` | — | Receives a handle. navigateTo moves the month to that day and focuses it. |
| `label` | `string` | `Calendar` / `日历` | Accessible name for the calendar. English by default; Chinese when `zh-CN` is provided. |
| `disabled` | `boolean` | `false` | Disables the whole calendar. |

## Calendar events

| Name | Payload | Description |
| --- | --- | --- |
| `update:modelValue` | `value: Date \| null` | Emitted when the selected date changes. |
| `update:range` | `value: { start: Date \| null, end: Date \| null }` | Emitted when the range changes. |
| `change` | `value: Date \| null` | Emitted when the selected date changes. |
| `update:focusDate` | `value: Date` | Emitted when the visible month changes. The value is the first day of that month. |

## Calendar slots

| Name | Props | Description |
| --- | --- | --- |
| `header` | — | Custom month header. |
| `day` | `{ date, isSelected, isDisabled, isToday, isInMonth }` | Custom day cell. |
