Merge pull request #3239 from Safelite/feature/CASH-2880

CASH-2880 | More animations
This commit is contained in:
scottkiener-at-safelite 2026-06-29 09:47:35 -04:00 committed by GitHub
commit 9e1528697c
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
6 changed files with 209 additions and 85 deletions

View file

@ -10,22 +10,42 @@
class="date-picker__nav-icon date-picker__nav-icon--flipped" class="date-picker__nav-icon date-picker__nav-icon--flipped"
alt="" /> alt="" />
</button> </button>
<div class="date-picker__track d-flex flex-grow-1"> <div
<button class="date-picker__track d-flex flex-grow-1"
v-for="date in visibleDates" :style="isLoadingDates ? { '--card-count': windowSize } : null">
:key="date.value" <template v-if="showLoadingPlaceholders">
class="date-picker__day-card d-flex flex-column align-items-center justify-content-center" <div
:class="{ v-for="index in windowSize"
'date-picker__day-card--selected': isSelected(date.value), :key="index"
'date-picker__day-card--disabled': !date.isAvailable, class="date-picker__day-card date-picker__day-card--loading d-flex flex-column align-items-center justify-content-center"
}" :style="{ '--card-index': index - 1 }"
:disabled="!date.isAvailable" aria-hidden="true">
:aria-pressed="isSelected(date.value)" <!-- Placeholder text is intentionally non-empty so the spans occupy the
:aria-label="`${date.dayAbbr} ${date.monthAbbr} ${date.day}`" same height as real content. The --loading CSS hides them via
@click="selectDate(date)"> visibility:hidden, so they are never visible to users. -->
<span class="date-picker__day-abbr">{{ date.dayAbbr }}</span> <span class="date-picker__day-abbr">MON</span>
<span class="date-picker__day-date">{{ date.monthAbbr }} {{ date.day }}</span> <span class="date-picker__day-date">Jan 00</span>
</button> </div>
</template>
<template v-else>
<button
v-for="(date, index) 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,
'date-picker__day-card--loading': isLoadingDates,
}"
:style="isLoadingDates ? { '--card-index': index } : null"
:disabled="!date.isAvailable || isLoadingDates"
: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>
</template>
</div> </div>
<button <button
class="date-picker__nav-btn" class="date-picker__nav-btn"
@ -110,6 +130,9 @@ export default {
}; };
}, },
computed: { computed: {
showLoadingPlaceholders() {
return this.isLoadingDates && !this.allDates.length;
},
allDates() { allDates() {
if (this.availableDates === null) return []; if (this.availableDates === null) return [];
@ -308,6 +331,23 @@ export default {
color: $gray-300; color: $gray-300;
} }
} }
&--loading,
&--loading.date-picker__day-card--selected,
&--loading.date-picker__day-card--disabled {
border-color: $gray-200;
cursor: default;
background-color: $gray-200;
background-image: linear-gradient(90deg, $gray-200 25%, $gray-100 50%, $gray-200 75%);
background-repeat: no-repeat;
background-size: calc(var(--card-count) * 200%) 100%;
animation: date-picker-shimmer 1.5s ease-in-out infinite;
.date-picker__day-abbr,
.date-picker__day-date {
visibility: hidden;
}
}
} }
&__day-abbr, &__day-abbr,
@ -336,4 +376,13 @@ export default {
} }
} }
} }
@keyframes date-picker-shimmer {
0% {
background-position-x: calc(var(--card-index) / (var(--card-count) - 1) * 100% + 100%);
}
100% {
background-position-x: calc(var(--card-index) / (var(--card-count) - 1) * 100% - 100%);
}
}
</style> </style>

View file

