calendar — Calendar
A calendar is a persistent date panel: date-picker hides the panel inside a popup, while calendar spreads it out on the page. It supports three selection modes — single, range, and multiple — plus optional date-range constraints. core/calendar reuses date-picker’s grid algorithm (generateCalendarDays) and layers shifting, range constraints, and selection-state toggling on top.
When to use
Section titled “When to use”- Each port implements
LhCalendar: schedule views, check-in tracking, hotel/flight date-selection pages. - Scenarios where dates need to be persistently visible rather than picked from a popup.
- Should not use it: inside space-constrained forms (use
date-picker, a popup saves space); when only one date is needed and the page is already crowded.
Import
Section titled “Import”import { getCalendarClassNames, shiftCalendarDate, shiftCalendarMonth, isCalendarDateDisabled, clampDateToRange, isCalendarSelected, normalizeCalendarSpan, isCalendarInSpan, toggleCalendarDate, getCalendarCellAria, generateCalendarDays, isSameDay,} from '@lh-ui/share/core/calendar'import type { LhCalendarRange, LhCalendarSelection } from '@lh-ui/share/core/calendar'Design intent
Section titled “Design intent”The dependency direction is semantic: calendar imports generateCalendarDays from date-picker instead of reimplementing it on top of core/shared/date — the calendar-grid algorithm is date-picker’s responsibility, and calendar is its calendar view. General primitives (compareDate / isDateDisabled / isSameDay) go straight to the shared kernel; calendar does not reverse-depend on date-picker’s logic. This is the layering convention of “general primitives sink down, domain algorithms stay in their place.”
Tolerance for reversed spans: both isCalendarDateDisabled and clampDateToRange call normalizeCalendarSpan first to normalize the span — a user passing [end, start] (reversed) does not cause everything to be disabled or clamped incorrectly. The UI layer does not force the caller to sort first.
shiftCalendarMonth normalization: year * 12 + month + delta split back into year and month, with ((total % 12) + 12) % 12 handling negative modulo — when a negative delta crosses a year boundary (e.g. January -2), the month never becomes -1.
Immutable multi-select toggling: toggleCalendarDate removes on a hit, otherwise adds and sorts chronologically — it returns a new array that can be fed straight into a reactive update, and the order is stable (avoids UI jitter).
Function table
Section titled “Function table”| Function | Signature | Returns | Purpose |
|---|---|---|---|
getCalendarClassNames |
() => string[] |
['lh-calendar'] |
Container class names |
shiftCalendarDate |
(date, days) => Date |
new Date | Shift by days (cross-month safe) |
shiftCalendarMonth |
(year, month, delta) => { year, month } |
year and month | Shift by months |
isCalendarDateDisabled |
(date, range?) => boolean |
disabled or not | Always false with no constraint |
clampDateToRange |
(date, range?) => Date |
clamped date | Pull out-of-range values back to the boundary |
isCalendarSelected |
(date, selected) => boolean |
selected or not | Same-day check |
normalizeCalendarSpan |
(a, b) => [Date, Date] |
normalized span | Guarantees start ≤ end |
isCalendarInSpan |
(date, start, end) => boolean |
inside closed span or not | Returns false if either end is null |
toggleCalendarDate |
(dates, date) => Date[] |
new array | Remove on hit / otherwise add and sort |
getCalendarCellAria |
(options: { date, selected, range? }) => LhAriaAttrs |
aria attribute bag | aria-current / aria-disabled |
Type definitions
Section titled “Type definitions”export type LhCalendarRange = [Date, Date]export type LhCalendarSelection = 'single' | 'range' | 'multiple'The grid data structure reuses LhDatePickerDayData (see date-picker).
Accessibility
Section titled “Accessibility”getCalendarCellAria’s output is deliberately restrained — attributes are emitted only when there is state (the falsy-omit convention of LhAriaAttrs):
getCalendarCellAria({ date: selectedDate, selected: selectedDate, range: undefined })// { 'aria-current': 'date' }
getCalendarCellAria({ date: new Date(2026, 0, 1), selected: null, range: [new Date(2026, 2, 1), new Date(2026, 3, 1)] })// { 'aria-disabled': true }
getCalendarCellAria({ date: someDate, selected: null })// {} (empty object when there is no state)- Selected days use
aria-current="date"(notaria-selected) — a calendar is a navigational control, and APG convention usescurrentto express “the current position.” - Disabled days use
aria-disabled; both clicks and keyboard navigation in the UI layer must skip these cells. - Keyboard interaction: after focusing a cell, ↑/↓/←/→ move via
shiftCalendarDate(date, ±1|±7), and Enter/Space triggers selection. - The grid as a whole should still carry
role="grid", the same structure as the date-picker panel.
Usage examples
Section titled “Usage examples”import { shiftCalendarMonth, normalizeCalendarSpan, toggleCalendarDate, isCalendarInSpan,} from '@lh-ui/share/core/calendar'
shiftCalendarMonth(2026, 0, -2) // { year: 2025, month: 10 } (negative modulo handled correctly)shiftCalendarMonth(2026, 11, 1) // { year: 2027, month: 0 }normalizeCalendarSpan(new Date(2026, 3, 1), new Date(2026, 2, 1))// [2026-03-01, 2026-04-01] (reversed input auto-normalized)isCalendarInSpan(new Date(2026, 2, 15), new Date(2026, 2, 1), new Date(2026, 2, 28)) // trueisCalendarInSpan(new Date(2026, 2, 15), null, null) // false (either end null)toggleCalendarDate([new Date(2026, 2, 1)], new Date(2026, 2, 5))// [2026-03-01, 2026-03-05] (added and sorted)toggleCalendarDate([new Date(2026, 2, 1), new Date(2026, 2, 5)], new Date(2026, 2, 1))// [2026-03-05] (removed on hit)<!-- Vue3: range mode --><div :class="getCalendarClassNames()"> <div class="lh-calendar__header"> <button type="button" @click="shift(-1)">‹</button> <span class="lh-calendar__title">{{ year }} / {{ month + 1 }}</span> <button type="button" @click="shift(1)">›</button> </div> <div class="lh-calendar__body" role="grid"> <div v-for="row in weekRows(days)" :key="row[0].date.getTime()" class="lh-calendar__row" role="row"> <button v-for="cell in row" :key="cell.date.getTime()" type="button" class="lh-calendar__cell" :class="cellClass(cell)" role="gridcell" v-bind="getCalendarCellAria({ date: cell.date, selected: selected, range: allowedRange })" @click="onPick(cell.date)" > {{ cell.day }} </button> </div> </div></div>// Two-step selection in range mode (UI-layer rule, not built into core)// if (!rangeStart || (rangeStart && rangeEnd)) { rangeStart = date; rangeEnd = null }// else rangeEnd = date (earlier than rangeStart, normalizeCalendarSpan normalizes it)// React: multiple mode// const [dates, setDates] = useState<Date[]>([])// onClick: setDates(toggleCalendarDate(dates, cell.date))// uniapp: <view> grid + @touchstart; core's range constraints and selection-toggle functions work as-isStyles and tokens
Section titled “Styles and tokens”| Class | Semantics |
|---|---|
.lh-calendar |
Base class |
.lh-calendar__header / __title |
Header navigation |
.lh-calendar__body |
Grid area |
.lh-calendar__row |
One week |
.lh-calendar__cell |
Date cell |
.lh-calendar__cell.is-today / .is-selected / .is-in-range / .is-start / .is-end / .is-disabled / .is-other-month |
State modifiers |
Selected uses --lh-primary-color, in-range segments use a low-opacity primary color, today gets an outline, and disabled uses --lh-text-disabled.
Caveats
Section titled “Caveats”- month is 0-based: consistent with date-picker and the native
Date; the UI displays it+1. - The three selection modes vs core:
LhCalendarSelectionis only a type declaration; core’s functions are mode-agnostic —isCalendarSelectedhandles single,isCalendarInSpanhandles range, andtoggleCalendarDatehandles multiple. The wiring for mode switching lives in the UI layer. - The half-selected state in range mode: once
rangeStartis set andrangeEndis not, hovering a cell should highlight the middle segment in real time (the UI layer renders with a temporary end); core’sgenerateCalendarDaysaccepts a null rangeEnd and will not error. - Purpose of
clampDateToRange: when a value is set externally (e.g. an out-of-range date passed viav-model), it collapses it back into the valid span; the UI layer calls it once when watching value changes. - Performance of the persistent panel: unlike date-picker, which computes the grid only when opened, calendar recomputes when the month changes; do not recompute
generateCalendarDaysevery frame (it creates 35–42 Date objects). - Boundary with date-picker: the two share grid data but not the state model — date-picker’s
LhDatePickerStateis oriented toward popup interaction, while calendar has no visible state.
@lh-ui/share/core/calendar → shiftCalendarMonth( )(填好参数后点运行)Copyright © 2024-2026 FengMo WenXue