[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.
  • The month picker area click event is fired
  • 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();
        }
    }

    23

    This website collects cookies to deliver better user experience

    [TypeScript][Moment] Create month picker