@ -91,6 +91,7 @@
import { PREMIUM_TIME_SLOT_ID_FLAG, AppointmentTypeStrings } from "@/constants/schedule-constants"; import { PREMIUM_TIME_SLOT_ID_FLAG, AppointmentTypeStrings } from "@/constants/schedule-constants";
import { militaryToTwelveHourTime } from "@/layouts/schedule/helpers/schedule-helper"; import { militaryToTwelveHourTime } from "@/layouts/schedule/helpers/schedule-helper";
import schedulingCardLoader from "@/layouts/scheduling/scheduling-card-loader/scheduling-card-loader.vue"; import schedulingCardLoader from "@/layouts/scheduling/scheduling-card-loader/scheduling-card-loader.vue";
export default { export default {
name: "mobile-scheduling-card", name: "mobile-scheduling-card",
emits: ["update:modelValue", "zip-code-clicked"], emits: ["update:modelValue", "zip-code-clicked"],

View file

@ -63,8 +63,9 @@ function setupMocks() {
describe("scheduling.vue", () => { describe("scheduling.vue", () => {
describe("intercept overlay", () => { describe("intercept overlay", () => {
test("does not render interceptOverlay when isLoadingDates is false", () => { test("does not render interceptOverlay when isLoadingDates is false", async () => {
const { wrapper } = setupMocks(); const { wrapper } = setupMocks();
await wrapper.setData({ isLoadingDates: false });
expect(wrapper.find("intercept-overlay-stub").exists()).toBe(false); expect(wrapper.find("intercept-overlay-stub").exists()).toBe(false);
wrapper.unmount(); wrapper.unmount();
}); });

View file

@ -1,7 +1,6 @@
<template> <template>
<Form @submit="onSubmit" @invalid-submit="onInvalidSubmit" ref="theForm"> <Form @submit="onSubmit" @invalid-submit="onInvalidSubmit" ref="theForm">
<interceptOverlay v-if="isLoadingDates" /> <interceptOverlay v-if="isLoadingDates" />
<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">
@ -20,29 +19,35 @@
:availableDates="availableDates" :availableDates="availableDates"
:isLoadingDates="isLoadingDates" :isLoadingDates="isLoadingDates"
@requestMoreDates="handleRequestMoreDates" /> @requestMoreDates="handleRequestMoreDates" />
<mobileSchedulingCard <Transition name="card-slide" mode="out-in">
v-if="showMobileSchedulingCard" <div :key="selectedDate">
class="mt-4" <mobileSchedulingCard
v-model="selectedScheduling" v-if="showMobileSchedulingCard"
:providerNumber="mobileProviderAndTimeSlot.providerNumber" class="mt-4"
:timeSlots="mobileTimeSlotsForSelectedDate" v-model="selectedScheduling"
:premiumTimeSlotPrice="premiumTimeSlotPrice" :providerNumber="mobileProviderAndTimeSlot.providerNumber"
:zipCode="serviceZipCode" :timeSlots="mobileTimeSlotsForSelectedDate"
:showFreeFlag="showMobileFreeFlag" :premiumTimeSlotPrice="premiumTimeSlotPrice"
:radioGroupName="schedulingRadioGroupName" :zipCode="serviceZipCode"
:isLoading="isLoadingDates" :showFreeFlag="showMobileFreeFlag"
@zip-code-clicked="onMobileZipCodeClicked" /> :radioGroupName="schedulingRadioGroupName"
<inshopSchedulingCard :isLoading="isLoadingDates"
v-for="{ provider } in inShopProvidersAndTimeslots" @zip-code-clicked="onMobileZipCodeClicked" />
v-show="selectedDate" <inshopSchedulingCard
:key="provider.providerNumber" v-for="{ provider } in inShopProvidersAndTimeslots"
class="mt-4" v-show="showInshopSchedulingCards"
v-model="selectedScheduling" :key="provider.providerNumber"
:provider="provider" class="mt-4"
:timeSlots="getInshopTimeSlotsForSelectedDate(provider.providerNumber)" v-model="selectedScheduling"
:radioGroupName="schedulingRadioGroupName" :provider="provider"
:isLoading="isLoadingDates" :timeSlots="
@address-clicked="onInshopAddressClicked(provider)" /> getInshopTimeSlotsForSelectedDate(provider.providerNumber)
"
:radioGroupName="schedulingRadioGroupName"
:isLoading="isLoadingDates"
@address-clicked="onInshopAddressClicked(provider)" />
</div>
</Transition>
<navbar <navbar
cmsWidgetName="FunnelFooterWidget" cmsWidgetName="FunnelFooterWidget"
ref="navbar" ref="navbar"
@ -62,7 +67,6 @@ import { Form } from "vee-validate";
import datePicker from "@/layouts/scheduling/date-picker/date-picker"; import datePicker from "@/layouts/scheduling/date-picker/date-picker";
import mobileSchedulingCard from "@/layouts/scheduling/mobile-scheduling-card/mobile-scheduling-card"; import mobileSchedulingCard from "@/layouts/scheduling/mobile-scheduling-card/mobile-scheduling-card";
import inshopSchedulingCard from "@/layouts/scheduling/inshop-scheduling-card/inshop-scheduling-card"; import inshopSchedulingCard from "@/layouts/scheduling/inshop-scheduling-card/inshop-scheduling-card";
import loadingModal from "@/fmg-components/loading-modal/loading-modal.vue";
import interceptOverlay from "@/ux-components/intercept-overlay/intercept-overlay"; import interceptOverlay from "@/ux-components/intercept-overlay/intercept-overlay";
import store from "@/store"; import store from "@/store";
import { AppointmentTypeStrings, PREMIUM_FEE_PART_TYPE } from "@/constants/schedule-constants"; import { AppointmentTypeStrings, PREMIUM_FEE_PART_TYPE } from "@/constants/schedule-constants";
@ -138,17 +142,6 @@ export default {
async beforeRouteEnter(to, from, next) { async beforeRouteEnter(to, from, next) {
const serviceZipCode = store.getters.order.serviceLocation.zipCode; const serviceZipCode = store.getters.order.serviceLocation.zipCode;
const shopProviderData = await store.dispatch("getProviders", {
payload: { serviceZipCode },
pageNameToLog: to.name,
});
// Get the first 3 providers from the shopProviderData
const providers = shopProviderData?.data?.shopProviders?.slice(0, 3) ?? [];
const mobileProviderNumber = shopProviderData?.data?.mobileProviderNumber ?? null;
const startDate = toDateString(0);
const endDate = toDateString(SCHEDULE_FETCH_DAYS - 1);
const promiseResultMap = [ const promiseResultMap = [
{ {
resultKey: "cmsContent", resultKey: "cmsContent",
@ -160,45 +153,64 @@ export default {
pageNameToLog: to.name, pageNameToLog: to.name,
}), }),
}, },
...providers.map((provider, i) => ({ {
resultKey: `inshopTimeSlots_${i}`, resultKey: "providers",
promise: fetchInshopTimeSlots({ promise: store.dispatch("getProviders", {
startDate, payload: { serviceZipCode },
endDate,
providerNumber: provider.providerNumber,
pageNameToLog: to.name, pageNameToLog: to.name,
}), }),
})), },
// Get the mobile time slots if a mobile provider number is available
...(mobileProviderNumber
? [
{
resultKey: "mobileTimeSlots",
promise: fetchMobileTimeSlots({
startDate,
endDate,
zipCode: serviceZipCode,
pageNameToLog: to.name,
}),
},
]
: []),
]; ];
const resultMap = await settleAllPromises(promiseResultMap); const resultMap = await settleAllPromises(promiseResultMap);
next((vm) => { // Get the first 3 providers from the shopProviderData
const providers = resultMap.providers?.shopProviders?.slice(0, 3) ?? [];
const mobileProviderNumber = resultMap.providers?.mobileProviderNumber ?? null;
next(async (vm) => {
vm.setCmsContent(resultMap.cmsContent); vm.setCmsContent(resultMap.cmsContent);
vm.inShopProvidersAndTimeslots = providers.map((provider, i) => ({ vm.inShopProvidersAndTimeslots = providers.map((provider) => ({
provider, provider,
timeSlots: resultMap[`inshopTimeSlots_${i}`] ?? null, timeSlots: null,
})); }));
vm.mobileProviderAndTimeSlot = mobileProviderNumber vm.mobileProviderAndTimeSlot = mobileProviderNumber
? { ? { providerNumber: mobileProviderNumber, timeSlots: null }
providerNumber: mobileProviderNumber,
timeSlots: resultMap.mobileTimeSlots ?? null,
}
: null; : null;
const startDate = toDateString(0);
const endDate = toDateString(SCHEDULE_FETCH_DAYS - 1);
const timeSlotsPromiseResultMap = [
...providers.map((provider, i) => ({
resultKey: `inshopTimeSlots_${i}`,
promise: fetchInshopTimeSlots({
startDate,
endDate,
providerNumber: provider.providerNumber,
pageNameToLog: to.name,
}),
})),
// Get the mobile time slots if a mobile provider number is available
...(mobileProviderNumber
? [
{
resultKey: "mobileTimeSlots",
promise: fetchMobileTimeSlots({
startDate,
endDate,
zipCode: serviceZipCode,
pageNameToLog: to.name,
}),
},
]
: []),
];
const timeSlotsResultMap = await settleAllPromises(timeSlotsPromiseResultMap);
vm.inShopProvidersAndTimeslots.forEach((entry, i) => {
entry.timeSlots = timeSlotsResultMap[`inshopTimeSlots_${i}`] ?? null;
});
if (vm.mobileProviderAndTimeSlot) {
vm.mobileProviderAndTimeSlot.timeSlots = timeSlotsResultMap.mobileTimeSlots ?? null;
}
vm.datesLoaded = true; vm.datesLoaded = true;
vm.mobilePremiumAppointmentFee = resultMap.mobilePremiumFee ?? null; vm.mobilePremiumAppointmentFee = resultMap.mobilePremiumFee ?? null;
vm.isLoadingDates = false;
}); });
}, },
watch: { watch: {
@ -217,7 +229,12 @@ export default {
return store.getters.order.serviceLocation.zipCode; return store.getters.order.serviceLocation.zipCode;
}, },
showMobileSchedulingCard() { showMobileSchedulingCard() {
return Boolean(this.mobileProviderAndTimeSlot && this.selectedDate); return Boolean(
this.mobileProviderAndTimeSlot && (this.selectedDate || this.isLoadingDates)
);
},
showInshopSchedulingCards() {
return this.selectedDate || this.isLoadingDates;
}, },
mobileTimeSlotsForSelectedDate() { mobileTimeSlotsForSelectedDate() {
if (!this.selectedDate) { if (!this.selectedDate) {
@ -251,7 +268,7 @@ export default {
data() { data() {
return { return {
selectedDate: null, selectedDate: null,
isLoadingDates: false, isLoadingDates: true,
datesLoaded: false, datesLoaded: false,
datePickerStartDate: toDateString(0), datePickerStartDate: toDateString(0),
datePickerEndDate: toDateString(SCHEDULE_FETCH_DAYS - 1), datePickerEndDate: toDateString(SCHEDULE_FETCH_DAYS - 1),
@ -378,7 +395,6 @@ export default {
datePicker, datePicker,
mobileSchedulingCard, mobileSchedulingCard,
inshopSchedulingCard, inshopSchedulingCard,
loadingModal,
interceptOverlay, interceptOverlay,
}, },
}; };
@ -391,4 +407,20 @@ h5 {
line-height: 32px; line-height: 32px;
font-size: 1.25rem; font-size: 1.25rem;
} }
.card-slide-enter-active {
transition:
opacity 0.3s ease-out,
transform 0.3s ease-out;
}
.card-slide-leave-active {
transition: opacity 0.32s ease-in;
}
.card-slide-enter-from {
opacity: 0;
transform: translateX(24px);
}
.card-slide-leave-to {
opacity: 0;
}
</style> </style>

View file

@ -14,4 +14,33 @@ describe("intercept-overlay.vue", () => {
expect(wrapper.text()).toBe(""); expect(wrapper.text()).toBe("");
wrapper.unmount(); wrapper.unmount();
}); });
describe("keyboard interception", () => {
test("adds a capturing keydown listener on mount and removes it on unmount", () => {
const addSpy = jest.spyOn(document, "addEventListener");
const removeSpy = jest.spyOn(document, "removeEventListener");
const wrapper = shallowMount(interceptOverlay);
expect(addSpy).toHaveBeenCalledWith("keydown", expect.any(Function), true);
const [, handler] = addSpy.mock.calls.find(
([type, , capture]) => type === "keydown" && capture === true
);
wrapper.unmount();
expect(removeSpy).toHaveBeenCalledWith("keydown", handler, true);
addSpy.mockRestore();
removeSpy.mockRestore();
});
test("blockKey prevents default and stops immediate propagation", () => {
const wrapper = shallowMount(interceptOverlay);
const event = { preventDefault: jest.fn(), stopImmediatePropagation: jest.fn() };
wrapper.vm.blockKey(event);
expect(event.preventDefault).toHaveBeenCalled();
expect(event.stopImmediatePropagation).toHaveBeenCalled();
wrapper.unmount();
});
});
}); });

View file

@ -1,10 +1,22 @@
<template> <template>
<div class="intercept-overlay" aria-hidden="true"></div> <div class="intercept-overlay" aria-hidden="true" @keydown.capture="blockKey"></div>
</template> </template>
<script> <script>
export default { export default {
name: "intercept-overlay", name: "intercept-overlay",
mounted() {
document.addEventListener("keydown", this.blockKey, true);
},
beforeUnmount() {
document.removeEventListener("keydown", this.blockKey, true);
},
methods: {
blockKey(event) {
event.preventDefault();
event.stopImmediatePropagation();
},
},
}; };
</script> </script>