[TypeScript][Moment] Create month picker
Masui Masanori
Posted on July 4, 2021
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();
}
}
Posted on July 4, 2021
Join Our Newsletter. No Spam, Only the good stuff.
Sign up to receive the latest update from our blog.