# 日期区间输入框

## 使用方法

`DateRangeInput` 选择开始日期和结束日期。值是 `{ start, end }`，两端都是 ISO 日期；没有选择时是 `null`。

```vue
<script setup lang="ts">
import type { DateRange } from '@astryx-vue/core/DateRangeInput'
import { DateRangeInput } from '@astryx-vue/core/DateRangeInput'
import { shallowRef } from 'vue'

const range = shallowRef<DateRange | null>(null)
</script>

<template>
  <DateRangeInput v-model="range" label="日期区间" />
</template>
```

弹出层默认把两个月放在同一条标题下面。第一次点击记下起点，第二次点击在跨度合法时提交区间。再次点击起点时，最短天数是 1 就提交单日，否则取消这次选择。两端都在今年时，收起后的文字不写年份。预设按钮一直在日历旁边。两个月并排放不下时，月份会自动换成上下两行。约束函数可以再排除具体的日子。正在保存时字段不能改，保存会在区间写进去之后进行。

### 结构

- **标签**: 触发器上方的文字，说明期望的是什么日期范围。
- **字段表面**: 带边框的控件，装着日历开关、范围触发器和尾部操作。
- **触发按钮**: 显示格式化后的范围或占位的按钮。点击打开浮层。
- **日历图标**: 同样能打开浮层的尾部图标。
- **日历浮层**: 双月历网格，支持范围选择和悬停预览。
- **预设侧栏**: 日历旁边的预设范围列表。
- **预设按钮**: 一键选中某个预设范围，并反映当前和禁用状态。
- **清除按钮**: 把范围重置为空的 × 按钮。
- **状态消息**: 触发器下面的错误、警告或成功提示。

## 最佳实践

| 指引 | 实践 |
| --- | --- |
| 推荐 | 把人们最常选的区间做成预设。 |
| 推荐 | 单日或过长区间不合法时，设置 `minRangeSpan` 和 `maxRangeSpan`。天数包含两端。 |
| 避免 | 用它选单独一天。那是 DateInput 的事。 |

## 示例

### 校验

已填区间上的错误、警告和成功。

```vue
<script setup lang="ts">
import type { DateRange } from '@astryx-vue/core/DateRangeInput'
import { DateRangeInput } from '@astryx-vue/core/DateRangeInput'
import { VStack } from '@astryx-vue/core/Stack'
import { shallowRef } from 'vue'

const errorRange = shallowRef<DateRange | null>({ start: '2026-01-01', end: '2026-01-31' })
const warningRange = shallowRef<DateRange | null>({ start: '2026-06-01', end: '2026-06-30' })
const successRange = shallowRef<DateRange | null>({ start: '2026-03-01', end: '2026-03-31' })
</script>

<template>
  <VStack :gap="4" style="width: 240px; max-width: 100%">
    <DateRangeInput v-model="errorRange" label="Booking period" :status="{ type: 'error', message: 'Selected dates are no longer available' }" />
    <DateRangeInput v-model="warningRange" label="Preferred period" :status="{ type: 'warning', message: 'High demand — limited availability' }" />
    <DateRangeInput v-model="successRange" label="Confirmed period" :status="{ type: 'success', message: 'Dates confirmed and available' }" />
  </VStack>
</template>
```
### 带预设

最近 7、14、30、90 天的预设，也可以自己选区间。

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

function isoDaysAgo(days: number): string {
  const date = new Date()
  date.setDate(date.getDate() - days)
  const month = String(date.getMonth() + 1).padStart(2, '0')
  const day = String(date.getDate()).padStart(2, '0')
  return `${date.getFullYear()}-${month}-${day}`
}

const range = shallowRef<DateRange | null>(null)
const presets = [
  { label: 'Last 7 days', getRange: () => ({ start: isoDaysAgo(7), end: isoDaysAgo(0) }) },
  { label: 'Last 14 days', getRange: () => ({ start: isoDaysAgo(14), end: isoDaysAgo(0) }) },
  { label: 'Last 30 days', getRange: () => ({ start: isoDaysAgo(30), end: isoDaysAgo(0) }) },
  { label: 'Last 90 days', getRange: () => ({ start: isoDaysAgo(90), end: isoDaysAgo(0) }) },
]
</script>

<template>
  <VStack :gap="4" style="width: 240px; max-width: 100%">
    <Text type="supporting" color="secondary">
      {{ range ? `${range.start} → ${range.end}` : 'No range selected' }}
    </Text>
    <DateRangeInput
      v-model="range"
      label="Report period"
      description="Use a preset or pick a custom range"
      :presets="presets"
    />
  </VStack>
</template>
```

## DateRangeInput 属性

| 名称 | 类型 | 默认值 | 说明 |
| --- | --- | --- | --- |
| `v-model` | `DateRange \| null` | `null` | `{ start, end }`，ISO 日期。 |
| `label` | `string` | — | 必填。无障碍名称。 |
| `presets` | `{ label, getRange }[]` | — | 快捷按钮。`getRange` 返回区间。 |
| `monthCount` | `number` | `2` | 弹出层显示几个月。 |
| `numberOfMonths` | `1 \| 2` | — | 并排月份数。传了这项时优先于 monthCount。 |
| `dateConstraints` | `Array<(date: Date) => boolean>` | — | 任一函数返回 false 时该日期不可选。 |
| `min` / `max` | `string` | — | 区间外的日期不可选。 |
| `minRangeSpan` | `number` | `1` | 最短天数，包含两端。 |
| `maxRangeSpan` | `number` | — | 最长天数，包含两端。 |
| `weekStartsOn` | `0-6 \| 'sun' \| 'mon' \| ...` | 周日，zh-CN 为周一 | 日历的第一列。 |
| `hasClear` | `boolean` | `true` | 显示清除按钮。键盘激活清除时，焦点回到触发按钮，并避免页面滚动。 |
| `isLabelHidden` | `boolean` | `false` | 视觉上隐藏标签，读屏仍然读得到。旧名 `labelHidden` 同样可用，两者任一为真就隐藏。演练场里写 `is-label-hidden`。 |
| `containerClass` | `string \| object \| array` | — | 加到字段外壳的 class。在 InputGroup 和横向标签的 FormLayout 里没有外壳，此时不生效，开发环境会警告。 |
| `containerStyle` | `StyleValue` | — | 加到字段外壳的行内样式。落点规则同 containerClass。 |
| `controlClass` | `string \| object \| array` | — | 加到带边框本体的 class。直接写在组件上的 class 也落在本体，不会落到触发按钮上。 |
| `controlStyle` | `StyleValue` | — | 加到带边框本体的行内样式。 |
| `placeholder` | `string` | `选择日期区间` | 空值文案，跟随语言。 |
| `status` | `FieldStatusInput` | — | 错误、警告或成功。 |
| `statusVariant` | `'attached' \| 'detached' \| 'tooltip'` | `'attached'` | 状态消息的位置。 |
| `isDisabled` | `boolean` | `false` | 禁用字段。 |
| `disabledMessage` | `string` | — | 禁用原因。和 `isDisabled` 一起用时，悬停或键盘聚焦会显示这段提示，按钮仍可聚焦（带 `aria-disabled`），但打不开日历。 |
| `isLoading` | `boolean` | `false` | 正在保存。字段不可改。 |
| `changeAction` | `(value) => void \| Promise<void>` | — | 区间提交之后再跑。返回 Promise 时保持加载。 |
| `isOptional` | `boolean` | — | 在标签旁标成可选。 |
| `isRequired` | `boolean` | — | 把字段标成必填。 |
| `labelTooltip` | `string` | — | 标签末尾信息图标里的说明。 |
