[TypeScript][Moment] Create month picker

Intro

I like Pikaday. This is because it is framework independent and great browser compatibility (includes IE7!).

But it can only select the date. And this time i want to choose the month.

For Chrome and Edge or some other browsers, I can use "input type="month"".

But because it can't be used in all modern browsers, and I don't like its selecting years design, I don't want to use it.

Because I only could find month pickers for jQuery, React, and etc, I will try creating it.

In this sample, I don't care about old browsers.
If you want compatibility for them, you can use Autoprefixer or stop using Flexbox and change to tables for layout.

Environments

  • Node.js ver.16.3.0
  • TypeScript ver.4.3.4
  • Moment ver.2.29.1
  • ts-loader ver.9.2.3
  • Webpack ver.5.42.0
  • webpack-cli ver.4.7.2

Result

Layout

I want to put the window under the target input element.
So I use "getBoundingClientRect" to get the target rect.

monthPicker.ts

export class MonthPicker {
    private inputTarget: HTMLInputElement|null = null;
    private pickerArea: HTMLElement;

    public constructor(target: HTMLInputElement) {
        this.pickerArea = document.createElement('div');
        if(target == null) {
            console.error('target was null');
            return;
        }
        this.inputTarget = target;
        const parentElement = (target.parentElement == null)? document.body: target.parentElement;
        this.addMonthPickerArea(parentElement, target.getBoundingClientRect());
    }
    private addMonthPickerArea(parent: HTMLElement, targetRect: DOMRect) {
        this.pickerArea.className = 'monthpicker_area';
        this.pickerArea.style.position = 'absolute';
        this.pickerArea.style.left = `${targetRect.left}px`;
        this.pickerArea.style.top = `${targetRect.top + targetRect.height}px`;
        this.pickerArea.hidden = true;
        parent.appendChild(this.pickerArea);
    }
}

After creating root element of month picker("pickerArea"), I just add elements in it.

Show|Hide

When I click the target input element, month picker area will be shown.
After that, if I click other place, it will be hidden.

When I click the area after showing, the click events wll be fired like this order.

  1. The month picker area click event is fired
  2. The document.body click event is fired

So I use "setTimeout" on 1. and ignore 2. when I click the month picker area.

monthPicker.ts

import { MonthPickerOption } from './monthPicker.type';

export class MonthPicker {
...
    public constructor(target: HTMLInputElement, option?: MonthPickerOption) {
...        
        target.addEventListener('click', _ => this.show());
        window.addEventListener('click', _ => this.hide());
    }
    private addMonthPickerArea(parent: HTMLElement, targetRect: DOMRect) {
...
        this.pickerArea.onclick = () => this.setIgnoreHiding();
        parent.appendChild(this.pickerArea);
    }
...
    private setIgnoreHiding() {
        if(this.ignoreHiding === true) {
            return;
        }
        this.ignoreHiding = true;
        setTimeout((_) => this.ignoreHiding = false, 500);
    }
    private show() {
        this.selectSelectedMonth();
        this.setIgnoreHiding();
        this.pickerArea.hidden = false;
    }
...
    private hide() {
        if(this.ignoreHiding === true) {
            return;
        }
        this.pickerArea.hidden = true;
    }
...
}

Get selected month

Because if I use "Date" type to set the target input eleent text, the value will be like below.

Mon Jul 05 2021

Although I case use fixed format like "${date.getFullYear()} ${date.getMonth()}", I decided using "Moment".

monthPicker.type.ts

export type MonthPickerOption = {
    months?: string[],
    outputFormat?: string,
}

monthPicker.ts

import moment from 'moment';
import { MonthPickerOption } from './monthPicker.type';

