Merge pull request #3288 from Safelite/feature/CASH-2951

Unify pattern for stopping user interaction during loading.
This commit is contained in:
chloeherdsafelite 2026-07-27 14:34:45 -04:00 committed by GitHub
commit fd760ce8f0
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
21 changed files with 227 additions and 279 deletions

View file

@ -114,11 +114,9 @@
</label>
</div>
</div>
<loader
class="date-picker-loader"
:class="[loadingStatus !== 'none' ? 'show-loader' : '']"
loaderColor="blue"
loaderPosition="center" />
<div v-if="loadingStatus !== 'none'" class="date-picker-loader">
<loader loaderColor="blue" loaderPosition="center" />
</div>
<div class="row form-test-error" id="date-of-month-error">
<ErrorMessage :name="customComponentId" class="small mt-1"></ErrorMessage>
</div>
@ -742,15 +740,6 @@ export default {
</script>
<style lang="scss" scoped>
.date-picker-loader {
opacity: 0;
max-height: 0;
&.show-loader {
opacity: 1;
max-height: none;
}
}
.date-picker {
position: relative;
flex-grow: 0;
@ -769,7 +758,13 @@ export default {
font-family: UrbanistSemiBold;
}
}
.loader {
.date-picker-loader {
display: flex;
justify-content: center;
align-items: center;
width: 100%;
:deep(.loader) {
height: 2rem;
width: calc(100% - 1.5rem);
@ -778,6 +773,7 @@ export default {
height: 1.5rem;
}
}
}
.month {
margin: 0 auto 1rem auto;
position: relative;

View file

@ -39,6 +39,7 @@
:id="modalId + '-modalbtn'"
ref="modalButtonMain"
loaderColor="white"
loaderPosition="center"
:isDisabled="footerButtonDisabled"
:buttonText="footerButtonText"
@click-event="validateAndEmit"

View file

@ -36,48 +36,6 @@ describe("modal-button-main.vue", () => {
expect(button.attributes()["aria-disabled"]).toEqual("true");
});
it("Should return loader color", async () => {
// Arrange
const wrapper = shallowMount(
modalButtonMain,
setupMocks({
propsData: {
loaderColor: "blue",
loaderEnabled: true,
},
})
);
// Act
wrapper.vm.clicked();
await nextTick();
// Assert
const loader = wrapper.find("loader-stub");
expect(loader.attributes("class")).toContain("blue");
});
it("Should return loader position", async () => {
// Arrange
const wrapper = shallowMount(
modalButtonMain,
setupMocks({
propsData: {
loaderPosition: "right",
loaderEnabled: true,
},
})
);
// Act
wrapper.vm.clicked();
await nextTick();
// Assert
const loader = wrapper.find("loader-stub");
expect(loader.attributes("class")).toContain("right");
});
it("Should set 'isLoaderDisplayed' to false when calling 'removeLoader'", async () => {
// Arrange
const wrapper = shallowMount(

View file

@ -11,9 +11,9 @@
@click="clicked">
<span class="m-0">{{ this.buttonText }}</span>
<loader
class="ms-2"
v-if="isLoaderDisplayed && !suppressLoader"
v-bind:class="[this.loaderColor, this.loaderPosition]" />
:loaderColor="loaderColor"
:loaderPosition="loaderPosition" />
</button>
</template>

View file

@ -65,10 +65,9 @@
data-test="image-upload"
@change="imageChanged" />
</label>
<loader
v-show="isImageProcessing"
loaderColor="blue"
class="loading-icon"></loader>
<span v-if="isImageProcessing" class="loading-icon">
<loader loaderColor="blue" />
</span>
</template>
</template>
</div>

View file

@ -50,6 +50,7 @@
ref="buttonMain"
:buttonText="DonationAddButtonCopy"
loaderColor="blue"
loaderPosition="center"
class="w-100 mb-2 custom-secondary"
@click-event="handleDonationAction" />
</div>

View file

@ -28,6 +28,7 @@
isPrimary
:buttonText="overrideButtonText || buttonText"
loaderColor="white"
loaderPosition="center"
:class="isForwardActionDisabled && 'form-test-invalid'"
:aria-disabled="isForwardActionDisabled"
:isDisabled="isForwardActionDisabled"
@ -68,10 +69,6 @@ export default {
customButtontext: "",
};
},
unmounted() {
document.onkeydown = null;
},
computed: {
backLink() {
return (
@ -91,15 +88,8 @@ export default {
},
removeLoader() {
this.$refs.buttonMain.removeLoader();
document.onkeydown = function (e) {
return true;
};
},
buttonClick() {
//prevent keyboard input after button click
document.onkeydown = function (e) {
return false;
};
// check session expired and initSession to recreate cookies
if (analyticsMixin.methods.sessionExpired()) {
this.routeReturnUser();

View file

@ -33,21 +33,15 @@ describe("nav-bar.vue", () => {
expect(wrapper.componentVM.customButtontext).toBe("newText");
});
test("should run removeLoader fn on buttonMain and return false for onkeydown fn", async () => {
// Arrange
test("should run removeLoader on buttonMain", async () => {
const wrapper = mount(navbar, {
mixins: [mockMixin],
});
// Act
wrapper.vm.$refs.buttonMain.removeLoader = jest.fn();
wrapper.vm.removeLoader();
const spy = jest.spyOn(document, "onkeydown");
document.onkeydown();
// Assert
expect(wrapper.vm.$refs.buttonMain.removeLoader).toHaveBeenCalled();
expect(spy).toReturnWith(true);
});
});

View file

@ -16,6 +16,7 @@
isPrimary
:buttonText="overrideButtonText || buttonText"
loaderColor="white"
loaderPosition="center"
:class="isForwardActionDisabled && 'form-test-invalid'"
:aria-disabled="isForwardActionDisabled"
:isDisabled="isForwardActionDisabled"
@ -69,9 +70,6 @@ export default {
};
},
unmounted() {
document.onkeydown = null;
},
computed: {
backLink() {
return (
@ -91,15 +89,8 @@ export default {
},
removeLoader() {
this.$refs.buttonMain.removeLoader();
document.onkeydown = function (e) {
return true;
};
},
buttonClick() {
//prevent keyboard input after button click
document.onkeydown = function (e) {
return false;
};
// check session expired and initSession to recreate cookies
if (analyticsMixin.methods.sessionExpired()) {
this.routeReturnUser();

View file

@ -233,6 +233,66 @@ describe("save-progress-popup-question ", () => {
});
});
describe("footer button suppression", () => {
test("should suppress the modal footer button on the phone tab", async () => {
const { wrapper } = setupMocks({
props: {
modalWidgetName: "SaveProgressPopupWidget",
},
});
wrapper.vm.selectContactMethod("PhoneAnswer");
await wrapper.vm.$nextTick();
expect(wrapper.vm.isFooterButtonSuppressed).toBe(true);
});
test("should show the modal footer button on the email tab before save", () => {
const { wrapper } = setupMocks({
props: {
modalWidgetName: "SaveProgressPopupWidget",
},
});
expect(wrapper.vm.isFooterButtonSuppressed).toBe(false);
});
test("should suppress the modal footer button after a successful email save", async () => {
const dispatchStoreAction = jest.fn().mockResolvedValue(undefined);
const { wrapper } = setupMocks({
props: {
modalWidgetName: "SaveProgressPopupWidget",
pageName: "quote",
},
});
wrapper.vm.dispatchStoreAction = dispatchStoreAction;
wrapper.vm.userInput = "test@example.com";
await wrapper.vm.saveProgress();
expect(wrapper.vm.isFooterButtonSuppressed).toBe(true);
});
test("should reset the modal footer button loader before suppressing it after save", async () => {
const dispatchStoreAction = jest.fn().mockResolvedValue(undefined);
const { wrapper } = setupMocks({
props: {
modalWidgetName: "SaveProgressPopupWidget",
pageName: "quote",
},
});
wrapper.vm.dispatchStoreAction = dispatchStoreAction;
wrapper.vm.userInput = "test@example.com";
wrapper.vm.modal.resetButtonStyle = jest.fn();
await wrapper.vm.saveProgress();
expect(wrapper.vm.modal.resetButtonStyle).toHaveBeenCalled();
});
});
describe("saveProgress", () => {
test("should save phone number and sms consent when phone tab is selected", async () => {
const dispatchStoreAction = jest.fn().mockResolvedValue(undefined);

View file

@ -7,7 +7,7 @@
:onModalClosedCallback="onModalClosed"
:footerButtonText="modalButtonText"
:isFooterButtonPrimary="true"
:isFooterButtonSuppressed="isPhoneTabSelected && !isProgressSaved"
:isFooterButtonSuppressed="isFooterButtonSuppressed"
@footer-button-event="saveProgress">
<p class="modal-body-inner">{{ modalBodyText }}</p>
<fieldset class="save-progress-popup-question__tabs">
@ -71,9 +71,14 @@
ref="phoneSendButton"
:isPrimary="true"
class="w-100 modal-footer-button"
loaderColor="white"
loaderPosition="center"
:buttonText="modalButtonText"
@click-event="validatePhoneAndSave" />
<p class="modal-disclaimer" v-html="modalDisclaimerText"></p>
<p
v-if="!isProgressSaved"
class="modal-disclaimer"
v-html="modalDisclaimerText"></p>
</template>
<alert
class="my-4"
@ -193,6 +198,9 @@ export default {
isPhoneTabSelected() {
return this.contactMethod === saveProgressPopupContactMethods.PHONE;
},
isFooterButtonSuppressed() {
return this.isProgressSaved || this.isPhoneTabSelected;
},
phoneQuestionWidgetName() {
return PHONE_QUESTION_WIDGET;
},
@ -272,6 +280,9 @@ export default {
// send store call to send to new API (that triggers an email/SMS send)
await saveQuote({ pageNameToLog: this.pageName });
this.modal?.resetButtonStyle();
this.resetPhoneSendButtonStyle();
// hide save progress button; show success message alert
this.savedContactMethod = this.contactMethod;
this.isProgressSaved = true;
@ -528,10 +539,6 @@ export default {
}
&.progress-saved {
.modal-footer-button,
.modal-disclaimer {
display: none;
}
.skip-button {
margin-bottom: 0;
}

View file

@ -27,7 +27,7 @@
</span>
<loader
v-if="isLoaderDisplayed"
:loaderPosition="left"
loaderPosition="left"
:allowPageInteraction="true" />
</div>
</div>

View file

@ -32,10 +32,10 @@
:isRequired="false"
:validationRules="''" />
</form>
<div v-show="isLoading" class="loader-wrapper">
<div v-if="isLoading" class="loader-wrapper">
<loader loaderColor="blue" loaderPosition="center" />
</div>
<div v-show="!isLoading">
<div v-if="!isLoading">
<alert
ref="alertInvalidZip"
v-if="displayInvalidZipAlert"

View file

@ -36,48 +36,6 @@ describe("buttonMain.vue", () => {
expect(button.attributes()["aria-disabled"]).toEqual("true");
});
it("Should return loader color", async () => {
// Arrange
const wrapper = shallowMount(
buttonMain,
setupMocks({
propsData: {
loaderColor: "blue",
loaderEnabled: true,
},
})
);
// Act
wrapper.vm.clicked();
await nextTick();
// Assert
const loader = wrapper.find("loader-stub");
expect(loader.attributes("class")).toContain("blue");
});
it("Should return loader position", async () => {
// Arrange
const wrapper = shallowMount(
buttonMain,
setupMocks({
propsData: {
loaderPosition: "right",
loaderEnabled: true,
},
})
);
// Act
wrapper.vm.clicked();
await nextTick();
// Assert
const loader = wrapper.find("loader-stub");
expect(loader.attributes("class")).toContain("right");
});
it("Should set 'isLoaderDisplayed' to false when calling 'removeLoader'", async () => {
// Arrange
const wrapper = shallowMount(

View file

@ -12,7 +12,8 @@
<loader
class="ms-2"
v-if="isLoaderDisplayed && !suppressLoader"
v-bind:class="[this.loaderColor, this.loaderPosition]" />
:loaderColor="loaderColor"
:loaderPosition="loaderPosition" />
</button>
</template>
@ -64,6 +65,7 @@ export default {
<style lang="scss">
.btn {
font-family: UrbanistSemibold, Arial, Helvetica, sans-serif;
&.btn-primary {
position: relative;
background: $red;

View file

@ -1,4 +1,4 @@
import { shallowMount } from "@vue/test-utils";
import { mount, shallowMount } from "@vue/test-utils";
import interceptOverlay from "./intercept-overlay";
describe("intercept-overlay.vue", () => {
@ -43,4 +43,30 @@ describe("intercept-overlay.vue", () => {
wrapper.unmount();
});
});
describe("click interception", () => {
test("captureClick prevents default and stops propagation", () => {
const wrapper = shallowMount(interceptOverlay);
const event = { preventDefault: jest.fn(), stopPropagation: jest.fn() };
wrapper.vm.captureClick(event);
expect(event.preventDefault).toHaveBeenCalled();
expect(event.stopPropagation).toHaveBeenCalled();
wrapper.unmount();
});
test("clicking the overlay does not propagate to document listeners", async () => {
const documentClickFn = jest.fn();
document.addEventListener("click", documentClickFn);
const wrapper = mount(interceptOverlay, { attachTo: document.body });
const overlay = wrapper.find(".intercept-overlay");
await overlay.trigger("click");
expect(documentClickFn).not.toHaveBeenCalled();
document.removeEventListener("click", documentClickFn);
wrapper.unmount();
});
});
});

View file

@ -1,5 +1,9 @@
<template>
<div class="intercept-overlay" aria-hidden="true" @keydown.capture="blockKey"></div>
<div
class="intercept-overlay"
aria-hidden="true"
@keydown.capture="blockKey"
@click="captureClick"></div>
</template>
<script>
@ -16,6 +20,10 @@ export default {
event.preventDefault();
event.stopImmediatePropagation();
},
captureClick(event) {
event.preventDefault();
event.stopPropagation();
},
},
};
</script>

View file

@ -30,60 +30,6 @@ describe("list-button.vue", () => {
const loader = wrapper.findComponent({ name: "loader" });
expect(loader.exists()).toBe(true);
});
it("Should return loader color", async () => {
// Arrange
const { wrapper } = setupMocks({
mockData: {
global: {
mocks: {
$route: { query: { fmgPage: "page-name" } },
GaActions: GaActions,
pushEventToGA: jest.fn(),
},
},
propsData: {
loaderColor: "blue",
selectingInitiatesLoad: true,
},
},
});
// Act
wrapper.vm.selectedValue = "something";
await wrapper.vm.$nextTick();
// Assert
const loader = wrapper.findComponent({ name: "loader" });
expect(loader.attributes("class")).toContain("blue");
});
it("Should return loader position", async () => {
// Act
const { wrapper } = setupMocks({
mockData: {
global: {
mocks: {
$route: { query: { fmgPage: "page-name" } },
GaActions: GaActions,
pushEventToGA: jest.fn(),
},
},
propsData: {
loaderPosition: "right",
selectingInitiatesLoad: true,
},
},
});
// Act
wrapper.vm.selectedValue = "something";
await wrapper.vm.$nextTick();
// Assert
const loader = wrapper.findComponent({ name: "loader" });
expect(loader.attributes("class")).toContain("right");
});
});
describe("baseInputButton checks", () => {

View file

@ -18,7 +18,8 @@
</span>
<loader
v-if="isLoaderDisplayed && selectingInitiatesLoad"
:class="[this.loaderColor, this.loaderPosition]" />
:loaderColor="loaderColor"
:loaderPosition="loaderPosition" />
</div>
</baseInputButton>
</template>

View file

@ -3,18 +3,16 @@ import loader from "./loader";
describe("loader.vue", () => {
test("if it rendered the HTML element with class", () => {
// Arrange/Act
const wrapper = shallowMount(loader, {
props: {},
});
// Assert
expect(wrapper.find("div").exists()).toBeTruthy();
expect(wrapper.find(".loader").exists()).toBeTruthy();
wrapper.unmount();
});
test("if it correctly passed props", () => {
// Arrange/Act
test("applies loaderColor and loaderPosition as CSS classes on the loader element", () => {
const wrapper = shallowMount(loader, {
props: {
loaderColor: "blue",
@ -22,64 +20,95 @@ describe("loader.vue", () => {
},
});
// Assert
expect(wrapper.props()).toMatchObject({
loaderColor: "blue",
loaderPosition: "left",
});
const loaderElement = wrapper.find(".loader");
expect(loaderElement.classes()).toContain("blue");
expect(loaderElement.classes()).toContain("left");
wrapper.unmount();
});
describe("Blocking interaction on page", () => {
test("Does capture clicks if enabled (default)", async () => {
// Arrange
const div = document.createElement("div");
div.id = "parent";
document.body.appendChild(div);
let parent;
const parentClickFn = jest.fn();
beforeEach(() => {
parent = document.createElement("div");
parent.id = "loader-test-parent";
document.body.appendChild(parent);
});
div.addEventListener("click", parentClickFn);
afterEach(() => {
document.body.querySelectorAll(".intercept-overlay").forEach((el) => el.remove());
if (parent?.parentNode) {
parent.parentNode.removeChild(parent);
}
});
const wrapper = shallowMount(loader, {
test("teleports intercept overlay to document.body when blocking is enabled", () => {
const wrapper = mount(loader, {
props: {},
attachTo: "#parent",
attachTo: "#loader-test-parent",
});
// Act
await wrapper.trigger("click");
const overlayInBody = document.body.querySelector(".intercept-overlay");
const overlayInParent = parent.querySelector(".intercept-overlay");
// Assert
expect(parentClickFn).not.toBeCalled();
expect(overlayInBody).toBeTruthy();
expect(overlayInParent).toBeNull();
expect(wrapper.find(".intercept-overlay").exists()).toBe(false);
// Cleanup
document.body.removeChild(div);
wrapper.unmount();
});
test("Does not capture clicks if disabled", async () => {
// Arrange
const div = document.createElement("div");
div.id = "parent";
document.body.appendChild(div);
const parentClickFn = jest.fn();
div.addEventListener("click", parentClickFn);
const wrapper = shallowMount(loader, {
test("does not render intercept overlay when allowPageInteraction is true", () => {
const wrapper = mount(loader, {
props: {
allowPageInteraction: true,
},
attachTo: "#parent",
attachTo: "#loader-test-parent",
});
// Act
await wrapper.trigger("click");
expect(document.body.querySelector(".intercept-overlay")).toBeNull();
expect(wrapper.find(".loader").exists()).toBe(true);
// Assert
expect(parentClickFn).toBeCalled();
wrapper.unmount();
});
// Cleanup
document.body.removeChild(div);
test("clicking the teleported overlay does not propagate to document listeners", async () => {
const documentClickFn = jest.fn();
document.addEventListener("click", documentClickFn);
const wrapper = mount(loader, {
props: {},
attachTo: "#loader-test-parent",
});
const overlay = document.body.querySelector(".intercept-overlay");
expect(overlay).toBeTruthy();
overlay.dispatchEvent(new MouseEvent("click", { bubbles: true, cancelable: true }));
expect(documentClickFn).not.toHaveBeenCalled();
document.removeEventListener("click", documentClickFn);
wrapper.unmount();
});
test("teleports overlay to body even when loader is mounted inside a button", async () => {
const button = document.createElement("button");
button.type = "button";
parent.appendChild(button);
const wrapper = mount(loader, {
props: {},
attachTo: button,
});
const overlayInBody = document.body.querySelector(".intercept-overlay");
const overlayInButton = button.querySelector(".intercept-overlay");
expect(overlayInBody).toBeTruthy();
expect(overlayInButton).toBeNull();
wrapper.unmount();
});
});
});

View file

@ -1,17 +1,17 @@
<template>
<Teleport to="body">
<interceptOverlay v-if="!allowPageInteraction" />
</Teleport>
<div
class="loader"
role="alert"
aria-label="Loading new page"
@click="captureClick"
v-bind:class="[
this.loaderColor,
this.loaderPosition,
this.allowPageInteraction ? 'allow-ui-interaction' : '',
]"></div>
v-bind:class="[this.loaderColor, this.loaderPosition]"></div>
</template>
<script>
import interceptOverlay from "../intercept-overlay/intercept-overlay.vue";
export default {
name: "loader",
/* Specify size in number value which translates to rem value. For example, 1.5 = 1.5rem = 24px */
@ -31,12 +31,9 @@ export default {
default: false,
},
},
methods: {
captureClick(event) {
if (!this.allowPageInteraction) {
event.stopPropagation();
}
},
methods: {},
components: {
interceptOverlay,
},
};
</script>
@ -46,18 +43,6 @@ export default {
display: flex;
pointer-events: all;
//Open an overlay to prevent page interaction
&:before {
content: "";
position: fixed;
top: 0;
bottom: 0;
left: 0;
right: 0;
background-color: transparent;
z-index: 9999;
cursor: default;
}
//Spinner basics
&:after {
content: "";
@ -101,9 +86,5 @@ export default {
&.black:after {
background-color: $black;
}
&.allow-ui-interaction::before {
//no-block to enable clicking on certain buttons with loaders while the loader is actives
pointer-events: none;
}
}
</style>