18F/identity-idp

View on GitHub
app/javascript/packages/validated-field/validated-field-element.spec.ts

Summary

Maintainability
A
0 mins
Test Coverage
import sinon from 'sinon';
import { getByRole, getByText } from '@testing-library/dom';
import userEvent from '@testing-library/user-event';
import { computeAccessibleDescription } from 'dom-accessibility-api';
import './validated-field-element';

describe('ValidatedFieldElement', () => {
  let idCounter = 0;

  function createAndConnectElement({ hasInitialError = false, errorInsideField = true } = {}) {
    const element = document.createElement('lg-validated-field');
    const errorMessageId = `validated-field-error-${++idCounter}`;
    element.setAttribute('error-id', errorMessageId);
    const errorHtml =
      hasInitialError || !errorInsideField
        ? `<div class="usa-error-message display-none" id="${errorMessageId}">${
            hasInitialError ? 'Invalid value' : ''
          }</div>`
        : '';
    element.innerHTML = `
      <script type="application/json" class="validated-field__error-strings">
        {
          "valueMissing": "This field is required"
        }
      </script>
      <div class="validated-field__input-wrapper">
        <label for="zipcode">ZIP code</label>
        <span id="validated-field-hint">Required Field</span>
        <input
          aria-invalid="${hasInitialError}"
          aria-describedby="validated-field-hint${hasInitialError ? ` ${errorMessageId}` : ''}"
          required="required"
          class="validated-field__input${hasInitialError ? ' usa-input--error' : ''}"
        />
        ${errorHtml && errorInsideField ? errorHtml : ''}
      </div>
    `;

    const form = document.querySelector('form') || document.createElement('form');
    form.appendChild(element);
    if (errorHtml && !errorInsideField) {
      const errorContainer = document.createElement('div');
      errorContainer.innerHTML = errorHtml;
      form.appendChild(errorContainer);
    }
    document.body.appendChild(form);

    return element;
  }

  it('does not have an error message by default', () => {
    const element = createAndConnectElement();

    expect(element.querySelector('.usa-error-message')).to.not.exist();
  });

  it('does not have an error message while the value is valid', async () => {
    const element = createAndConnectElement();

    const input = getByRole(element, 'textbox');
    await userEvent.type(input, '5');

    input.closest('form')!.checkValidity();

    expect(element.querySelector('.usa-error-message')).to.not.exist();
  });

  it('does not needlessly update DOM state when validity does not change', async () => {
    const element = createAndConnectElement();
    const input = getByRole(element, 'textbox');
    sinon.spy(input, 'setAttribute');

    await userEvent.type(input, '5');

    expect(input.setAttribute).not.to.have.been.called();
  });

  it('shows error state and focuses on form validation', () => {
    const element = createAndConnectElement();

    const input = getByRole(element, 'textbox') as HTMLInputElement;

    const form = element.parentNode as HTMLFormElement;
    form.checkValidity();

    expect(input.classList.contains('usa-input--error')).to.be.true();
    expect(input.getAttribute('aria-invalid')).to.equal('true');
    expect(document.activeElement).to.equal(input);
    expect(form.querySelector('.usa-error-message:not(.display-none)')).to.exist();
    expect(computeAccessibleDescription(document.activeElement!)).to.equal(
      'Required Field This field is required',
    );
  });

  it('shows custom validity as message content', () => {
    const element = createAndConnectElement();

    const input = getByRole(element, 'textbox') as HTMLInputElement;
    input.value = 'a';
    input.setCustomValidity('custom validity');

    const form = element.parentNode as HTMLFormElement;
    form.checkValidity();

    expect(getByText(element, 'custom validity')).to.be.ok();
  });

  it('clears existing validation state on input', async () => {
    const element = createAndConnectElement();

    const input = getByRole(element, 'textbox') as HTMLInputElement;

    const form = element.parentNode as HTMLFormElement;
    form.checkValidity();

    await userEvent.type(input, '5');

    expect(input.classList.contains('usa-input--error')).to.be.false();
    expect(input.getAttribute('aria-invalid')).to.equal('false');
    expect(form.querySelector('.usa-error-message:not(.display-none)')).not.to.exist();
    expect(computeAccessibleDescription(document.activeElement!)).to.equal('Required Field');
  });

  it('focuses the first element with an error', () => {
    const firstElement = createAndConnectElement();
    createAndConnectElement();

    const firstInput = getByRole(firstElement, 'textbox') as HTMLInputElement;

    const form = document.querySelector('form') as HTMLFormElement;

    form.checkValidity();

    expect(document.activeElement).to.equal(firstInput);
  });

  context('with initial error message', () => {
    it('clears existing validation state on input', async () => {
      const element = createAndConnectElement();

      const input = getByRole(element, 'textbox') as HTMLInputElement;

      const form = element.parentNode as HTMLFormElement;
      form.checkValidity();

      await userEvent.type(input, '5');

      expect(input.classList.contains('usa-input--error')).to.be.false();
      expect(input.getAttribute('aria-invalid')).to.equal('false');
      expect(() => getByText(element, 'Invalid value')).to.throw();
      expect(form.querySelector('.usa-error-message:not(.display-none)')).not.to.exist();
    });
  });

  context('with error message element pre-rendered in the DOM', () => {
    it('reuses the error message element from inside the tag', () => {
      const element = createAndConnectElement({ hasInitialError: true, errorInsideField: true });
      const input = getByRole(element, 'textbox');

      expect(computeAccessibleDescription(input)).to.equal('Required Field Invalid value');

      const form = element.parentNode as HTMLFormElement;
      form.checkValidity();

      expect(computeAccessibleDescription(input)).to.equal('Required Field This field is required');
      expect(() => getByText(element, 'Invalid value')).to.throw();
      expect(form.querySelector('.usa-error-message:not(.display-none)')).to.exist();
    });

    it('reuses the error message element from outside the tag', () => {
      const element = createAndConnectElement({ hasInitialError: true, errorInsideField: false });
      const input = getByRole(element, 'textbox');
      const form = element.parentNode as HTMLFormElement;

      expect(computeAccessibleDescription(input)).to.equal('Required Field Invalid value');

      form.checkValidity();

      expect(computeAccessibleDescription(input)).to.equal('Required Field This field is required');
      expect(() => getByText(form, 'Invalid value')).to.throw();
      expect(form.querySelector('.usa-error-message:not(.display-none)')).to.exist();
    });

    it('links input to external error message element when input is invalid', () => {
      const element = createAndConnectElement({ hasInitialError: false, errorInsideField: false });
      const form = element.parentNode as HTMLFormElement;

      form.checkValidity();

      const input = getByRole(element, 'textbox');
      expect(computeAccessibleDescription(input)).to.equal('Required Field This field is required');
      expect(form.querySelector('.usa-error-message:not(.display-none)')).to.exist();
    });

    it('clears error message when field becomes valid', async () => {
      const element = createAndConnectElement({ hasInitialError: true });
      const input = getByRole(element, 'textbox');
      await userEvent.type(input, '5');

      expect(computeAccessibleDescription(input)).to.equal('Required Field');
      expect(element.querySelector('.usa-error-message:not(.display-none)')).not.to.exist();
    });
  });

  describe('#isValid', () => {
    context('without initial error', () => {
      it('is true', () => {
        const element = createAndConnectElement({ hasInitialError: false });

        expect(element.isValid).to.be.true();
      });
    });

    context('with initial error', () => {
      it('is false', () => {
        const element = createAndConnectElement({ hasInitialError: true });

        expect(element.isValid).to.be.false();
      });
    });

    context('after becoming invalid', () => {
      it('is false', () => {
        const element = createAndConnectElement();
        element.closest('form')!.checkValidity();

        expect(element.isValid).to.be.false();
      });
    });

    context('after becoming valid', () => {
      it('is true', async () => {
        const element = createAndConnectElement();
        const input = getByRole(element, 'textbox');
        element.closest('form')!.checkValidity();
        await userEvent.type(input, '5');
        element.closest('form')!.checkValidity();

        expect(element.isValid).to.be.true();
      });
    });
  });
});