DigitalConsumer.FixMyGlass/src/layouts/scheduling/date-picker/date-picker.vue
scottkiener-at-safelite 85e28701f3 CASH-2572 | Add in empty dates logic
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
2026-06-05 10:19:31 -04:00

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>