diff --git a/app/javascript/packages/captcha-submit-button/captcha-submit-button-element.spec.ts b/app/javascript/packages/captcha-submit-button/captcha-submit-button-element.spec.ts
index e961ba9d37b..634aeb4ebfd 100644
--- a/app/javascript/packages/captcha-submit-button/captcha-submit-button-element.spec.ts
+++ b/app/javascript/packages/captcha-submit-button/captcha-submit-button-element.spec.ts
@@ -1,6 +1,6 @@
import type { SinonStub } from 'sinon';
import userEvent from '@testing-library/user-event';
-import { screen, waitFor } from '@testing-library/dom';
+import { screen, waitFor, fireEvent } from '@testing-library/dom';
import { useSandbox, useDefineProperty } from '@18f/identity-test-helpers';
import '@18f/identity-spinner-button/spinner-button-element';
import { CAPTCHA_EVENT_NAME } from './captcha-submit-button-element';
@@ -35,58 +35,30 @@ describe('CaptchaSubmitButtonElement', () => {
`;
});
- it('submits the form', async () => {
+ it('does not prevent default form submission', async () => {
const button = screen.getByRole('button', { name: 'Submit' });
const form = document.querySelector('form')!;
- sandbox.stub(form, 'submit');
+ let didSubmit = false;
+ form.addEventListener('submit', (event) => {
+ expect(event.defaultPrevented).to.equal(false);
+ event.preventDefault();
+ didSubmit = true;
+ });
await userEvent.click(button);
- await waitFor(() => expect((form.submit as SinonStub).called).to.be.true());
+ await waitFor(() => expect(didSubmit).to.be.true());
});
- context('with form validation errors', () => {
- beforeEach(() => {
- document.body.innerHTML = `
-
- `;
- });
-
- it('does not submit the form and reports validity', async () => {
- const button = screen.getByRole('button', { name: 'Submit' });
- const form = document.querySelector('form')!;
- const input = document.querySelector('input')!;
-
- let didSubmit = false;
- form.addEventListener('submit', (event) => {
- event.preventDefault();
- didSubmit = true;
- });
-
- let didReportInvalid = false;
- input.addEventListener('invalid', () => {
- didReportInvalid = true;
- });
+ it('unbinds form events when disconnected', () => {
+ const submitButton = document.querySelector('lg-captcha-submit-button')!;
+ const form = submitButton.form!;
+ form.removeChild(submitButton);
- await userEvent.click(button);
+ sandbox.spy(submitButton, 'shouldInvokeChallenge');
+ fireEvent.submit(form);
- expect(didSubmit).to.be.false();
- expect(didReportInvalid).to.be.true();
- });
-
- it('stops or otherwise prevents the spinner button from spinning', async () => {
- const button = screen.getByRole('button', { name: 'Submit' });
- await userEvent.click(button);
-
- expect(document.querySelector('.spinner-button--spinner-active')).to.not.exist();
- });
+ expect(submitButton.shouldInvokeChallenge).not.to.have.been.called();
});
context('with configured recaptcha', () => {
@@ -130,7 +102,7 @@ describe('CaptchaSubmitButtonElement', () => {
expect(grecaptcha.execute).to.have.been.calledWith(RECAPTCHA_SITE_KEY, {
action: RECAPTCHA_ACTION_NAME,
});
- expect(Object.fromEntries(new FormData(form))).to.deep.equal({
+ expect(Object.fromEntries(new window.FormData(form))).to.deep.equal({
recaptcha_token: RECAPTCHA_TOKEN_VALUE,
});
});
@@ -145,10 +117,14 @@ describe('CaptchaSubmitButtonElement', () => {
const button = screen.getByRole('button', { name: 'Submit' });
const form = document.querySelector('form')!;
- sandbox.stub(form, 'submit');
+ let didSubmit = false;
+ form.addEventListener('submit', (event) => {
+ event.preventDefault();
+ didSubmit = true;
+ });
await userEvent.click(button);
- await waitFor(() => expect((form.submit as SinonStub).called).to.be.true());
+ await waitFor(() => expect(didSubmit).to.be.true());
expect(grecaptcha.ready).not.to.have.been.called();
});
diff --git a/app/javascript/packages/captcha-submit-button/captcha-submit-button-element.ts b/app/javascript/packages/captcha-submit-button/captcha-submit-button-element.ts
index 3828d484d39..7f5eecde1c1 100644
--- a/app/javascript/packages/captcha-submit-button/captcha-submit-button-element.ts
+++ b/app/javascript/packages/captcha-submit-button/captcha-submit-button-element.ts
@@ -1,8 +1,16 @@
export const CAPTCHA_EVENT_NAME = 'lg:captcha-submit-button:challenge';
class CaptchaSubmitButtonElement extends HTMLElement {
+ form: HTMLFormElement | null;
+
connectedCallback() {
- this.button.addEventListener('click', (event) => this.handleButtonClick(event));
+ this.form = this.closest('form');
+
+ this.form?.addEventListener('submit', this.handleFormSubmit);
+ }
+
+ disconnectedCallback() {
+ this.form?.removeEventListener('submit', this.handleFormSubmit);
}
get button(): HTMLButtonElement {
@@ -13,10 +21,6 @@ class CaptchaSubmitButtonElement extends HTMLElement {
return this.querySelector('[type=hidden]')!;
}
- get form(): HTMLFormElement | null {
- return this.closest('form');
- }
-
get recaptchaSiteKey(): string | null {
return this.getAttribute('recaptcha-site-key');
}
@@ -48,21 +52,12 @@ class CaptchaSubmitButtonElement extends HTMLElement {
return !event.defaultPrevented;
}
- handleButtonClick(event: MouseEvent) {
- event.preventDefault();
-
- if (this.form && !this.form.reportValidity()) {
- // Prevent any associated custom click handling, e.g. spinner button spinning
- event.stopImmediatePropagation();
- return;
- }
-
+ handleFormSubmit = (event: SubmitEvent) => {
if (this.shouldInvokeChallenge()) {
+ event.preventDefault();
this.invokeChallenge();
- } else {
- this.submit();
}
- }
+ };
}
declare global {
diff --git a/app/javascript/packages/spinner-button/spinner-button-element.spec.ts b/app/javascript/packages/spinner-button/spinner-button-element.spec.ts
index ed2678340bc..12e132e0d1b 100644
--- a/app/javascript/packages/spinner-button/spinner-button-element.spec.ts
+++ b/app/javascript/packages/spinner-button/spinner-button-element.spec.ts
@@ -3,7 +3,6 @@ import { getByRole, fireEvent, screen } from '@testing-library/dom';
import type { SinonStub } from 'sinon';
import { useSandbox } from '@18f/identity-test-helpers';
import './spinner-button-element';
-import type { SpinnerButtonElement } from './spinner-button-element';
describe('SpinnerButtonElement', () => {
const sandbox = useSandbox({ useFakeTimers: true });
@@ -14,20 +13,37 @@ describe('SpinnerButtonElement', () => {
interface WrapperOptions {
actionMessage?: string;
-
tagName?: string;
-
spinOnClick?: boolean;
+ inForm?: boolean;
+ isButtonTo?: boolean;
}
- function createWrapper({ actionMessage, tagName = 'a', spinOnClick }: WrapperOptions = {}) {
- document.body.innerHTML = `
+ function createWrapper({
+ actionMessage,
+ tagName = 'a',
+ spinOnClick,
+ inForm,
+ isButtonTo,
+ }: WrapperOptions = {}) {
+ let tag;
+ if (tagName === 'a') {
+ tag = 'Click Me';
+ } else {
+ tag = '';
+ }
+
+ if (isButtonTo) {
+ tag = ``;
+ }
+
+ let html = `
- ${tagName === 'a' ? '
Click Me' : '
'}
+ ${tag}
@@ -44,7 +60,13 @@ describe('SpinnerButtonElement', () => {
}
`;
- return document.body.firstElementChild as SpinnerButtonElement;
+ if (inForm) {
+ html = ``;
+ }
+
+ document.body.innerHTML = html;
+
+ return document.querySelector('lg-spinner-button')!;
}
it('shows spinner on click', async () => {
@@ -56,24 +78,64 @@ describe('SpinnerButtonElement', () => {
expect(wrapper.classList.contains('spinner-button--spinner-active')).to.be.true();
});
- it('disables button without preventing form handlers', async () => {
- const wrapper = createWrapper({ tagName: 'button' });
- let submitted = false;
- const form = document.createElement('form');
- form.action = '#';
- form.addEventListener('submit', (event) => {
- submitted = true;
- event.preventDefault();
+ context('inside form', () => {
+ it('disables button without preventing form handlers', async () => {
+ const wrapper = createWrapper({ tagName: 'button', inForm: true });
+ let didSubmit = false;
+ wrapper.form!.addEventListener('submit', (event) => {
+ didSubmit = true;
+ event.preventDefault();
+ });
+ const button = screen.getByRole('button', { name: 'Click Me' });
+
+ await userEvent.type(button, '{Enter}');
+ clock.tick(0);
+
+ expect(didSubmit).to.be.true();
+ expect(button.hasAttribute('disabled')).to.be.true();
+ });
+
+ it('unbinds events when disconnected', () => {
+ const wrapper = createWrapper({ tagName: 'button', inForm: true });
+ const form = wrapper.form!;
+ form.removeChild(wrapper);
+
+ sandbox.spy(wrapper, 'toggleSpinner');
+ fireEvent.submit(form);
+
+ expect(wrapper.toggleSpinner).not.to.have.been.called();
+ });
+ });
+
+ context('with form inside (button_to)', () => {
+ it('disables button without preventing form handlers', async () => {
+ const wrapper = createWrapper({ tagName: 'button', isButtonTo: true });
+ let didSubmit = false;
+ wrapper.form!.addEventListener('submit', (event) => {
+ didSubmit = true;
+ event.preventDefault();
+ });
+ const button = screen.getByRole('button', { name: 'Click Me' });
+
+ await userEvent.type(button, '{Enter}');
+ clock.tick(0);
+
+ expect(didSubmit).to.be.true();
+ expect(button.hasAttribute('disabled')).to.be.true();
});
- document.body.appendChild(form);
- form.appendChild(wrapper);
+ });
+
+ it('does not show spinner if form is invalid', async () => {
+ const wrapper = createWrapper({ tagName: 'button', inForm: true });
+ const form = wrapper.closest('form')!;
+ const input = document.createElement('input');
+ input.required = true;
+ form.appendChild(input);
const button = screen.getByRole('button', { name: 'Click Me' });
await userEvent.type(button, '{Enter}');
- clock.tick(0);
- expect(submitted).to.be.true();
- expect(button.hasAttribute('disabled')).to.be.true();
+ expect(wrapper.classList.contains('spinner-button--spinner-active')).to.be.false();
});
it('announces action message', async () => {
diff --git a/app/javascript/packages/spinner-button/spinner-button-element.ts b/app/javascript/packages/spinner-button/spinner-button-element.ts
index 903fbd9ce28..902f1a5a3ae 100644
--- a/app/javascript/packages/spinner-button/spinner-button-element.ts
+++ b/app/javascript/packages/spinner-button/spinner-button-element.ts
@@ -12,8 +12,43 @@ const DEFAULT_LONG_WAIT_DURATION_MS = 15000;
export class SpinnerButtonElement extends HTMLElement {
elements: SpinnerButtonElements;
+ form: HTMLFormElement | null;
+
#longWaitTimeout?: number;
+ connectedCallback() {
+ this.form = this.querySelector('form') || this.closest('form');
+
+ this.addEventListener('spinner.start', () => this.toggleSpinner(true));
+ this.addEventListener('spinner.stop', () => this.toggleSpinner(false));
+
+ if (this.spinOnClick) {
+ if (this.form) {
+ this.form.addEventListener('submit', this.showSpinner);
+ } else {
+ this.button.addEventListener('click', this.showSpinner);
+ }
+ }
+ }
+
+ disconnectedCallback() {
+ window.clearTimeout(this.#longWaitTimeout);
+
+ if (this.form) {
+ this.form.removeEventListener('submit', this.showSpinner);
+ } else {
+ this.button.removeEventListener('click', this.showSpinner);
+ }
+ }
+
+ get button(): HTMLElement {
+ return this.querySelector('a,button:not([type]),[type="submit"],[type="button"]')!;
+ }
+
+ get actionMessage(): HTMLElement {
+ return this.querySelector('.spinner-button__action-message')!;
+ }
+
get spinOnClick(): boolean {
return this.getAttribute('spin-on-click') !== 'false';
}
@@ -25,38 +60,19 @@ export class SpinnerButtonElement extends HTMLElement {
return Number(this.getAttribute('long-wait-duration-ms')) || DEFAULT_LONG_WAIT_DURATION_MS;
}
- connectedCallback() {
- this.elements = {
- button: this.querySelector('a,button:not([type]),[type="submit"],[type="button"]')!,
- actionMessage: this.querySelector('.spinner-button__action-message')!,
- };
-
- if (this.spinOnClick) {
- this.elements.button.addEventListener('click', () => this.toggleSpinner(true));
- }
- this.addEventListener('spinner.start', () => this.toggleSpinner(true));
- this.addEventListener('spinner.stop', () => this.toggleSpinner(false));
- }
-
- disconnectedCallback() {
- window.clearTimeout(this.#longWaitTimeout);
- }
+ showSpinner = () => this.toggleSpinner(true);
toggleSpinner(isVisible: boolean) {
- const { button, actionMessage } = this.elements;
this.classList.toggle('spinner-button--spinner-active', isVisible);
- // Avoid setting disabled immediately to allow click event to propagate for form submission.
- setTimeout(() => {
- if (isVisible) {
- button.setAttribute('disabled', '');
- } else {
- button.removeAttribute('disabled');
- }
- }, 0);
+ if (isVisible) {
+ this.button.setAttribute('disabled', '');
+ } else {
+ this.button.removeAttribute('disabled');
+ }
- if (actionMessage) {
- actionMessage.textContent = isVisible ? (actionMessage.dataset.message as string) : '';
+ if (this.actionMessage) {
+ this.actionMessage.textContent = isVisible ? this.actionMessage.dataset.message! : '';
}
window.clearTimeout(this.#longWaitTimeout);
@@ -69,7 +85,7 @@ export class SpinnerButtonElement extends HTMLElement {
}
handleLongWait() {
- this.elements.actionMessage?.classList.remove('usa-sr-only');
+ this.actionMessage?.classList.remove('usa-sr-only');
}
}
diff --git a/app/javascript/packs/form-steps-wait.tsx b/app/javascript/packs/form-steps-wait.tsx
index bff1f7d833e..8aab193c404 100644
--- a/app/javascript/packs/form-steps-wait.tsx
+++ b/app/javascript/packs/form-steps-wait.tsx
@@ -99,7 +99,6 @@ export class FormStepsWait {
bind() {
this.elements.form.addEventListener('submit', (event) => this.handleSubmit(event));
- this.elements.form.addEventListener('invalid', () => this.stopSpinner(), true);
}
/**
diff --git a/spec/javascripts/packs/form-steps-wait-spec.js b/spec/javascripts/packs/form-steps-wait-spec.js
index abf9a28c0a1..a35319a5c84 100644
--- a/spec/javascripts/packs/form-steps-wait-spec.js
+++ b/spec/javascripts/packs/form-steps-wait-spec.js
@@ -161,22 +161,6 @@ describe('FormStepsWait', () => {
});
});
- context('invalid input', () => {
- let form;
- let input;
- beforeEach(() => {
- form = createForm({ action, method });
- input = form.querySelector('#text-name');
- input.setAttribute('required', '');
- });
- it('stops spinner', (done) => {
- new FormStepsWait(form).bind();
- form.addEventListener('spinner.stop', () => done());
-
- fireEvent.invalid(input);
- });
- });
-
context('handled error', () => {
context('alert not in response', () => {
const redirect = window.location.href;