Material Design features pretty cool widgets for date range selection, but if you are just like me, a curious learner, you could implement a widget like that with some CSS/HTML and TypeScript.
Let’s constrain ourselves with a use case that I found and helped to resolve at Stack Overflow which had the following criteria:
At a high level, there seem to be a few components here:
Implement all components mentioned above with the first iteration to roughly position everything.
<div class="outerCard">
<div class="topPanel">
<button class="prevYearButton">
<i class="arrow left"></i>
</button>
<span class="yearLabel">CURRENT YEAR</span>
<button class="nextYearButton">
<i class="arrow right"></i>
</button>
</div>
<div class="contentPanel">
<div class="monthItem">
<div class="monthItemHighlight">
MON
</div>
</div>
</div>
</div>
As seen above, we created basic elements and added classes according to the components we mentioned. Now, let’s add some CSS to make it all look close to what we need:
.outerCard {
touch-action: none;
overflow: hidden;
width: 400px;
height: 400px;
box-shadow: 0 4px 8px 0 rgba(0,0,0,0.2);
}
.topPanel {
width: 400px;
height: 50px;
text-align: center;
line-height: 50px;
}
.prevYearButton {
float: left;
}
.nextYearButton {
float: right;
}
button {
width: 50px;
height: 50px;
background:none;
border:none;
margin:0;
padding:0;
cursor: pointer;
}
button:focus {outline:0;}
i {
border: solid black;
border-width: 0 3px 3px 0;
display: inline-block;
padding: 6px;
}
.right {
transform: rotate(-45deg);
-webkit-transform: rotate(-45deg);
}
.left {
transform: rotate(135deg);
-webkit-transform: rotate(135deg);
}
.topPanel.yearLabel {
display: inline-block;
margin: 0 auto;
}
.contentPanel {
padding: 24px 8px;
}
.monthItem {
display: inline-block;
height: 64px;
width: 64px;
cursor: pointer;
text-align: center;
line-height: 64px;
margin-top: 1px;
margin-bottom: 1px;
}
Some highlights of the above:
touch-action: none
to prevent default actions such as text select. Since we will add listeners ourselves, this makes interaction with the component cleaner.text-align
, line-height
, and margin: 0 auto
magic to position text elements horizontally and vertically centered.inline-block
— this helps to render all 24 months line-by-line, forming a table-like view (without actually creating a table), this way our months are just an ordered list and we do not have to worry about actual rows and columns.Let’s add some TypeScript and let’s also use *ngFor
in our template to replicate all 24 months and see what things look like:
.import { Component } from '@angular/core';
@Component({
selector: 'month-picker',
templateUrl: 'month-picker.component.html',
styleUrls: ['month-picker.component.scss']
})
export class MonthPickerComponent {
years: Array<number>;
months: Array<string>;
ngOnInit() {
this.years = [ 2018, 2019, 2020, 2021, 2022, 2023, 2024 ];
this.months = ['Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun', 'Jul', 'Aug', 'Sep', 'Oct', 'Nov', 'Dec'];
}
}
We declare properties with their type and we initialize the values inside the ngOnInit
Hook. Let’s not worry about these being “hardcoded” for now. It is indeed a tech debt and we will deal with it later in the article.
<div class="outerCard">
<div class="topPanel">
<button class="prevYearButton">
<i class="arrow left"></i>
</button>
<span class="yearLabel">{{ years[0] }}</span>
<button class="nextYearButton">
<i class="arrow right"></i>
</button>
</div>
<div class="contentPanel">
<div class="monthItem" *ngFor="let month of months">
<div class="monthItemHighlight">
{{ month }}
</div>
</div>
</div>
</div>
Our template is now a thing, but we need to start working on the next set of challenges here:
label
?)First, let’s create monthsData
property that will store the list of all the months with their current state:
monthsData: Array<{
monthName: string,
monthYear: number,
isInRange: boolean,
isLowerEdge: boolean,
isUpperEdge: boolean
}>
It is an array of objects (Month) that can help us track important states. Next, we will create the list of all the months for all the years we have so far (2018–2024):
initMonthsData() {
this.monthsData = new Array();
this.years.forEach( year => {
this.months.forEach( month => {
this.monthsData.push({
monthName: month,
monthYear: year,
isInRange: false,
isLowerEdge: false,
isUpperEdge: false
})
})
})
}
ngOnInit() {
this.years = [ 2018, 2019, 2020, 2021, 2022, 2023, 2024 ];
this.months = ['Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun', 'Jul', 'Aug', 'Sep', 'Oct', 'Nov', 'Dec'];
this.initMonthsData();
}
In the snippet above, we are iterating over our initial months
and years
properties to create our monthsData
array in its initial state. Since we might need to use such a data “reset” in our component, we wrapped this operation inside the initMonthsData
method.
Next, we can update our view (HTML) to read from this entire (huge) list:
<div class="contentPanel">
<div class="monthItem" *ngFor="let month of monthsData">
<div class="monthItemHighlight">
{{ month.monthName }}
</div>
</div>
</div>
OK, good stuff. Next, we need a simple way to track the state of our range
, for this I chose (you will see later on why) to use a simple array with two values representing the indexes of a chosen “range start” month and “range end” month:
rangeIndexes: Array<number>;
initRangeIndexes() {
this.rangeIndexes = [ null, null ];
};
The null value is intentional and will help us track the range’s state. As with our monthsData
— we are adding this method into our ngOnInit
Hook.
Alright, now we need to add a method that we will call each time a user clicks on a month:
onClick(indexClicked) {
if (this.rangeIndexes[0] === null) {
this.rangeIndexes[0] = indexClicked;
} else
if (this.rangeIndexes[1] === null) {
this.rangeIndexes[1] = indexClicked;
this.rangeIndexes.sort((a,b) => a-b);
this.monthsData.forEach((month, index) => {
if ((this.rangeIndexes[0] <= index) && (index <= this.rangeIndexes[1])) {
month.isInRange = true;
};
if (this.rangeIndexes[0] === index) {
month.isLowerEdge = true;
};
if (this.rangeIndexes[1] === index) {
month.isUpperEdge = true;
};
})
} else {
this.initRangeIndexes();
this.initMonthsData();
this.onClick(indexClicked);
};
}
The logic here should be straightforward:
rangeIndexes
.Alright, the above gives us data model changes but our widget does not yet handle any changes based on model changes. We will leverage ngClass
to bind data states to the CSS classes that will help alter the view:
<div class="outerCard">
<div class="topPanel">
<button class="prevYearButton">
<i class="arrow left"></i>
</button>
<span class="yearLabel">{{ years[0] }}</span>
<button class="nextYearButton">
<i class="arrow right"></i>
</button>
</div>
<div class="contentPanel">
<div (click)="onClick(i)" class="monthItem" *ngFor="let month of monthsData; let i = index" [ngClass]="{ isEdge: rangeIndexes[0]===i || rangeIndexes[1]===i }">
<div class="monthItemHighlight" [ngClass]="{ inRange: month.isInRange, isLowerEdge: month.isLowerEdge, isUpperEdge: month.isUpperEdge }">
{{ month.monthName }}
</div>
</div>
</div>
</div>
The isEdge
class will now be auto-added to our month at .monthItem
level if it equals the index of our range edges.
Month highlight is a bit trickier: here we need to know which month represents the lower edge vs. the upper edge as the class we are adding is specific — we are using the “gradient” CSS trick to only color one half of the highlight div.
See the full CSS classes here:
.monthItem:hover {
border-radius: 100%;
background-color: #1474a4;
color: white;
}
.isEdge {
border-radius: 100%;
background-color: #1474a4;
color: white;
}
.inRange {
background-color: #1474a4;
opacity: 0.5;
color: white;
}
.isLowerEdge {
background-color: none;
background: linear-gradient(to right, transparent 50%, #1474a4 50%);
}
.isUpperEdge {
background-color: none;
background: linear-gradient(to right, #1474a4 50%, transparent 50%);
}
Cool, now our range is sort of operational, we can actually select a range of months. Still, three problems remain for us to solve:
Let’s push for the last few steps, we are almost there!
Since we need to display 6+12+6 = 24 months for each year, I decided to leverage the Array.slice
method and periodically slice our source of truth (monthsData
) and display it to our user, which will require new functionality and some refactoring for what we already created:
initViewSlices
).monthsData
array as well as new methods for our arrow buttons to increment/decrement the current year in the view.[ngClass]
changes to our template.It seems like a lot. Let’s start:
initYearLabels() {
const currentYear = (new Date()).getFullYear();
const range = (start, stop, step) => Array.from({ length: (stop - start) / step + 1}, (_, i) => start + (i * step));
this.years = range(currentYear-1, currentYear + 5, 1)
};
initMonthLabels() {
this.months = new Array(12).fill(0).map((_, i) => {
return new Date(`${i + 1}/1`).toLocaleDateString(undefined, {month: 'short'})
});
};
2. Obtaining the current year’s index:
this.currentYearIndex = this.years.findIndex(year => year === (new Date()).getFullYear());
3. The InitViewSlices
method will create a simple array that will contain offset for each year (0, 6, 18, 30, 42, 54, 66). The offset basically tells us where to start slicing our global data model (monthsData
) and we know where to finish it already (it is always going to be == 24, which is the number of month delegates in our view):
initViewSlices() {
this.monthViewSlicesIndexes = [];
this.years.forEach((year,index) => {
if (index===0) { this.monthViewSlicesIndexes.push(0) } else
if (index===1) { this.monthViewSlicesIndexes.push(6) } else
this.monthViewSlicesIndexes.push(this.monthViewSlicesIndexes[index-1]+12);
})
};
4. Slicing data into the view and implementing increment/decrement per year:
sliceDataIntoView() {
this.globalIndexOffset = this.monthViewSlicesIndexes[this.currentYearIndex];
this.monthDataSlice = this.monthsData.slice(this.globalIndexOffset,this.globalIndexOffset+24);
};
incrementYear() {
if (this.currentYearIndex !== this.years.length-1) {
this.currentYearIndex++
this.sliceDataIntoView()
};
};
decrementYear() {
if (this.currentYearIndex !==0) {
this.currentYearIndex--;
this.sliceDataIntoView()
};
};
5. Added the missing notCurrentYear
class (.notCurrentYear { color: #c4cbd6;}
) and added all the changes to our template which is now final:
<div class="outerCard">
<div class="topPanel">
<button class="prevYearButton" (click)="decrementYear()">
<i class="arrow left"></i>
</button>
<span class="yearLabel">{{ years[currentYearIndex] }}</span>
<button class="nextYearButton" (click)="incrementYear()">
<i class="arrow right"></i>
</button>
</div>
<div class="contentPanel">
<div (click)="onClick(i)" class="monthItem" *ngFor="let month of monthDataSlice; let i = index" [ngClass]="{ isEdge: rangeIndexes[0]===globalIndexOffset+i || rangeIndexes[1]===globalIndexOffset+i, notCurrentYear: currentYearIndex===0? i > 11:(i < 6 || i > 17)}">
<div class="monthItemHighlight" [ngClass]="{ inRange: month.isInRange, isLowerEdge: month.isLowerEdge, isUpperEdge: month.isUpperEdge }">
{{ month.monthName }}
</div>
</div>
</div>
</div>
The last step is actually refactoring everything (just a bit) to support the logic of sliced views. Specifically, we needed changes with the onClick
method, which now looks like this:
onClick(indexClicked) {
if (this.rangeIndexes[0] === null) {
this.rangeIndexes[0] = this.globalIndexOffset+indexClicked;
} else
if (this.rangeIndexes[1] === null) {
this.rangeIndexes[1] = this.globalIndexOffset+indexClicked;
this.rangeIndexes.sort((a,b) => a-b);
this.monthsData.forEach((month, index) => {
if ((this.rangeIndexes[0] <= index) && (index <= this.rangeIndexes[1])) {
month.isInRange = true;
};
if (this.rangeIndexes[0] === index) {
month.isLowerEdge = true;
};
if (this.rangeIndexes[1] === index) {
month.isUpperEdge = true;
};
})
let fromMonthYear = this.monthsData[this.rangeIndexes[0]];
let toMonthYear = this.monthsData[this.rangeIndexes[1]];
this.emitData(`Range is: ${fromMonthYear.monthName} ${fromMonthYear.monthYear} to ${toMonthYear.monthName} ${toMonthYear.monthYear}`)
} else {
this.initRangeIndexes();
this.initMonthsData();
this.onClick(indexClicked);
this.sliceDataIntoView();
};
};
As seen above, now we are supporting a global index offset and we reset the view before each new range.
For convenience, I added a simple event emitter as well…
Thakn you for reading !
#Angular #Angular8 #JavaScript #Front End Development #Web Development