It wasn't possible to display 15 days that had no actual available dates Changed initialization logic to no longer assume an empty availableDates meant we hadn't loaded yet Updated unit tests Fixed a styling bug for 2571
339 lines
10 KiB
Vue
339 lines
10 KiB
Vue
<template>
|
|
<div class="date-picker d-flex align-items-center">
|
|
<button
|
|
class="date-picker__nav-btn"
|
|
:disabled="!canGoBack"
|
|
aria-label="Previous dates"
|
|
@click="goBack">
|
|
<img
|
|
:src="navIconSrc(canGoBack)"
|
|
class="date-picker__nav-icon date-picker__nav-icon--flipped"
|
|
alt="" />
|
|
</button>
|
|
<div class="date-picker__track d-flex flex-grow-1">
|
|
<button
|
|
v-for="date in visibleDates"
|
|
:key="date.value"
|
|
class="date-picker__day-card d-flex flex-column align-items-center justify-content-center"
|
|
:class="{
|
|
'date-picker__day-card--selected': isSelected(date.value),
|
|
'date-picker__day-card--disabled': !date.isAvailable,
|
|
}"
|
|
:disabled="!date.isAvailable"
|
|
:aria-pressed="isSelected(date.value)"
|
|
:aria-label="`${date.dayAbbr} ${date.monthAbbr} ${date.day}`"
|
|
@click="selectDate(date)">
|
|
<span class="date-picker__day-abbr">{{ date.dayAbbr }}</span>
|
|
<span class="date-picker__day-date">{{ date.monthAbbr }} {{ date.day }}</span>
|
|
</button>
|
|
</div>
|
|
<button
|
|
class="date-picker__nav-btn"
|
|
:disabled="!canGoForward"
|
|
aria-label="Next dates"
|
|
@click="goForward">
|
|
<img :src="navIconSrc(canGoForward)" class="date-picker__nav-icon" alt="" />
|
|
</button>
|
|
</div>
|
|
</template>
|
|
|
|
<script>
|
|
import { Breakpoints } from "@/constants/breakpoints";
|
|
|
|
const DAY_ABBRS = ["SUN", "MON", "TUE", "WED", "THU", "FRI", "SAT"];
|
|
const MONTH_ABBRS = [
|
|
"Jan",
|
|
"Feb",
|
|
"Mar",
|
|
"Apr",
|
|
"May",
|
|
"Jun",
|
|
"Jul",
|
|
"Aug",
|
|
"Sep",
|
|
"Oct",
|
|
"Nov",
|
|
"Dec",
|
|
];
|
|
const MOBILE_WINDOW_SIZE = 3;
|
|
const DESKTOP_WINDOW_SIZE = 5;
|
|
const DESKTOP_BREAKPOINT_PX = Breakpoints.MD;
|
|
const MAX_DATE_RANGE = 180;
|
|
|
|
function toLocalDateString(date) {
|
|
const y = date.getFullYear();
|
|
const m = String(date.getMonth() + 1).padStart(2, "0");
|
|
const d = String(date.getDate()).padStart(2, "0");
|
|
return `${y}-${m}-${d}`;
|
|
}
|
|
|
|
function parseLocalDate(str) {
|
|
const [year, month, day] = str.split("-").map(Number);
|
|
return new Date(year, month - 1, day);
|
|
}
|
|
|
|
function getWindowSize() {
|
|
return window.innerWidth >= DESKTOP_BREAKPOINT_PX ? DESKTOP_WINDOW_SIZE : MOBILE_WINDOW_SIZE;
|
|
}
|
|
|
|
export default {
|
|
name: "datePicker",
|
|
props: {
|
|
availableDates: {
|
|
default: null,
|
|
validator: (v) => v === null || Array.isArray(v),
|
|
},
|
|
startDate: {
|
|
type: String,
|
|
default: null,
|
|
},
|
|
endDate: {
|
|
type: String,
|
|
default: null,
|
|
},
|
|
modelValue: {
|
|
type: String,
|
|
default: null,
|
|
},
|
|
isLoadingDates: {
|
|
type: Boolean,
|
|
default: false,
|
|
},
|
|
},
|
|
emits: ["update:modelValue", "requestMoreDates"],
|
|
data() {
|
|
return {
|
|
windowStart: 0,
|
|
windowSize: getWindowSize(),
|
|
pendingAutoSelect: false,
|
|
initialized: false,
|
|
};
|
|
},
|
|
computed: {
|
|
allDates() {
|
|
if (this.availableDates === null) return [];
|
|
|
|
const availableSet = new Set(this.availableDates);
|
|
|
|
if (!this.startDate || !this.endDate) return [];
|
|
|
|
const first = parseLocalDate(this.startDate);
|
|
const last = parseLocalDate(this.endDate);
|
|
|
|
const dates = [];
|
|
const cursor = new Date(first);
|
|
while (cursor <= last) {
|
|
const value = toLocalDateString(cursor);
|
|
dates.push({
|
|
value,
|
|
isAvailable: availableSet.has(value),
|
|
dayAbbr: DAY_ABBRS[cursor.getDay()],
|
|
monthAbbr: MONTH_ABBRS[cursor.getMonth()],
|
|
day: cursor.getDate(),
|
|
});
|
|
cursor.setDate(cursor.getDate() + 1);
|
|
}
|
|
return dates;
|
|
},
|
|
visibleDates() {
|
|
return this.allDates.slice(this.windowStart, this.windowStart + this.windowSize);
|
|
},
|
|
canGoBack() {
|
|
return this.windowStart > 0;
|
|
},
|
|
canGoForward() {
|
|
if (this.isLoadingDates || !this.allDates.length) return false;
|
|
const atEnd = this.windowStart + this.windowSize >= this.allDates.length;
|
|
return !(atEnd && this.allDates.length >= MAX_DATE_RANGE);
|
|
},
|
|
},
|
|
watch: {
|
|
allDates(newDates) {
|
|
if (!newDates.length) return;
|
|
|
|
if (!this.initialized && !this.modelValue) {
|
|
this.initialize();
|
|
return;
|
|
}
|
|
// Pending auto select is set when the user is at the end of the dates and the next page of dates is loaded.
|
|
if (this.pendingAutoSelect) {
|
|
this.pendingAutoSelect = false;
|
|
this.advanceWindowAndAutoSelect();
|
|
}
|
|
},
|
|
},
|
|
mounted() {
|
|
this.updateWindowSize();
|
|
window.addEventListener("resize", this.updateWindowSize);
|
|
if (!this.allDates.length) return;
|
|
// If a model value is set, we need to set the window start to the index of the model value.
|
|
// Otherwise, we need to initialize the window.
|
|
if (this.modelValue) {
|
|
const index = this.allDates.findIndex((d) => d.value === this.modelValue);
|
|
if (index !== -1) this.windowStart = this.getWindowStartFromIndex(index);
|
|
} else {
|
|
this.initialize();
|
|
}
|
|
this.initialized = true;
|
|
},
|
|
beforeUnmount() {
|
|
window.removeEventListener("resize", this.updateWindowSize);
|
|
},
|
|
methods: {
|
|
updateWindowSize() {
|
|
this.windowSize = getWindowSize();
|
|
},
|
|
navIconSrc(enabled) {
|
|
return enabled
|
|
? require("@/assets/img/icons/chevron-right-blue.svg")
|
|
: require("@/assets/img/icons/chevron-right-grey.svg");
|
|
},
|
|
getWindowStartFromIndex(index) {
|
|
return Math.floor(index / this.windowSize) * this.windowSize;
|
|
},
|
|
goBack() {
|
|
this.windowStart = Math.max(0, this.windowStart - this.windowSize);
|
|
this.autoSelectFirstAvailable();
|
|
},
|
|
goForward() {
|
|
if (this.isLoadingDates) return;
|
|
const isAtEnd = this.windowStart + this.windowSize >= this.allDates.length;
|
|
if (isAtEnd) {
|
|
this.$emit("requestMoreDates");
|
|
this.pendingAutoSelect = true;
|
|
} else {
|
|
this.advanceWindowAndAutoSelect();
|
|
}
|
|
},
|
|
advanceWindowAndAutoSelect() {
|
|
const maxStart = Math.max(0, this.allDates.length - this.windowSize);
|
|
this.windowStart = Math.min(maxStart, this.windowStart + this.windowSize);
|
|
this.autoSelectFirstAvailable();
|
|
},
|
|
autoSelectFirstAvailable() {
|
|
// Finds the first available date in the visible dates and emits the value.
|
|
const first = this.visibleDates.find((d) => d.isAvailable);
|
|
this.$emit("update:modelValue", first ? first.value : null);
|
|
},
|
|
initialize() {
|
|
this.initialized = true;
|
|
if (this.modelValue !== null) return;
|
|
|
|
const firstAvailable = this.allDates.find((d) => d.isAvailable);
|
|
if (firstAvailable) {
|
|
// If an available date is found, set the window start ensures the first available date is visible.
|
|
const index = this.allDates.indexOf(firstAvailable);
|
|
this.windowStart = this.getWindowStartFromIndex(index);
|
|
this.$emit("update:modelValue", firstAvailable.value);
|
|
} else {
|
|
// If no available dates are found, set the window start to the last window.
|
|
this.windowStart = Math.max(0, this.allDates.length - this.windowSize);
|
|
}
|
|
},
|
|
selectDate(date) {
|
|
if (!date.isAvailable) return;
|
|
this.$emit("update:modelValue", date.value);
|
|
},
|
|
isSelected(value) {
|
|
return this.modelValue === value;
|
|
},
|
|
},
|
|
};
|
|
</script>
|
|
|
|
<style lang="scss">
|
|
.date-picker {
|
|
gap: 0.25rem;
|
|
|
|
&__nav-btn {
|
|
flex-shrink: 0;
|
|
width: 2rem;
|
|
height: 2rem;
|
|
border: none;
|
|
background: transparent;
|
|
cursor: pointer;
|
|
display: flex;
|
|
align-items: center;
|
|
justify-content: center;
|
|
padding: 0;
|
|
border-radius: 50%;
|
|
transition: background 150ms ease;
|
|
|
|
&:hover:not(:disabled) {
|
|
background: $blue-100;
|
|
}
|
|
|
|
&:disabled {
|
|
cursor: default;
|
|
}
|
|
}
|
|
|
|
&__track {
|
|
gap: 0.25rem;
|
|
}
|
|
|
|
&__day-card {
|
|
flex: 1;
|
|
min-width: 0;
|
|
border: 1px solid $blue;
|
|
border-radius: $border-radius-lg;
|
|
background: $white;
|
|
padding: 0.625rem 0.25rem;
|
|
cursor: pointer;
|
|
transition:
|
|
background 150ms ease,
|
|
border-color 150ms ease;
|
|
|
|
&:hover:not(:disabled):not(.date-picker__day-card--selected) {
|
|
background: $blue-100;
|
|
}
|
|
|
|
&--selected {
|
|
background: $blue;
|
|
border-color: $blue;
|
|
|
|
.date-picker__day-abbr,
|
|
.date-picker__day-date {
|
|
color: $white;
|
|
}
|
|
}
|
|
|
|
&--disabled {
|
|
background: $gray-100;
|
|
border-color: $gray-200;
|
|
cursor: default;
|
|
|
|
.date-picker__day-abbr,
|
|
.date-picker__day-date {
|
|
color: $gray-300;
|
|
}
|
|
}
|
|
}
|
|
|
|
&__day-abbr,
|
|
&__day-date {
|
|
font-family: $font-family-sans-serif-bold;
|
|
font-size: $font-size-14;
|
|
color: $blue;
|
|
}
|
|
|
|
&__day-abbr {
|
|
text-transform: uppercase;
|
|
line-height: 1.2;
|
|
}
|
|
|
|
&__day-date {
|
|
line-height: 1.4;
|
|
white-space: nowrap;
|
|
}
|
|
|
|
&__nav-icon {
|
|
width: 24px;
|
|
height: 24px;
|
|
|
|
&--flipped {
|
|
transform: scaleX(-1);
|
|
}
|
|
}
|
|
}
|
|
</style>
|