13월 캘린더 만들기
회계 상 월을 13월까지 사용하는 경우가 있어. 이러한 상황에 맞추어 만드는 방법을 알아본다.
먼저 캘린더를 만들기 위해 달력의 핵심인 날짜에 대한 정보를 계산하는 것인데 locale, 날짜 계산 등 지원해야 할게 많으니 잘 되어 있는 라이브러리를 쓰자. 그래서 날짜 라이브러리는 momentjs를 사용하여 만들어 볼 것이다.
ExtendDate 클래스 만들기
날짜 라이브러리가 있지만 13월은 없기 때문에 13월을 계산하기 위한 Date를 만든다.
extraDates
라는 속성은 배열이며 그 요소의 인덱스는 추가되는 달이 된다. 값은 마지막 날짜가 된다.
extraDates = [20,21];
// [13월 20일, 14월 21일]
이렇게 정보를 가지고 만들어 보겠다.
생성자
생성자에서 date, dateFormat, extraDates를 받는다. date는 string 일 수 있고, 날짜 객체도 받을 수 있어야 한다. dateFormat은 date가 string 일때 해석 하기 위한 포맷이다. extraDates 확장할 달의 마지막 일이 설정된 배열을 이어야 한다.
ExtendDate.js
var ExtendDate = function(date, dateFormat, extraDates) {
//momentjs 라이브러리 필요. https://momentjs.com/
this._mm = moment();
if (date instanceof ExtendDate) {
this._mm = date._mm.clone();
this._extraDates = date._extraDates;
this.setFullYear(date.getFullYear()).setMonth(date.getMonth());
} else {
this._extraDates = extraDates || [];
if (dateFormat) {
var dateObj = ExtendDate.parseDate(date, dateFormat);
this.setFullYear(Number(dateObj.YYYY)).setMonth(Number(dateObj.MM) - 1).setDate(Number(dateObj.DD));
} else {
if (date instanceof moment || date instanceof Date) {
this._mm = moment(date);
}
this._currentMonth = this._mm.month() + 1;
this._currentYear = this._mm.year();
}
}
}
전역 API
ExtendDate.js
/**
* YYYY,MM,DD 기준으로 작성된 문자열을 해석합니다.
* @param {string} date
* @param {string} format
* @return {{YYYY:string,MM:string,DD:string}}
*/
ExtendDate.parseDate = function(date, format) {
var y = format.indexOf("YYYY");
var m = format.indexOf("MM");
var d = format.indexOf("DD");
return {
YYYY: date.substring(y, y + 4),
MM: date.substring(m, m + 2),
DD: date.substring(d, d + 2)
};
}
날짜 정보 설정/반환 API
ExtendDate.js
/**
* 현재 년도의 마지막 달을 반환합니다.
* @return {number}
*/
ExtendDate.prototype.endMonth = function() {
return 12 + this._extraDates.length;
}
/**
* 현재 달의 마지막 일을 반환합니다.
* @return {number}
*/
ExtendDate.prototype.endDate = function() {
return this._currentMonth > 12 ? this._getExtraMonthEndDate(this._mm) : this._mm.clone().endOf("month").date();
}
/**
* 추가 달들을 반환합니다.
* @return {number[]}
*/
ExtendDate.prototype.getExtraMonths = function() {
return this._extraDates;
}
/**
* 마지막 날짜를 설정하여 달을 추가합니다. ex) setExtraMonths([20]) // 13월의 20일이 마지막
* @param {number[]} dates
*/
ExtendDate.prototype.setExtraMonths = function(dates) {
this._extraDates = dates;
}
/**
* 현재 달의 값을 설정합니다. 0부터 시작합니다.
* @param {number} num 0~11
* @return {ExtendDate}
*/
ExtendDate.prototype.setMonth = function(num) {
var realMonth = num + 1;
if (realMonth > 12 && this.endMonth() >= realMonth) {
this._currentMonth = realMonth;
this._mm.year(this._currentYear + 1);
this._mm.month(this._currentMonth - 13);
} else {
this._currentMonth = realMonth;
this._mm.year(this._currentYear);
this._mm.month(this._currentMonth - 1);
}
return this;
}
/**
* 현재 달의 값을 반환합니다. 0부터 시작합니다.
* @return {number} 0~11
*/
ExtendDate.prototype.getMonth = function() {
return this._currentMonth - 1;
}
/**
* 요일을 반환합니다.
* @return {number}
*/
ExtendDate.prototype.day = function() {
return this._mm.day();
}
/**
* 0~9999의 값을 반환합니다.
* @return {number}
*/
ExtendDate.prototype.getFullYear = function() {
return this._currentYear;
}
/**
* 0~9999의 값을 설정합니다.
* @param {ExtendDate} num
*/
ExtendDate.prototype.setFullYear = function(num) {
if (this._currentMonth > 12) {
this._mm.year(num + 1);
} else {
this._mm.year(num);
}
this._currentYear = num;
return this;
}
/**
* 1~31의 값을 반환합니다.
* @return {number}
*/
ExtendDate.prototype.getDate = function() {
return this._mm.date();
}
/**
* 1~31의 값을 설정합니다.
* @param {number} num
* @return {ExtendDate}
*/
ExtendDate.prototype.setDate = function(num) {
this._mm.date(num);
return this;
}
/**
* 요일의 배열을 반환합니다.
* @returns {string[]}
*/
ExtendDate.prototype.weekdaysMin = function() {
return this._mm.localeData().weekdaysMin(null);
}
/**
* 자신을 복제합니다.
* @returns {ExtendDate}
*/
ExtendDate.prototype.clone = function() {
return new ExtendDate(this);
}
/**
* 포맷으로 값을 반환합니다.
* @param {string} format
* @returns {string}
*/
ExtendDate.prototype.format = function(format) {
if (this._currentMonth > 12) {
format = format.replace(/M{4}|M{3}/, "[" + this._currentMonth + "월]");
format = format.replace(/M{2}|M{1}/, "[" + this._currentMonth + "]");
format = format.replace(/Y{4}/, "[" + this._currentYear + "]");
}
return this._mm.format(format);
}
날짜 조작 API
ExtendDate.js
/**
* 달의 시작 일로 설정합니다. ex) 2022-05-22인 경우 2022-05-01로 설정됩니다.
* @return {ExtendDate}
*/
ExtendDate.prototype.startOfMonth = function() {
this._mm.startOf("month");
return this;
}
/**
* 년의 시작 월로 설정합니다. ex) 2022-05-22인 경우 2022-01-22로 설정됩니다.
* @return {ExtendDate}
*/
ExtendDate.prototype.startOfYear = function() {
this._mm.startOf("year");
this._mm.year(this._currentYear);
this._currentMonth = this._mm.month() + 1;
return this;
}
/**
* 다음 일로 이동합니다.
* @return {ExtendDate}
*/
ExtendDate.prototype.nextDate = function() {
var oldDate = this._mm.toDate();
this._mm.add(1, "days");
if (oldDate.getMonth() != this._mm.month()) {
this._currentMonth++;
if (this._currentMonth > this.endMonth()) {
this._currentMonth = 1;
if (this.endMonth() > 12) {
this._mm.year(this._mm.year());
this._mm.month(0);
}
this._currentYear = this._mm.year();
}
}
if (this._currentMonth > 12) {
var maxDateNum = this._getExtraMonthEndDate(this._mm);
if (maxDateNum && this._mm.date() > maxDateNum) {
var nextMonth = this._mm.month() + 1;
if (this._extraDates[nextMonth]) {
this._currentMonth++;
this._mm.add(1, "months").date(1);
} else {
this._mm.month(0).date(1);
this._currentMonth = this._mm.month() + 1;
this._currentYear = this._mm.year();
}
}
}
return this;
}
/**
* 달의 마지막 날짜를 반환합니다. 없는 달인 경우 null을 반환합니다.
* @param {moment.Moment} mom
*/
ExtendDate.prototype._getExtraMonthEndDate = function(mom) {
var maxDate = this._extraDates[mom.month()];
if (maxDate && maxDate == -1) {
maxDate = mom.clone().endOf("months").date();
}
return maxDate;
}
/**
* 다음 달로 이동합니다.
* @return {ExtendDate}
*/
ExtendDate.prototype.nextMonth = function() {
var oldDate = this._mm.toDate();
this._mm.add(1, "months");
var maxDateNum = this._getExtraMonthEndDate(this._mm);
if (oldDate.getFullYear() < this._mm.year() && maxDateNum) {
this._currentMonth = 12 + (this._mm.month() + 1);
this._currentYear = oldDate.getFullYear();
} else if (oldDate.getFullYear() > this._currentYear) {
if (maxDateNum != null) {
this._currentMonth = 12 + (this._mm.month() + 1);
if (this._mm.date() >= maxDateNum) {
this._mm.date(maxDateNum);
}
} else {
this._mm.month(0);
this._currentMonth = this._mm.month() + 1;
this._currentYear = this._mm.year();
}
} else {
this._currentMonth = this._mm.month() + 1;
this._currentYear = this._mm.year();
}
return this;
}
/**
* 이전 일로 이동합니다.
* @return {prevDate}
*/
ExtendDate.prototype.prevDate = function() {
var oldDate = this._mm.toDate();
this._mm.subtract(1, "days");
if (oldDate.getMonth() != this._mm.month()) {
this._currentMonth--;
if (this._currentMonth < 1) {
this._currentMonth = this.endMonth();
if (this.endMonth() > 12) {
this._mm.year(oldDate.getFullYear());
this._mm.month(this.endMonth() - 13);
}
this._currentYear = this._mm.year();
}
}
return this;
}
/**
* 이전 달로 이동합니다.
* @return {ExtendDate}
*/
ExtendDate.prototype.prevMonth = function() {
var oldDate = this._mm.toDate();
this._mm.subtract(1, "months");
this._currentMonth--;
if (this._currentMonth < 1) {
this._currentMonth = this.endMonth();
this._currentYear = this._mm.year();
if (this.endMonth() > 12) {
this._mm.year(oldDate.getFullYear());
this._mm.month(this.endMonth() - 13);
}
}
return this;
}
/**
* 다음 년으로 이동합니다.
* @return {ExtendDate}
*/
ExtendDate.prototype.nextYear = function() {
this._mm.add(1, "years");
this._currentYear++;
if (this._currentMonth > 12) {
this._mm.add(1, "years");
}
}
/**
* 이전 년으로 이동합니다.
* @return {ExtendDate}
*/
ExtendDate.prototype.prevYear = function() {
this._mm.subtract(1, "years");
this._currentYear--;
if (this._currentMonth > 12) {
this._mm.add(1, "years");
}
}
Calendar 만들기
생성자
Calendar.js
/**
*
* @param {extraMonths:number[], type:"yearmonthdate"|"yearmonth"} options
*/
var Calendar = function(options) {
this._current = new ExtendDate();
this.setOption(options);
/**
* @type {HTMLElement}
*/
this._rootEle = null;
this._timeId = null;
/**
* @type {[key:string]:Function[]}
*/
this._listener = {};
this._selectedNode = null;
var _value = "";
var _format = "";
/**
* @type {[key:string]:{[key:string]:()=>any}}
*/
this._bindProperty = {
value: {
get: function() {
return _value;
},
set: function(val) {
_value = val;
}
},
format: {
get: function() {
return _format;
},
set: function(val) {
_format = val;
}
}
};
for (var key in this._bindProperty) {
(function(owner, name) {
Object.defineProperty(owner, name, {
get: function() {
return owner._bindProperty[name].get();
},
set: function(val) {
if (owner[name] == val) {
return;
}
owner._bindProperty[name].set(val);
}
});
})(this, key);
}
}
속성
/**
* 클래스 이름 목록
*/
Calendar.CLASS_NAME = {
root: "calendar",
header: "header",
headerText: "header-text",
headerPrev: "header-prev",
headerNext: "header-next",
content: "content",
contentHeader: "content-header",
contentDay: "content-day",
contentMonth: "content-month",
footer: "footer",
footerText: "footer-text",
selected: "selected",
text: "text"
}
/**
* 설정을 반환합니다.
* @return {{extraMonths: number[], type: string}}
*/
Calendar.prototype.getOption = function() {
return {
extraMonths: this._current.getExtraMonths()
};
}
/**
*
* @param {{extraMonths:number[], type:string}} options
*/
Calendar.prototype.setOption = function(options) {
if (!options) {
return
}
if (options.extraMonths) this._current.setExtraMonths(options.extraMonths);
}
/**
* 속성을 바인딩합니다.
* @param {"value"|"format"} name
* @param {()=>any} getter
* @param {(string)=>void} setter
*/
Calendar.prototype.bindProperty = function(name, getter, setter) {
if (!this._bindProperty[name]) {
return;
}
this._bindProperty[name].get = getter;
this._bindProperty[name].set = setter;
}
이벤트
/**
* 이벤트를 추가합니다.
* @param {"value-change"|"before-value-change"} name
* @param {{type:string}} func
*/
Calendar.prototype.addEventListener = function(name, func) {
if (!this._listener[name]) {
this._listener[name] = [];
}
this._listener[name].push(func);
}
/**
* 이벤트를 제거합니다.
* @param {string} name
* @param {()=>void)} func
*/
Calendar.prototype.removeEventListener = function(name, func) {
if (!this._listener[name]) {
return;
}
var idx = this._listener[name].indexOf(func);
this._listener[name].splice(idx, 1);
}
/**
* 이벤트를 전파합니다.
* @param {type:string, userData:any,defaultPrevented:boolean} event
*/
Calendar.prototype.dispatchEvent = function(event) {
if (!this._listener[event.type]) {
return true;
}
var me = this;
this._listener[event.type].forEach(function(each) {
if (!event.userData) {
event.userData = {};
}
event.userData["target"] = me;
each(event);
});
return event["defaultPrevented"] != null? !event.defaultPrevented : true;
}
UI 구현
/**
* 헤더의 텍스트를 반환합니다.
*/
Calendar.prototype._getHeaderText = function() {
return this._current.format("YYYY MMM");
}
/**
* 엘리먼트에 스타일을 설정합니다.
* @param ele HTMLElement
* @param style {[key:string]:string}
*/
Calendar.prototype._setStyle = function(ele, style) {
for (var key in style) {
ele.style[key] = style[key];
}
}
/**
* 엘리먼트를 생성합니다.
*/
Calendar.prototype.create = function() {
var calendar = document.createElement("div");
calendar.classList.add(Calendar.CLASS_NAME.root);
this._setStyle(calendar, {
height: "100%"
});
var content = document.createElement("div");
this._setStyle(content, {
width: "100%",
height: "100%",
overflow: "hidden",
display: "flex",
"flex-direction": "column"
});
var header = this._createHeader();
var headerWrap = document.createElement("div");
this._setStyle(headerWrap, {
"flex-grow": "0",
"flex-shrink": "1",
"flex-basis": "auto"
});
headerWrap.appendChild(header);
var body = this._createBody();
var bodyWrap = document.createElement("div");
this._setStyle(bodyWrap, {
"flex-grow": "1",
"flex-shrink": "1",
"flex-basis": "auto"
});
bodyWrap.appendChild(body);
var footer = this._createFooter();
var footerWrap = document.createElement("div");
this._setStyle(footerWrap, {
"flex-grow": "0",
"flex-shrink": "1",
"flex-basis": "auto"
});
footerWrap.appendChild(footer);
content.appendChild(headerWrap);
content.appendChild(bodyWrap);
content.appendChild(footerWrap);
calendar.appendChild(content);
calendar.addEventListener("click", this._onclick.bind(this));
this._rootEle = calendar;
return calendar;
}
/**
* 헤더를 만듭니다.
*/
Calendar.prototype._createHeader = function() {
var header = document.createElement("div");
this._setStyle(header, {
display: "table",
tableLayout: "auto"
});
header.classList.add(Calendar.CLASS_NAME.header);
var buttons = ["prev", "title", "next"];
buttons.forEach(function(each) {
switch (each) {
case "prev": {
var prev = document.createElement("div");
this._setStyle(prev, {
display: "table-cell"
});
prev.classList.add(Calendar.CLASS_NAME.headerPrev);
prev.innerHTML = " "
header.appendChild(prev);
break;
}
case "title": {
var title = document.createElement("div");
this._setStyle(title, {
display: "table-cell",
verticalAlign: "middle",
cursor: "default"
});
title.classList.add(Calendar.CLASS_NAME.headerText);
title.textContent = this._getHeaderText();
header.appendChild(title);
break;
}
case "next": {
var next = document.createElement("div");
this._setStyle(next, {
display: "table-cell"
});
next.innerHTML = " ";
next.classList.add(Calendar.CLASS_NAME.headerNext);
header.appendChild(next);
break;
}
}
}.bind(this));
return header;
}
/**
* 바디를 만듭니다.
*/
Calendar.prototype._createBody = function() {
return this._createDateBody();
}
/**
* 바디의 날짜를 만듭니다.
*/
Calendar.prototype._createDateBody = function() {
var table = document.createElement("table");
this._setStyle(table, {
width: "100%",
"border-spacing": "0px",
"table-layout": "fixed"
});
table.classList.add(Calendar.CLASS_NAME.content);
var localeWeekdays = this._current.weekdaysMin();
var dayOfWeek = ["sun", "mon", "tue", "wed", "thu", "fri", "sat"];
var headerRow = document.createElement("tr");
localeWeekdays.forEach(function(each, idx) {
var th = document.createElement("th");
this._setStyle(th, {
border: "none"
});
th.textContent = each;
th.classList.add(Calendar.CLASS_NAME.content + "-" + dayOfWeek[idx], Calendar.CLASS_NAME.contentHeader);
headerRow.appendChild(th);
}.bind(this));
table.appendChild(headerRow);
var viewDate = this._current.clone();
var colIdx, rowIdx;
viewDate.startOfMonth(); // 시작 날짜로 설정
for (rowIdx = 1; rowIdx <= 6; rowIdx++) {
var bodyRow = document.createElement("tr");
this._setStyle(bodyRow, {
height: "14.35%"
});
for (colIdx = 0; colIdx < 7; colIdx++) {
var col = document.createElement("td");
this._setStyle(col, {
padding: "0px"
});
if (viewDate.getMonth() == this._current.getMonth() && viewDate.day() == colIdx) {
col.setAttribute("data-date", viewDate.format("YYYY-MM-DD"));
col.classList.add(Calendar.CLASS_NAME.contentDay);
var text = document.createElement("span");
text.textContent = viewDate.getDate() + "";;
text.classList.add(Calendar.CLASS_NAME.text);
col.appendChild(text);
// 선택된 값 표시
if (this.value == viewDate.format(this.format)) {
col.classList.add(Calendar.CLASS_NAME.selected);
this._selectedNode = col;
}
viewDate.nextDate();
} else {
col.innerHTML = " ";
}
bodyRow.appendChild(col);
}
table.appendChild(bodyRow);
}
return table;
}
/**
* 푸터를 만듭니다.
*/
Calendar.prototype._createFooter = function() {
var footer = document.createElement("div");
footer.classList.add(Calendar.CLASS_NAME.footer);
var text = document.createElement("div");
this._setStyle(text, {
display: "inline-block"
});
text.classList.add(Calendar.CLASS_NAME.footerText);
var today = new ExtendDate();
text.textContent = today.format("YYYY[월] MMM Do");
text.setAttribute("data-date", today.format("YYYY-MM-DD"));
footer.appendChild(text);
return footer;
}
기능 구현
/**
* 조상의 엘리먼트를 찾습니다.
* @param {HTMLElement} ele
*/
Calendar.closestAncestor = function(ele, rootEle, selector) {
var matchSelector = ele.matches || ele.msMatchesSelector;
if (!matchSelector) {
return null;
}
if (matchSelector.call(ele, selector)) {
return ele;
}
if (rootEle == ele) {
return ele;
}
var parent = ele;
while (parent != null) {
if (matchSelector.call(parent, selector)) {
return parent;
}
if (parent == rootEle) {
return null;
}
parent = parent.parentNode;
}
return null;
}
Calendar.prototype._onclick = function(e) {
var target = e.target;
if (Calendar.closestAncestor(target, e.currentTarget, "."+Calendar.CLASS_NAME.content) != null) {
target = Calendar.closestAncestor(target, e.currentTarget, "." + Calendar.CLASS_NAME.contentDay)
if (target == null) {
return;
}
var event = {type:"before-value-change",userData:{}};
var defaultPrevented = !this.dispatchEvent(event);
if (defaultPrevented) {
return;
}
var date = target.getAttribute("data-date");
var value = "";
value = this._current.clone().setDate(Number(ExtendDate.parseDate(date, "YYYY-MM-DD").DD)).format(this.format);
if (this._selectedNode) {
this._selectedNode.classList.remove(Calendar.CLASS_NAME.selected);
}
this._selectedNode = target;
this._selectedNode.classList.add(Calendar.CLASS_NAME.selected);
this.value = value;
var event = {type:"value-change",userData:{}};
this.dispatchEvent(event);
} else if (target.classList.contains(Calendar.CLASS_NAME.headerNext)) {
this._current.nextMonth();
this.redraw();
} else if (target.classList.contains(Calendar.CLASS_NAME.headerPrev)) {
this._current.prevMonth();
this.redraw();
} else if (target.classList.contains(Calendar.CLASS_NAME.footerText)) {
var date = target.getAttribute("data-date");
var parsedDate = ExtendDate.parseDate(date, "YYYY-MM-DD");
var isSame = parseInt(parsedDate.YYYY) == this._current.getFullYear() && parseInt(parsedDate.MM) == this._current.getMonth() + 1;
if (isSame) {
return;
}
this._current.setFullYear(Number(parsedDate.YYYY)).setMonth(Number(parsedDate.MM) - 1).setDate(Number(parsedDate.DD));
this.redraw();
}
}
/**
* 캘린더를 다시 그립니다.
*/
Calendar.prototype.redraw = function() {
if(this._timeId){
clearTimeout(this._timeId);
}
this._timeId = setTimeout(function(){
this._timeId = null;
var parent = this._rootEle.parentNode;
var target = this._rootEle;
parent.removeChild(target);
parent.appendChild(this.create());
}.bind(this), 0);
}
/**
* 변경된 값을 적용합니다.
*/
Calendar.prototype.update = function() {
if (!this.value) {
return;
}
var date = new ExtendDate(this.value, this.format);
this._current.setFullYear(date.getFullYear()).setMonth(date.getMonth()).setDate(date.getDate());
this.redraw();
}
사용 방법
사용 방법은 다음과 같이 format, value, 추가날짜에 설정을 해주고 어떤 엘리먼트에 붙일지 작성해주면 된다.
`...`
<body>
<div class="root"></div>
<script>
var root = document.querySelector(".root");
var calendar = new Calendar({extraMonths:[20]});
var format = "YYYY-MM-DD";
var value = "";
calendar.bindProperty("format",function(){
return format;
}, function(v){
format = v;
});
calendar.bindProperty("value",function(){
return value;
},function(v){
value = v;
});
calendar.addEventListener("value-change",function(e){
console.log(calendar.value);
});
root.appendChild(calendar.create());
</script>
</body>
`...`
캘린더의 스타일은 CodePen을 참고 하세요.