跳到正文
册页

表单

DatePicker - 日期选择器

一个日期——或者一段日期——从日历里选。

用法

什么时候用它

刻意不做成“输入框加日历”:解析手打的日期需要格式,而 03/04 在一个国家是 3 月 4 日,在下一个国家是 4 月 3 日。日期很久远时,日历的月份和年份是下拉。
TSX
import { DatePicker } from '@misoto22/folio'

说明

一个日期,从日历里选。

一个触发器加一个放在 Popover 里的 Calendar——与其说是个新组件,不如说是大家本来就会在每个界面上各拼一遍、还都拼得略有不同的那个组合。

它刻意不是“输入框加一个日历”。手打的日期需要解析,解析需要格式,而格式是一场没人赢得了的地区之争。当打字确实要紧的时候——比如很久以前的生日——日历的月份和年份是下拉框,那是同一条路径,但没有歧义。

结构

DatePicker anatomy
部件说明
触发器必填一个打印 format(value) 或 placeholder 的 <button>,行尾钉着一个日历图标。它的名字由 label 和打印出来的那个日期合起来构成,所以那个格式既看得见,也听得到。
面板一个装着快捷栏和日历格的 Popover——sm 及以上并排,再往下就上下堆叠,反正那个宽度本来也放不下两个月。
快捷栏一个由普通按钮组成的 role="group",只在设了 presets 时才有:DateRangePicker 默认开,DatePicker 默认关。落在 disabledDates 某一天上的快捷项会被画成不可用,并且拒绝点击。
日历格共用的那个 Calendar,打开时 autoFocus,所以键盘落在月份里,而不是退回触发器上。范围选择器一次显示两个月,由 months 决定。
半段范围文字只选了一端时,范围触发器打印的是「起始 – …」,所以答了一半的范围会在合起来的控件上把这件事说出来,而不是装作已经答完了。

实践建议

推荐

  • 限制写进 disabledDates,不要写在你自己的处理函数里。快捷栏也会去问它,所以落在被禁那天上的快捷项是禁用的,而不会提交一个旁边日历格拒绝的日期——范围类的快捷项只在两端受检,所以一个跨过被禁日期的范围照样会被提供,正如日历格自己也照样允许它。
  • 受控还是非受控,选一个就别再换。当前值是 value ?? uncontrolled,所以一个受控的选择器如果靠把 value 设成 undefined 来清空,就会掉到 defaultValue 埋下的那个值上,旧日期又冒出来了。
  • 范围用之前先校验:半段范围在这里是合法状态——from 有值,to 是 undefined——所以不加检查就读 value.to 的提交处理函数,会从一个只是早点关掉面板的读者那里拿到 undefined。

避免

  • Field 的 required 到不了触发器。它是个普通的 <button>,这个 role 没有地方安放 aria-required,所以上面那个星号就是全部的标记,而读屏用户遇到的是一个普普通通的选填字段。
  • 不要拿它来填出生日期。没有 defaultMonth 可传:面板永远从当前月打开,所以一个几十年前的日期,会让每一个读者都从月份和年份的下拉开始。
  • 不要用禁用它的办法来展示一个固定日期。disabled 会把触发器挪出 Tab 顺序、并挡掉它的指针事件,而触发器是选中的那个日期唯一被打印出来的地方。

示例

default

一个触发器,加一个装在 Popover 里的 Calendar,而且刻意不是一个挂着日历的文本输入框:敲进去的日期需要一个格式,而 03/04 在一个国家是三月四号,在下一个国家是四月三号。触发器按访客自己的地区习惯印出所选日期,也是同一个理由。真正打字更快的地方——比如往回好几年的一个日期——月份和年份是下拉框,那条路径还在,只是没了歧义。

Month and year are dropdowns — reaching two years back is one click, not twenty-four.

range

两个月并排,因为跨月的区间才是常见情况,而为了同时看到两头来回翻页,正是区间选择器让人累的地方。两端都选定之前,面板不关——一个区间在拿到第二个日期之前还不成其为一个值——而只定了一端的时候,触发器印的是「起 – …」,于是一个答了一半的区间会在收起的控件上就说明这件事,而不是装作已经完成。这个半成状态是合法的,所以读第二个日期之前先校验:一个提前关掉面板的读者,会把它留成 undefined。

Last 30 days and its neighbours are one click; the grid is for everything else.

presets

网格旁边那条边栏——单日选择器上默认关,区间选择器上默认开,因为「最近 30 天」几乎就是区间选择器被问到的全部,而单个日期通常是某个具体的日子。它们是普通按钮,不是菜单,所以它们设的值和网格设的是同一个,Tab 也和网格在同一轮里过。每个快捷方式都是在点击那一刻算出来的,不是渲染时:一份只构建一次的列表,会把「今天」冻结在页面加载的那一刻。