export class MonthPicker {
    public constructor(target: HTMLInputElement, option?: MonthPickerOption) {
...
        this.addMonthPickerArea(parentElement, target.getBoundingClientRect());
        this.addMonthPickerFrame(this.pickerArea, (option)? option: null);
...
    }
...
    private addMonthArea(parent: HTMLElement, option: MonthPickerOption|null) {
        const pickerMonthArea = document.createElement('div');
        pickerMonthArea.className = 'monthpicker_month_area';
        parent.appendChild(pickerMonthArea);

        const monthRow1 = document.createElement('div');
        monthRow1.className = 'monthpicker_month_row';
        pickerMonthArea.appendChild(monthRow1);

        const monthRow2 = document.createElement('div');
        monthRow2.className = 'monthpicker_month_row';
        pickerMonthArea.appendChild(monthRow2);

        const format = (option?.outputFormat)? option.outputFormat: null;
        const defaultMonths = ['1', '2', '3', '4', '5', '6', '7', '8', '9', '10', '11', '12'];
        const months = (option?.months)? option.months: defaultMonths;

        for(let i = 0; i < defaultMonths.length; i++) {
            const pickerMonth = document.createElement('button');
            const month = (months.length > i)? months[i]: defaultMonths[i];
            pickerMonth.textContent = month;
            pickerMonth.className = 'monthpicker_month_input';
            pickerMonth.onclick = (ev) => this.setSelectedDate(ev, month, format);
            if(this.pickerMonths.length < 6) {
                monthRow1.appendChild(pickerMonth);
            } else {
                monthRow2.appendChild(pickerMonth);
            }
            this.pickerMonths.push(pickerMonth);
        }
    }
...
    private setSelectedDate(ev: MouseEvent, month: string, format: string|null) {
        if(this.inputTarget == null) {
            return;
        }
        const selectedDate = new Date(`${this.currentYear} ${month}`);        
        this.inputTarget.value = moment(selectedDate).format((format == null)? 'YYYY-MM': format);
        this.hide();
    }
...

Full code

index.html

<!DOCTYPE html>
<html lang="en">
    <head>
        <title>Month picker sample</title>
        <meta charset="utf-8">
        <link rel="stylesheet" href="../css/month_picker.css" />
    </head>
    <body>
        <div class="input_area">
            <input type="text" id="month_picker_target">        
        </div>
        <input type="month">
        <script src="js/main.page.js"></script>
    </body>
</html>

month_picker.css

.monthpicker_frame {
    border: 1px solid black;
    display: flex;
    flex-direction: column;
    align-items: center;
    justify-content: space-around;
    z-index: 9999;
    box-shadow: 0 5px 15px -5px rgba(0,0,0,.5);

    width: 400px;
    height: 160px;
    background-color: white;
}
.monthpicker_year_area {
    display: flex;
    flex-direction: row;
    align-items: center;
    justify-content: space-between;

    width: 90%;
    height: 20%;
}
.monthpicker_year_move_input {
    background-color: white;
    border: 0;
}
.monthpicker_month_area {
    display: flex;
    flex-direction: column;
    align-items: center;
    justify-content: space-around;
    width: 90%;
    height: 65%;

}
.monthpicker_month_row {
    display: flex;
    flex-direction: row;
    align-items: center;
    justify-content: space-between;

    width: 100%;
    height: 40%;
}
.monthpicker_month_input {
    background-color: #f5f5f5;
    border: 0;
    border-radius: 0.4rem;
    display: flex;
    align-items: center;
    justify-content: center;
    width: 16%;
    height: 100%;
    outline: none;
}

.monthpicker_month_input:hover{
    background-color: #ff8000;
    color: white;
}
.monthpicker_month_input:disabled {
    background-color: rgb(51, 170, 255);
    color: white;
}

main.page.ts

import { MonthPicker } from "./monthPicker";

export function init() {
    const inputElement = document.getElementById('month_picker_target') as HTMLInputElement;
    const monthPicker = new MonthPicker(inputElement,{
        months: ['Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun', 'Jul', 'Aug', 'Sep', 'Oct', 'Nov', 'Dec'],
        outputFormat: 'MMMM-YYYY'
    });

}
init();

monthPicker.ts

import moment from 'moment';
import { MonthPickerOption } from './monthPicker.type';

export class MonthPicker {
    private inputTarget: HTMLInputElement|null = null;
    private ignoreHiding: boolean = false;
    private pickerArea: HTMLElement;
    private pickerMonths: HTMLButtonElement[] = [];

    private currentYear: number;
    private currentYearElement: HTMLElement;

