@lekoala/date-picker
v0.3.0
Published
Native-first date picker and inline calendar primitives
Maintainers
Readme
@lekoala/date-picker
Small, native-first date picker and inline calendar primitives.
v0.2: the interaction contract, event names, public styling tokens and the component API are frozen — see docs/DECISIONS.md. Internal DOM and class names are
.dp-*implementation details and stay private.
This project fills the gap between a normal editable date field and a full scheduling calendar. A plain date input remains the right answer for simple CMS/editing forms. This component becomes useful when a date has context: availability, disabled days, annotations, remote state, start/end relationships, or a mini calendar that navigates another view.
The two primitives
<date-calendar>
An inline calendar with real month/year navigation, keyboard support and three distinct pieces of state:
display— the month being rendered (YYYY-MM);focusedDate— the roving keyboard target (YYYY-MM-DD);value— the selected date (YYYY-MM-DD, optional).
display and focusedDate are coupled during navigation/display: changing the displayed month re-clamps the focused day into that month, because the roving-tabbable grid cell must stay visible. value and activation stay independent of both.
User activation is exposed separately as dateactivate. This is what makes the same calendar useful as a selector and as a mini navigator.
<date-calendar value="2026-09-10" fixed-weeks></date-calendar>For a navigator that should not own a selection:
<date-calendar id="mini" selection="none" fixed-weeks show-week-numbers></date-calendar>
<script type="module">
const mini = document.querySelector("#mini");
mini.addEventListener("dateactivate", (event) => {
agenda.gotoDate(event.detail.date);
});
</script><date-picker>
A composition around a real editable text input. The user sees and edits a short locale-aware numeric date while form submission stays canonical ISO.
Use <date-picker> when choosing a date benefits from calendar context. For a simple editable date field, a normal input with formatting and validation is the right tool instead.
<date-picker value="2026-09-10" locale="fr-BE">
<input name="appointment_date" required>
</date-picker>With JavaScript enabled the input displays 10/09/2026; the hidden submitted value remains 2026-09-10. The visible input keeps the label, focus, required state and validation UI.
By default the calendar opens when the field receives focus — without stealing the keyboard focus, so typing works immediately. open-on-focus="false" makes the calendar opt-in (trigger button or ArrowDown). Under readonly the value stays editable-by-calendar-off but is still submitted; under disabled the field is also unsubmitted.
Start/end ranges
Two product shapes cover ranges.
One shared calendar surface — two editable fields wired to a single picker popup:
<date-picker range>
<input data-range-start name="arrival" aria-label="Arrival">
<input data-range-end name="departure" aria-label="Departure">
</date-picker>Opening from a field targets that bound; picking the first date moves the active endpoint to the other bound without closing, and the second pick completes and closes. picker.range is the atomic { start, end } API (single rangechange event).
Two independent pickers — a small external link for visually separate fields:
import { linkDateRange } from "@lekoala/date-picker";
const cleanup = linkDateRange(startPicker, endPicker);The relationship applies end >= start and start <= end as effective bounds. Neither picker knows about its sibling.
Date + time
A date pairs with one or two native time companions. The picker owns the civil date; the time inputs own their civil time values:
<date-picker>
<input name="appointment[date]">
<input type="time" data-time-start name="appointment[time]">
</date-picker>appointment[date] = 2026-09-10
appointment[time] = 09:30The application/server combines these civil values and applies the relevant timezone when a datetime/instant is needed. The picker never constructs a combined datetime value.
Availability and per-day metadata
The calendar accepts a source function or { load() } object with the same abortable shape used elsewhere in LeKoala components:
calendar.source = async ({ start, end }, { signal }) => {
const response = await fetch(`/availability?start=${start}&end=${end}`, { signal });
return response.json();
};Accepted payloads are deliberately simple:
{
dates: {
"2026-09-10": {
enabled: true,
description: "Available morning and afternoon",
morning: true,
afternoon: true
}
}
}Business metadata is not interpreted by the core. Consumers can add visual content without replacing the grid cell or its accessibility semantics:
calendar.renderDay = (_date, state) => {
if (!state.morning && !state.afternoon) return "";
return `${state.morning ? "●" : "○"}${state.afternoon ? "●" : "○"}`;
};Date math
The public value model is intentionally boring: canonical YYYY-MM-DD strings. There is no public Date, timestamp, timezone or Temporal dependency.
Pure civil-date helpers are exported under dates:
import { dates } from "@lekoala/date-picker";
const weeks = dates.getMonthWeeks("2026-09-03", { firstDay: 1 });getMonthWeeks() returns the true 4–6 full civil weeks covering the month. It never pads to six rows. fixed-weeks is a rendering choice made by <date-calendar>.
Accessibility baseline
The calendar follows the WAI-ARIA date-picker/grid interaction model:
- semantic
table[role="grid"]; - one roving
tabindex="0"grid cell; - arrow keys move by day/week;
- Home/End move to week boundaries;
- PageUp/PageDown move by month;
- Shift+PageUp/PageDown move by year;
- Enter/Space activate a date;
- keyboard movement never implicitly selects;
aria-selectedidentifies the selected date;aria-current="date"identifies today;- abbreviated weekday headers keep full
abbrnames.
The picker popup uses native Popover for the top layer and @lekoala/floating for geometry. It has role="dialog", but it is intentionally not marked aria-modal because the current implementation does not inert the rest of the document.
The calendar trigger is a real button painted inside the field's own box (like the native input[type=time] indicator): the field reserves its place with padding-inline-end, keeps its own border and background, and its focus ring wraps the trigger too. The trigger draws no chrome of its own and shows a small inner ring when focused.
When the popup opens on the field's focus, grid navigation is reachable via ArrowDown (or the trigger button) so the focus-restore/Escape flow stays predictable. Closing returns focus to the control that opened the popover: the field when it opened by focus, the trigger when it was activated. In range mode focus follows the active bound instead, because a first pick moves the workflow to the other bound.
Install / develop
bun install
bun run devIf a previous bun run dev is still holding port 4859, clear it with bun run dev:kill (or restart in one go with bun run dev:restart).
Tooling is aligned with the current LeKoala component repos:
- Bun 1.4.2
- TypeScript 7.0.2 (
checkJs+ declaration emit) - Biome 2.5.13
- Playwright 1.63.0
@lekoala/floating ^0.2.0
Useful commands:
bun run test
bun run typecheck
bun run lint
bun run build
bun run test:browser
bun run test:browser:all
bun run check:allBuild outputs
bun run build produces:
dist/date-picker.js
dist/date-picker.min.js
dist/date-picker.css
dist/date-picker.min.css
dist/date-picker.standalone.min.js
dist/types/**The default ESM exports point at src/, matching the native-first development style of @lekoala/combobox. The classic build is file:// friendly; the standalone build additionally injects the component CSS.
Scope
Included in v0.2:
- inline single-date selection;
- mini-calendar/navigation-only use;
- month and year selection;
- previous/next navigation;
- editable localized input + canonical submitted value;
- min/max and custom disabled rules;
- presentation-only range band (
highlightedRange); - shared-calendar two-field range picker (
<date-picker range>); - linked start/end pickers (
linkDateRange); - native time companions (one or two
input[type=time]beside the date); Intlmonth/year control order +month-format="short";- RTL through the inherited
dir(noisRTLoption); - readable keyboard focus on disabled/selected days;
- compact-consumer CSS tokens (
--dp-header-control-size,--dp-day-radius, …); - async per-day state with cancellation;
- custom day decorations that cannot replace the accessible cell;
- fixed six-row presentation as an opt-in;
- ISO week numbers;
- keyboard and focus contracts.
Deliberately out of scope for v0.2:
- custom time-picker UI and combined datetime values (only native
input[type=time]companions are supported); - timezones;
- in-place single-calendar range selection interaction (use the two-field picker);
- multi-date selection;
- recurrence;
- natural-language parsing;
- plugin registry;
- virtualized months;
- scheduling/time slots.