Shortcuts are computed when clicked, so “today” means today even on a tab left open overnight.

blocked dates

disabledDates 一路透传给日历:这里拒掉了过去的日子和周末,网格不会让你提交这样一个日期。注意旁边没有快捷方式边栏——那些快捷方式是把自己的值直接塞进网格用的同一个 setter,从来不会拿 disabledDates 检验,所以在这套规则旁边摆一条边栏,它会痛快地提交一个网格自己都拒绝的星期天。两个都要,就自己挨个检查每个快捷方式。另外,要用 disabledDates 而不是 disabled:disabled 会把触发器从 tab 顺序里拿走、把它的指针事件挡掉,而触发器是唯一印出所选日期的地方。

Weekdays only, and not in the past.

组成部分

在调用处组合,而不是通过 props 配置——所以这个组件没有预料到的布局,依然表达得出来。

DateRangePicker

一段日期——一次入住、一个报表周期、一个筛选条件。

两个月并排,因为跨月的范围才是常见情况,而为了看清两端来回翻页正是范围选择器让人累的地方。在 sm 以下它们会堆叠,因为那里放不下两个——日历自己的 months 类已经带了这个规则,所以这里没什么要覆盖的。

面板会一直开着,直到两端都选完:一段范围在有第二个日期之前不算一个值,选完第一个就关掉意味着还得再打开一次才能选完。

DateRangePicker props
属性类型默认值说明
label必填string给控件命名。它是和印出来的那段范围一起播报的,不是顶替它。
aria-describedbystring描述这个控件的那些文字的 id。`Field` 会从 hint、error 和 description 里替你设好。
aria-invalidboolean | 'true' | 'false'在触发器上播报。`Field` 会从 `error` 替你设好。
classNamestring—
defaultValueDateRange—
disabledbooleanfalse—
disabledDatesComponentProps<typeof Calendar>['disabled']读者不能选的日期。 它除了到日期格,也会到快捷项那一栏,作用在每个预设范围的**两端**——一个内部跨过了被禁日期的快捷项依然会提供,就像日期格里读者依然可以拖出一段跨过它的范围。
format(date: Date) => stringformatDate—
idstring**触发器**的 id——标签指向的就是这个元素。`Field` 会替你设好。
monthsnumber2并排显示几个月。在 `sm` 以下退回一个。
onValueChange(value: DateRange | undefined) => void—
placeholderstring'Pick a range'—
presetsboolean | DatePreset<DateRange>[]true显示在日历格旁边的快捷项——“最近 30 天”和它的邻居们。传 `true` 用内置那一套,传数组用你自己的。 这里默认开启,单日选择器上默认关闭,因为“最近 30 天”几乎就是范围选择器被问到的全部,而单个日期通常是某个具体的日子。
valueDateRange—

再导出

RANGE_PRESETS = [ { label: 'Last 7 days', value: () => daysAgo(7) }, { label: 'Last 30 days', value: () => daysAgo(30) }, { label: 'Last 90 days', value: () => daysAgo(90) }, { label: 'Last 12 months', value: () => daysAgo(365) }, { label: 'Month to date', value: () => { const to = new Date() return { from: new Date(to.getFullYear(), to.getMonth(), 1), to } }, }, { label: 'Year to date', value: () => { const to = new Date() return { from: new Date(to.getFullYear(), 0, 1), to } }, }, ]

范围选择器几乎在每个出现它的界面上都会被要求的那几个快捷项,所以直接内置,而不是每个仪表盘再造一遍。

点击时才计算:渲染时就建好的预设列表,会把“今天”冻结在页面加载的那一刻,对任何开了一整夜的标签页来说都是错的。

DATE_PRESETS = [ { label: 'Today', value: () => new Date() }, { label: 'Tomorrow', value: () => { const date = new Date() date.setDate(date.getDate() + 1) return date }, }, { label: 'In a week', value: () => { const date = new Date() date.setDate(date.getDate() + 7) return date }, }, { label: 'In a month', value: () => { const date = new Date() date.setMonth(date.getMonth() + 1) return date }, }, ]

单个日期的对应版本。

键盘操作

DatePicker keyboard interactions
按键作用
EnterSpace打开日历。
Escape不选日期直接关闭。

无障碍

  • 触发器按访问者自己的地区格式打印日期,而不是写死的 dd/mm/yyyy,并把它作为自身名称的一部分播报出来——所以 format 对读屏用户同样管用。
  • DateRangePicker 会等到两端都选完才关——一段范围在有第二个日期之前不算一个值。
  • 右侧快捷选项是普通按钮,不是菜单:它们设的值和旁边的日历格设的是同一个,所以它们属于同一个控件,Tab 也在同一轮里走到。
  • 快捷值是点击那一刻才算的,所以标签页开了一整夜,“今天”仍然是今天。