# 日历

## 使用方法

`Calendar` 用月历网格选日期。可以选一天，也可以选一段起止日期，并排显示一个月或两个月。打开时先落到指定的月份；没有指定时，落在已选日期或今天，并尽量落进最早和最晚日期之间。

最早日期、最晚日期、返回真值的判断，以及一组返回假值的约束，都会让对应的日子不能选。范围还可以限制最短和最长天数，天数把两端都算进去。周数可以显示在每一行左侧。相邻月份的日期可以留在格子里，但不能选。行数可以按当月缩短，也可以固定六行。一周从哪一天开始，用数字或星期缩写指定。外面也可以拿到一个句柄，直接把月份翻到某一天并聚焦它。

方向键按天或按周移动，到头会翻月，并跳过不能选的日子。Home 和 End 移到这一周里能选的第一天和最后一天。PageUp 和 PageDown 翻一个月。Enter 或空格确认。范围还没选完时，Escape 取消。每个月是一块带月份名字的网格，今天、选中和禁用都能被读屏读到。

基本用法只需要绑定一个日期值：

```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="选择日期" />
</template>
```

绑定值是原生 `Date`。通过 `min` 和 `max` 限制可选日期范围，通过 `isDateDisabled` 函数禁用特定日期。月份标题和星期名称跟随当前语言。组件自带文案默认是英文，调用 `provideAstryxLocale('zh-CN')` 后改为中文。

### 结构

- **日历根**: 装着月份标题和日期网格的容器。
- **月份标题**: 月份、年份，以及切换月份的箭头。dir="rtl" 时箭头会自动对调。
- **日期网格**: 七列的日期格，列头是星期名。
- **日期按钮**: 网格里的一天，用来选日期。
- **选中的日期**: 当前选中的日期，会高亮。范围模式下，还包括起止日期和中间那些天。
- **今天标记**: 标在当天上的淡提示，用来帮人定位。

## 最佳实践

| 指引 | 实践 |
| --- | --- |
| 推荐 | 为业务场景设置合理的日期范围限制。 |
| 推荐 | 使用 `isDateDisabled` 禁用范围内仍不可选的日期。 |
| 推荐 | 用固定的本地日期构造 `Date`，避免把带时区的时间戳直接当成日历上的某一天。 |
| 避免 | 在日历上层叠过多的装饰或自定义内容，保持日期网格清晰。 |

## 示例

### 日期限制

只能选择 2026 年 1 月 10 日到 3 月 20 日之间的工作日。

```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>
```

### 日期范围

选择开始和结束日期，中间的日子会高亮。

```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>
```

### 单日

在月历里选择一天。

```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>
```

### 两个月

并排显示两个月，用来选择跨月的日期范围。

```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>
```

### 周数和周起始

可以打开周数、月外日期、按月缩短行数，并把一周的第一天改成周一。

```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>
```

### 跨度与翻月

这段范围最长三天，周日不可选。六月按钮会把月份翻过去。

```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

| 名称 | 类型 | 默认值 | 说明 |
| --- | --- | --- | --- |
| `modelValue` | `Date \| null` | `null` | 当前选中的日期。 |
| `mode` | `'single' \| 'range'` | `'single'` | 选一天，或选一段起止日期。 |
| `range` | `{ start: Date \| null, end: Date \| null } \| null` | `null` | 范围值，用 `v-model:range` 绑定。 |
| `numberOfMonths` | `1 \| 2` | `1` | 并排显示的月份数。 |
| `focusDate` | `Date` | — | 先显示这一天所在的月份。 |
| `min` | `Date` | — | 最早可选日期。 |
| `max` | `Date` | — | 最晚可选日期。 |
| `isDateDisabled` | `(date: Date) => boolean` | — | 返回 true 时该日期不可选。 |
| `dateConstraints` | `Array<(date: Date) => boolean>` | — | 任一函数返回 false 时该日期不可选。和最早、最晚、禁用判断一起生效。 |
| `minRangeSpan` | `number` | `1` | 范围最短天数，含两端。大于 1 时，再点一次起点会取消这次选择。 |
| `maxRangeSpan` | `number` | — | 范围最长天数，含两端。起点还没选中时不生效。 |
| `hasOutsideDays` | `boolean` | `true` | 显示相邻月份的日期。这些日期不能选。 |
| `hasWeekNumbers` | `boolean` | `false` | 在每一行左侧显示周数。 |
| `hasVariableRowCount` | `boolean` | `false` | 行数随当月缩短。关闭时固定六行。 |
| `weekStartsOn` | `0-6 \| 'sun' \| 'mon' \| 'tue' \| 'wed' \| 'thu' \| 'fri' \| 'sat'` | `0` | 一周从哪一天开始。0 是周日。 |
| `handleRef` | `Ref \| (handle) => void` | — | 接收翻月句柄。调用 navigateTo 会翻到那一天并聚焦它。 |
| `label` | `string` | `Calendar` / `日历` | 日历的无障碍名称。未设置语言时为英文，提供 `zh-CN` 后为中文。 |
| `disabled` | `boolean` | `false` | 是否禁用整个日历。 |

## Calendar 事件

| 名称 | 载荷 | 说明 |
| --- | --- | --- |
| `update:modelValue` | `value: Date \| null` | 选中日期变化时触发。 |
| `update:range` | `value: { start: Date \| null, end: Date \| null }` | 日期范围变化时触发。 |
| `change` | `value: Date \| null` | 选中日期变化时触发。 |
| `update:focusDate` | `value: Date` | 可见月份变化时触发，值是该月第一天。 |

## Calendar 插槽

| 名称 | 参数 | 说明 |
| --- | --- | --- |
| `header` | — | 自定义月份头部。 |
| `day` | `{ date, isSelected, isDisabled, isToday, isInMonth }` | 自定义日期单元格。 |
