Repository: flatpickr/flatpickr
Pull request #2297 (2020-10-09 10:07:26)

monthSelect plugin: Support "range" mode
> **Note:** This PR consists of eight separate commits. I put in a good amount of effort keeping changes isolated so that the commit history could be used as an instructive reference to help future developers who might wonder, "Why was the code written this way?" (see [Telling Stories through Your Commits (2015)](https://blog.mocoso.co.uk/talks/2015/01/12/telling-stories-through-your-commits/) and [A Branch in Time (2019)](https://tekin.co.uk/2019/02/a-talk-about-revision-histories) for my rationale here). Keeping commits bite-sized and cohesive is crucial to this approach.
>
> To preserve this effort, please keep these commits separate when merging (_i.e.,_ do **not** "squash merge"). Thanks!

This PR adds support for "range" mode to the monthSelect plugin. It also includes various minor refactorings and bugfixes that are not always directly related to range mode functionality.

In virtually every case, there is an extensive commit message detailing the scope and reasons for the changes, as well as any important design considerations. For a deeper look at this PR, check there first.


Patch:
diff --git a/src/index.ts b/src/index.ts
index 3b8f0834..c44dad09 100644
--- a/src/index.ts
+++ b/src/index.ts
@@ -64,11 +64,14 @@ function FlatpickrInstance(
   self.changeYear = changeYear;
   self.clear = clear;
   self.close = close;
+  self.onMouseOver = onMouseOver;
 
   self._createElement = createElement;
+  self.createDay = createDay;
   self.destroy = destroy;
   self.isEnabled = isEnabled;
   self.jumpToDate = jumpToDate;
+  self.updateValue = updateValue;
   self.open = open;
   self.redraw = redraw;
   self.set = set;
@@ -380,7 +383,7 @@ function FlatpickrInstance(
     element: E | E[],
     event: string | string[],
     handler: (e?: any) => void,
-    options?: object
+    options?: { capture?: boolean; once?: boolean; passive?: boolean }
   ): void {
     if (event instanceof Array)
       return event.forEach((ev) => bind(element, ev, handler, options));
@@ -679,7 +682,7 @@ function FlatpickrInstance(
     const dateIsEnabled = isEnabled(date, true),
       dayElement = createElement<DayElement>(
         "span",
-        "flatpickr-day " + className,
+        className,
         date.getDate().toString()
       );
 
@@ -860,7 +863,7 @@ function FlatpickrInstance(
     for (; dayNumber <= prevMonthDays; dayNumber++, dayIndex++) {
       days.appendChild(
         createDay(
-          prevMonthDayClass,
+          `flatpickr-day ${prevMonthDayClass}`,
           new Date(year, month - 1, dayNumber),
           dayNumber,
           dayIndex
@@ -871,7 +874,12 @@ function FlatpickrInstance(
     // Start at 1 since there is no 0th day
     for (dayNumber = 1; dayNumber <= daysInMonth; dayNumber++, dayIndex++) {
       days.appendChild(
-        createDay("", new Date(year, month, dayNumber), dayNumber, dayIndex)
+        createDay(
+          "flatpickr-day",
+          new Date(year, month, dayNumber),
+          dayNumber,
+          dayIndex
+        )
       );
     }
 
@@ -884,7 +892,7 @@ function FlatpickrInstance(
     ) {
       days.appendChild(
         createDay(
-          nextMonthDayClass,
+          `flatpickr-day ${nextMonthDayClass}`,
           new Date(year, month + 1, dayNum % daysInMonth),
           dayNum,
           dayIndex
@@ -1495,10 +1503,8 @@ function FlatpickrInstance(
           self.config &&
           self.config.mode === "range" &&
           self.selectedDates.length === 1
-        ) {
+        )
           self.clear(false);
-          self.redraw();
-        }
       }
     }
   }
@@ -1806,11 +1812,11 @@ function FlatpickrInstance(
     }
   }
 
-  function onMouseOver(elem?: DayElement) {
+  function onMouseOver(elem?: DayElement, cellClass = "flatpickr-day") {
     if (
       self.selectedDates.length !== 1 ||
       (elem &&
-        (!elem.classList.contains("flatpickr-day") ||
+        (!elem.classList.contains(cellClass) ||
           elem.classList.contains("flatpickr-disabled")))
     )
       return;
@@ -1841,51 +1847,52 @@ function FlatpickrInstance(
       }
     }
 
-    for (let m = 0; m < self.config.showMonths; m++) {
-      const month = (self.daysContainer as HTMLElement).children[m];
-
-      for (let i = 0, l = month.children.length; i < l; i++) {
-        const dayElem = month.children[i] as DayElement,
-          date = dayElem.dateObj;
+    const hoverableCells = Array.from(
+      self.rContainer!.querySelectorAll(
+        `*:nth-child(-n+${self.config.showMonths}) > .${cellClass}`
+      )
+    ) as DayElement[];
 
-        const timestamp = date.getTime();
+    hoverableCells.forEach((dayElem) => {
+      const date = dayElem.dateObj;
 
-        const outOfRange =
-          (minRange > 0 && timestamp < minRange) ||
-          (maxRange > 0 && timestamp > maxRange);
+      const timestamp = date.getTime();
 
-        if (outOfRange) {
-          dayElem.classList.add("notAllowed");
-          ["inRange", "startRange", "endRange"].forEach((c) => {
-            dayElem.classList.remove(c);
-          });
-          continue;
-        } else if (containsDisabled && !outOfRange) continue;
+      const outOfRange =
+        (minRange > 0 && timestamp < minRange) ||
+        (maxRange > 0 && timestamp > maxRange);
 
-        ["startRange", "inRange", "endRange", "notAllowed"].forEach((c) => {
+      if (outOfRange) {
+        dayElem.classList.add("notAllowed");
+        ["inRange", "startRange", "endRange"].forEach((c) => {
           dayElem.classList.remove(c);
         });
+        return;
+      } else if (containsDisabled && !outOfRange) return;
 
-        if (elem !== undefined) {
-          elem.classList.add(
-            hoverDate <= self.selectedDates[0].getTime()
-              ? "startRange"
-              : "endRange"
-          );
+      ["startRange", "inRange", "endRange", "notAllowed"].forEach((c) => {
+        dayElem.classList.remove(c);
+      });
 
-          if (initialDate < hoverDate && timestamp === initialDate)
-            dayElem.classList.add("startRange");
-          else if (initialDate > hoverDate && timestamp === initialDate)
-            dayElem.classList.add("endRange");
-          if (
-            timestamp >= minRange &&
-            (maxRange === 0 || timestamp <= maxRange) &&
-            isBetween(timestamp, initialDate, hoverDate)
-          )
-            dayElem.classList.add("inRange");
-        }
+      if (elem !== undefined) {
+        elem.classList.add(
+          hoverDate <= self.selectedDates[0].getTime()
+            ? "startRange"
+            : "endRange"
+        );
+
+        if (initialDate < hoverDate && timestamp === initialDate)
+          dayElem.classList.add("startRange");
+        else if (initialDate > hoverDate && timestamp === initialDate)
+          dayElem.classList.add("endRange");
+        if (
+          timestamp >= minRange &&
+          (maxRange === 0 || timestamp <= maxRange) &&
+          isBetween(timestamp, initialDate, hoverDate)
+        )
+          dayElem.classList.add("inRange");
       }
-    }
+    });
   }
 
   function onResize() {
diff --git a/src/plugins/monthSelect/index.ts b/src/plugins/monthSelect/index.ts
index aab54d72..eb445947 100644
--- a/src/plugins/monthSelect/index.ts
+++ b/src/plugins/monthSelect/index.ts
@@ -1,13 +1,14 @@
 import { Plugin } from "../../types/options";
-import { Instance } from "../../types/instance";
+import { Instance, DayElement } from "../../types/instance";
 import { monthToStr } from "../../utils/formatting";
-import { getEventTarget } from "../../utils/dom";
+import { clearNode, getEventTarget } from "../../utils/dom";
 
 export interface Config {
   shorthand: boolean;
   dateFormat: string;
   altFormat: string;
   theme: string;
+  _stubbedCurrentMonth?: number;
 }
 
 export interface ElementDate extends Element {
@@ -32,10 +33,9 @@ function monthSelectPlugin(pluginConfig?: Partial<Config>): Plugin {
     const self = { monthsContainer: null as null | HTMLDivElement };
 
     function clearUnnecessaryDOMElements(): void {
-      if (!fp.rContainer || !fp.daysContainer || !fp.weekdayContainer) return;
+      if (!fp.rContainer) return;
 
-      fp.rContainer.removeChild(fp.daysContainer);
-      fp.rContainer.removeChild(fp.weekdayContainer);
+      clearNode(fp.rContainer);
 
       for (let index = 0; index < fp.monthElements.length; index++) {
         const element = fp.monthElements[index];
@@ -45,25 +45,7 @@ function monthSelectPlugin(pluginConfig?: Partial<Config>): Plugin {
       }
     }
 
-    function addListeners() {
-      fp._bind(fp.prevMonthNav, "click", (e) => {
-        e.preventDefault();
-        e.stopPropagation();
-
-        fp.changeYear(fp.currentYear - 1);
-        selectYear();
-      });
-
-      fp._bind(fp.nextMonthNav, "click", (e) => {
-        e.preventDefault();
-        e.stopPropagation();
-
-        fp.changeYear(fp.currentYear + 1);
-        selectYear();
-      });
-    }
-
-    function addMonths() {
+    function build() {
       if (!fp.rContainer) return;
 
       self.monthsContainer = fp._createElement<HTMLDivElement>(
@@ -73,34 +55,77 @@ function monthSelectPlugin(pluginConfig?: Partial<Config>): Plugin {
 
       self.monthsContainer.tabIndex = -1;
 
+      buildMonths();
+
+      fp.rContainer.appendChild(self.monthsContainer);
+
       fp.calendarContainer.classList.add(
         `flatpickr-monthSelect-theme-${config.theme}`
       );
+    }
+
+    function buildMonths() {
+      if (!self.monthsContainer) return;
+
+      clearNode(self.monthsContainer);
+
+      const frag = document.createDocumentFragment();
 
       for (let i = 0; i < 12; i++) {
-        const month = fp._createElement<MonthElement>(
-          "span",
-          "flatpickr-monthSelect-month"
+        const month = fp.createDay(
+          "flatpickr-monthSelect-month",
+          new Date(fp.currentYear, i),
+          0,
+          i
         );
-        month.dateObj = new Date(fp.currentYear, i);
-        month.$i = i;
+        if (
+          month.dateObj.getMonth() === new Date().getMonth() &&
+          month.dateObj.getFullYear() === new Date().getFullYear()
+        )
+          month.classList.add("today");
         month.textContent = monthToStr(i, config.shorthand, fp.l10n);
-        month.tabIndex = -1;
         month.addEventListener("click", selectMonth);
-        self.monthsContainer.appendChild(month);
-        if (
-          (fp.config.minDate && month.dateObj < fp.config.minDate) ||
-          (fp.config.maxDate && month.dateObj > fp.config.maxDate)
-        ) {
-          month.classList.add("disabled");
-        }
+        frag.appendChild(month);
       }
 
-      fp.rContainer.appendChild(self.monthsContainer);
+      self.monthsContainer.appendChild(frag);
+    }
+
+    function bindEvents() {
+      fp._bind(fp.prevMonthNav, "click", (e) => {
+        e.preventDefault();
+        e.stopPropagation();
+
+        fp.changeYear(fp.currentYear - 1);
+        selectYear();
+        buildMonths();
+      });
+
+      fp._bind(fp.nextMonthNav, "click", (e) => {
+        e.preventDefault();
+        e.stopPropagation();
+
+        fp.changeYear(fp.currentYear + 1);
+        selectYear();
+        buildMonths();
+      });
+
+      fp._bind(
+        self.monthsContainer as HTMLElement,
+        "mouseover",
+        (e: MouseEvent) => {
+          if (fp.config.mode === "range")
+            fp.onMouseOver(
+              getEventTarget(e) as DayElement,
+              "flatpickr-monthSelect-month"
+            );
+        }
+      );
     }
 
     function setCurrentlySelected() {
       if (!fp.rContainer) return;
+      if (!fp.selectedDates.length) return;
 
       const currentlySelected = fp.rContainer.querySelectorAll(
         ".flatpickr-monthSelect-month.selected"
@@ -110,7 +135,7 @@ function monthSelectPlugin(pluginConfig?: Partial<Config>): Plugin {
         currentlySelected[index].classList.remove("selected");
       }
 
-      const targetMonth = (fp.selectedDates[0] || new Date()).getMonth();
+      const targetMonth = fp.selectedDates[0].getMonth();
       const month = fp.rContainer.querySelector(
         `.flatpickr-monthSelect-month:nth-child(${targetMonth + 1})`
       );
@@ -146,9 +171,9 @@ function monthSelectPlugin(pluginConfig?: Partial<Config>): Plugin {
             (fp.config.minDate && month.dateObj < fp.config.minDate) ||
             (fp.config.maxDate && month.dateObj > fp.config.maxDate)
           ) {
-            month.classList.add("disabled");
+            month.classList.add("flatpickr-disabled");
           } else {
-            month.classList.remove("disabled");
+            month.classList.remove("flatpickr-disabled");
           }
         });
       }
@@ -158,21 +183,49 @@ function monthSelectPlugin(pluginConfig?: Partial<Config>): Plugin {
     function selectMonth(e: Event) {
       e.preventDefault();
       e.stopPropagation();
+
       const eventTarget = getEventTarget(e);
-      if (
-        eventTarget instanceof Element &&
-        !eventTarget.classList.contains("disabled")
-      ) {
-        setMonth((eventTarget as MonthElement).dateObj);
-        fp.close();
+
+      if (!(eventTarget instanceof Element)) return;
+      if (eventTarget.classList.contains("flatpickr-disabled")) return;
+      if (eventTarget.classList.contains("notAllowed")) return; // necessary??
+
+      setMonth((eventTarget as MonthElement).dateObj);
+
+      if (fp.config.closeOnSelect) {
+        const single = fp.config.mode === "single";
+        const range =
+          fp.config.mode === "range" && fp.selectedDates.length === 2;
+
+        if (single || range) fp.close();
       }
     }
 
     function setMonth(date: Date) {
-      const selectedDate = new Date(date);
-      selectedDate.setFullYear(fp.currentYear);
+      const selectedDate = new Date(
+        fp.currentYear,
+        date.getMonth(),
+        date.getDate()
+      );
+
+      switch (fp.config.mode) {
+        case "single":
+          fp.selectedDates = [selectedDate];
 
-      fp.setDate(selectedDate, true);
+          break;
+        case "range":
+          if (fp.selectedDates.length === 2) {
+            fp.clear(false, false);
+            buildMonths();
+          }
+          fp.selectedDates.push(selectedDate);
+          fp.selectedDates.sort((a, b) => a.getTime() - b.getTime());
+
+          break;
+      }
+
+      fp.latestSelectedDateObj = selectedDate;
+      fp.updateValue();
 
       setCurrentlySelected();
     }
@@ -220,6 +273,32 @@ function monthSelectPlugin(pluginConfig?: Partial<Config>): Plugin {
       }
     }
 
+    function closeHook() {
+      if (
+        fp.config?.mode === "range" &&
+        fp.selectedDates.length === 1
+      )
+        fp.clear(false);
+
+      if (!fp.selectedDates.length) buildMonths();
+    }
+
+    // Help the prev/next year nav honor config.minDate (see 3fa5a69)
+    function stubCurrentMonth() {
+      config._stubbedCurrentMonth = fp._initialDate.getMonth();
+
+      fp._initialDate.setMonth(0);
+      fp.currentMonth = 0;
+    }
+
+    function unstubCurrentMonth() {
+      if (!config._stubbedCurrentMonth) return;
+
+      fp._initialDate.setMonth(config._stubbedCurrentMonth);
+      fp.currentMonth = config._stubbedCurrentMonth;
+      delete config._stubbedCurrentMonth;
+    }
+
     function destroyPluginInstance() {
       if (self.monthsContainer !== null) {
         const months = self.monthsContainer.querySelectorAll(
@@ -234,24 +313,28 @@ function monthSelectPlugin(pluginConfig?: Partial<Config>): Plugin {
 
     return {
       onParseConfig() {
-        fp.config.mode = "single";
         fp.config.enableTime = false;
       },
       onValueUpdate: setCurrentlySelected,
       onKeyDown,
       onReady: [
-        () => {
-          fp.currentMonth = 0;
-        },
+        stubCurrentMonth,
         clearUnnecessaryDOMElements,
-        addListeners,
-        addMonths,
+        build,
+        bindEvents,
         setCurrentlySelected,
         () => {
+          fp.config.onClose.push(closeHook);
           fp.loadedPlugins.push("monthSelect");
         },
       ],
-      onDestroy: destroyPluginInstance,
+      onDestroy: [
+        unstubCurrentMonth,
+        destroyPluginInstance,
+        () => {
+          fp.config.onClose = fp.config.onClose.filter(hook => hook !== closeHook)
+        },
+      ],
     };
   };
 }
diff --git a/src/plugins/monthSelect/style.css b/src/plugins/monthSelect/style.css
index f49a9ece..5711cbfc 100644
--- a/src/plugins/monthSelect/style.css
+++ b/src/plugins/monthSelect/style.css
@@ -5,8 +5,8 @@
 
 .flatpickr-monthSelect-month {
   background: none;
-  border: 0;
-  border-radius: 2px;
+  border: 1px solid transparent;
+  border-radius: 4px;
   -webkit-box-sizing: border-box;
   box-sizing: border-box;
   color: #393939;
@@ -24,12 +24,12 @@
   width: 33%;
 }
 
-.flatpickr-monthSelect-month.disabled {
+.flatpickr-monthSelect-month.flatpickr-disabled {
 	color: #eee;
 }
 
-.flatpickr-monthSelect-month.disabled:hover,
-.flatpickr-monthSelect-month.disabled:focus {
+.flatpickr-monthSelect-month.flatpickr-disabled:hover,
+.flatpickr-monthSelect-month.flatpickr-disabled:focus {
 	cursor: not-allowed;
 	background: none !important;
 }
@@ -52,25 +52,63 @@
   color: rgba(255, 255, 255, 0.95);
 }
 
+.flatpickr-monthSelect-month.today {
+  border-color: #959ea9;
+}
+
+.flatpickr-monthSelect-month.inRange,
+.flatpickr-monthSelect-month.inRange.today,
 .flatpickr-monthSelect-month:hover,
 .flatpickr-monthSelect-month:focus {
   background: #e6e6e6;
   cursor: pointer;
   outline: 0;
+  border-color: #e6e6e6;
 }
 
+.flatpickr-monthSelect-theme-dark .flatpickr-monthSelect-month.inRange,
 .flatpickr-monthSelect-theme-dark .flatpickr-monthSelect-month:hover,
 .flatpickr-monthSelect-theme-dark .flatpickr-monthSelect-month:focus {
   background: #646c8c;
   border-color: #646c8c;
 }
 
-.flatpickr-monthSelect-month.selected {
+.flatpickr-monthSelect-month.today:hover,
+.flatpickr-monthSelect-month.today:focus {
+  background: #959ea9;
+  border-color: #959ea9;
+  color: #fff;
+}
+
+.flatpickr-monthSelect-month.selected,
+.flatpickr-monthSelect-month.startRange,
+.flatpickr-monthSelect-month.endRange {
   background-color: #569ff7;
+  box-shadow: none;
   color: #fff;
+  border-color: #569ff7;
+}
+
+.flatpickr-monthSelect-month.startRange {
+  border-radius: 50px 0 0 50px;
+}
+
+.flatpickr-monthSelect-month.endRange {
+  border-radius: 0 50px 50px 0;
+}
+
+.flatpickr-monthSelect-month.startRange.endRange {
+  border-radius: 50px;
+}
+
+.flatpickr-monthSelect-month.inRange {
+  border-radius: 0;
+  box-shadow: -5px 0 0 #e6e6e6, 5px 0 0 #e6e6e6;
 }
 
-.flatpickr-monthSelect-theme-dark .flatpickr-monthSelect-month.selected {
+.flatpickr-monthSelect-theme-dark .flatpickr-monthSelect-month.selected,
+.flatpickr-monthSelect-theme-dark .flatpickr-monthSelect-month.startRange,
+.flatpickr-monthSelect-theme-dark .flatpickr-monthSelect-month.endRange {
   background: #80cbc4;
   -webkit-box-shadow: none;
   box-shadow: none;
diff --git a/src/types/instance.ts b/src/types/instance.ts
index 73c609c8..d9d2c050 100644
--- a/src/types/instance.ts
+++ b/src/types/instance.ts
@@ -95,6 +95,8 @@ export type Instance = Elements &
     destroy: () => void;
     isEnabled: (date: DateOption, timeless?: boolean) => boolean;
     jumpToDate: (date?: DateOption, triggerChange?: boolean) => void;
+    updateValue: (triggerChange?: boolean) => void;
+    onMouseOver: (elem?: DayElement, cellClass?: string) => void;
     open: (e?: FocusEvent | MouseEvent, positionElement?: HTMLElement) => void;
     redraw: () => void;
     set: (
@@ -122,16 +124,23 @@ export type Instance = Elements &
       remove: () => void;
     }[];
 
-    _bind: <E extends Element>(
+    _bind: <E extends Element | Window | Document>(
       element: E | E[],
       event: string | string[],
-      handler: (e?: any) => void
+      handler: (e?: any) => void,
+      options?: { capture?: boolean; once?: boolean; passive?: boolean }
     ) => void;
     _createElement: <E extends HTMLElement>(
       tag: keyof HTMLElementTagNameMap,
       className: string,
       content?: string
     ) => E;
+    createDay: (
+      className: string,
+      date: Date,
+      dayNumber: number,
+      i: number
+    ) => DayElement;
     _setHoursFromDate: (date: Date) => void;
     _debouncedChange: () => void;
     __hideNextMonthArrow: boolean;


Test patch:
diff --git a/__tests__/src/plugins/monthSelect/index.spec.ts b/__tests__/src/plugins/monthSelect/index.spec.ts
index f9232cf6..e043d47d 100644
--- a/__tests__/src/plugins/monthSelect/index.spec.ts
+++ b/__tests__/src/plugins/monthSelect/index.spec.ts
@@ -8,118 +8,319 @@ flatpickr.defaultConfig.animate = false;
 
 jest.useFakeTimers();
 
-const createInstance = (config?: Options): Instance => {
-  return flatpickr(
-    document.createElement("input"),
-    config as Options
-  ) as Instance;
-};
-
 describe("monthSelect", () => {
-  it("should correctly preload defaultDate", () => {
-    const fp = createInstance({
-      defaultDate: new Date("2019-04-20"),
-      plugins: [monthSelectPlugin({})],
-    }) as Instance;
+  // memoized instance
+  const fp = (): Instance => {
+    return (fpInstance =
+      fpInstance ||
+      flatpickr(document.createElement("input"), {
+        plugins: [monthSelectPlugin({})],
+        ...options,
+      }));
+  };
 
-    expect(fp.input.value).toEqual("April 2019");
-  });
+  let fpInstance: Instance | undefined;
+  let options: Options;
 
-  it("should correctly preload defaultDate with locale", () => {
-    const fp = createInstance({
-      defaultDate: new Date("2019-03-20"),
-      locale: German,
-      plugins: [monthSelectPlugin({})],
-    }) as Instance;
+  const getPrevButton = (): Element => {
+    return fp().monthNav.querySelector(".flatpickr-prev-month")!;
+  };
 
-    expect(fp.input.value).toEqual("März 2019");
+  const getNextButton = (): Element => {
+    return fp().monthNav.querySelector(".flatpickr-next-month")!;
+  };
+
+  beforeEach(() => {
+    fpInstance = undefined;
+    options = {};
   });
 
-  it("should correctly preload defaultDate with format", () => {
-    const fp = createInstance({
-      defaultDate: new Date("2019-03-20"),
-      locale: German,
-      plugins: [monthSelectPlugin({ dateFormat: "m.y" })],
-    }) as Instance;
+  describe("with explicit defaultDate", () => {
+    beforeEach(() => {
+      options = { defaultDate: new Date("2019-03-20") };
+    });
 
-    expect(fp.input.value).toEqual("03.19");
-  });
+    it("preloads defaultDate", () => {
+      expect(fp().input.value).toEqual("March 2019");
+    });
 
-  it("should correctly preload defaultDate and altInput with format", () => {
-    const fp = createInstance({
-      defaultDate: new Date("2019-03-20"),
-      altInput: true,
-      plugins: [monthSelectPlugin({ dateFormat: "m.y", altFormat: "m y" })],
-    }) as Instance;
+    describe("and locale", () => {
+      beforeEach(() => {
+        options = {
+          defaultDate: new Date("2019-03-20"),
+          locale: German,
+        };
+      });
 
-    expect(fp.input.value).toEqual("03.19");
+      it("preloads defaultDate", () => {
+        expect(fp().input.value).toEqual("März 2019");
+      });
 
-    expect(fp.altInput).toBeDefined();
-    if (!fp.altInput) return;
+      describe("and custom date format", () => {
+        beforeEach(() => {
+          options = {
+            defaultDate: new Date("2019-03-20"),
+            locale: German,
+            plugins: [monthSelectPlugin({ dateFormat: "m.y" })],
+          };
+        });
 
-    expect(fp.altInput.value).toEqual("03 19");
+        it("preloads defaultDate", () => {
+          expect(fp().input.value).toEqual("03.19");
+        });
+      });
+    });
+
+    describe("and altInput with custom formats", () => {
+      beforeEach(() => {
+        options = {
+          defaultDate: new Date("2019-03-20"),
+          altInput: true,
+          plugins: [monthSelectPlugin({ dateFormat: "m.y", altFormat: "m y" })],
+        };
+      });
+
+      it("preloads defaultDate and altInput", () => {
+        expect(fp().input.value).toEqual("03.19");
+        expect(fp().altInput).toBeDefined();
+        expect(fp().altInput!.value).toEqual("03 19");
+      });
+    });
   });
 
   describe("year nav", () => {
     describe("next/prev year buttons", () => {
-      it("should increment/decrement year when clicked (#2275)", () => {
-        const initYear = 2020;
+      const thisYear = new Date().getFullYear();
 
-        const fp = createInstance({
-          plugins: [monthSelectPlugin()],
-          defaultDate: new Date(`${initYear}-03-20`),
-        }) as Instance;
+      it("updates year value shown to user #2277", () => {
+        getPrevButton().dispatchEvent(new MouseEvent("click"));
+        expect(fp().currentYearElement.value).toEqual(`${thisYear - 1}`);
 
-        const prevButton = fp.monthNav.querySelector(".flatpickr-prev-month")!;
-        prevButton.dispatchEvent(new MouseEvent("click"));
+        getNextButton().dispatchEvent(new MouseEvent("click"));
+        getNextButton().dispatchEvent(new MouseEvent("click"));
+        expect(fp().currentYearElement.value).toEqual(`${thisYear + 1}`);
+      });
 
-        expect(fp.currentYear).toEqual(initYear - 1);
+      describe("when current month is not Jan/Dec (#2275)", () => {
+        beforeEach(() => {
+          options = { defaultDate: new Date(`${thisYear}-03-20`) };
+        });
 
-        const nextButton = fp.monthNav.querySelector(".flatpickr-next-month")!;
-        nextButton.dispatchEvent(new MouseEvent("click"));
+        it("increments/decrements .currentYear property", () => {
+          getPrevButton().dispatchEvent(new MouseEvent("click"));
+          expect(fp().currentYear).toEqual(thisYear - 1);
 
-        expect(fp.currentYear).toEqual(initYear);
+          getNextButton().dispatchEvent(new MouseEvent("click"));
+          expect(fp().currentYear).toEqual(thisYear);
+        });
       });
 
-      it("should update displayed year when clicked (#2277)", () => {
-        const initYear = new Date().getFullYear();
+      describe("with minDate/maxDate options (#2279)", () => {
+        beforeEach(() => {
+          options = {
+            minDate: `${thisYear - 1}-03-20`,
+            maxDate: `${thisYear + 1}-03-20`,
+          };
+        });
+
+        it("prohibits paging beyond them", () => {
+          getPrevButton().dispatchEvent(new MouseEvent("click"));
+          expect(getPrevButton().classList).toContain("flatpickr-disabled");
+
+          getNextButton().dispatchEvent(new MouseEvent("click"));
+          getNextButton().dispatchEvent(new MouseEvent("click"));
+          expect(getNextButton().classList).toContain("flatpickr-disabled");
+        });
+      });
 
-        const fp = createInstance({
-          plugins: [monthSelectPlugin()],
-        }) as Instance;
+      describe("when in range mode, after abandoning input", () => {
+        beforeEach(() => {
+          options = {
+            mode: "range",
+            minDate: `${thisYear - 1}-03-20`,
+          };
 
-        const prevButton = fp.monthNav.querySelector(".flatpickr-prev-month")!;
-        prevButton.dispatchEvent(new MouseEvent("click"));
+          fp().input.dispatchEvent(new MouseEvent("click")); // open flatpickr
 
-        expect(fp.currentYearElement.value).toEqual(`${initYear - 1}`);
+          fp()
+            .rContainer!.querySelectorAll(".flatpickr-monthSelect-month")![1]
+            .dispatchEvent(new MouseEvent("click")); // pick start date
 
-        const nextButton = fp.monthNav.querySelector(".flatpickr-next-month")!;
-        nextButton.dispatchEvent(new MouseEvent("click"));
-        nextButton.dispatchEvent(new MouseEvent("click"));
+          document.dispatchEvent(new MouseEvent("click")); // abandon input
+        });
 
-        expect(fp.currentYearElement.value).toEqual(`${initYear + 1}`);
+        it("still honors minDate options", () => {
+          getPrevButton().dispatchEvent(new MouseEvent("click"));
+          expect(getPrevButton().classList).toContain("flatpickr-disabled");
+        });
       });
+    });
+  });
+
+  describe("month cell styling", () => {
+    describe("for current month of current year ('today' cell)", () => {
+      const getTodayCell = (): Element | null | undefined =>
+        fp().rContainer?.querySelector(".today");
+      const currentMonth = fp().l10n.months.longhand[new Date().getMonth()];
+
+      it("applies .today CSS class", () => {
+        expect(getTodayCell()?.textContent).toEqual(currentMonth);
+
+        getPrevButton().dispatchEvent(new MouseEvent("click"));
+        expect(getTodayCell()).toBeNull();
+
+        getNextButton().dispatchEvent(new MouseEvent("click"));
+        expect(getTodayCell()?.textContent).toEqual(currentMonth);
+      });
+    });
+
+    describe("for selected cells", () => {
+      const getSelectedCell = (): Element | null | undefined =>
+        fp().rContainer?.querySelector(".selected");
+
+      const getSelectionTarget = (): Element | null | undefined =>
+        fp().rContainer!.querySelector(
+          ".flatpickr-monthSelect-month:nth-child(6)"
+        )!;
+
+      it("applies .selected CSS class", () => {
+        expect(getSelectedCell()).toBeNull();
 
-      it("should honor minDate / maxDate options (#2279)", () => {
-        const lastYear = new Date().getFullYear() - 1;
-        const nextYear = new Date().getFullYear() + 1;
+        getSelectionTarget()?.dispatchEvent(new MouseEvent("click"));
+        expect(getSelectedCell()?.textContent).toEqual("June");
 
-        const fp = createInstance({
-          plugins: [monthSelectPlugin()],
-          minDate: `${lastYear}-03-20`,
-          maxDate: `${nextYear}-03-20`,
-        }) as Instance;
+        getPrevButton().dispatchEvent(new MouseEvent("click"));
+        expect(getSelectedCell()).toBeNull();
+
+        getNextButton().dispatchEvent(new MouseEvent("click"));
+        expect(getSelectedCell()?.textContent).toEqual("June");
+      });
+    });
+  });
+
+  describe("range mode", () => {
+    const getMonthCells = (): NodeListOf<Element> => {
+      return fp().rContainer!.querySelectorAll(".flatpickr-monthSelect-month")!;
+    };
+
+    describe("after first selection/click", () => {
+      beforeEach(() => {
+        options = { mode: "range" };
+
+        fp().input.dispatchEvent(new MouseEvent("click")); // open flatpickr
+        getMonthCells()[1].dispatchEvent(new MouseEvent("click"));
+      });
+
+      it("keeps calendar open until second selection/click", () => {
+        expect(fp().calendarContainer.classList).toContain("open");
+
+        getMonthCells()[5].dispatchEvent(new MouseEvent("click"));
+        expect(fp().calendarContainer.classList).not.toContain("open");
+      });
+
+      describe("when hovering over other another month cell", () => {
+        beforeEach(() => {
+          getMonthCells()[5].dispatchEvent(
+            new MouseEvent("mouseover", { bubbles: true })
+          );
+        });
+
+        it("highlights all cells in the tentative range", () => {
+          expect(getMonthCells()[1].classList).toContain("startRange");
+
+          Array.from(getMonthCells())
+            .slice(2, 5)
+            .forEach((cell) => {
+              expect(cell.classList).toContain("inRange");
+            });
+
+          expect(getMonthCells()[5].classList).toContain("endRange");
+        });
+
+        describe("and then prematurely abandoning input", () => {
+          describe("by clicking out", () => {
+            beforeEach(() => {
+              document.dispatchEvent(new MouseEvent("mousedown")); // close flatpickr
+            });
+
+            it("clears the highlighting", () => {
+              getMonthCells().forEach((cell) => {
+                expect(cell.classList).not.toContain("startRange");
+                expect(cell.classList).not.toContain("inRange");
+                expect(cell.classList).not.toContain("endRange");
+              });
+            });
+          });
+
+          describe("by alt-tabbing out and back in", () => {
+            beforeEach(() => {
+              window.document.dispatchEvent(new FocusEvent("blur"));
+              window.document.dispatchEvent(new FocusEvent("focus"));
+            });
+
+            it("clears the highlighting", () => {
+              getMonthCells().forEach((cell) => {
+                expect(cell.classList).not.toContain("startRange");
+                expect(cell.classList).not.toContain("inRange");
+                expect(cell.classList).not.toContain("endRange");
+              });
+            });
+          });
+        });
+      });
+
+      describe("when hovering over another month cell in a different year", () => {
+        beforeEach(() => {
+          getNextButton().dispatchEvent(new MouseEvent("click"));
+
+          getMonthCells()[5].dispatchEvent(
+            new MouseEvent("mouseover", { bubbles: true })
+          );
+        });
+
+        it("highlights all visible cells in the tentative range", () => {
+          Array.from(getMonthCells())
+            .slice(0, 5)
+            .forEach((cell) => {
+              expect(cell.classList).toContain("inRange");
+            });
+
+          expect(getMonthCells()[5].classList).toContain("endRange");
+        });
+      });
+    });
+
+    describe("after two clicks (completed range selection)", () => {
+      beforeEach(() => {
+        options = { mode: "range" };
+
+        fp().input.dispatchEvent(new MouseEvent("click")); // open flatpickr
+        getMonthCells()[1].dispatchEvent(new MouseEvent("click"));
+        getMonthCells()[5].dispatchEvent(new MouseEvent("click"));
+      });
 
-        const prevButton = fp.monthNav.querySelector(".flatpickr-prev-month")!;
-        prevButton.dispatchEvent(new MouseEvent("click"));
+      describe("when clicking again to start over", () => {
+        beforeEach(() => {
+          fp().input.dispatchEvent(new MouseEvent("click")); // reopen flatpickr
 
-        expect(prevButton.classList).toContain("flatpickr-disabled");
+          getMonthCells()[3].dispatchEvent(new MouseEvent("click"));
+          getMonthCells()[3].dispatchEvent(
+            new MouseEvent("mouseover", { bubbles: true })
+          ); // class changes seem to be triggered by hover, not click...
+        });
 
-        const nextButton = fp.monthNav.querySelector(".flatpickr-next-month")!;
-        nextButton.dispatchEvent(new MouseEvent("click"));
-        nextButton.dispatchEvent(new MouseEvent("click"));
+        it("clears the highlighting", () => {
+          expect(getMonthCells()[3].classList).toContain("startRange");
 
-        expect(nextButton.classList).toContain("flatpickr-disabled");
+          [
+            ...Array.from(getMonthCells()).slice(0, 3),
+            ...Array.from(getMonthCells()).slice(4),
+          ].forEach((cell) => {
+            expect(cell.classList).not.toContain("startRange");
+            expect(cell.classList).not.toContain("inRange");
+            expect(cell.classList).not.toContain("endRange");
+          });
+        });
       });
     });
   });