|
|
@@ -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();
|
|
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
})();
|
|
|
|
})();
|