表单
DatePicker - 日期选择器
一个日期——或者一段日期——从日历里选。
用法
什么时候用它
import { DatePicker } from '@misoto22/folio'说明
一个日期,从日历里选。
一个触发器加一个放在 Popover 里的 Calendar——与其说是个新组件,不如说是大家本来就会在每个界面上各拼一遍、还都拼得略有不同的那个组合。
它刻意不是“输入框加一个日历”。手打的日期需要解析,解析需要格式,而格式是一场没人赢得了的地区之争。当打字确实要紧的时候——比如很久以前的生日——日历的月份和年份是下拉框,那是同一条路径,但没有歧义。
结构
| 部件 | 说明 |
|---|---|
| 触发器必填 | 一个打印 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 类已经带了这个规则,所以这里没什么要覆盖的。
面板会一直开着,直到两端都选完:一段范围在有第二个日期之前不算一个值,选完第一个就关掉意味着还得再打开一次才能选完。
| 属性 | 类型 | 默认值 | 说明 |
|---|---|---|---|
| label必填 | string | 给控件命名。它是和印出来的那段范围一起播报的,不是顶替它。 | |
| aria-describedby | string | 描述这个控件的那些文字的 id。`Field` 会从 hint、error 和 description 里替你设好。 | |
| aria-invalid | boolean | 'true' | 'false' | 在触发器上播报。`Field` 会从 `error` 替你设好。 | |
| className | string | — | |
| defaultValue | DateRange | — | |
| disabled | boolean | false | — |
| disabledDates | ComponentProps<typeof Calendar>['disabled'] | 读者不能选的日期。 它除了到日期格,也会到快捷项那一栏,作用在每个预设范围的**两端**——一个内部跨过了被禁日期的快捷项依然会提供,就像日期格里读者依然可以拖出一段跨过它的范围。 | |
| format | (date: Date) => string | formatDate | — |
| id | string | **触发器**的 id——标签指向的就是这个元素。`Field` 会替你设好。 | |
| months | number | 2 | 并排显示几个月。在 `sm` 以下退回一个。 |
| onValueChange | (value: DateRange | undefined) => void | — | |
| placeholder | string | 'Pick a range' | — |
| presets | boolean | DatePreset<DateRange>[] | true | 显示在日历格旁边的快捷项——“最近 30 天”和它的邻居们。传 `true` 用内置那一套,传数组用你自己的。 这里默认开启,单日选择器上默认关闭,因为“最近 30 天”几乎就是范围选择器被问到的全部,而单个日期通常是某个具体的日子。 |
| value | DateRange | — |
再导出
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
},
},
]单个日期的对应版本。
键盘操作
| 按键 | 作用 |
|---|---|
| EnterSpace | 打开日历。 |
| Escape | 不选日期直接关闭。 |
无障碍
- 触发器按访问者自己的地区格式打印日期,而不是写死的 dd/mm/yyyy,并把它作为自身名称的一部分播报出来——所以 format 对读屏用户同样管用。
- DateRangePicker 会等到两端都选完才关——一段范围在有第二个日期之前不算一个值。
- 右侧快捷选项是普通按钮,不是菜单:它们设的值和旁边的日历格设的是同一个,所以它们属于同一个控件,Tab 也在同一轮里走到。
- 快捷值是点击那一刻才算的,所以标签页开了一整夜,“今天”仍然是今天。