[TypeScript][Moment] Create month picker

masanori_msl

Masui Masanori

Posted on July 4, 2021

[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"".

Alt Text

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

Alt Text

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);
    }
}
Enter fullscreen mode Exit fullscreen mode

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;
    }
...
}
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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,
}
Enter fullscreen mode Exit fullscreen mode

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();
    }
...
Enter fullscreen mode Exit fullscreen mode

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>
Enter fullscreen mode Exit fullscreen mode

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;
}
Enter fullscreen mode Exit fullscreen mode

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();
Enter fullscreen mode Exit fullscreen mode

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();
    }
}
Enter fullscreen mode Exit fullscreen mode
💖 💪 🙅 🚩
masanori_msl
Masui Masanori

Posted on July 4, 2021

Join Our Newsletter. No Spam, Only the good stuff.

Sign up to receive the latest update from our blog.

Related