Merge branch 'feature/schedule-page-v2' into feature/CASH-2571
This commit is contained in:
commit
e048ce0eec
3 changed files with 542 additions and 0 deletions
|
|
@ -1,5 +1,6 @@
|
||||||
<template>
|
<template>
|
||||||
<Form @submit="onSubmit" @invalid-submit="onInvalidSubmit" ref="theForm">
|
<Form @submit="onSubmit" @invalid-submit="onInvalidSubmit" ref="theForm">
|
||||||
|
<loadingModal notFullScreen ref="loadingModal" />
|
||||||
<funnelHeader cmsWidgetName="FunnelHeaderWidget" ref="funnelHeader" />
|
<funnelHeader cmsWidgetName="FunnelHeaderWidget" ref="funnelHeader" />
|
||||||
<div class="container page-container-grouped-styles">
|
<div class="container page-container-grouped-styles">
|
||||||
<div class="row">
|
<div class="row">
|
||||||
|
|
@ -10,6 +11,14 @@
|
||||||
</span>
|
</span>
|
||||||
</h5>
|
</h5>
|
||||||
<funnelSubHeader cmsWidgetName="FunnelSubHeaderWidget" alignLeft />
|
<funnelSubHeader cmsWidgetName="FunnelSubHeaderWidget" alignLeft />
|
||||||
|
<datePicker
|
||||||
|
class="mt-5"
|
||||||
|
v-model="selectedDate"
|
||||||
|
:startDate="datePickerStartDate"
|
||||||
|
:endDate="datePickerEndDate"
|
||||||
|
:availableDates="availableDates"
|
||||||
|
:isLoadingDates="isLoadingDates"
|
||||||
|
@requestMoreDates="handleRequestMoreDates" />
|
||||||
<navbar
|
<navbar
|
||||||
cmsWidgetName="FunnelFooterWidget"
|
cmsWidgetName="FunnelFooterWidget"
|
||||||
ref="navbar"
|
ref="navbar"
|
||||||
|
|
@ -25,6 +34,8 @@
|
||||||
import funnelHeader from "@/fmg-components/funnel-header/funnel-header";
|
import funnelHeader from "@/fmg-components/funnel-header/funnel-header";
|
||||||
import navbar from "@/fmg-components/nav-bar/nav-bar";
|
import navbar from "@/fmg-components/nav-bar/nav-bar";
|
||||||
import { Form } from "vee-validate";
|
import { Form } from "vee-validate";
|
||||||
|
import datePicker from "@/ux-components/date-picker/date-picker";
|
||||||
|
import loadingModal from "@/fmg-components/loading-modal/loading-modal.vue";
|
||||||
import store from "@/store";
|
import store from "@/store";
|
||||||
import { fetchCmsContentForPage } from "@/helpers/cms-content-helper";
|
import { fetchCmsContentForPage } from "@/helpers/cms-content-helper";
|
||||||
import { settleAllPromises } from "@/helpers/layout-helper";
|
import { settleAllPromises } from "@/helpers/layout-helper";
|
||||||
|
|
@ -38,6 +49,16 @@ import {
|
||||||
} from "@/helpers/page-prerequisites-helper.js";
|
} from "@/helpers/page-prerequisites-helper.js";
|
||||||
import { debugLog } from "@/helpers/debug-log-helper";
|
import { debugLog } from "@/helpers/debug-log-helper";
|
||||||
|
|
||||||
|
// Mock scheduling helpers — replace with real API data when integrating
|
||||||
|
// Offsets of available days within any 15-day block (gaps on days 2, 6, 7, 10, 13)
|
||||||
|
const MOCK_AVAILABLE_OFFSETS = [0, 1, 3, 4, 5, 8, 9, 11, 12, 14];
|
||||||
|
|
||||||
|
function toDateString(offsetDays, base = new Date()) {
|
||||||
|
const d = new Date(base);
|
||||||
|
d.setDate(d.getDate() + offsetDays);
|
||||||
|
return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, "0")}-${String(d.getDate()).padStart(2, "0")}`;
|
||||||
|
}
|
||||||
|
|
||||||
export default {
|
export default {
|
||||||
name: "scheduling",
|
name: "scheduling",
|
||||||
async beforeRouteEnter(to, from, next) {
|
async beforeRouteEnter(to, from, next) {
|
||||||
|
|
@ -57,6 +78,15 @@ export default {
|
||||||
return this.getCmsContent("ServiceLocationText", "Text");
|
return this.getCmsContent("ServiceLocationText", "Text");
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
data() {
|
||||||
|
return {
|
||||||
|
selectedDate: null,
|
||||||
|
isLoadingDates: false,
|
||||||
|
datePickerStartDate: toDateString(0),
|
||||||
|
datePickerEndDate: toDateString(14),
|
||||||
|
availableDates: MOCK_AVAILABLE_OFFSETS.map((n) => toDateString(n)),
|
||||||
|
};
|
||||||
|
},
|
||||||
methods: {
|
methods: {
|
||||||
arePagePrerequisitesValid() {
|
arePagePrerequisitesValid() {
|
||||||
const order = store.getters.order;
|
const order = store.getters.order;
|
||||||
|
|
@ -104,6 +134,22 @@ export default {
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
async handleRequestMoreDates() {
|
||||||
|
this.isLoadingDates = true;
|
||||||
|
this.$refs.loadingModal.showModal();
|
||||||
|
|
||||||
|
await new Promise((resolve) => setTimeout(resolve, 1000));
|
||||||
|
|
||||||
|
const currentEnd = new Date(
|
||||||
|
...this.datePickerEndDate.split("-").map((n, i) => (i === 1 ? Number(n) - 1 : Number(n)))
|
||||||
|
);
|
||||||
|
const newAvailable = MOCK_AVAILABLE_OFFSETS.map((n) => toDateString(n + 1, currentEnd));
|
||||||
|
this.availableDates = [...this.availableDates, ...newAvailable];
|
||||||
|
this.datePickerEndDate = toDateString(15, currentEnd);
|
||||||
|
|
||||||
|
this.isLoadingDates = false;
|
||||||
|
this.$refs.loadingModal.hideModal();
|
||||||
|
},
|
||||||
forwardButtonAction() {
|
forwardButtonAction() {
|
||||||
const appointmentType = store.getters.order.serviceLocation.appointmentType;
|
const appointmentType = store.getters.order.serviceLocation.appointmentType;
|
||||||
if (appointmentType === AppointmentTypeStrings.MOBILE) {
|
if (appointmentType === AppointmentTypeStrings.MOBILE) {
|
||||||
|
|
@ -123,6 +169,8 @@ export default {
|
||||||
funnelHeader,
|
funnelHeader,
|
||||||
navbar,
|
navbar,
|
||||||
Form,
|
Form,
|
||||||
|
datePicker,
|
||||||
|
loadingModal,
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
</script>
|
</script>
|
||||||
|
|
|
||||||
224
src/ux-components/date-picker/date-picker.spec.js
Normal file
224
src/ux-components/date-picker/date-picker.spec.js
Normal file
|
|
@ -0,0 +1,224 @@
|
||||||
|
import { shallowMount } from "@vue/test-utils";
|
||||||
|
import datePicker from "./date-picker";
|
||||||
|
|
||||||
|
const AVAILABLE_DATES = ["2026-01-21", "2026-01-22", "2026-01-23", "2026-01-25"];
|
||||||
|
|
||||||
|
function mountDesktop(props = {}) {
|
||||||
|
Object.defineProperty(window, "innerWidth", {
|
||||||
|
writable: true,
|
||||||
|
configurable: true,
|
||||||
|
value: 1024,
|
||||||
|
});
|
||||||
|
return shallowMount(datePicker, {
|
||||||
|
props: { availableDates: AVAILABLE_DATES, modelValue: null, ...props },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function mountMobile(props = {}) {
|
||||||
|
Object.defineProperty(window, "innerWidth", {
|
||||||
|
writable: true,
|
||||||
|
configurable: true,
|
||||||
|
value: 375,
|
||||||
|
});
|
||||||
|
return shallowMount(datePicker, {
|
||||||
|
props: { availableDates: AVAILABLE_DATES, modelValue: null, ...props },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("date-picker.vue", () => {
|
||||||
|
afterEach(() => {
|
||||||
|
Object.defineProperty(window, "innerWidth", {
|
||||||
|
writable: true,
|
||||||
|
configurable: true,
|
||||||
|
value: 1024,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("rendering", () => {
|
||||||
|
test("renders 5 date cards on desktop", () => {
|
||||||
|
const wrapper = mountDesktop();
|
||||||
|
expect(wrapper.findAll(".date-picker__day-card").length).toBe(5);
|
||||||
|
wrapper.unmount();
|
||||||
|
});
|
||||||
|
|
||||||
|
test("renders 3 date cards on mobile", () => {
|
||||||
|
const wrapper = mountMobile();
|
||||||
|
expect(wrapper.findAll(".date-picker__day-card").length).toBe(3);
|
||||||
|
wrapper.unmount();
|
||||||
|
});
|
||||||
|
|
||||||
|
test("renders disabled cards for dates not in availableDates", () => {
|
||||||
|
const wrapper = mountDesktop();
|
||||||
|
// Jan 24 is not in AVAILABLE_DATES but falls in the range Jan 21–25
|
||||||
|
const disabledCards = wrapper.findAll(".date-picker__day-card--disabled");
|
||||||
|
expect(disabledCards.length).toBeGreaterThan(0);
|
||||||
|
wrapper.unmount();
|
||||||
|
});
|
||||||
|
|
||||||
|
test("back navigation button is disabled at the start", () => {
|
||||||
|
const wrapper = mountDesktop();
|
||||||
|
const backBtn = wrapper.findAll(".date-picker__nav-btn")[0];
|
||||||
|
expect(backBtn.attributes("disabled")).toBeDefined();
|
||||||
|
wrapper.unmount();
|
||||||
|
});
|
||||||
|
|
||||||
|
test("forward navigation button is disabled when all dates fit in window", () => {
|
||||||
|
// Only 2 available dates → range is 2 days, window size 5 → no forward
|
||||||
|
const wrapper = mountDesktop({ availableDates: ["2026-01-21", "2026-01-22"] });
|
||||||
|
const forwardBtn = wrapper.findAll(".date-picker__nav-btn")[1];
|
||||||
|
expect(forwardBtn.attributes("disabled")).toBeDefined();
|
||||||
|
wrapper.unmount();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("allDates computation", () => {
|
||||||
|
test("builds a contiguous date range between first and last available date", () => {
|
||||||
|
const wrapper = mountDesktop();
|
||||||
|
const allValues = wrapper.vm.allDates.map((d) => d.value);
|
||||||
|
// Range Jan 21–25 should include all 5 dates
|
||||||
|
expect(allValues).toEqual([
|
||||||
|
"2026-01-21",
|
||||||
|
"2026-01-22",
|
||||||
|
"2026-01-23",
|
||||||
|
"2026-01-24",
|
||||||
|
"2026-01-25",
|
||||||
|
]);
|
||||||
|
wrapper.unmount();
|
||||||
|
});
|
||||||
|
|
||||||
|
test("marks dates not in availableDates as unavailable", () => {
|
||||||
|
const wrapper = mountDesktop();
|
||||||
|
const jan24 = wrapper.vm.allDates.find((d) => d.value === "2026-01-24");
|
||||||
|
expect(jan24.isAvailable).toBe(false);
|
||||||
|
wrapper.unmount();
|
||||||
|
});
|
||||||
|
|
||||||
|
test("marks dates in availableDates as available", () => {
|
||||||
|
const wrapper = mountDesktop();
|
||||||
|
const jan21 = wrapper.vm.allDates.find((d) => d.value === "2026-01-21");
|
||||||
|
expect(jan21.isAvailable).toBe(true);
|
||||||
|
wrapper.unmount();
|
||||||
|
});
|
||||||
|
|
||||||
|
test("attaches correct day abbreviation to each date", () => {
|
||||||
|
const wrapper = mountDesktop();
|
||||||
|
const jan21 = wrapper.vm.allDates.find((d) => d.value === "2026-01-21");
|
||||||
|
// 2026-01-21 is a Wednesday
|
||||||
|
expect(jan21.dayAbbr).toBe("WED");
|
||||||
|
wrapper.unmount();
|
||||||
|
});
|
||||||
|
|
||||||
|
test("returns empty array when availableDates is empty", () => {
|
||||||
|
const wrapper = mountDesktop({ availableDates: [] });
|
||||||
|
expect(wrapper.vm.allDates).toEqual([]);
|
||||||
|
wrapper.unmount();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("date selection", () => {
|
||||||
|
test("emits update:modelValue with the date string when an available card is clicked", async () => {
|
||||||
|
const wrapper = mountDesktop();
|
||||||
|
const availableCard = wrapper.find(
|
||||||
|
".date-picker__day-card:not(.date-picker__day-card--disabled)"
|
||||||
|
);
|
||||||
|
await availableCard.trigger("click");
|
||||||
|
expect(wrapper.emitted("update:modelValue")).toBeTruthy();
|
||||||
|
expect(wrapper.emitted("update:modelValue")[0][0]).toBe("2026-01-21");
|
||||||
|
wrapper.unmount();
|
||||||
|
});
|
||||||
|
|
||||||
|
test("does not emit when a disabled card is clicked", async () => {
|
||||||
|
const wrapper = mountDesktop();
|
||||||
|
const disabledCard = wrapper.find(".date-picker__day-card--disabled");
|
||||||
|
await disabledCard.trigger("click");
|
||||||
|
expect(wrapper.emitted("update:modelValue")).toBeFalsy();
|
||||||
|
wrapper.unmount();
|
||||||
|
});
|
||||||
|
|
||||||
|
test("applies the selected class to the card matching modelValue", async () => {
|
||||||
|
const wrapper = mountDesktop({ modelValue: "2026-01-21" });
|
||||||
|
const selectedCard = wrapper.find(".date-picker__day-card--selected");
|
||||||
|
expect(selectedCard.exists()).toBeTruthy();
|
||||||
|
wrapper.unmount();
|
||||||
|
});
|
||||||
|
|
||||||
|
test("no card has the selected class when modelValue is null", () => {
|
||||||
|
const wrapper = mountDesktop({ modelValue: null });
|
||||||
|
expect(wrapper.find(".date-picker__day-card--selected").exists()).toBe(false);
|
||||||
|
wrapper.unmount();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("navigation", () => {
|
||||||
|
test("goForward advances windowStart by the window size", async () => {
|
||||||
|
// Use a longer range so forward is possible on desktop (window 5)
|
||||||
|
const manyDates = [
|
||||||
|
"2026-01-01",
|
||||||
|
"2026-01-02",
|
||||||
|
"2026-01-03",
|
||||||
|
"2026-01-10",
|
||||||
|
];
|
||||||
|
const wrapper = mountDesktop({ availableDates: manyDates });
|
||||||
|
expect(wrapper.vm.canGoForward).toBe(true);
|
||||||
|
await wrapper.vm.goForward();
|
||||||
|
expect(wrapper.vm.windowStart).toBe(5);
|
||||||
|
wrapper.unmount();
|
||||||
|
});
|
||||||
|
|
||||||
|
test("goBack decrements windowStart by the window size", async () => {
|
||||||
|
const manyDates = ["2026-01-01", "2026-01-02", "2026-01-03", "2026-01-10"];
|
||||||
|
const wrapper = mountDesktop({ availableDates: manyDates });
|
||||||
|
await wrapper.vm.goForward();
|
||||||
|
await wrapper.vm.goBack();
|
||||||
|
expect(wrapper.vm.windowStart).toBe(0);
|
||||||
|
wrapper.unmount();
|
||||||
|
});
|
||||||
|
|
||||||
|
test("goBack does not go below 0", () => {
|
||||||
|
const wrapper = mountDesktop();
|
||||||
|
wrapper.vm.goBack();
|
||||||
|
expect(wrapper.vm.windowStart).toBe(0);
|
||||||
|
wrapper.unmount();
|
||||||
|
});
|
||||||
|
|
||||||
|
test("goForward does not exceed the last window position", async () => {
|
||||||
|
// Range Jan 21–25 = 5 dates, window 5 on desktop → already at max
|
||||||
|
const wrapper = mountDesktop();
|
||||||
|
await wrapper.vm.goForward();
|
||||||
|
// windowStart should not push visible window past the array length
|
||||||
|
expect(wrapper.vm.windowStart + wrapper.vm.windowSize).toBeLessThanOrEqual(
|
||||||
|
wrapper.vm.allDates.length + wrapper.vm.windowSize
|
||||||
|
);
|
||||||
|
wrapper.unmount();
|
||||||
|
});
|
||||||
|
|
||||||
|
test("back button becomes enabled after navigating forward", async () => {
|
||||||
|
const manyDates = ["2026-01-01", "2026-01-02", "2026-01-03", "2026-01-10"];
|
||||||
|
const wrapper = mountDesktop({ availableDates: manyDates });
|
||||||
|
await wrapper.vm.goForward();
|
||||||
|
await wrapper.vm.$nextTick();
|
||||||
|
const backBtn = wrapper.findAll(".date-picker__nav-btn")[0];
|
||||||
|
expect(backBtn.attributes("disabled")).toBeUndefined();
|
||||||
|
wrapper.unmount();
|
||||||
|
});
|
||||||
|
|
||||||
|
test("resets windowStart to 0 when availableDates prop changes", async () => {
|
||||||
|
const manyDates = ["2026-01-01", "2026-01-02", "2026-01-03", "2026-01-10"];
|
||||||
|
const wrapper = mountDesktop({ availableDates: manyDates });
|
||||||
|
await wrapper.vm.goForward();
|
||||||
|
expect(wrapper.vm.windowStart).toBeGreaterThan(0);
|
||||||
|
await wrapper.setProps({ availableDates: ["2026-02-01", "2026-02-02"] });
|
||||||
|
expect(wrapper.vm.windowStart).toBe(0);
|
||||||
|
wrapper.unmount();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("initial window positioning", () => {
|
||||||
|
test("scrolls to the window containing the pre-selected date on mount", () => {
|
||||||
|
// Jan 25 is the 5th date (index 4); with window size 5 on desktop it's still in window 0
|
||||||
|
const wrapper = mountDesktop({ modelValue: "2026-01-25" });
|
||||||
|
expect(wrapper.vm.windowStart).toBe(0);
|
||||||
|
wrapper.unmount();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
270
src/ux-components/date-picker/date-picker.vue
Normal file
270
src/ux-components/date-picker/date-picker.vue
Normal file
|
|
@ -0,0 +1,270 @@
|
||||||
|
<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>
|
||||||
Loading…
Reference in a new issue