date-picker — Date Picker
Date picking is one of the most complex controls in a UI library: a single 6×7 grid must simultaneously express six states — “current-month dates / previous and next month padding / today / selected / range highlight / disabled.” core/date-picker distills calendar-grid generation, month and year panels, and the date adapter seam into pure functions, leaving the UI layer to only render and wire things up.
When to use
Section titled “When to use”- Each port implements
LhDatePicker: birthdays, schedules, order dates, start and end date ranges. - The value is a “date” or a “date range.”
- Should not use it: when only time is needed (use
time-picker); when the user should freely type any textual date (useinputwith your own validation for more flexibility).
Import
Section titled “Import”import { generateCalendarDays, generateMonths, generateYears, getDatePickerClassNames, getDatePickerTriggerAria, getDatePickerPanelAria, // General date primitives (re-exported from shared/date to keep existing import paths intact) parseDate, formatDate, compareDate, isSameDay, isSameMonth, isDateDisabled, getDaysInMonth, getFirstDayOfMonth,} from '@lh-ui/share/core/date-picker'import type { LhDatePickerMode, LhDatePickerDayData, LhDatePickerMonthData, LhDatePickerYearData, LhDatePickerState, LhFirstDayOfWeek, LhDateAdapter,} from '@lh-ui/share/core/date-picker'Design intent
Section titled “Design intent”The adapter is this module’s core design. LhDateAdapter is a deliberately minimal seam (parse / format / getDaysInMonth / getFirstDayOfMonth / compare / isSameDay / addDays — seven methods): the default implementation wraps the shared kernel core/shared/date’s hand-written algorithms, and when you need to plug in dayjs / date-fns / Intl, you just implement the interface — the core layer depends on no date library.
There are two injection paths:
- Explicit parameter (recommended):
generateCalendarDays(year, month, …, firstDayOfWeek, myAdapter)— no global state is polluted, and multiple instances do not interfere. setDateAdapter(adapter)(@deprecated): a module-level singleton. It is marked deprecated because of cross-instance leakage — two date pickers on one page would pollute each other’s injection. It is kept only for compatibility with existing downstream calls; no new consumers are added.
Unraveling the circular dependency: in the early days logic.ts and adapter.ts imported each other; the general primitives have since sunk into core/shared/date, and the adapter depends only on the kernel and no longer reverse-imports logic, breaking the cycle (see commit 9e21865).
Grid padding alignment: totalCells = Math.ceil(days.length / 7) * 7 guarantees the total cell count is a multiple of 7 (5 or 6 rows), and firstDayOfWeek computes the leading empty slots via (firstDay - firstDayOfWeek + 7) % 7 — the +7 avoids negative modulo.
Day-level range comparison: cell dates are local 00:00, while rangeStart / rangeEnd may carry a time component; comparing directly would make the “end day” also count as isInRange. Uniformly use adapter.compare for day-level comparison.
Function table
Section titled “Function table”| Function | Signature | Returns | Purpose |
|---|---|---|---|
generateCalendarDays |
(year, month, selected, rangeStart, rangeEnd, today, firstDayOfWeek?, adapter?) => LhDatePickerDayData[] |
42 or 35 cells | Six-state grid |
generateMonths |
(year, selected) => LhDatePickerMonthData[] |
12 items | Month panel |
generateYears |
(startYear, selected) => LhDatePickerYearData[] |
year panel | Year selection |
getDatePickerClassNames |
(state: { size, disabled, visible }) => string[] |
class names | size/disabled/expanded |
getDatePickerTriggerAria |
(visible, panelId?) => LhAriaAttrs |
aria attribute bag | haspopup dialog |
getDatePickerPanelAria |
(id?) => LhAriaAttrs |
aria attribute bag | role=dialog |
parseDate / formatDate |
(value, format) => Date | null / (date, format) => string |
inverses | fixed-width tokens |
compareDate |
(a, b) => number |
day-level comparison | drops the time component |
isSameDay / isSameMonth |
(a, b) => boolean |
same day/month or not | selection determination |
isDateDisabled |
(date, min, max, disabledDate?) => boolean |
disabled or not | three sources merged |
getDaysInMonth / getFirstDayOfMonth |
(year, month) => number |
day count / first weekday | grid computation |
Type definitions
Section titled “Type definitions”export type LhDatePickerMode = 'date' | 'month' | 'year' | 'datetime' | 'range'export type LhFirstDayOfWeek = 0 | 1 | 2 | 3 | 4 | 5 | 6 // 0=Sunday (default)
export interface LhDatePickerDayData { date: Date day: number month: number // 0-based year: number isCurrentMonth: boolean // current month vs previous/next month padding isToday: boolean isSelected: boolean isInRange: boolean // range mode only, open interval isStart: boolean isEnd: boolean}
export interface LhDatePickerMonthData { month: number; year: number; isSelected: boolean }export interface LhDatePickerYearData { year: number; isSelected: boolean }
export interface LhDateAdapter { parse(value: string, format: string): Date | null format(date: Date, format: string): string getDaysInMonth(year: number, month: number): number getFirstDayOfMonth(year: number, month: number): number compare(a: Date, b: Date): number isSameDay(a: Date, b: Date): boolean addDays(date: Date, amount: number): Date}Accessibility
Section titled “Accessibility”The date panel pops up as a dialog, with the same semantics as time-picker (both go through getPopupTriggerAria, haspopup: 'dialog'):
getDatePickerTriggerAria(true, 'lh-date-panel')// { 'aria-expanded': true, 'aria-haspopup': 'dialog', 'aria-controls': 'lh-date-panel' }
getDatePickerPanelAria('lh-date-panel')// { role: 'dialog', id: 'lh-date-panel' }The grid itself should carry role="grid", each row role="row", each cell role="gridcell"; the month/year panels use role="listbox". Keyboard interaction (implemented by the UI layer):
- Inside the panel, ↑/↓/←/→ move between cells (
adapter.addDays(date, ±1|±7)); Enter selects. - Moving onto a previous/next-month padding cell (
isCurrentMonth: false) auto-flips the month. - Esc closes the panel and returns focus to the trigger.
- Cells for which
disabledDatereturns true must havearia-disabled="true"and be unselectable.
Usage examples
Section titled “Usage examples”import { generateCalendarDays, parseDate, formatDate, isDateDisabled } from '@lh-ui/share/core/date-picker'
// October 2026 (month is 0-based), starting on Sundayconst days = generateCalendarDays(2026, 9, parseDate('2026-10-12', 'yyyy-MM-dd'), null, null, new Date(), 0)days.length // 35 or 42days.filter((d) => d.isCurrentMonth).length // 31days[0] // leading padding cell 2026-09-27 (Sunday)days.find((d) => d.isToday)?.day // 12 (if today is in 2026-10)
parseDate('2026-10-12', 'yyyy-MM-dd') // Date(2026, 9, 12)formatDate(new Date(2026, 9, 12), 'yyyy-MM-dd') // '2026-10-12'parseDate('not a date', 'yyyy-MM-dd') // nullisSameDay(new Date(2026, 9, 12, 5), new Date(2026, 9, 12, 23)) // true// Plugging in a dayjs adapter (explicit parameter, does not pollute the global state)import type { LhDateAdapter } from '@lh-ui/share/core/date-picker'import dayjs from 'dayjs'import customParseFormat from 'dayjs/plugin/customParseFormat'dayjs.extend(customParseFormat)
const dayjsAdapter: LhDateAdapter = { parse: (v, f) => { const d = dayjs(v, f, true); return d.isValid() ? d.toDate() : null }, format: (d, f) => dayjs(d).format(f), getDaysInMonth: (y, m) => dayjs(new Date(y, m, 1)).daysInMonth(), getFirstDayOfMonth: (y, m) => dayjs(new Date(y, m, 1)).day(), compare: (a, b) => dayjs(a).startOf('day').valueOf() - dayjs(b).startOf('day').valueOf(), isSameDay: (a, b) => dayjs(a).isSame(dayjs(b), 'day'), addDays: (d, n) => dayjs(d).add(n, 'day').toDate(),}
const days = generateCalendarDays(2026, 9, null, null, null, new Date(), 1, dayjsAdapter)<!-- Vue3 --><div :class="getDatePickerClassNames({ size, disabled, visible })"> <input class="lh-date-picker__input" :value="value ? formatDate(value, format) : ''" v-bind="getDatePickerTriggerAria(visible, panelId)" @click="open" /> <div v-if="visible" class="lh-date-picker__panel" v-bind="getDatePickerPanelAria(panelId)"> <div class="lh-date-picker__header"> <button type="button" class="lh-date-picker__prev" @click="prevMonth">‹</button> <span class="lh-date-picker__title">{{ year }} / {{ month + 1 }}</span> <button type="button" class="lh-date-picker__next" @click="nextMonth">›</button> </div> <div class="lh-date-picker__body" role="grid"> <div v-for="row in weekRows" :key="row[0].date.getTime()" class="lh-date-picker__row" role="row"> <button v-for="cell in row" :key="cell.date.getTime()" type="button" class="lh-date-picker__cell" :class="{ 'is-other-month': !cell.isCurrentMonth, 'is-today': cell.isToday, 'is-selected': cell.isSelected, 'is-in-range': cell.isInRange, 'is-start': cell.isStart, 'is-end': cell.isEnd, }" role="gridcell" :aria-disabled="isDateDisabled(cell.date, minDate, maxDate, disabledDate)" @click="onPick(cell)" > {{ cell.day }} </button> </div> </div> </div></div>// React: two-step selection in range mode// The first click sets rangeStart (rangeEnd cleared); the second sets rangeEnd (swapped if earlier than start)// generateCalendarDays recomputes on every render, and isInRange highlights the middle segment automatically// uniapp: <picker mode="date"> is a built-in component (start/end constrain the range);// for a custom panel use <scroll-view> + a grid; core's generateCalendarDays works as-isStyles and tokens
Section titled “Styles and tokens”| Class | Semantics |
|---|---|
.lh-date-picker |
Base class |
.lh-date-picker--{small|large} |
Size tiers |
.lh-date-picker--disabled |
Disabled |
.lh-date-picker__input |
Trigger input |
.lh-date-picker__panel |
Popup panel |
.lh-date-picker__header / __prev / __next / __title |
Header navigation |
.lh-date-picker__body |
Grid area |
.lh-date-picker__row |
One week |
.lh-date-picker__cell |
Date cell (is- prefixed state modifiers) |
.lh-date-picker__cell.is-other-month / .is-today / .is-selected / .is-in-range / .is-start / .is-end |
Six states |
.lh-date-picker__footer |
Shortcut bar (today/clear) |
Selected uses --lh-primary-color, the range uses a low-opacity --lh-primary-color, today gets a --lh-primary-color outline, and previous/next-month padding text uses --lh-text-disabled.
Caveats
Section titled “Caveats”- month is always 0-based (consistent with the native
Date), and the UI layer must display it+1. This is the most common source of off-by-one errors. firstDayOfWeekand the locale pack:zh-CNusually uses 1 (Monday),en-USusually 0 (Sunday); set it in ConfigProvider or the locale, do not hardcode it.- Cross-instance leakage of
setDateAdapter: on pages where multiple date pickers coexist, do not use global injection — use the explicit parameter instead. - Fixed-width tokens of
parseDate/formatDate:yyyy/MM/dd/HH/mm/ss, case-sensitive (mmis minutes,MMis months). parse and format must use the same format string, otherwise the round trip breaks. datetimemode: the panel embeds time-picker columns, and the time part usescore/time-picker’s functions; when merging values, note thatcompareDatedrops the time component — useisSameDayto compare selection state.- Swapping in
rangemode: core does not handle the “second click earlier than the first swaps them” interaction rule; the UI layer decides that itself. disabledDateand min/max are merged:isDateDisabled(date, min, max, disabledDate)— a hit on any of the three sources disables the date.- 6 rows vs 5 rows:
totalCellsrounds up to a multiple of 7; some months are 35 cells (5 rows), others 42 (6 rows). The panel height must accommodate 6 rows, otherwise the panel jumps when the month changes.
@lh-ui/share/core/date-picker → parseDate( )(填好参数后点运行)Copyright © 2024-2026 FengMo WenXue