    public constructor(target: HTMLInputElement, option?: MonthPickerOption) {
        this.currentYearElement = document.createElement('div');
        this.currentYear = (new Date()).getFullYear();
        this.pickerArea = document.createElement('div');
        if(target == null) {
            console.error('target was null');
            return;
        }
        this.inputTarget = target;
        const parentElement = (target.parentElement == null)? document.body: target.parentElement;
        this.addMonthPickerArea(parentElement, target.getBoundingClientRect());
        this.addMonthPickerFrame(this.pickerArea, (option)? option: null);

        target.addEventListener('click', _ => this.show());
        window.addEventListener('click', _ => this.hide());
    }
    private addMonthPickerArea(parent: HTMLElement, targetRect: DOMRect) {
        this.pickerArea.className = 'monthpicker_area';
        this.pickerArea.style.position = 'absolute';
        this.pickerArea.style.left = `${targetRect.left}px`;
        this.pickerArea.style.top = `${targetRect.top + targetRect.height}px`;
        this.pickerArea.hidden = true;
        this.pickerArea.onclick = () => this.setIgnoreHiding();
        parent.appendChild(this.pickerArea);
    }
    private addMonthPickerFrame(area: HTMLElement, option: MonthPickerOption|null) {
        const pickerFrame = document.createElement('div');
        pickerFrame.className = 'monthpicker_frame';
        area.appendChild(pickerFrame);

        this.addYearArea(pickerFrame);
        this.addMonthArea(pickerFrame, option);
    }
    private addYearArea(parent: HTMLElement) {
        const pickerYearArea = document.createElement('div');
        pickerYearArea.className = 'monthpicker_year_area';
        parent.appendChild(pickerYearArea);

        const moveBackward = document.createElement('button');
        moveBackward.className = 'monthpicker_year_move_input';
        moveBackward.textContent = '◀';
        pickerYearArea.appendChild(moveBackward);
        moveBackward.onclick = () => this.changeYear(-1);

        this.currentYearElement.className = 'monthpicker_year_current';
        this.currentYearElement.textContent = `${this.currentYear}`;
        pickerYearArea.appendChild(this.currentYearElement);

        const moveForward = document.createElement('button');
        moveForward.className = 'monthpicker_year_move_input';
        moveForward.textContent = '▶';
        pickerYearArea.appendChild(moveForward);
        moveForward.onclick = () => this.changeYear(1);
    }
    private addMonthArea(parent: HTMLElement, option: MonthPickerOption|null) {
        const pickerMonthArea = document.createElement('div');
        pickerMonthArea.className = 'monthpicker_month_area';
        parent.appendChild(pickerMonthArea);

        const monthRow1 = document.createElement('div');
        monthRow1.className = 'monthpicker_month_row';
        pickerMonthArea.appendChild(monthRow1);

        const monthRow2 = document.createElement('div');
        monthRow2.className = 'monthpicker_month_row';
        pickerMonthArea.appendChild(monthRow2);

        const format = (option?.outputFormat)? option.outputFormat: null;
        const defaultMonths = ['1', '2', '3', '4', '5', '6', '7', '8', '9', '10', '11', '12'];
        const months = (option?.months)? option.months: defaultMonths;

        for(let i = 0; i < defaultMonths.length; i++) {
            const pickerMonth = document.createElement('button');
            const month = (months.length > i)? months[i]: defaultMonths[i];
            pickerMonth.textContent = month;
            pickerMonth.className = 'monthpicker_month_input';
            pickerMonth.onclick = (ev) => this.setSelectedDate(ev, month, format);
            if(this.pickerMonths.length < 6) {
                monthRow1.appendChild(pickerMonth);
            } else {
                monthRow2.appendChild(pickerMonth);
            }
            this.pickerMonths.push(pickerMonth);
        }
    }
    private setIgnoreHiding() {
        if(this.ignoreHiding === true) {
            return;
        }
        this.ignoreHiding = true;
        setTimeout((_) => this.ignoreHiding = false, 500);
    }
    private show() {
        this.selectSelectedMonth();
        this.setIgnoreHiding();
        this.pickerArea.hidden = false;
    }
    private getSelectedMonthIndex(currentValue: string): number {
        const currentDate = new Date(currentValue);
        if(currentDate.getFullYear() !== this.currentYear) {
            return -1;
        }
        return currentDate.getMonth();
    }
    private selectSelectedMonth() {
        if(this.inputTarget?.value == null) {
            return;
        }
        const selectedMonthIndex = this.getSelectedMonthIndex(this.inputTarget.value);
        for(let i = 0; i < this.pickerMonths.length; i++) {
            this.pickerMonths[i].disabled =  (i === selectedMonthIndex);
        }
    }
    private hide() {
        if(this.ignoreHiding === true) {
            return;
        }
        this.pickerArea.hidden = true;
    }
    private setSelectedDate(ev: MouseEvent, month: string, format: string|null) {
        if(this.inputTarget == null) {
            return;
        }
        const selectedDate = new Date(`${this.currentYear} ${month}`);        
        this.inputTarget.value = moment(selectedDate).format((format == null)? 'YYYY-MM': format);
        this.hide();
    }
    private changeYear(addYear: number) {
        this.currentYear += addYear;
        this.currentYearElement.textContent = `${this.currentYear}`;
        this.selectSelectedMonth();
    }
}

15