634 lines
24 KiB
Vue
634 lines
24 KiB
Vue
<template>
|
|
<div class="date-picker" :class="calendarViewDirection">
|
|
<fieldset v-if="months">
|
|
<legend class="sr-only">Date Picker</legend>
|
|
<div
|
|
v-for="month in months"
|
|
:key="`${month.monthLabel}-${month.yearNum.toString()}-${months.length}`">
|
|
<div
|
|
class="calendar-grid-container"
|
|
:class="[isInitialView === true ? 'initial-view' : '', month.monthClass]">
|
|
<div class="month-year body-small d-flex align-items-center ps-3 small">
|
|
{{ month.monthLabel }} {{ month.yearNum.toString() }}
|
|
</div>
|
|
<div
|
|
v-if="calendarViewDirection === 'future'"
|
|
class="legend caption d-flex align-items-center justify-content-end">
|
|
<span class="legend-circle me-1"></span> = Available
|
|
</div>
|
|
<div class="nav-back ps-3"><button></button></div>
|
|
<div class="nav-forward pe-3"><button></button></div>
|
|
<!-- Do the days of the weeek need to be read? -->
|
|
<div class="grid-item caption"><span class="sr-only">Sunday</span>S</div>
|
|
<div class="grid-item caption"><span class="sr-only">Monday</span>M</div>
|
|
<div class="grid-item caption"><span class="sr-only">Tuesday</span>T</div>
|
|
<div class="grid-item caption"><span class="sr-only">Wednesday</span>W</div>
|
|
<div class="grid-item caption"><span class="sr-only">Thursday</span>T</div>
|
|
<div class="grid-item caption"><span class="sr-only">Friday</span>F</div>
|
|
<div class="grid-item caption"><span class="sr-only">Saturday</span>S</div>
|
|
<div
|
|
v-for="date in month.dates"
|
|
:key="`${month.monthLabel}-${date.dateNum.toString()}-${months.length}`"
|
|
class="grid-item radio-wrapper"
|
|
:class="[
|
|
date.dateNum === 1 ? 'first-day-' + month.startDateDayIndex : '',
|
|
date.dayClasses,
|
|
]">
|
|
<input
|
|
:disabled="!isSelectableDate(date.inputValue)"
|
|
type="radio"
|
|
name="day-of-month"
|
|
v-model="selectedDate"
|
|
:value="date.inputValue"
|
|
:id="`${month.monthLabel}-${date.dateNum.toString()}`" />
|
|
<label :for="`${month.monthLabel}-${date.dateNum.toString()}`">
|
|
<span>{{ date.dateNum.toString() }}</span>
|
|
</label>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</fieldset>
|
|
<button
|
|
v-if="calendarViewDirection === 'future'"
|
|
type="button"
|
|
class="btn-link"
|
|
@click="goForward">
|
|
View more dates
|
|
</button>
|
|
</div>
|
|
</template>
|
|
|
|
<script>
|
|
// Supporting files
|
|
import { monthsOfYear } from "@/constants/scheduling.js";
|
|
|
|
const SelectableDaysOptions = Object.freeze({
|
|
CUSTOM: "custom",
|
|
PAST: "past",
|
|
});
|
|
|
|
export default {
|
|
name: "datePicker",
|
|
data() {
|
|
return {
|
|
calendarData: [],
|
|
monthsBeforeToLoadOffset: 0,
|
|
monthsAfterToLoadOffset: 1,
|
|
selectableDatesData: [], // NOTE: uses monthNum (1-based), NOT monthIndex (0-based)
|
|
isInitialView: true,
|
|
initialViewRowsToShow: 5,
|
|
initialViewRowsTally: 0,
|
|
};
|
|
},
|
|
props: {
|
|
selectableDates: {
|
|
type: String,
|
|
validator(value) {
|
|
return Object.values(SelectableDaysOptions).includes(value);
|
|
},
|
|
default: SelectableDaysOptions.PAST,
|
|
},
|
|
modelValue: {
|
|
type: Object,
|
|
},
|
|
todayOverrideDateString: {
|
|
// only used for unit tests to override today's date
|
|
type: String,
|
|
default: null,
|
|
},
|
|
customSelectableDatesCallback: {
|
|
type: Function,
|
|
default() {
|
|
return [];
|
|
},
|
|
},
|
|
},
|
|
computed: {
|
|
todayDate() {
|
|
return this.todayOverrideDateString
|
|
? new Date(this.todayOverrideDateString)
|
|
: new Date();
|
|
},
|
|
months() {
|
|
if (this.calendarData?.length < 1) {
|
|
this.setCalendarData();
|
|
}
|
|
return this.calendarData;
|
|
},
|
|
calendarViewDirection() {
|
|
if (this.selectableDates === "past") return "past";
|
|
if (this.selectableDates === "custom") return "future";
|
|
return "none";
|
|
},
|
|
selectedDate: {
|
|
get() {
|
|
return this.modelValue;
|
|
},
|
|
set(newSelectedDate) {
|
|
this.$emit("update:modelValue", newSelectedDate);
|
|
},
|
|
},
|
|
},
|
|
methods: {
|
|
goForward() {
|
|
if (this.isInitialView) {
|
|
this.isInitialView = false; // 1st click removes hidden styling
|
|
} else {
|
|
this.addMonthData(); // 2nd click adds 1 month data
|
|
}
|
|
},
|
|
setCalendarData() {
|
|
// GENERATE MONTHS AND PUSH THEM INTO ARRAY
|
|
const months = [];
|
|
if (this.calendarViewDirection === "future") {
|
|
// first 0, then 1
|
|
for (let i = 0; i <= this.monthsAfterToLoadOffset; i++) {
|
|
months.push(this.getMonthData(i));
|
|
}
|
|
} else if (this.calendarViewDirection === "past") {
|
|
// first 0, then -1
|
|
for (let i = 0; i >= 0 - this.monthsAfterToLoadOffset; i--) {
|
|
months.unshift(this.getMonthData(i));
|
|
}
|
|
} else {
|
|
// TK - IF A CALENDAR WITH BOTH PAST AND FUTURE WAS EVER NEEDED
|
|
// for (let i = this.monthsAfterToLoadOffset; i >= this.monthsBeforeToLoadOffset; i--) {
|
|
// months.push(this.getMonthDataPAST(i));
|
|
// }
|
|
}
|
|
this.calendarData = months;
|
|
},
|
|
getMonthData(offset) {
|
|
const direction = this.calendarViewDirection; // "past" or "future"
|
|
let yearNum;
|
|
let monthIndex;
|
|
let monthClass = "";
|
|
let initialViewEndDate; // only used for setCalendarData FUTURE
|
|
let initialViewStartDate; // only used for setCalendarData PAST
|
|
let currentWeekEndDateNum; // only used for setCalendarData PAST
|
|
let currentWeekStartDateNum; // only used for setCalendarData FUTURE
|
|
let firstMonthDayTally; // only used for setCalendarData on 1st month generated
|
|
const datesArray = [];
|
|
const todayDateNum = this.todayDate.getDate();
|
|
|
|
if (typeof offset !== "undefined") {
|
|
// offset only passed during initial setup with setCalendarData
|
|
yearNum = this.todayDate.getFullYear();
|
|
monthIndex = this.todayDate.getMonth() + offset;
|
|
|
|
if (direction === "future" && offset > 0) {
|
|
while (monthIndex > 11) {
|
|
monthIndex = monthIndex - 12;
|
|
yearNum++;
|
|
}
|
|
} else if (direction === "past" && offset < 0) {
|
|
while (monthIndex < 0) {
|
|
monthIndex = 12 + monthIndex;
|
|
yearNum--;
|
|
}
|
|
}
|
|
} else {
|
|
// used only when adding an additional month
|
|
|
|
if (direction === "future") {
|
|
// get last day of current calendar data
|
|
const lastMonthInCalendarData = this.calendarData[this.calendarData.length - 1];
|
|
|
|
if (lastMonthInCalendarData.monthIndex === 11) {
|
|
monthIndex = 0;
|
|
yearNum = lastMonthInCalendarData.yearNum + 1;
|
|
} else {
|
|
monthIndex = lastMonthInCalendarData.monthIndex + 1;
|
|
yearNum = lastMonthInCalendarData.yearNum;
|
|
}
|
|
}
|
|
if (direction === "past") {
|
|
// get last day of current calendar data
|
|
const firstMonthInCalendarData = this.calendarData[0];
|
|
|
|
if (firstMonthInCalendarData.monthIndex === 0) {
|
|
monthIndex = 11;
|
|
yearNum = firstMonthInCalendarData.yearNum - 1;
|
|
} else {
|
|
monthIndex = firstMonthInCalendarData.monthIndex - 1;
|
|
yearNum = firstMonthInCalendarData.yearNum;
|
|
}
|
|
}
|
|
}
|
|
|
|
const todayDayIndex = this.todayDate.getDay(); // get day of week index of today (0-6)
|
|
currentWeekStartDateNum =
|
|
todayDayIndex > todayDateNum ? 1 : todayDateNum - todayDayIndex; // FUTURE
|
|
const monthEndDate = new Date(yearNum, monthIndex + 1, 0); // BOTH
|
|
let monthEndDateNum = monthEndDate.getDate(); // BOTH
|
|
currentWeekEndDateNum = todayDateNum + 6 - todayDayIndex; // PAST, aka Sat. (ok if it's larger than the month end?)
|
|
if (offset === 0 && direction === "past" && monthEndDateNum > currentWeekEndDateNum)
|
|
monthEndDateNum = currentWeekEndDateNum; // PAST
|
|
const monthStartDateNum =
|
|
offset === 0 && direction === "future" ? currentWeekStartDateNum : 1; // FUTURE
|
|
const monthStartDate = new Date(yearNum, monthIndex, monthStartDateNum); // BOTH
|
|
const startDateDayIndex = monthStartDate.getDay(); // FUTURE
|
|
const endDateDayIndex = monthEndDate.getDay(); // PAST
|
|
|
|
if (typeof offset !== "undefined") {
|
|
if (offset === 0 && direction === "future")
|
|
firstMonthDayTally = monthStartDateNum - startDateDayIndex; // FUTURE (starts low, counts up)
|
|
if (offset === 0 && direction === "past")
|
|
firstMonthDayTally = currentWeekEndDateNum; // PAST (starts high, counts down)
|
|
|
|
while (
|
|
direction === "future" &&
|
|
offset === 0 &&
|
|
firstMonthDayTally < monthEndDateNum
|
|
) {
|
|
// FUTURE
|
|
firstMonthDayTally = firstMonthDayTally + 7;
|
|
this.initialViewRowsTally++;
|
|
}
|
|
|
|
while (
|
|
direction === "past" &&
|
|
offset === 0 &&
|
|
firstMonthDayTally > monthStartDateNum
|
|
) {
|
|
// PAST
|
|
firstMonthDayTally = firstMonthDayTally - 7;
|
|
this.initialViewRowsTally++;
|
|
}
|
|
|
|
if (Math.abs(offset) === 1) {
|
|
if (this.initialViewRowsTally < this.initialViewRowsToShow) {
|
|
initialViewEndDate = 6 - startDateDayIndex + monthStartDateNum; // FUTURE
|
|
initialViewStartDate = monthEndDateNum - endDateDayIndex; // PAST
|
|
this.initialViewRowsTally++;
|
|
} else {
|
|
monthClass = monthClass + " month-hidden";
|
|
}
|
|
|
|
while (this.initialViewRowsTally < this.initialViewRowsToShow) {
|
|
initialViewEndDate = initialViewEndDate + 7;
|
|
initialViewStartDate = initialViewStartDate - 7;
|
|
this.initialViewRowsTally++;
|
|
}
|
|
}
|
|
}
|
|
|
|
if (this.selectableDates === "custom") {
|
|
const monthStart = {
|
|
year: yearNum,
|
|
month: monthIndex + 1,
|
|
date: offset === 0 ? todayDateNum : monthStartDateNum,
|
|
};
|
|
const monthEnd = {
|
|
year: monthEndDate.getFullYear(),
|
|
month: monthEndDate.getMonth() + 1,
|
|
date: monthEndDateNum,
|
|
};
|
|
// get available dates
|
|
this.updateSelectableDates(monthStart, monthEnd);
|
|
}
|
|
|
|
// populate datesArray
|
|
for (let i = monthStartDateNum; i <= monthEndDateNum; i++) {
|
|
let dayClasses = "";
|
|
const thisDate = {
|
|
// NOTE: uses monthNum (1-based), NOT monthIndex (0-based)
|
|
year: yearNum,
|
|
month: monthIndex + 1,
|
|
date: i,
|
|
};
|
|
if (offset === 0 && i === todayDateNum) {
|
|
dayClasses += "current-day";
|
|
}
|
|
if (offset === 0 && i < todayDateNum && direction === "future") {
|
|
dayClasses += "unavailable-day";
|
|
}
|
|
if (offset === 0 && i > todayDateNum && direction === "past") {
|
|
dayClasses += "unavailable-day";
|
|
}
|
|
if (Math.abs(offset) === 1 && direction === "future" && i > initialViewEndDate) {
|
|
dayClasses += "day-hidden";
|
|
}
|
|
if (Math.abs(offset) === 1 && direction === "past" && i < initialViewStartDate) {
|
|
dayClasses += "day-hidden";
|
|
}
|
|
if (this.selectableDates === "custom" && this.isSelectableDate(thisDate)) {
|
|
dayClasses += " selectable-day";
|
|
}
|
|
const dateObject = {
|
|
dateNum: i,
|
|
dayClasses: dayClasses,
|
|
inputValue: thisDate,
|
|
};
|
|
datesArray.push(dateObject);
|
|
}
|
|
|
|
const monthToAdd = {
|
|
monthLabel: monthsOfYear[monthIndex],
|
|
monthIndex: monthIndex,
|
|
yearNum: yearNum,
|
|
dates: datesArray,
|
|
startDateDayIndex: startDateDayIndex,
|
|
monthClass: monthClass,
|
|
};
|
|
|
|
return monthToAdd;
|
|
},
|
|
addMonthData() {
|
|
const monthToAdd = this.getMonthData();
|
|
if (this.calendarViewDirection === "future") {
|
|
this.calendarData.push(monthToAdd);
|
|
}
|
|
if (this.calendarViewDirection === "past") {
|
|
this.calendarData.unshift(monthToAdd);
|
|
}
|
|
},
|
|
isSelectableDate(thisDate) {
|
|
const testForDate = (dateInArray) => {
|
|
return (
|
|
dateInArray.date === thisDate.date &&
|
|
dateInArray.month === thisDate.month &&
|
|
dateInArray.year === thisDate.year
|
|
);
|
|
};
|
|
const isSelectable =
|
|
this.selectableDatesData.findIndex(testForDate) > -1 ? true : false;
|
|
return isSelectable;
|
|
},
|
|
updateSelectableDates(monthStart, monthEnd) {
|
|
this.customSelectableDatesCallback(monthStart, monthEnd)?.forEach((newObj) => {
|
|
const index = this.selectableDatesData.findIndex(
|
|
(obj) =>
|
|
obj.year === newObj.year &&
|
|
obj.month === newObj.month &&
|
|
obj.date === newObj.date
|
|
);
|
|
if (index === -1) this.selectableDatesData.push(newObj);
|
|
});
|
|
},
|
|
},
|
|
};
|
|
</script>
|
|
|
|
<style lang="scss" scoped>
|
|
.calendar-grid-container {
|
|
margin: 0 auto;
|
|
max-width: 414px;
|
|
display: grid;
|
|
grid-template-columns: repeat(7, 1fr);
|
|
justify-content: center;
|
|
align-items: center;
|
|
padding: 4px;
|
|
|
|
.grid-item {
|
|
text-align: center;
|
|
margin: 10%;
|
|
|
|
&.first-day-,
|
|
&.first-day-0 {
|
|
grid-column-start: 1;
|
|
}
|
|
&.first-day-1 {
|
|
grid-column-start: 2;
|
|
}
|
|
&.first-day-2 {
|
|
grid-column-start: 3;
|
|
}
|
|
&.first-day-3 {
|
|
grid-column-start: 4;
|
|
}
|
|
&.first-day-4 {
|
|
grid-column-start: 5;
|
|
}
|
|
&.first-day-5 {
|
|
grid-column-start: 6;
|
|
}
|
|
&.first-day-6 {
|
|
grid-column-start: 7;
|
|
}
|
|
}
|
|
|
|
.month-year {
|
|
grid-area: 1 / 1 / 2 / 5;
|
|
text-transform: uppercase;
|
|
font-weight: 300;
|
|
letter-spacing: 0.75px;
|
|
}
|
|
.legend {
|
|
grid-area: 1 / 5 / 2 / 8;
|
|
.legend-circle {
|
|
border-radius: 50%;
|
|
width: 16px;
|
|
height: 16px;
|
|
background-color: $blue-100;
|
|
border: 1px solid $blue;
|
|
}
|
|
}
|
|
.nav-back,
|
|
.nav-forward {
|
|
display: none;
|
|
}
|
|
|
|
.radio-wrapper {
|
|
position: relative;
|
|
display: flex;
|
|
justify-content: center;
|
|
align-items: center;
|
|
outline: none;
|
|
|
|
input[type="radio"] {
|
|
position: absolute; //override bootstrap
|
|
height: 0;
|
|
opacity: 0;
|
|
|
|
&:focus-visible + label {
|
|
box-shadow: 0 0 0 2.5px $blue;
|
|
}
|
|
|
|
&:focus + label,
|
|
&:checked:focus + label {
|
|
box-shadow: 0 0 0 3px #fff, 0 0 0 5.5px #1574a1;
|
|
background-color: $blue;
|
|
color: $white;
|
|
&.past-day {
|
|
box-shadow: none;
|
|
background-color: transparent;
|
|
color: $gray-500;
|
|
font-weight: normal;
|
|
}
|
|
&.current-day {
|
|
&:after {
|
|
background-color: $white;
|
|
}
|
|
.first-day {
|
|
color: $white;
|
|
}
|
|
}
|
|
}
|
|
|
|
&:checked + label {
|
|
color: $white;
|
|
background: $blue;
|
|
box-shadow: 0 0 0 3px $white, 0 0 0 5.5px $blue;
|
|
&:after {
|
|
background-color: $white;
|
|
}
|
|
.first-day {
|
|
color: $white;
|
|
}
|
|
}
|
|
}
|
|
|
|
.first-day {
|
|
position: absolute;
|
|
font-size: 9px;
|
|
font-weight: 500;
|
|
color: $gray-600;
|
|
top: 0;
|
|
z-index: 1;
|
|
}
|
|
|
|
label {
|
|
position: relative;
|
|
cursor: pointer;
|
|
display: flex;
|
|
justify-content: center;
|
|
align-items: center;
|
|
width: 40px;
|
|
height: 40px;
|
|
min-width: 40px;
|
|
border-radius: 50%;
|
|
|
|
span {
|
|
&.small {
|
|
font-size: 0.75rem;
|
|
color: $gray-550;
|
|
}
|
|
}
|
|
|
|
&:hover,
|
|
&:checked {
|
|
@include media-breakpoint-up(sm) {
|
|
box-shadow: 0 0 0 4px transparent;
|
|
background-color: $blue;
|
|
color: $white;
|
|
&:after {
|
|
background-color: $white;
|
|
}
|
|
}
|
|
cursor: pointer;
|
|
&.current-day {
|
|
+ .first-day {
|
|
color: $white;
|
|
}
|
|
}
|
|
.first-day {
|
|
color: $white;
|
|
}
|
|
}
|
|
+ p {
|
|
display: none;
|
|
}
|
|
}
|
|
|
|
&.selectable-day {
|
|
label {
|
|
color: $blue;
|
|
background-color: $blue-100;
|
|
border: 1px solid $blue;
|
|
}
|
|
}
|
|
&.past-day,
|
|
&.future-day,
|
|
&.unavailable-day {
|
|
label {
|
|
color: $gray-500;
|
|
background-color: transparent;
|
|
border: 1px solid transparent;
|
|
pointer-events: none;
|
|
}
|
|
}
|
|
&.current-day {
|
|
label {
|
|
&:after {
|
|
content: "";
|
|
width: 0.25rem;
|
|
height: 0.25rem;
|
|
border-radius: 50%;
|
|
background-color: $blue;
|
|
position: absolute;
|
|
top: 28px;
|
|
}
|
|
.first-day {
|
|
color: $blue;
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
&.initial-view {
|
|
&.month-hidden {
|
|
display: none;
|
|
}
|
|
.day-hidden {
|
|
display: none;
|
|
}
|
|
}
|
|
}
|
|
.past {
|
|
.calendar-grid-container {
|
|
.month-year {
|
|
grid-area: 1 / 1 / 2 / 6;
|
|
}
|
|
.nav-forward {
|
|
grid-area: 1 / 7 / 2 / 8;
|
|
display: flex;
|
|
justify-content: flex-end;
|
|
button {
|
|
&:after {
|
|
content: "";
|
|
position: absolute;
|
|
width: 7px;
|
|
height: 12px;
|
|
background-image: url("data:image/svg+xml,%3Csvg width='7' height='12' viewBox='0 0 7 12' fill='none' xmlns='http://www.w3.org/2000/svg'%3E%3Cpath d='M6.66831 5.99879C6.66955 6.17554 6.60074 6.34558 6.47695 6.47168L1.15501 11.8017C1.02812 11.9287 0.85603 12 0.676587 12C0.497145 12 0.325053 11.9287 0.198168 11.8017C0.0712831 11.6748 7.268e-09 11.5026 8.07183e-09 11.3231C8.87567e-09 11.1436 0.0712831 10.9714 0.198168 10.8445L5.05126 5.99879L0.198168 1.14736C0.0725521 1.02042 0.00248585 0.848755 0.00338306 0.670131C0.00428028 0.491506 0.0760674 0.320554 0.202952 0.194881C0.329837 0.0692091 0.501426 -0.000888818 0.679971 9.54485e-06C0.858515 0.000906955 1.02939 0.0727263 1.15501 0.199668L6.47312 5.52399C6.59843 5.65017 6.66862 5.82092 6.66831 5.99879Z' fill='%231574A1'/%3E%3C/svg%3E%0A");
|
|
}
|
|
}
|
|
}
|
|
.nav-back {
|
|
grid-area: 1 / 6 / 2 / 7;
|
|
display: flex;
|
|
justify-content: flex-end;
|
|
button {
|
|
&:after {
|
|
content: "";
|
|
position: absolute;
|
|
width: 7px;
|
|
height: 12px;
|
|
background-image: url("data:image/svg+xml,%3Csvg width='7' height='12' viewBox='0 0 7 12' fill='none' xmlns='http://www.w3.org/2000/svg'%3E%3Cpath d='M0.331685 6.00121C0.330445 5.82446 0.399256 5.65442 0.523053 5.52832L5.84499 0.198256C5.97188 0.0713149 6.14397 -8.63821e-08 6.32341 -6.72174e-08C6.50285 -4.80527e-08 6.67495 0.071315 6.80183 0.198256C6.92872 0.325198 7 0.497368 7 0.67689C7 0.856412 6.92872 1.02858 6.80183 1.15552L1.94874 6.00121L6.80183 10.8526C6.92745 10.9796 6.99751 11.1512 6.99662 11.3299C6.99572 11.5085 6.92393 11.6794 6.79705 11.8051C6.67016 11.9308 6.49857 12.0009 6.32003 12C6.14148 11.9991 5.97061 11.9273 5.84499 11.8003L0.526881 6.47601C0.401568 6.34983 0.331375 6.17908 0.331685 6.00121Z' fill='%231574A1'/%3E%3C/svg%3E%0A");
|
|
}
|
|
}
|
|
}
|
|
.nav-back,
|
|
.nav-forward {
|
|
button {
|
|
position: relative;
|
|
border-radius: 50%;
|
|
width: 28px;
|
|
height: 28px;
|
|
background-color: $blue-100;
|
|
border: 1px solid $blue;
|
|
display: flex;
|
|
justify-content: center;
|
|
align-items: center;
|
|
&:focus,
|
|
&:focus-visible {
|
|
border: 2.5px solid $blue;
|
|
box-shadow: none;
|
|
background-color: $blue-100;
|
|
color: $white;
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
</style>
|