TypeScript
에어비앤비 스타일 날짜 범위 선택 달력 직접 만들기 (Vue 2)
왜 직접 만들었나
진행 중인 프로젝트에서 에어비앤비처럼 시작일과 종료일을 한 번에 고르는 날짜 선택 화면이 필요했습니다. Vuetify의 DatePicker는 프로젝트 디자인과 맞지 않았고, 범위 선택 기능을 붙이기에도 제약이 많았습니다.
라이브러리를 고쳐 쓰는 것보다 새로 만드는 편이 코드가 더 간결하겠다고 판단해 직접 구현했습니다. 3일 정도 예상했는데 생각보다 순조롭게 풀려 이틀 만에 완성했습니다. 프로젝트에 맞춘 코드지만 다른 곳에서도 쉽게 고쳐 쓸 수 있도록 정리해 공유합니다.
마크업
<template>
<div class="date-picker-wrap custom">
<div class="week-wrap">
<span v-for="(weekName, index) in weekNames" v-bind:key="index">
{{ weekName }}</span
>
</div>
<div
class="date-wrap"
v-for="(calendar, index) in currentCalendar"
v-bind:key="index"
>
<p class="date-tit">{{ calendar.dateTitle }}</p>
<ul class="picker-wrap">
<li
v-for="(el, index) in calendar.dateStart"
:key="`blank${index}`"
class="blank-date"
></li>
<li v-for="(el, index) in calendar.dateArr" :key="index">
<button
v-bind:disabled="!checkDate(el)"
v-on:click="onHandleRangeDate(el)"
v-bind:class="[
rangeActiveDate(el) && 'data-active',
rangeSelectDate(el) && 'data-select',
rangePositionDate(el),
]"
>
<span>{{ el.getDate() }}</span>
</button>
</li>
</ul>
</div>
</div>
</template>
Vuetify 컴포넌트 중에는 맞는 것이 없어서 마크업은 직접 작성했습니다. 달력은 보통 table로 많이 만들지만, 요일 표시를 달마다 반복하지 않고 위에 한 번만 두는 구조라 ul/li로 만드는 편이 CSS를 더 간단하게 짤 수 있다고 판단했습니다.
달력 데이터 만들기
<script>
export default {
...
methods: {
initCalendar: function () {
const startDate = this.startDate; // 현재 날짜가 검사 시작 날짜를 넘지 못했을 경우 : 현재 날짜가 검사 시작 날짜를 넘었을경우
const endDate = this.endDate; // 검사 마지막 날짜
const diffDate = (endDate - startDate) / 1000; // 두 달의 차이 값
const monthCount = Math.round(diffDate / (60 * 60 * 24 * 30)); // 남은 개월수
const tempArr = new Array(monthCount + 1).fill({}); // 개월 수 만큼 배열 공간 만들기
this.currentCalendar = tempArr.map((_, idx) => {
const selectDate = new Date(
startDate.getFullYear(),
startDate.getMonth() + idx
); // 선택된 날짜의 연도와 달 구하기
const dateTitle = `${selectDate.getFullYear()}년 ${
selectDate.getMonth() + 1
}월`; // 선택된 날짜의 연도와 달 구하기
const dateArrFill = new Array(
new Date(
selectDate.getFullYear(),
selectDate.getMonth() + 1,
0
).getDate()
).fill(" "); // 선택된 달의 말일 구하기
const dateArr = dateArrFill.map((_, idx) => {
return new Date(
selectDate.getFullYear(),
selectDate.getMonth(),
idx + 1
); // 일자별로 배열에 담기
});
const dateStart = selectDate.getDay(); // 달의 시작 날짜 알아오기;
const tamp = {
dateTitle,
dateStart,
dateArr,
};
return tamp;
});
console.log(this.currentCalendar);
},
...
},
};
</script>
이전 달·다음 달 버튼으로 넘기는 방식이 아니라, 여러 달을 아래로 길게 이어서 보여주는 형태였습니다. 그래서 시작일과 종료일의 차이로 개월 수를 구하고, 달마다 제목, 1일의 요일, 날짜 배열을 만들었습니다. 무한 스크롤이 필요하다면 스크롤이 끝에 닿을 때 endDate를 늘리는 방식으로 확장할 수 있습니다.
날짜 범위 선택
...
onHandleRangeDate(date) {
// console.log(date, this.rangeDate[0], date > this.rangeDate);
if (date < this.rangeDate[0]) {
this.rangeDate = [];
}
if (this.rangeDate.length >= 2) {
this.rangeDate = [];
}
const select = date;
const selectArr = [...this.rangeDate, select].sort((a, b) => {
if (a > b) {
return 1;
} else {
return -1;
}
});
this.rangeDate = [...selectArr];
console.log(this.rangeDate);
},
...
onHandleRangeDate는 첫 번째와 두 번째로 선택한 날짜를 저장합니다.
- 첫 날짜보다 이전 날짜를 누르거나, 이미 두 날짜를 고른 상태에서 다시 누르면 선택을 초기화합니다.
sort는 두 날짜의 순서를 맞추려고 넣었지만, 위의date < this.rangeDate[0]조건 때문에 실제로는 동작하지 않습니다. 필요 없다면 빼도 됩니다.
선택할 수 없는 날짜
...
checkDate(date) {
const yseterDate = new Date().setDate(this.todayDate.getDate() - 1);
return (
date >= this.startDate &&
date <= this.endDate &&
date.getDay() !== 0 &&
date >= yseterDate
);
},
...
checkDate는 선택하면 안 되는 날짜(기간 밖, 일요일, 지난 날짜)를 막습니다. 지금은 조건을 코드에 직접 넣었지만, props로 받아서 쓰는 곳마다 바꿀 수 있게 개선할 예정입니다.
선택 상태 표시용 클래스
...
rangeActiveDate(date) {
return date >= this.rangeDate[0] && date <= this.rangeDate[1];
},
rangeSelectDate(date) {
return this.rangeDate.includes(date);
},
rangePositionDate(date) {
let className = "";
if (date === this.rangeDate[0]) {
className += "data-first ";
}
if (date === this.rangeDate[1]) {
className += "data-last ";
}
return className;
},
...
선택된 범위, 시작일, 종료일에 각각 클래스를 붙여서 스타일로 범위를 표시합니다.
TypeScript (Class Component) 버전
같은 기능을 vue-property-decorator의 클래스 컴포넌트로 옮긴 코드입니다.
<script lang="ts">
import { Component, Vue } from "vue-property-decorator";
interface ICurrentCalendar {
dateTitle: string;
dateStart: number;
dateArr: Date[];
}
@Component
export default class TestPicker extends Vue {
weekNames: string[] = ["일", "월", "화", "수", "목", "금", "토"];
todayDate: Date = new Date();
startDate: Date = new Date("2023/1/12");
endDate: Date = new Date("2026/12/11");
rangeDate: Date[] = [];
currentCalendar: ICurrentCalendar[] = [];
mounted(): void {
this.init();
}
private init() {
this.initCalendar();
}
private initCalendar() {
const startDate = this.startDate; // 현재 날짜가 검사 시작 날짜를 넘지 못했을 경우 : 현재 날짜가 검사 시작 날짜를 넘었을경우
const endDate = this.endDate; // 검사 마지막 날짜
const diffDate = (endDate.getTime() - startDate.getTime()) / 1000; // 두 달의 차이 값
const monthCount = Math.round(diffDate / (60 * 60 * 24 * 30)); // 남은 개월수
const tempArr = new Array(monthCount + 1).fill({}); // 개월 수 만큼 배열 공간 만들기
this.currentCalendar = tempArr.map((_, idx) => {
const selectDate = new Date(
startDate.getFullYear(),
startDate.getMonth() + idx
); // 선택된 날짜의 연도와 달 구하기
const dateTitle = `${selectDate.getFullYear()}년 ${
selectDate.getMonth() + 1
}월`; // 선택된 날짜의 연도와 달 구하기
const dateArrFill = new Array(
new Date(
selectDate.getFullYear(),
selectDate.getMonth() + 1,
0
).getDate()
).fill(" "); // 선택된 달의 말일 구하기
const dateArr = dateArrFill.map((_, idx) => {
return new Date(
selectDate.getFullYear(),
selectDate.getMonth(),
idx + 1
); // 일자별로 배열에 담기
});
const dateStart = selectDate.getDay(); // 달의 시작 날짜 알아오기;
const tamp: ICurrentCalendar = {
dateTitle,
dateStart,
dateArr,
};
return tamp;
});
console.log(this.currentCalendar);
}
public onHandleRangeDate(date: Date) {
// console.log(date, this.rangeDate[0], date > this.rangeDate);
if (date < this.rangeDate[0]) {
this.rangeDate = [];
}
if (this.rangeDate.length >= 2) {
this.rangeDate = [];
}
const select = date;
const selectArr = [...this.rangeDate, select].sort((a, b) => {
if (a > b) {
return 1;
} else {
return -1;
}
});
this.rangeDate = [...selectArr];
console.log(this.rangeDate);
}
public checkDate(date: Date) {
const yseterDate = new Date().setDate(this.todayDate.getDate() - 1);
return (
date >= this.startDate &&
date <= this.endDate &&
date.getDay() !== 0 &&
date.getTime() >= yseterDate
);
}
public rangeActiveDate(date: Date) {
return date >= this.rangeDate[0] && date <= this.rangeDate[1];
}
public rangeSelectDate(date: Date) {
return this.rangeDate.includes(date);
}
public rangePositionDate(date: Date) {
let className = "";
if (date === this.rangeDate[0]) {
className += "data-first ";
}
if (date === this.rangeDate[1]) {
className += "data-last ";
}
return className;
}
}
전체 코드는 GitHub 저장소에서 확인할 수 있습니다.
댓글 0