Repository: flatpickr/flatpickr
Pull request #1699 (2019-03-15 20:52:01)

Months only picker
Uploading code for review before proceeding

Can be instantiated with:

```javascript
       flatpickr(".date", {
          altInput: true,
          defaultDate: new Date('10 february 2019'),
          plugins: [new monthSelect({shorthand: false, dateFormat: "m.y", altFormat: "F Y"})]
        });
```

Features:

- [x] Show months as shorthand or normal name
- [x] Supports locale
- [x] Supports altInput
- [x] Format can be defined both for normal and altInput (format needs to be defined in plugin instantiation and it overwrites flatpickr format definitions) - when no format is defined, it defaults to `F Y`
- [x] Supports themes (needs to be implemented in another commit)
- [ ] Works with confirmDate plugin (works partially, `showAlways: false` currently not working, needs to be implemented in another commit)
- [x] Accessibility 
- [x] Implement `onDestroy`


Patch:
diff --git a/build.ts b/build.ts
index 0aea35d6..6b692f15 100644
--- a/build.ts
+++ b/build.ts
@@ -92,11 +92,10 @@ function buildExtras(folder: "plugins" | "l10n") {
             resolveGlob(`./src/${folder}/**/*.css`),
           ]
     );
-    console.log(src_paths, css_paths);
 
     try {
       await Promise.all([
-        ...src_paths.map(async sourcePath => {
+        ...src_paths.filter(p => !p.includes(".spec.ts")).map(async sourcePath => {
           const bundle = await rollup.rollup({
             ...rollupConfig,
             cache: undefined,
@@ -104,13 +103,17 @@ function buildExtras(folder: "plugins" | "l10n") {
           });
 
           const fileName = path.basename(sourcePath, path.extname(sourcePath));
+          const folderName = path.basename(path.dirname(sourcePath));
 
           return bundle.write({
             exports: folder === "l10n" ? "named" : "default",
             format: "umd",
             sourcemap: false,
             file: sourcePath.replace("src", "dist").replace(".ts", ".js"),
-            name: customModuleNames[fileName] || fileName,
+            name:
+              sourcePath.includes("plugins") && fileName === "index"
+                ? `${folderName}Plugin`
+                : customModuleNames[fileName] || fileName,
           });
         }),
         ...(css_paths.map(p => fs.copy(p, p.replace("src", "dist"))) as any),
diff --git a/config/jest.json b/config/jest.json
index b14f46e3..f4b0f18d 100644
--- a/config/jest.json
+++ b/config/jest.json
@@ -1,10 +1,7 @@
 {
   "preset": "ts-jest",
   "rootDir": "../",
-  "moduleNameMapper": {
-    "^plugins/(.*)$": "<rootDir>/src/plugins/$1",
-    "^index$": "<rootDir>/src/index.ts"
-  },
+  "moduleDirectories": ["node_modules", "src"],
   "globals": {
     "ts-jest": {
       "isolatedModules": true
diff --git a/src/index.ts b/src/index.ts
index 21875936..8b2cb467 100644
--- a/src/index.ts
+++ b/src/index.ts
@@ -426,8 +426,6 @@ function FlatpickrInstance(
 
     bind(window.document.body, "keydown", onKeyDown);
 
-    if (!self.config.static) bind(self._input, "keydown", onKeyDown);
-
     if (!self.config.inline && !self.config.static)
       bind(window, "resize", debouncedResize);
 
@@ -1501,7 +1499,9 @@ function FlatpickrInstance(
             : self.config.dateFormat
         );
         return (e.target as HTMLElement).blur();
-      } else self.open();
+      } else {
+        self.open();
+      }
     } else if (
       isCalendarElem(e.target as HTMLElement) ||
       allowKeydown ||
@@ -1595,7 +1595,15 @@ function FlatpickrInstance(
               e.preventDefault();
               (target || self._input).focus();
             }
+          } else if (
+            !self.config.noCalendar &&
+            self.daysContainer!.contains(e.target as Node) &&
+            e.shiftKey
+          ) {
+            e.preventDefault();
+            self._input.focus();
           }
+
           break;
 
         default:
@@ -1622,7 +1630,10 @@ function FlatpickrInstance(
           break;
       }
     }
-    triggerEvent("onKeyDown", e);
+
+    if (isInput || isCalendarElem(e.target as HTMLElement)) {
+      triggerEvent("onKeyDown", e);
+    }
   }
 
   function onMouseOver(elem?: DayElement) {
diff --git a/src/plugins/monthSelect/index.ts b/src/plugins/monthSelect/index.ts
new file mode 100644
index 00000000..caa7931f
--- /dev/null
+++ b/src/plugins/monthSelect/index.ts
@@ -0,0 +1,195 @@
+import { Plugin } from "../../types/options";
+import { DayElement as MonthElement, Instance } from "../../types/instance";
+import { monthToStr } from "../../utils/formatting";
+
+export interface Config {
+  shorthand: boolean;
+  dateFormat: string;
+  altFormat: string;
+  theme: string;
+}
+
+export type MonthElement = HTMLSpanElement & { dateObj: Date; $i: number };
+
+const defaultConfig: Config = {
+  shorthand: false,
+  dateFormat: "F Y",
+  altFormat: "F Y",
+  theme: "light",
+};
+
+function monthSelectPlugin(pluginConfig?: Partial<Config>): Plugin {
+  const config = { ...defaultConfig, ...pluginConfig };
+
+  return (fp: Instance) => {
+    fp.config.dateFormat = config.dateFormat;
+    fp.config.altFormat = config.altFormat;
+    const self = { monthsContainer: null as null | HTMLDivElement };
+
+    function clearUnnecessaryDOMElements(): void {
+      if (!fp.rContainer || !fp.daysContainer || !fp.weekdayContainer) return;
+
+      fp.rContainer.removeChild(fp.daysContainer);
+      fp.rContainer.removeChild(fp.weekdayContainer);
+
+      fp.monthElements.forEach(element => {
+        if (!element.parentNode) return;
+
+        element.parentNode.removeChild(element);
+      });
+    }
+
+    function addListeners() {
+      fp._bind(fp.prevMonthNav, "click", () => {
+        fp.currentYear -= 1;
+        selectYear();
+      });
+
+      fp._bind(fp.nextMonthNav, "mousedown", () => {
+        fp.currentYear += 1;
+        selectYear();
+      });
+    }
+
+    function addMonths() {
+      if (!fp.rContainer) return;
+
+      self.monthsContainer = fp._createElement<HTMLDivElement>(
+        "div",
+        "flatpickr-monthSelect-months"
+      );
+
+      self.monthsContainer.tabIndex = -1;
+
+      self.monthsContainer.classList.add(
+        `flatpickr-monthSelect-theme-${config.theme}`
+      );
+
+      for (let i = 0; i < 12; i++) {
+        const month = fp._createElement<MonthElement>(
+          "span",
+          "flatpickr-monthSelect-month"
+        );
+        month.dateObj = new Date(fp.currentYear, i);
+        month.$i = i;
+        month.textContent = monthToStr(i, config.shorthand === true, fp.l10n);
+        month.tabIndex = -1;
+        month.addEventListener("click", selectMonth);
+        self.monthsContainer.appendChild(month);
+      }
+
+      fp.rContainer.appendChild(self.monthsContainer);
+    }
+
+    function setCurrentlySelected() {
+      if (!fp.rContainer) return;
+
+      const currentlySelected = fp.rContainer.querySelectorAll(
+        ".flatpickr-monthSelect-month.selected"
+      );
+
+      currentlySelected.forEach(month => {
+        month.classList.remove("selected");
+      });
+
+      const month = fp.rContainer.querySelector(
+        `.flatpickr-monthSelect-month:nth-child(${fp.currentMonth + 1})`
+      );
+
+      if (month) {
+        month.classList.add("selected");
+      }
+    }
+
+    function selectYear() {
+      let selectedDate = fp.selectedDates[0];
+      selectedDate.setFullYear(fp.currentYear);
+
+      fp.setDate(selectedDate, true);
+    }
+
+    function selectMonth(e: Event) {
+      e.preventDefault();
+      e.stopPropagation();
+
+      setMonth((e.target as MonthElement).dateObj);
+    }
+
+    function setMonth(date: Date) {
+      const selectedDate = new Date(date);
+      selectedDate.setFullYear(fp.currentYear);
+      fp.currentMonth = selectedDate.getMonth();
+
+      fp.setDate(selectedDate, true);
+
+      setCurrentlySelected();
+    }
+
+    const shifts: Record<number, number> = {
+      37: -1,
+      39: 1,
+      40: 3,
+      38: -3,
+    };
+
+    function onKeyDown(_: any, __: any, ___: any, e: KeyboardEvent) {
+      const shouldMove = shifts[e.keyCode] !== undefined;
+      if (!shouldMove && e.keyCode !== 13) {
+        return;
+      }
+
+      const currentlySelected = fp.rContainer!.querySelector(
+        ".flatpickr-monthSelect-month.selected"
+      ) as HTMLElement;
+
+      let index = Array.prototype.indexOf.call(
+        self.monthsContainer!.children,
+        document.activeElement
+      );
+
+      if (index === -1) {
+        const target =
+          currentlySelected || self.monthsContainer!.firstElementChild;
+        target.focus();
+        index = (target as MonthElement).$i;
+      }
+
+      if (shouldMove) {
+        (self.monthsContainer!.children[
+          (12 + index + shifts[e.keyCode]) % 12
+        ] as HTMLElement).focus();
+      } else if (
+        e.keyCode === 13 &&
+        self.monthsContainer!.contains(document.activeElement)
+      ) {
+        setMonth((document.activeElement as MonthElement).dateObj);
+      }
+    }
+
+    function destroyPluginInstance() {
+      self
+        .monthsContainer!.querySelectorAll(".flatpickr-monthSelect-month")
+        .forEach(element => {
+          element.removeEventListener("click", selectMonth);
+        });
+    }
+
+    return {
+      onParseConfig() {
+        fp.config.mode = "single";
+        fp.config.enableTime = false;
+      },
+      onValueUpdate: setCurrentlySelected,
+      onKeyDown,
+      onReady: [
+        clearUnnecessaryDOMElements,
+        addListeners,
+        addMonths,
+        setCurrentlySelected,
+      ],
+      onDestroy: destroyPluginInstance,
+    };
+  };
+}
+
+export default monthSelectPlugin;
diff --git a/src/plugins/monthSelect/monthSelect.css b/src/plugins/monthSelect/monthSelect.css
deleted file mode 100644
index 1bbba935..00000000
--- a/src/plugins/monthSelect/monthSelect.css
+++ /dev/null
@@ -1,10 +0,0 @@
-.flatpickr-day.month.selected {
-  border-radius: 0;
-  box-shadow: -5px 0 0 #569ff7, 5px 0 0 #569ff7
-}
-.flatpickr-day.month.selected.startRange {
-  border-radius: 50px 0 0 50px;
-}
-.flatpickr-day.month.selected.endRange {
-  border-radius: 0 50px 50px 0;
-}
\ No newline at end of file
diff --git a/src/plugins/monthSelect/monthSelect.ts b/src/plugins/monthSelect/monthSelect.ts
deleted file mode 100644
index e60b0550..00000000
--- a/src/plugins/monthSelect/monthSelect.ts
+++ /dev/null
@@ -1,91 +0,0 @@
-import { Plugin } from "../../types/options";
-import { DayElement } from "../../types/instance";
-
-export interface ExtraFields {
-  monthStartDay: number;
-  monthEndDay: number;
-}
-
-function monthSelectPlugin(): Plugin<ExtraFields> {
-  return function(fp) {
-    var days: NodeListOf<DayElement>;
-
-    function onDayHover(event: MouseEvent) {
-      if (
-        !event.target ||
-        !(event.target as Element).classList.contains("flatpickr-day")
-      )
-        return;
-
-      const dayIndex = Array.prototype.indexOf.call(days, event.target);
-      fp.monthStartDay = new Date(
-        days[dayIndex].dateObj.getFullYear(),
-        days[dayIndex].dateObj.getMonth(),
-        1,
-        0,
-        0,
-        0,
-        0
-      ).getTime();
-      fp.monthEndDay = new Date(
-        days[dayIndex].dateObj.getFullYear(),
-        days[dayIndex].dateObj.getMonth() + 1,
-        0,
-        0,
-        0,
-        0,
-        0
-      ).getTime();
-      //console.log(days[dayIndex].dateObj.toString());
-      for (var i = days.length; i--; ) {
-        var date = days[i].dateObj.getTime();
-        if (date > fp.monthEndDay || date < fp.monthStartDay)
-          days[i].classList.remove("inRange");
-        else days[i].classList.add("inRange");
-        if (date != fp.monthEndDay) days[i].classList.remove("endRange");
-        else days[i].classList.add("endRange");
-        if (date != fp.monthStartDay) days[i].classList.remove("startRange");
-        else days[i].classList.add("startRange");
-      }
-    }
-
-    function highlightMonth() {
-      for (var i = days.length; i--; ) {
-        var date = days[i].dateObj.getTime();
-        if (date >= fp.monthStartDay && date <= fp.monthEndDay)
-          days[i].classList.add("month", "selected");
-        if (date != fp.monthEndDay) days[i].classList.remove("endRange");
-        else days[i].classList.add("endRange");
-        if (date != fp.monthStartDay) days[i].classList.remove("startRange");
-        else days[i].classList.add("startRange");
-      }
-    }
-
-    function clearHover() {
-      for (var i = days.length; i--; ) {
-        days[i].classList.remove("inRange");
-      }
-    }
-
-    return {
-      onChange: highlightMonth,
-      onMonthChange: highlightMonth,
-      onClose: clearHover,
-      onParseConfig: function onParseConfig() {
-        fp.config.mode = "single";
-        fp.config.enableTime = false;
-      },
-      onReady: [
-        function() {
-          days = fp.days.childNodes as NodeListOf<DayElement>;
-        },
-        function() {
-          return fp.days.addEventListener("mouseover", onDayHover);
-        },
-        highlightMonth,
-      ],
-    };
-  };
-}
-
-export default monthSelectPlugin;
diff --git a/src/plugins/monthSelect/style.css b/src/plugins/monthSelect/style.css
new file mode 100644
index 00000000..5b77503e
--- /dev/null
+++ b/src/plugins/monthSelect/style.css
@@ -0,0 +1,55 @@
+.flatpickr-monthSelect-months {
+  margin: 10px 1px 3px 1px;
+  flex-wrap: wrap;
+}
+
+.flatpickr-monthSelect-month {
+  background: none;
+  border: 0;
+  border-radius: 2px;
+  -webkit-box-sizing: border-box;
+  box-sizing: border-box;
+  color: #393939;
+  cursor: pointer;
+  display: inline-block;
+  font-weight: 400;
+  margin: 0.5px;
+  justify-content: center;
+  padding: 10px;
+  position: relative;
+  -webkit-box-pack: center;
+  -webkit-justify-content: center;
+  -ms-flex-pack: center;
+  text-align: center;
+  width: 33%;
+}
+
+.flatpickr-monthSelect-theme-dark .flatpickr-monthSelect-month {
+  color: rgba(255, 255, 255, 0.95);
+}
+
+.flatpickr-monthSelect-month:hover,
+.flatpickr-monthSelect-month:focus {
+  background: #e6e6e6;
+  cursor: pointer;
+  outline: 0;
+}
+
+.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 {
+  background-color: #569ff7;
+  color: #fff;
+}
+
+.flatpickr-monthSelect-theme-dark .flatpickr-monthSelect-month.selected {
+  background: #80cbc4;
+  -webkit-box-shadow: none;
+  box-shadow: none;
+  color: #fff;
+  border-color: #80cbc4;
+}


Test patch:
diff --git a/src/plugins/monthSelect/tests.spec.ts b/src/plugins/monthSelect/tests.spec.ts
new file mode 100644
index 00000000..6289a532
--- /dev/null
+++ b/src/plugins/monthSelect/tests.spec.ts
@@ -0,0 +1,58 @@
+import flatpickr from "../../index";
+import monthSelectPlugin from "./index";
+import { German } from "l10n/de";
+import { Instance } from "types/instance";
+import { Options } from "types/options";
+
+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;
+
+    expect(fp.input.value).toEqual("April 2019");
+  });
+
+  it("should correctly preload defaultDate with locale", () => {
+    const fp = createInstance({
+      defaultDate: new Date("2019-03-20"),
+      locale: German,
+      plugins: [monthSelectPlugin({})],
+    }) as Instance;
+
+    expect(fp.input.value).toEqual("März 2019");
+  });
+
+  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;
+
+    expect(fp.input.value).toEqual("03.19");
+  });
+
+  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;
+
+    expect(fp.input.value).toEqual("03.19");
+    expect(fp.altInput!.value).toEqual("03 19");
+  });
+});