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