270 lines
7.8 KiB
Vue
270 lines
7.8 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">
|
||
‹
|
||
</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">
|
||
›
|
||
</button>
|
||
</div>
|
||
</template>
|
||
|
||
<script>
|
||
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 = 768;
|
||
|
||
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);
|
||
}
|
||
|
||
export default {
|
||
name: "datePicker",
|
||
props: {
|
||
availableDates: {
|
||
type: Array,
|
||
default: () => [],
|
||
},
|
||
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: DESKTOP_WINDOW_SIZE,
|
||
};
|
||
},
|
||
computed: {
|
||
allDates() {
|
||
const availableSet = new Set(this.availableDates);
|
||
|
||
const hasBounds = this.startDate && this.endDate;
|
||
const hasAvailable = this.availableDates.length > 0;
|
||
if (!hasBounds && !hasAvailable) return [];
|
||
|
||
let first, last;
|
||
if (hasBounds) {
|
||
first = parseLocalDate(this.startDate);
|
||
last = parseLocalDate(this.endDate);
|
||
} else {
|
||
const sorted = [...this.availableDates].sort();
|
||
first = parseLocalDate(sorted[0]);
|
||
last = parseLocalDate(sorted[sorted.length - 1]);
|
||
}
|
||
|
||
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() {
|
||
return !this.isLoadingDates && this.allDates.length > 0;
|
||
},
|
||
},
|
||
watch: {
|
||
// Only reset position when startDate changes — that signals a fresh range.
|
||
// Extending endDate/availableDates (loading more) keeps the current window.
|
||
startDate() {
|
||
this.windowStart = 0;
|
||
},
|
||
},
|
||
mounted() {
|
||
this.updateWindowSize();
|
||
window.addEventListener("resize", this.updateWindowSize);
|
||
if (this.modelValue && this.allDates.length) {
|
||
const idx = this.allDates.findIndex((d) => d.value === this.modelValue);
|
||
if (idx !== -1) {
|
||
this.windowStart = Math.floor(idx / this.windowSize) * this.windowSize;
|
||
}
|
||
}
|
||
},
|
||
beforeUnmount() {
|
||
window.removeEventListener("resize", this.updateWindowSize);
|
||
},
|
||
methods: {
|
||
updateWindowSize() {
|
||
this.windowSize =
|
||
window.innerWidth >= DESKTOP_BREAKPOINT_PX
|
||
? DESKTOP_WINDOW_SIZE
|
||
: MOBILE_WINDOW_SIZE;
|
||
},
|
||
goBack() {
|
||
this.windowStart = Math.max(0, this.windowStart - this.windowSize);
|
||
},
|
||
goForward() {
|
||
if (this.isLoadingDates) return;
|
||
const isAtEnd = this.windowStart + this.windowSize >= this.allDates.length;
|
||
if (isAtEnd) {
|
||
this.$emit("requestMoreDates");
|
||
} else {
|
||
const maxStart = Math.max(0, this.allDates.length - this.windowSize);
|
||
this.windowStart = Math.min(maxStart, this.windowStart + 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;
|
||
font-size: 2rem;
|
||
line-height: 1;
|
||
color: $blue;
|
||
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 {
|
||
color: $gray-300;
|
||
cursor: default;
|
||
}
|
||
}
|
||
|
||
&__track {
|
||
gap: 0.5rem;
|
||
}
|
||
|
||
&__day-card {
|
||
flex: 1;
|
||
min-width: 0;
|
||
border: 1px solid $gray-200;
|
||
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) {
|
||
border-color: $blue;
|
||
}
|
||
|
||
&--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 {
|
||
font-family: $font-family-sans-serif-semibold;
|
||
font-size: $font-size-12;
|
||
color: $gray-600;
|
||
text-transform: uppercase;
|
||
line-height: 1.2;
|
||
}
|
||
|
||
&__day-date {
|
||
font-family: $font-family-sans-serif-bold;
|
||
font-size: $font-size-14;
|
||
color: $gray-700;
|
||
line-height: 1.4;
|
||
white-space: nowrap;
|
||
}
|
||
}
|
||
</style>
|