Convert date_range_picker.js to TypeScript (issue #17)

- Add ts/date_range_picker.ts: typed port. CalendarState interface (with the
  dynamically-assigned refreshFromField) and an Anchor union replace the loose
  state object; date helpers and DOM queries fully typed; var → const/let
- Replace the DOMContentLoaded + per-element guard-flag + window global with
  onSwap("[data-date-range-picker]", ...), the documented init pattern — so the
  picker now also initializes inside htmx-swapped fragments. Drops the dead
  window.initDateRangePickers export
- Point the DateRangePicker component Media at dist/date_range_picker.js and load
  it as an ES module in the e2e page (was a deferred classic script)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-06-19 13:52:48 +02:00
parent c809341064
commit 19e9fd1419
4 changed files with 173 additions and 164 deletions
+4 -4
View File
@@ -12,17 +12,17 @@
The committed value lives in two hidden ISO-date inputs named The committed value lives in two hidden ISO-date inputs named
``{input_name_prefix}-min`` / ``{input_name_prefix}-max`` — the same contract ``{input_name_prefix}-min`` / ``{input_name_prefix}-max`` — the same contract
as the older ``DateRangeFilter``, so ``filter_bar.js`` serializes either as the older ``DateRangeFilter``, so ``filter_bar.ts`` serializes either
widget into a ``DateCriterion`` unchanged. All behaviour is wired by widget into a ``DateCriterion`` unchanged. All behaviour is wired by
``games/static/js/date_range_picker.js``. ``ts/date_range_picker.ts`` (compiled to ``dist/date_range_picker.js``).
""" """
from common.components.core import Element, HTMLAttribute, Media, Node, Safe from common.components.core import Element, HTMLAttribute, Media, Node, Safe
from common.components.primitives import Div, Input, Span from common.components.primitives import Div, Input, Span
from common.time import DatePartSpec, date_parts from common.time import DatePartSpec, date_parts
# Wired by date_range_picker.js. # Wired by ts/date_range_picker.ts (compiled to dist/).
_DATE_RANGE_MEDIA = Media(js=("date_range_picker.js",)) _DATE_RANGE_MEDIA = Media(js=("dist/date_range_picker.js",))
_FIELD_CONTAINER_CLASS = ( _FIELD_CONTAINER_CLASS = (
"flex items-center gap-0.5 w-full rounded-base border border-default-medium " "flex items-center gap-0.5 w-full rounded-base border border-default-medium "
+1 -1
View File
@@ -31,7 +31,7 @@ def _bar_page(filter_json: str = "") -> str:
<script src="/static/js/htmx.min.js"></script> <script src="/static/js/htmx.min.js"></script>
<script src="/static/js/dist/range_slider.js" type="module"></script> <script src="/static/js/dist/range_slider.js" type="module"></script>
<script src="/static/js/dist/search_select.js" type="module"></script> <script src="/static/js/dist/search_select.js" type="module"></script>
<script src="/static/js/date_range_picker.js" defer></script> <script src="/static/js/dist/date_range_picker.js" type="module"></script>
<script src="/static/js/dist/filter_bar.js" type="module"></script> <script src="/static/js/dist/filter_bar.js" type="module"></script>
</head> </head>
<body> <body>
+1 -1
View File
@@ -149,7 +149,7 @@ class RealComponentMediaTest(unittest.TestCase):
media = collect_media( media = collect_media(
DateRangePicker(label="Played", input_name_prefix="played") DateRangePicker(label="Played", input_name_prefix="played")
) )
self.assertEqual(media.js, ("date_range_picker.js",)) self.assertEqual(media.js, ("dist/date_range_picker.js",))
def test_range_slider_declares_its_script(self): def test_range_slider_declares_its_script(self):
from common.components.filters import RangeSlider from common.components.filters import RangeSlider
@@ -1,5 +1,5 @@
/** /**
* DateRangePicker vanilla JavaScript implementation. * DateRangePicker vanilla TypeScript implementation.
* *
* Drives the DateRangePicker component (common/components/date_range_picker.py): * Drives the DateRangePicker component (common/components/date_range_picker.py):
* *
@@ -15,42 +15,62 @@
* clicked date. * clicked date.
* *
* The committed value lives in the two hidden ISO inputs ({prefix}-min / * The committed value lives in the two hidden ISO inputs ({prefix}-min /
* {prefix}-max) that filter_bar.js serializes into a DateCriterion. * {prefix}-max) that filter_bar.ts serializes into a DateCriterion.
* *
* NB: class strings below are emitted verbatim so the Tailwind scanner picks * NB: class strings below are emitted verbatim so the Tailwind scanner picks
* them up keep them as plain literals. * them up keep them as plain literals.
*/ */
(function () { import { onSwap } from "./utils.js";
type Anchor = "" | "start" | "end";
interface CalendarState {
open: boolean;
viewYear: number;
viewMonth: number;
startIso: string;
endIso: string;
// The anchor is the fixed endpoint: "start" while picking the EndDate,
// "end" once the range is complete (further picks move the StartDate).
anchor: Anchor;
hoverIso: string;
// True while showing a committed range the user has not edited yet —
// the track renders muted until the first pick.
readOnly: boolean;
refreshFromField: () => void;
}
(() => {
"use strict"; "use strict";
var WEEKDAY_LABELS = ["Mo", "Tu", "We", "Th", "Fr", "Sa", "Su"]; const WEEKDAY_LABELS = ["Mo", "Tu", "We", "Th", "Fr", "Sa", "Su"];
var WEEKDAY_CLASS = const WEEKDAY_CLASS =
"w-8 h-6 flex items-center justify-center text-xs text-body select-none"; "w-8 h-6 flex items-center justify-center text-xs text-body select-none";
var DAY_BASE_CLASS = const DAY_BASE_CLASS =
"date-range-day w-8 h-8 flex items-center justify-center text-sm " + "date-range-day w-8 h-8 flex items-center justify-center text-sm " +
"text-heading cursor-pointer hover:bg-neutral-tertiary-medium"; "text-heading cursor-pointer hover:bg-neutral-tertiary-medium";
var DAY_ROUNDED_CLASS = "rounded-base"; const DAY_ROUNDED_CLASS = "rounded-base";
var DAY_OUTSIDE_MONTH_CLASS = "opacity-40"; const DAY_OUTSIDE_MONTH_CLASS = "opacity-40";
var DAY_SELECTED_CLASS = "bg-brand text-white hover:bg-brand-strong"; const DAY_SELECTED_CLASS = "bg-brand text-white hover:bg-brand-strong";
var DAY_ANCHOR_CLASS = const DAY_ANCHOR_CLASS =
"bg-brand text-white ring-2 ring-inset ring-brand-strong hover:bg-brand-strong"; "bg-brand text-white ring-2 ring-inset ring-brand-strong hover:bg-brand-strong";
// The three visual states of the date range track (the days between the // The three visual states of the date range track (the days between the
// two endpoints): outlined while picking the second date, filled once both // two endpoints): outlined while picking the second date, filled once both
// are picked, muted when showing an already-committed range read-only. // are picked, muted when showing an already-committed range read-only.
var TRACK_OUTLINED_CLASS = "border-y border-brand/70 bg-brand/10"; const TRACK_OUTLINED_CLASS = "border-y border-brand/70 bg-brand/10";
var TRACK_FILLED_CLASS = "bg-brand/30"; const TRACK_FILLED_CLASS = "bg-brand/30";
var TRACK_MUTED_CLASS = "bg-brand/15"; const TRACK_MUTED_CLASS = "bg-brand/15";
// ── Date helpers (all local-time; values are ISO YYYY-MM-DD strings) ── // ── Date helpers (all local-time; values are ISO YYYY-MM-DD strings) ──
function padNumber(value, width) { function padNumber(value: number, width: number): string {
var text = String(value); let text = String(value);
while (text.length < width) text = "0" + text; while (text.length < width) text = "0" + text;
return text; return text;
} }
function isoFromDate(dateObject) { function isoFromDate(dateObject: Date): string {
return ( return (
padNumber(dateObject.getFullYear(), 4) + padNumber(dateObject.getFullYear(), 4) +
"-" + "-" +
@@ -60,8 +80,8 @@
); );
} }
function dateFromIso(isoString) { function dateFromIso(isoString: string): Date {
var pieces = isoString.split("-"); const pieces = isoString.split("-");
return new Date( return new Date(
parseInt(pieces[0], 10), parseInt(pieces[0], 10),
parseInt(pieces[1], 10) - 1, parseInt(pieces[1], 10) - 1,
@@ -69,15 +89,15 @@
); );
} }
function addDays(dateObject, dayCount) { function addDays(dateObject: Date, dayCount: number): Date {
var copy = new Date(dateObject.getTime()); const copy = new Date(dateObject.getTime());
copy.setDate(copy.getDate() + dayCount); copy.setDate(copy.getDate() + dayCount);
return copy; return copy;
} }
/** Validate a (year, month, day) triple as a real calendar date. */ /** Validate a (year, month, day) triple as a real calendar date. */
function isoFromParts(year, month, day) { function isoFromParts(year: number, month: number, day: number): string {
var candidate = new Date(year, month - 1, day); const candidate = new Date(year, month - 1, day);
if ( if (
candidate.getFullYear() !== year || candidate.getFullYear() !== year ||
candidate.getMonth() !== month - 1 || candidate.getMonth() !== month - 1 ||
@@ -88,12 +108,12 @@
return isoFromDate(candidate); return isoFromDate(candidate);
} }
function presetRange(presetName) { function presetRange(presetName: string): [Date, Date] | null {
var today = new Date(); const today = new Date();
today.setHours(0, 0, 0, 0); today.setHours(0, 0, 0, 0);
var yesterday = addDays(today, -1); const yesterday = addDays(today, -1);
var year = today.getFullYear(); const year = today.getFullYear();
var month = today.getMonth(); const month = today.getMonth();
switch (presetName) { switch (presetName) {
case "today": case "today":
return [today, today]; return [today, today];
@@ -116,42 +136,42 @@
// ── DateRangeField: segmented manual entry ────────────────────────────── // ── DateRangeField: segmented manual entry ──────────────────────────────
function segmentBuffer(segment) { function segmentBuffer(segment: HTMLInputElement): string {
return segment.dataset.typedDigits || ""; return segment.dataset.typedDigits || "";
} }
function setSegmentBuffer(segment, buffer) { function setSegmentBuffer(segment: HTMLInputElement, buffer: string): void {
segment.dataset.typedDigits = buffer; segment.dataset.typedDigits = buffer;
if (buffer === "") { if (buffer === "") {
segment.value = ""; segment.value = "";
return; return;
} }
var placeholder = segment.getAttribute("placeholder"); const placeholder = segment.getAttribute("placeholder") ?? "";
// Fill the placeholder from the right: typing 19 into YYYY shows YY19. // Fill the placeholder from the right: typing 19 into YYYY shows YY19.
segment.value = placeholder.slice(0, placeholder.length - buffer.length) + buffer; segment.value = placeholder.slice(0, placeholder.length - buffer.length) + buffer;
} }
function segmentsForSide(picker, side) { function segmentsForSide(picker: HTMLElement, side: string): HTMLInputElement[] {
return Array.prototype.slice.call( return Array.from(
picker.querySelectorAll('input[data-date-side="' + side + '"]') picker.querySelectorAll<HTMLInputElement>(`input[data-date-side="${side}"]`)
); );
} }
/** Recompute one hidden ISO input from its side's segment buffers. */ /** Recompute one hidden ISO input from its side's segment buffers. */
function syncHiddenFromSegments(picker, side) { function syncHiddenFromSegments(picker: HTMLElement, side: string): boolean {
var hidden = picker.querySelector( const hidden = picker.querySelector<HTMLInputElement>(
'input[data-date-range-hidden="' + side + '"]' `input[data-date-range-hidden="${side}"]`
); )!;
var partValues = {}; const partValues: Record<string, string> = {};
var complete = true; let complete = true;
segmentsForSide(picker, side).forEach(function (segment) { segmentsForSide(picker, side).forEach((segment) => {
var buffer = segmentBuffer(segment); const buffer = segmentBuffer(segment);
if (buffer.length !== parseInt(segment.getAttribute("maxlength"), 10)) { if (buffer.length !== parseInt(segment.getAttribute("maxlength") ?? "", 10)) {
complete = false; complete = false;
} }
partValues[segment.dataset.datePart] = buffer; partValues[segment.dataset.datePart ?? ""] = buffer;
}); });
var previousValue = hidden.value; const previousValue = hidden.value;
if (complete) { if (complete) {
hidden.value = isoFromParts( hidden.value = isoFromParts(
parseInt(partValues.year, 10), parseInt(partValues.year, 10),
@@ -165,69 +185,70 @@
} }
/** Push an ISO value (or "") into a side's segments and hidden input. */ /** Push an ISO value (or "") into a side's segments and hidden input. */
function setSideValue(picker, side, isoString) { function setSideValue(picker: HTMLElement, side: string, isoString: string): void {
var hidden = picker.querySelector( const hidden = picker.querySelector<HTMLInputElement>(
'input[data-date-range-hidden="' + side + '"]' `input[data-date-range-hidden="${side}"]`
); )!;
hidden.value = isoString; hidden.value = isoString;
var partValues = { year: "", month: "", day: "" }; let partValues: Record<string, string> = { year: "", month: "", day: "" };
if (isoString) { if (isoString) {
var pieces = isoString.split("-"); const pieces = isoString.split("-");
partValues = { year: pieces[0], month: pieces[1], day: pieces[2] }; partValues = { year: pieces[0], month: pieces[1], day: pieces[2] };
} }
segmentsForSide(picker, side).forEach(function (segment) { segmentsForSide(picker, side).forEach((segment) => {
setSegmentBuffer(segment, partValues[segment.dataset.datePart]); setSegmentBuffer(segment, partValues[segment.dataset.datePart ?? ""]);
}); });
} }
function initField(picker, calendarState) { function initField(picker: HTMLElement, calendarState: CalendarState): void {
var field = picker.querySelector("[data-date-range-field]"); const field = picker.querySelector<HTMLElement>("[data-date-range-field]")!;
var segments = Array.prototype.slice.call( const segments = Array.from(
picker.querySelectorAll("input[data-date-part]") picker.querySelectorAll<HTMLInputElement>("input[data-date-part]")
); );
// Adopt server-rendered values (prefilled filter) as typed buffers. // Adopt server-rendered values (prefilled filter) as typed buffers.
segments.forEach(function (segment) { segments.forEach((segment) => {
if (segment.value) setSegmentBuffer(segment, segment.value); if (segment.value) setSegmentBuffer(segment, segment.value);
}); });
// Clicking anywhere in the container that is not a date part activates // Clicking anywhere in the container that is not a date part activates
// the first date part. // the first date part.
field.addEventListener("mousedown", function (event) { field.addEventListener("mousedown", (event) => {
if (event.target.closest("input[data-date-part]")) return; const target = event.target as Element;
if (event.target.closest("[data-date-range-calendar-toggle]")) return; if (target.closest("input[data-date-part]")) return;
if (target.closest("[data-date-range-calendar-toggle]")) return;
event.preventDefault(); event.preventDefault();
segments[0].focus(); segments[0].focus();
}); });
segments.forEach(function (segment, segmentIndex) { segments.forEach((segment, segmentIndex) => {
segment.addEventListener("keydown", function (event) { segment.addEventListener("keydown", (event) => {
if (event.key === "Tab") return; // native Tab / Shift+Tab navigation if (event.key === "Tab") return; // native Tab / Shift+Tab navigation
if (event.key === "Enter") return; // let the filter form submit if (event.key === "Enter") return; // let the filter form submit
if (event.key === "Backspace" || event.key === "Delete") { if (event.key === "Backspace" || event.key === "Delete") {
event.preventDefault(); event.preventDefault();
setSegmentBuffer(segment, ""); setSegmentBuffer(segment, "");
syncHiddenFromSegments(picker, segment.dataset.dateSide); syncHiddenFromSegments(picker, segment.dataset.dateSide ?? "");
return; return;
} }
if (event.ctrlKey || event.metaKey || event.altKey) return; if (event.ctrlKey || event.metaKey || event.altKey) return;
event.preventDefault(); event.preventDefault();
if (!/^[0-9]$/.test(event.key)) return; // only numbers can be typed if (!/^[0-9]$/.test(event.key)) return; // only numbers can be typed
var maximumLength = parseInt(segment.getAttribute("maxlength"), 10); const maximumLength = parseInt(segment.getAttribute("maxlength") ?? "", 10);
var buffer = segmentBuffer(segment); let buffer = segmentBuffer(segment);
// Typing into an already-full part starts it over. // Typing into an already-full part starts it over.
buffer = buffer.length >= maximumLength ? event.key : buffer + event.key; buffer = buffer.length >= maximumLength ? event.key : buffer + event.key;
setSegmentBuffer(segment, buffer); setSegmentBuffer(segment, buffer);
syncHiddenFromSegments(picker, segment.dataset.dateSide); syncHiddenFromSegments(picker, segment.dataset.dateSide ?? "");
if (buffer.length === maximumLength && segmentIndex + 1 < segments.length) { if (buffer.length === maximumLength && segmentIndex + 1 < segments.length) {
segments[segmentIndex + 1].focus(); segments[segmentIndex + 1].focus();
} }
}); });
// Swallow any input that bypassed keydown (e.g. IME/paste). // Swallow any input that bypassed keydown (e.g. IME/paste).
segment.addEventListener("input", function () { segment.addEventListener("input", () => {
setSegmentBuffer(segment, segmentBuffer(segment)); setSegmentBuffer(segment, segmentBuffer(segment));
}); });
segment.addEventListener("focus", function () { segment.addEventListener("focus", () => {
if (calendarState) calendarState.refreshFromField(); if (calendarState) calendarState.refreshFromField();
}); });
}); });
@@ -235,51 +256,47 @@
// ── DateRangeCalendar: popup month grid ──────────────────────────────── // ── DateRangeCalendar: popup month grid ────────────────────────────────
function createCalendarState(picker) { function createCalendarState(picker: HTMLElement): CalendarState {
var popup = picker.querySelector("[data-date-range-calendar]"); const popup = picker.querySelector<HTMLElement>("[data-date-range-calendar]")!;
var grid = popup.querySelector("[data-date-range-grid]"); const grid = popup.querySelector<HTMLElement>("[data-date-range-grid]")!;
var monthLabel = popup.querySelector("[data-date-range-month-label]"); const monthLabel = popup.querySelector<HTMLElement>("[data-date-range-month-label]")!;
var today = new Date(); const today = new Date();
var state = {
function hiddenValue(side: string): string {
return picker.querySelector<HTMLInputElement>(
`input[data-date-range-hidden="${side}"]`
)!.value;
}
const state: CalendarState = {
open: false, open: false,
viewYear: today.getFullYear(), viewYear: today.getFullYear(),
viewMonth: today.getMonth(), viewMonth: today.getMonth(),
startIso: "", startIso: "",
endIso: "", endIso: "",
// The anchor is the fixed endpoint: "start" while picking the EndDate,
// "end" once the range is complete (further picks move the StartDate).
anchor: "", anchor: "",
hoverIso: "", hoverIso: "",
// True while showing a committed range the user has not edited yet —
// the track renders muted until the first pick.
readOnly: false, readOnly: false,
refreshFromField() {
if (state.open) return;
state.startIso = hiddenValue("min");
state.endIso = hiddenValue("max");
},
}; };
function hiddenValue(side) { function syncSelectionToField(): void {
return picker.querySelector(
'input[data-date-range-hidden="' + side + '"]'
).value;
}
state.refreshFromField = function () {
if (state.open) return;
state.startIso = hiddenValue("min");
state.endIso = hiddenValue("max");
};
function syncSelectionToField() {
setSideValue(picker, "min", state.startIso); setSideValue(picker, "min", state.startIso);
setSideValue(picker, "max", state.endIso); setSideValue(picker, "max", state.endIso);
} }
function openPopup() { function openPopup(): void {
state.startIso = hiddenValue("min"); state.startIso = hiddenValue("min");
state.endIso = hiddenValue("max"); state.endIso = hiddenValue("max");
state.anchor = state.startIso && state.endIso ? "end" : state.startIso ? "start" : ""; state.anchor = state.startIso && state.endIso ? "end" : state.startIso ? "start" : "";
state.readOnly = Boolean(state.startIso && state.endIso); state.readOnly = Boolean(state.startIso && state.endIso);
state.hoverIso = ""; state.hoverIso = "";
var focusDate = state.startIso ? dateFromIso(state.startIso) : new Date(); const focusDate = state.startIso ? dateFromIso(state.startIso) : new Date();
state.viewYear = focusDate.getFullYear(); state.viewYear = focusDate.getFullYear();
state.viewMonth = focusDate.getMonth(); state.viewMonth = focusDate.getMonth();
state.open = true; state.open = true;
@@ -287,13 +304,13 @@
render(); render();
} }
function closePopup() { function closePopup(): void {
state.open = false; state.open = false;
state.hoverIso = ""; state.hoverIso = "";
popup.classList.add("hidden"); popup.classList.add("hidden");
} }
function clearSelection() { function clearSelection(): void {
state.startIso = ""; state.startIso = "";
state.endIso = ""; state.endIso = "";
state.anchor = ""; state.anchor = "";
@@ -312,7 +329,7 @@
* moves the StartDate (extend/shorten); a pick after it clears the * moves the StartDate (extend/shorten); a pick after it clears the
* range and restarts from the clicked date * range and restarts from the clicked date
*/ */
function pickDate(isoString) { function pickDate(isoString: string): void {
state.readOnly = false; state.readOnly = false;
if (!state.startIso) { if (!state.startIso) {
state.startIso = isoString; state.startIso = isoString;
@@ -339,8 +356,8 @@
render(); render();
} }
function applyPreset(presetName) { function applyPreset(presetName: string): void {
var range = presetRange(presetName); const range = presetRange(presetName);
if (!range) return; if (!range) return;
state.startIso = isoFromDate(range[0]); state.startIso = isoFromDate(range[0]);
state.endIso = isoFromDate(range[1]); state.endIso = isoFromDate(range[1]);
@@ -355,28 +372,32 @@
/** The (inclusive-exclusive of endpoints) track between the two range /** The (inclusive-exclusive of endpoints) track between the two range
* ends; while picking the second date the hovered day acts as the * ends; while picking the second date the hovered day acts as the
* provisional other end. */ * provisional other end. */
function trackBounds() { function trackBounds(): [string, string, string] | null {
if (state.startIso && state.endIso) { if (state.startIso && state.endIso) {
return [state.startIso, state.endIso, state.readOnly ? TRACK_MUTED_CLASS : TRACK_FILLED_CLASS]; return [
state.startIso,
state.endIso,
state.readOnly ? TRACK_MUTED_CLASS : TRACK_FILLED_CLASS,
];
} }
if (state.startIso && state.hoverIso && state.hoverIso !== state.startIso) { if (state.startIso && state.hoverIso && state.hoverIso !== state.startIso) {
var lower = state.hoverIso < state.startIso ? state.hoverIso : state.startIso; const lower = state.hoverIso < state.startIso ? state.hoverIso : state.startIso;
var upper = state.hoverIso < state.startIso ? state.startIso : state.hoverIso; const upper = state.hoverIso < state.startIso ? state.startIso : state.hoverIso;
return [lower, upper, TRACK_OUTLINED_CLASS]; return [lower, upper, TRACK_OUTLINED_CLASS];
} }
return null; return null;
} }
function dayCellClass(isoString, inViewMonth) { function dayCellClass(isoString: string, inViewMonth: boolean): string {
var classes = [DAY_BASE_CLASS]; const classes = [DAY_BASE_CLASS];
var isStart = isoString === state.startIso; const isStart = isoString === state.startIso;
var isEnd = isoString === state.endIso; const isEnd = isoString === state.endIso;
var isAnchor = const isAnchor =
(state.anchor === "start" && isStart) || (state.anchor === "end" && isEnd); (state.anchor === "start" && isStart) || (state.anchor === "end" && isEnd);
var track = trackBounds(); const track = trackBounds();
var inTrack = track && isoString > track[0] && isoString < track[1]; const inTrack = track !== null && isoString > track[0] && isoString < track[1];
if (inTrack) { if (inTrack) {
classes.push(track[2]); classes.push(track![2]);
} else { } else {
classes.push(DAY_ROUNDED_CLASS); classes.push(DAY_ROUNDED_CLASS);
} }
@@ -390,7 +411,7 @@
return classes.join(" "); return classes.join(" ");
} }
function render() { function render(): void {
monthLabel.textContent = new Date( monthLabel.textContent = new Date(
state.viewYear, state.viewYear,
state.viewMonth, state.viewMonth,
@@ -398,20 +419,20 @@
).toLocaleDateString(undefined, { month: "long", year: "numeric" }); ).toLocaleDateString(undefined, { month: "long", year: "numeric" });
grid.textContent = ""; grid.textContent = "";
WEEKDAY_LABELS.forEach(function (weekdayLabel) { WEEKDAY_LABELS.forEach((weekdayLabel) => {
var headerCell = document.createElement("span"); const headerCell = document.createElement("span");
headerCell.className = WEEKDAY_CLASS; headerCell.className = WEEKDAY_CLASS;
headerCell.textContent = weekdayLabel; headerCell.textContent = weekdayLabel;
grid.appendChild(headerCell); grid.appendChild(headerCell);
}); });
var firstOfMonth = new Date(state.viewYear, state.viewMonth, 1); const firstOfMonth = new Date(state.viewYear, state.viewMonth, 1);
// Monday-first offset of the leading overflow days. // Monday-first offset of the leading overflow days.
var leadingDays = (firstOfMonth.getDay() + 6) % 7; const leadingDays = (firstOfMonth.getDay() + 6) % 7;
var cellDate = addDays(firstOfMonth, -leadingDays); let cellDate = addDays(firstOfMonth, -leadingDays);
for (var cellIndex = 0; cellIndex < 42; cellIndex++) { for (let cellIndex = 0; cellIndex < 42; cellIndex++) {
var isoString = isoFromDate(cellDate); const isoString = isoFromDate(cellDate);
var dayButton = document.createElement("button"); const dayButton = document.createElement("button");
dayButton.type = "button"; dayButton.type = "button";
dayButton.setAttribute("data-date", isoString); dayButton.setAttribute("data-date", isoString);
dayButton.className = dayCellClass( dayButton.className = dayCellClass(
@@ -426,30 +447,30 @@
// ── Wiring ── // ── Wiring ──
picker picker
.querySelector("[data-date-range-calendar-toggle]") .querySelector<HTMLElement>("[data-date-range-calendar-toggle]")!
.addEventListener("click", function () { .addEventListener("click", () => {
if (state.open) closePopup(); if (state.open) closePopup();
else openPopup(); else openPopup();
}); });
grid.addEventListener("click", function (event) { grid.addEventListener("click", (event) => {
var dayButton = event.target.closest("button[data-date]"); const dayButton = (event.target as Element).closest("button[data-date]");
if (dayButton) pickDate(dayButton.getAttribute("data-date")); if (dayButton) pickDate(dayButton.getAttribute("data-date") ?? "");
}); });
grid.addEventListener("mouseover", function (event) { grid.addEventListener("mouseover", (event) => {
if (!state.startIso || state.endIso) return; if (!state.startIso || state.endIso) return;
var dayButton = event.target.closest("button[data-date]"); const dayButton = (event.target as Element).closest("button[data-date]");
if (!dayButton) return; if (!dayButton) return;
var hoveredIso = dayButton.getAttribute("data-date"); const hoveredIso = dayButton.getAttribute("data-date") ?? "";
if (hoveredIso === state.hoverIso) return; if (hoveredIso === state.hoverIso) return;
state.hoverIso = hoveredIso; state.hoverIso = hoveredIso;
render(); render();
}); });
popup popup
.querySelector("[data-date-range-prev]") .querySelector<HTMLElement>("[data-date-range-prev]")!
.addEventListener("click", function () { .addEventListener("click", () => {
state.viewMonth -= 1; state.viewMonth -= 1;
if (state.viewMonth < 0) { if (state.viewMonth < 0) {
state.viewMonth = 11; state.viewMonth = 11;
@@ -459,8 +480,8 @@
}); });
popup popup
.querySelector("[data-date-range-next]") .querySelector<HTMLElement>("[data-date-range-next]")!
.addEventListener("click", function () { .addEventListener("click", () => {
state.viewMonth += 1; state.viewMonth += 1;
if (state.viewMonth > 11) { if (state.viewMonth > 11) {
state.viewMonth = 0; state.viewMonth = 0;
@@ -469,62 +490,50 @@
render(); render();
}); });
popup.querySelectorAll("[data-date-range-preset]").forEach(function (button) { popup.querySelectorAll<HTMLElement>("[data-date-range-preset]").forEach((button) => {
button.addEventListener("click", function () { button.addEventListener("click", () => {
applyPreset(button.getAttribute("data-date-range-preset")); applyPreset(button.getAttribute("data-date-range-preset") ?? "");
}); });
}); });
// Cancel: close the popup and clear the selected dates. // Cancel: close the popup and clear the selected dates.
popup popup
.querySelector("[data-date-range-cancel]") .querySelector<HTMLElement>("[data-date-range-cancel]")!
.addEventListener("click", function () { .addEventListener("click", () => {
clearSelection(); clearSelection();
closePopup(); closePopup();
}); });
// Clear: clear the selected dates but keep the popup open. // Clear: clear the selected dates but keep the popup open.
popup popup
.querySelector("[data-date-range-clear]") .querySelector<HTMLElement>("[data-date-range-clear]")!
.addEventListener("click", function () { .addEventListener("click", () => {
clearSelection(); clearSelection();
render(); render();
}); });
// Select: close the popup, keeping the selected dates. // Select: close the popup, keeping the selected dates.
popup popup
.querySelector("[data-date-range-select]") .querySelector<HTMLElement>("[data-date-range-select]")!
.addEventListener("click", function () { .addEventListener("click", () => {
closePopup(); closePopup();
}); });
document.addEventListener("keydown", function (event) { document.addEventListener("keydown", (event) => {
if (event.key === "Escape" && state.open) closePopup(); if (event.key === "Escape" && state.open) closePopup();
}); });
document.addEventListener("mousedown", function (event) { document.addEventListener("mousedown", (event) => {
if (state.open && !picker.contains(event.target)) closePopup(); if (state.open && !picker.contains(event.target as Node)) closePopup();
}); });
return state; return state;
} }
function initPicker(picker) { function initPicker(picker: HTMLElement): void {
if (picker.dataset.dateRangePickerInitialized) return; const calendarState = createCalendarState(picker);
picker.dataset.dateRangePickerInitialized = "true";
var calendarState = createCalendarState(picker);
initField(picker, calendarState); initField(picker, calendarState);
} }
function initAllPickers() { onSwap("[data-date-range-picker]", (picker) => initPicker(picker as HTMLElement));
document.querySelectorAll("[data-date-range-picker]").forEach(initPicker);
}
window.initDateRangePickers = initAllPickers;
if (document.readyState === "loading") {
document.addEventListener("DOMContentLoaded", initAllPickers);
} else {
initAllPickers();
}
})(); })();