const helpers = require('./support/helpers');
const PADDING_BETWEEN_STICKYS = 40;
const PADDING_BEFORE_STOPPING_POINT = 10;

function getScreenItemBottomPosition (screenItem) {
  return screenItem.offsetTop + screenItem.offsetHeight;
};

function getCaretPosition (caretPosition, textarea) {

  return {
    top: textarea.offsetTop + caretPosition.top,
    bottom: textarea.offsetTop + caretPosition.top + caretPosition.height,
    height: caretPosition.height
  };

};

function getStickyGroupPosition (screenMock, opts) {

  const edgePosition = screenMock.window[opts.edge];
  const height = opts.stickyEls
                  .map(el => el.offsetHeight)
                  .reduce((accumulator, height) => accumulator + height);

  if (opts.edge === 'top') {
    return {
      top: screenMock.window.top,
      bottom: edgePosition + height,
      height: height
    };
  } else {
    return {
      top: edgePosition - height,
      bottom: screenMock.window.bottom,
      height: height
    }
  }

};

class CaretCoordinates {
  constructor (data) {
    this.top = 5.5;
    this.left = 2;
    this.height = 19;
  }

  moveToLine (lineNumber) {
    const lineHeight = 30;
    const verticalPadding = 5.5;

    this.top = ((lineNumber - 1) * lineHeight) + verticalPadding;
  }
}

beforeAll(() => {
  require('../../app/assets/javascripts/stick-to-window-when-scrolling.js');
});

afterAll(() => {
  require('./support/teardown.js');
});

describe("Stick to top/bottom of window when scrolling", () => {

  let screenMock;

  describe("If intending to stick to the top", () => {

    let inputForm;
    let formFooter;
    let footer;
    let windowHeight;
    let getFurthestTopPoint;

    beforeEach(() => {

      document.body.innerHTML = `
        <div class="govuk-grid-row">
          <main class="govuk-grid-column-three-quarters column-main">
            <form method="post" autocomplete="off">
              <div class="govuk-grid-row js-stick-at-top-when-scrolling">
                <div class="govuk-grid-column-two-thirds ">
                  <div class="form-group" data-module="">
                    <label class="form-label" for="placeholder_value">
                      name
                    </label>
                    <input class="form-control form-control-1-1 " data-module="" id="placeholder_value" name="placeholder_value" required="" rows="8" type="text" value="">
                  </div>
                </div>
              </div>
              <div class="page-footer">
                <button type="submit" class="govuk-button">Continue</button>
              </div>
            </form>
          </main>
        </div>
        <footer class="js-footer"></footer>`;

      inputForm = document.querySelector('form > .govuk-grid-row');
      formFooter = document.querySelector('.page-footer');
      footer = document.querySelector('.js-footer');

      windowHeight = 940;

      // mock the rendering of all components
      screenMock = new helpers.ScreenMock(jest);
      screenMock.setWindow({
        width: 1990,
        height: windowHeight,
        scrollTop: 0
      });
      screenMock.mockPositionAndDimension('inputForm', inputForm, {
        offsetHeight: 168,
        offsetWidth: 727,
        offsetTop: 238
      });
      screenMock.mockPositionAndDimension('formFooter', formFooter, {
        offsetHeight: 200,
        offsetWidth: 727,
        offsetTop: inputForm.offsetTop + 200
      });
      screenMock.mockPositionAndDimension('footer', footer, {
        offsetHeight: 535,
        offsetWidth: 1990,
        offsetTop: formFooter.offsetTop + formFooter.offsetHeight
      });

      getFurthestTopPoint = (stickysHeight) => {
        return footer.offsetTop - PADDING_BEFORE_STOPPING_POINT - stickysHeight;
      };

      // the sticky JS polls for changes to position/dimension so we need to fake setTimeout and setInterval
      jest.useFakeTimers();

    });

    afterEach(() => {

      document.body.innerHTML = '';

      window.GOVUK.stickAtTopWhenScrolling.clearEvents();

      screenMock.reset();

    });

    test("if top of viewport is above top of element on load, the element should not be marked as sticky", () => {

      // scroll position defaults to 0, element top defaults to 138px

      window.GOVUK.stickAtTopWhenScrolling.init();

      expect(inputForm.classList.contains('content-fixed-onload')).toBe(false);
      expect(inputForm.classList.contains('content-fixed')).toBe(false);

    });

    test("if the window is 768px or less wide and the top of the viewport is below top of element on load, the element should not be marked as sticky", () => {

      screenMock.window.resizeTo({
        height: windowHeight,
        width: 768
      });

      // scroll past top of form
      screenMock.scrollTo(inputForm.offsetTop + 10);

      window.GOVUK.stickAtTopWhenScrolling.init();

      expect(inputForm.classList.contains('content-fixed')).toBe(false);
      expect(inputForm.classList.contains('content-fixed-onload')).toBe(false); // check the class for onload isn't applied

    });

    describe("if top of viewport is below top of element but still in the scroll area on load", () => {

      beforeEach(() => {

        // scroll past the top of the form
        screenMock.scrollTo(inputForm.offsetTop + 10);

        window.GOVUK.stickAtTopWhenScrolling.init();

      });

      test("the element should be marked as already sticky", () => {

        // `.content-fixed-onload` adds the drop-shadow without fading in to show it did not become sticky from user interaction
        expect(inputForm.classList.contains('content-fixed-onload')).toBe(true);
        expect(inputForm.classList.contains('content-fixed')).toBe(false);

      });

      test("a 'shim' element with dimensions matching the sticky element should be added to the document to take up the space it no longer occupies", () => {

        const shim = inputForm.previousElementSibling;

        expect(shim).not.toBeNull();
        expect(shim.classList.contains('shim')).toBe(true);
        expect(shim.style.height).toEqual(`${inputForm.offsetHeight}px`);
        expect(shim.style.marginTop).toEqual(''); // 0px would return an empty string
        expect(shim.style.marginBottom).toEqual(''); // 0px would return an empty string

      });

    });

    test("if top of viewport is below the furthest point the top of the element can go in the scroll area on load, the element should be marked as stopped", () => {

      // the element should stop a set distance from the stopping point
      const furthestTopPoint = getFurthestTopPoint(inputForm.offsetHeight);

      // scroll past the furthest point
      screenMock.scrollTo(furthestTopPoint + 10);

      window.GOVUK.stickAtTopWhenScrolling.init();

      // `.content-fixed-onload` adds the drop-shadow without fading in to show it did not become sticky from user interaction
      expect(inputForm.classList.contains('content-fixed-onload')).toBe(true);
      expect(inputForm.classList.contains('content-fixed')).toBe(false);

      // elements are stopped by adding inline styles
      expect(inputForm.style.position).toEqual('absolute');
      expect(inputForm.style.top).toEqual(`${furthestTopPoint}px`);

    });

    describe("if viewport top starts above element top", () => {

      beforeEach(() => {

        // default scroll position is above top of form
        window.GOVUK.stickAtTopWhenScrolling.init();

      });

      test("if window is scrolled so top of it is below the top of the element, the element should be marked so it becomes sticky to the user", () => {

        // scroll past top of form
        screenMock.scrollTo(inputForm.offsetTop + 10);
        jest.advanceTimersByTime(60); // fake advance of time to something similar to that for a scroll

        // `content-fixed` fades the drop-shadow in to show it became sticky from user interaction
        expect(inputForm.classList.contains('content-fixed')).toBe(true);
        expect(inputForm.classList.contains('content-fixed-onload')).toBe(false); // check the class for onload isn't applied

      });

      test("if window is scrolled so top of it is below the furthest point the top of the element can go in the scroll area, the element should be stopped", () => {

        const furthestTopPoint = getFurthestTopPoint(inputForm.offsetHeight);

        // scroll past top of form
        screenMock.scrollTo(furthestTopPoint + 10);
        jest.advanceTimersByTime(60); // fake advance of time to something similar to that for a scroll

        // `content-fixed` fades the drop-shadow in to show it became sticky from user interaction
        expect(inputForm.classList.contains('content-fixed')).toBe(true);
        expect(inputForm.classList.contains('content-fixed-onload')).toBe(false); // check the class for onload isn't applied

        // elements are stopped by adding inline styles
        expect(inputForm.style.position).toEqual('absolute');
        expect(inputForm.style.top).toEqual(`${furthestTopPoint}px`);

      });

    });

    describe("if viewport top starts below element top", () => {

      beforeEach(() => {

        // scroll past top of form
        screenMock.scrollTo(inputForm.offsetTop + 10);

        window.GOVUK.stickAtTopWhenScrolling.init();

      });

      test("if window is scrolled so top of it is above the top of the element, the element should be made not sticky", () => {

        // scroll to top
        screenMock.scrollTo(0);
        jest.advanceTimersByTime(60); // fake advance of time to something similar to that for a scroll

        expect(inputForm.classList.contains('content-fixed')).toBe(false);
        expect(inputForm.classList.contains('content-fixed-onload')).toBe(false); // check the class for onload isn't applied

      });

    });

    describe("if scrollToRevealElement is called with an element", () => {

      let link;
      let linkBottom;

      beforeEach(() => {

        const inputFormBottom = getScreenItemBottomPosition(inputForm);

        inputForm.insertAdjacentHTML('afterEnd', '<a href="" id="formatting-options">Formatting options</a>');
        link = document.querySelector('#formatting-options');

        screenMock.mockPositionAndDimension('link', link, {
          offsetHeight: 25, // 143px smaller than the sticky
          offsetWidth: 727,
          offsetTop: inputFormBottom
        });

        linkBottom = getScreenItemBottomPosition(link);

        // move the sticky over the link. It's 168px high so this position will cause it to overlap.
        screenMock.scrollTo(link.offsetTop - 140);

        window.GOVUK.stickAtTopWhenScrolling.init();

      });

      afterEach(() => {

        screenMock.window.spies.window.scrollTo.mockClear();

      });

      test("the window should scroll so the element is revealed", () => {

        // update inputForm position as DOM normally would
        inputForm.offsetTop = screenMock.window.top;

        let stickyPosition = getStickyGroupPosition(screenMock, { stickyEls: [inputForm], edge: 'top' });

        // sticky position should overlap link position
        expect(stickyPosition.top).toBeLessThanOrEqual(link.offsetTop);
        expect(stickyPosition.bottom).toBeGreaterThanOrEqual(linkBottom);

        window.GOVUK.stickAtTopWhenScrolling.scrollToRevealElement(link);

        stickyPosition = getStickyGroupPosition(screenMock, { stickyEls: [inputForm], edge: 'top' });

        // the bottom of the sticky element should be at the top of the link
        expect(screenMock.window.spies.window.scrollTo.mock.calls[0]).toEqual([0, link.offsetTop - stickyPosition.height]);

      });

    });

    describe("if element is made sticky and another element underneath it is focused", () => {

      let checkbox;
      let checkboxBottom;

      beforeEach(() => {

        const inputFormBottom = getScreenItemBottomPosition(inputForm);

        inputForm.insertAdjacentHTML('afterEnd', '<input type="checkbox" name="confirm" value="yes" />');
        checkbox = document.querySelector('input[type=checkbox]');

        screenMock.mockPositionAndDimension('checkbox', checkbox, {
          offsetHeight: 50, // 118px smaller than the sticky
          offsetWidth: 727,
          offsetTop: inputFormBottom
        });

        checkboxBottom = getScreenItemBottomPosition(checkbox);

        // move the sticky over the checkbox. It's 168px high so this position will cause it to overlap.
        screenMock.scrollTo(checkbox.offsetTop - 10);

        window.GOVUK.stickAtTopWhenScrolling.init();

      });

      afterEach(() => {

        screenMock.window.spies.window.scrollTo.mockClear();

      });

      test("the window should scroll so the focused element is revealed", () => {

        // update inputForm position as DOM normally would
        inputForm.offsetTop = screenMock.window.top;

        let stickyPosition = getStickyGroupPosition(screenMock, { stickyEls: [inputForm], edge: 'top' });

        // sticky position should overlap checkbox position
        expect(stickyPosition.top).toBeLessThanOrEqual(checkbox.offsetTop);
        expect(stickyPosition.bottom).toBeGreaterThanOrEqual(checkboxBottom);

        // the sticky element (page footer) is 50 high so should cover the last of the radios if the bottom edge of the viewport is at its bottom
        checkbox.focus();

        stickyPosition = getStickyGroupPosition(screenMock, { stickyEls: [inputForm], edge: 'top' });

        // the bottom of the sticky element should be at the top of the checkbox
        expect(screenMock.window.spies.window.scrollTo.mock.calls[0]).toEqual([0, checkbox.offsetTop - stickyPosition.height]);

      });

    });

    describe("if element is made sticky and overlaps a textarea", () => {

      let textarea;
      let textareaBottom;
      let caretCoordinates;
      let caretCoordinatesMock;

      beforeEach(() => {

        const inputFormBottom = getScreenItemBottomPosition(inputForm);

        inputForm.insertAdjacentHTML('afterEnd', '<textarea name="notes"></textarea>');
        textarea = document.querySelector('textarea');

        // line height: 30px, text height: 19px, lines: 10
        screenMock.mockPositionAndDimension('textarea', textarea, {
          offsetHeight: 300,
          offsetWidth: 727,
          offsetTop: inputFormBottom
        });

        textareaBottom = getScreenItemBottomPosition(textarea);

        // mock calls for caret position, relative to textarea
        caretCoordinatesMock = jest.fn(() => caretCoordinates);
        window.getCaretCoordinates = caretCoordinatesMock;

        // start caret on first line
        caretCoordinates = new CaretCoordinates();

        // move the sticky so it overlaps the top 168px of the textarea.
        screenMock.scrollTo(textarea.offsetTop);

        // update inputForm position as DOM normally would
        inputForm.offsetTop = screenMock.window.top;

        window.GOVUK.stickAtTopWhenScrolling.init();

      });

      afterEach(() => {

        screenMock.window.spies.window.scrollTo.mockClear();
        caretCoordinatesMock.mockClear();

      });

      test("if the textarea receives focus while its caret is underneath, the window should scroll to reveal the caret", () => {

        // caret is on first line
        const stickyPosition = getStickyGroupPosition(screenMock, { stickyEls: [inputForm], edge: 'top' });
        const caretPosition = getCaretPosition(caretCoordinates, textarea);

        // sticky position should overlap caret position
        expect(stickyPosition.top).toBeLessThanOrEqual(caretPosition.top);
        expect(stickyPosition.bottom).toBeGreaterThanOrEqual(caretPosition.bottom);

        // the sticky element (page footer) is 50 high so should cover the last of the radios if the bottom edge of the viewport is at its bottom
        textarea.focus();

        // the bottom of the sticky element should be at the top of the checkbox
        expect(screenMock.window.spies.window.scrollTo.mock.calls[0]).toEqual([0, caretPosition.top - stickyPosition.height]);

      });

      test("if the caret is moved so it isn't underneath the sticky element, the window shouldn't scroll", () => {

        // start caret on 7th line which isn't under the sticky element.
        caretCoordinates.moveToLine(7);

        const stickyPosition = getStickyGroupPosition(screenMock, { stickyEls: [inputForm], edge: 'top' });
        const caretPosition = getCaretPosition(caretCoordinates, textarea);

        // the sticky element should be above the caret
        expect(stickyPosition.bottom).toBeLessThan(caretPosition.top);

        textarea.focus();

        // no scrolling should have happened
        expect(screenMock.window.spies.window.scrollTo.mock.calls.length).toEqual(0);

      });

      test("if the caret is moved to be underneath the sticky element, the window should scroll to reveal the caret", () => {

        // start caret on 7th line which isn't under the sticky element.
        caretCoordinates.moveToLine(7);

        // make sure the textarea has focus
        textarea.focus();

        // line 6 is under the sticky element
        caretCoordinates.moveToLine(6);

        const stickyPosition = getStickyGroupPosition(screenMock, { stickyEls: [inputForm], edge: 'top' });
        const caretPosition = getCaretPosition(caretCoordinates, textarea);

        // sticky should now overlap the caret
        expect(stickyPosition.bottom).toBeGreaterThanOrEqual(caretPosition.top);

        // the sticky element (page footer) is 50 high so should cover the last of the radios if the bottom edge of the viewport is at its bottom
        helpers.triggerEvent(textarea, 'keyup', { interface: window.KeyboardEvent });

        // the bottom of the sticky element should be at the top of the checkbox
        expect(screenMock.window.spies.window.scrollTo.mock.calls[0]).toEqual([0, caretPosition.top - stickyPosition.height]);

      });

    });

    describe("if mode is set to 'dialog' and multiple sticky elements share the same scroll area", () => {

      let radios;

      beforeEach(() => {

        const inputFormBottom = getScreenItemBottomPosition(inputForm);

        // set mode to 'dialog' so sticky elements are treated as one item
        window.GOVUK.stickAtTopWhenScrolling.setMode('dialog')

        // add another sticky element before the form footer
        radios = helpers.getRadioGroup({
          cssClasses: ['js-stick-at-top-when-scrolling'],
          name: 'choose-send-time',
          label: 'Choose send time',
          fields: [
            {
              label: 'Now',
              value: 'now',
              checked: true
            },
            {
              label: 'Tomorrow',
              value: 'tomorrow',
              checked: false
            },
            {
              label: 'Friday',
              value: 'friday',
              checked: false
            }
          ]
        });
        formFooter.parentNode.insertBefore(radios, formFooter);
        screenMock.mockPositionAndDimension('radios', radios, {
          offsetHeight: 175,
          offsetWidth: 727,
          offsetTop: inputFormBottom
        });
        const radiosBottom = getScreenItemBottomPosition(radios);

        // adjust other screen items

        // formFooter top should be at the radios bottom
        formFooter.offsetTop = radiosBottom;

        // footer top should be at formFooter's bottom
        footer.offsetTop = getScreenItemBottomPosition(formFooter);

      });

      afterEach(() => {

        window.GOVUK.stickAtTopWhenScrolling.setMode('default');

      });

      describe("if window top is below the top of the highest element on load", () => {

        beforeEach(() => {

          // scroll past top of first sticky element
          screenMock.scrollTo(inputForm.offsetTop + 10);

          window.GOVUK.stickAtTopWhenScrolling.init();

        });

        test("both should be marked as already sticky", () => {

          // `.content-fixed-onload` adds the drop-shadow without fading in to show it did not become sticky from user interaction
          expect(radios.classList.contains('content-fixed-onload')).toBe(true);
          expect(radios.classList.contains('content-fixed')).toBe(false);
          expect(inputForm.classList.contains('content-fixed-onload')).toBe(true);
          expect(inputForm.classList.contains('content-fixed')).toBe(false);

        });

        test("the lowest element should have the drop-shadow", () => {

          expect(radios.classList.contains('content-fixed__top')).toBe(true);

        });

        test("they should be stacked from the top edge of the viewport in the order they appear in the document", () => {

          expect(inputForm.style.top).toEqual('0px');

          // dialog mode removes enough padding to deal with that each sticky element is given to give its content enough space from the surrounding page
          expect(radios.style.top).toEqual(`${inputForm.offsetHeight - PADDING_BETWEEN_STICKYS}px`);

        });

      });

      describe("if window top is below the furthest point the highest element can go on load", () => {

        let furthestTopPoint;

        beforeEach(() => {

          // ensure each sticky element is positioned correctly relative to the furthest point
          const dialogHeight = (inputForm.offsetHeight + radios.offsetHeight) - PADDING_BETWEEN_STICKYS;

          furthestTopPoint = getFurthestTopPoint(dialogHeight);

          // scroll past top of first sticky element
          screenMock.scrollTo(furthestTopPoint + 10);

          window.GOVUK.stickAtTopWhenScrolling.init();

        });

        test("both should be stopped so the bottom of their stack is at the furthest point", () => {

          expect(inputForm.style.position).toEqual('absolute');
          expect(radios.style.position).toEqual('absolute');

          expect(inputForm.style.top).toEqual(`${furthestTopPoint}px`);
          expect(radios.style.top).toEqual(`${footer.offsetTop - PADDING_BEFORE_STOPPING_POINT - radios.offsetHeight}px`);

        });

      });

      describe("if the group of sticky elements is taller than the window when stuck together", () => {

        // the sticky behaviour should stick as many elements as possible to the top of the viewport
        // the rest should be put back into their place in the document
        beforeEach(() => {

          const windowHeight = 460;

          function makeStickysBiggerThanWindow () {

            // make the radios too big to fit in the viewport
            const fields = ['Saturday', 'Sunday', 'Monday', 'Tuesday', 'Wednesday', 'Thursday', 'Friday']
              .map(field => {
                return {
                  label: field,
                  value: field.toLowerCase(),
                  checked: false
                }
              });

            radios.querySelector('fieldset').insertAdjacentHTML('beforeend', helpers.getRadios(fields, 'days'));

            radios.offsetHeight = 475;

          };

          function adjustScreenPositions () {

            const radiosBottom = getScreenItemBottomPosition(radios);

            screenMock.window.setHeightTo(windowHeight);

            // formFooter top should be at the radios bottom
            formFooter.offsetTop = radiosBottom;

            // footer top should be at formFooter's bottom
            footer.offsetTop = getScreenItemBottomPosition(formFooter);

          };

          makeStickysBiggerThanWindow();
          adjustScreenPositions();

          screenMock.scrollTo(inputForm.offsetTop + 10);

          window.GOVUK.stickAtTopWhenScrolling.init()

        });

        test("the number of elements in the group that are stuck should be reduced until it fits into the viewport", () => {

          // when the group is stuck, pageFooter is closest to the viewport edge so should stay sticky
          expect(inputForm.classList.contains('content-fixed-onload')).toBe(true);
          expect(inputForm.classList.contains('content-fixed')).toBe(false);

          // radios are made not sticky
          expect(radios.classList.contains('content-fixed-onload')).toBe(false);
          expect(radios.classList.contains('content-fixed')).toBe(false);

        });

        test("window is scrolled so the sticky elements (stuck and not stuck) appear in sequence", () => {

          // we assume it's useful to start by seeing all the elements in the position they normally sit in the page
          expect(screenMock.window.top).toEqual(inputForm.offsetTop);

        });

      });

    });

  });

  describe("If intending to stick to the bottom", () => {

    let header;
    let content;
    let pageFooter;
    let windowHeight;
    let getFurthestBottomPoint;

    beforeEach(() => {

      document.body.innerHTML = `
        <main role="main" class="govuk-grid-column-three-quarters column-main">
          <a class="usa-back-link" href="">Back</a>
          <h1 class="heading-large js-header">
            Preview of ‘Content email’
          </h1>
          <div class="wrapper">
            <div class="email-message-body">
              <h2>This is the title</h2>
              <p>This is a paragraph, defined as a block of content where the contents run horizontally across lines.</p>
              <table role="presentation">
                <tbody>
                  <tr>
                    <td>
                      <ul>
                        <li>these</li>
                        <li>are</li>
                        <li>bullet points</li>
                      </ul>
                    </td>
                  </tr>
                </tbody>
              </table>
              <blockquote>
                <p>This block of text is formatted to stand out from the rest</p>
              </blockquote>
              <p>This is a paragraph with a horizontal line underneath.</p>
              <hr>
              <p>This is a paragraph with a horizontal line above.</p>
              <p>This paragraph has a link in it: <a class="usa-link" href="https://www.gov.uk">https://www.gov.uk</a>.</p>
            </div>
            <div class="page-footer js-stick-at-bottom-when-scrolling">
              <form method="post" action="">
                  <button type="submit" class="govuk-button">Send 1 email </button>
              </form>
            </div>
          </div>
        </main>`;

      heading = document.querySelector('.js-header');
      content = document.querySelector('.email-message-body');
      pageFooter = document.querySelector('.page-footer');

      windowHeight = 940;

      // mock the rendering of all components
      screenMock = new helpers.ScreenMock(jest);
      screenMock.setWindow({
        width: 1990,
        height: windowHeight,
        scrollTop: 0
      });
      screenMock.mockPositionAndDimension('heading', heading, {
        offsetHeight: 75,
        offsetWidth: 727,
        offsetTop: 185
      });
      screenMock.mockPositionAndDimension('content', content, {
        offsetHeight: 900,
        offsetWidth: 727,
        offsetTop: heading.offsetTop + heading.offsetHeight
      });
      screenMock.mockPositionAndDimension('pageFooter', pageFooter, {
        offsetHeight: 50,
        offsetWidth: 727,
        offsetTop: content.offsetTop + content.offsetHeight
      });

      getFurthestBottomPoint = (stickysTotalHeight) => {
        const headingBottom = getScreenItemBottomPosition(heading);

        return headingBottom + PADDING_BEFORE_STOPPING_POINT + stickysTotalHeight;
      };

      // the sticky JS polls for changes to position/dimension so we need to fake setTimeout and setInterval
      jest.useFakeTimers();

    });

    afterEach(() => {

      window.GOVUK.stickAtBottomWhenScrolling.clearEvents();

    });

    test("if bottom of viewport is below bottom of element on load, the element should not be marked as sticky", () => {

      const pageFooterBottom = getScreenItemBottomPosition(pageFooter);

      // scroll so the bottom of the window goes past the bottom of the element
      screenMock.scrollTo((pageFooterBottom - windowHeight) + 10);

      window.GOVUK.stickAtBottomWhenScrolling.init();

      // `.content-fixed-onload` adds the drop-shadow without fading in to show it did not become sticky from user interaction
      expect(pageFooter.classList.contains('content-fixed-onload')).toBe(false);
      expect(pageFooter.classList.contains('content-fixed')).toBe(false);

    });

    test("if the window is 768px or less wide and the bottom of the viewport is above bottom of element on load, the element should not be marked as sticky", () => {

      const pageFooterBottom = getScreenItemBottomPosition(pageFooter);

      screenMock.window.resizeTo({
        height: windowHeight,
        width: 768
      });

      // scroll past top of form
      screenMock.scrollTo(pageFooterBottom - 10);

      window.GOVUK.stickAtTopWhenScrolling.init();

      expect(pageFooter.classList.contains('content-fixed')).toBe(false);
      expect(pageFooter.classList.contains('content-fixed-onload')).toBe(false); // check the class for onload isn't applied

    });

    describe("if bottom of viewport is above bottom of element on load", () => {

      beforeEach(() => {

        // scroll position defaults to 0 so bottom of window starts at 940px. Element bottom defaults to 1160px.
        window.GOVUK.stickAtBottomWhenScrolling.init();

      });

      test("the element should be marked as already sticky", () => {

        expect(pageFooter.classList.contains('content-fixed-onload')).toBe(true);
        expect(pageFooter.classList.contains('content-fixed')).toBe(false);

      });

      test("a 'shim' element with dimensions matching the sticky element should be added to the document to take up the space it no longer occupies", () => {

        const shim = pageFooter.nextElementSibling;

        expect(shim).not.toBeNull();
        expect(shim.classList.contains('shim')).toBe(true);
        expect(shim.style.height).toEqual(`${pageFooter.offsetHeight}px`);
        expect(shim.style.marginTop).toEqual(''); // 0px would return an empty string
        expect(shim.style.marginBottom).toEqual(''); // 0px would return an empty string

      });

    });

    test("if bottom of viewport is above the furthest point the bottom of the element can go in the scroll area on load, the element should be marked as stopped", () => {

      // change window size so its bottom can go past stopping position
      const windowHeight = 200;
      const furthestBottomPoint = getFurthestBottomPoint(pageFooter.offsetHeight);

      screenMock.window.setHeightTo(windowHeight);

      // scroll the window bottom past the furthest point
      screenMock.scrollTo((furthestBottomPoint - windowHeight) - 10);

      window.GOVUK.stickAtBottomWhenScrolling.init();

      // `.content-fixed-onload` adds the drop-shadow without fading in to show it did not become sticky from user interaction
      expect(pageFooter.classList.contains('content-fixed-onload')).toBe(true);
      expect(pageFooter.classList.contains('content-fixed')).toBe(false);

      // elements are stopped by adding inline styles
      expect(pageFooter.style.position).toEqual('absolute');
      expect(pageFooter.style.top).toEqual(`${furthestBottomPoint - pageFooter.offsetHeight}px`);

    });

    describe("if viewport bottom starts below element bottom", () => {

      let pageFooterBottom;

      beforeEach(() => {

        pageFooterBottom = getScreenItemBottomPosition(pageFooter);

        // change window size so its bottom can go past stopping position
        const windowHeight = 600;

        screenMock.window.setHeightTo(windowHeight);

        // scroll to just below the element
        screenMock.scrollTo((pageFooterBottom - windowHeight) + 10);

        window.GOVUK.stickAtBottomWhenScrolling.init();

      });

      test("if window is scrolled so bottom of it is above the bottom of the element, the element should be marked so it becomes sticky to the user", () => {

        // scroll above bottom of sticky element
        screenMock.scrollTo((pageFooterBottom - windowHeight) - 10);
        jest.advanceTimersByTime(60); // fake advance of time to something similar to that for a scroll

        // `content-fixed` fades the drop-shadow in to show it became sticky from user interaction
        expect(pageFooter.classList.contains('content-fixed')).toBe(true);
        expect(pageFooter.classList.contains('content-fixed-onload')).toBe(false); // check the class for onload isn't applied

      });

      test("if window is scrolled so bottom of it is above the furthest point the bottom of the element can go in the scroll area, the element should be stopped", () => {

        const furthestBottomPoint = getFurthestBottomPoint(pageFooter.offsetHeight);

        // scroll past furthest point
        screenMock.scrollTo((furthestBottomPoint - windowHeight) - 10);
        jest.advanceTimersByTime(60); // fake advance of time to something similar to that for a scroll

        // `content-fixed` fades the drop-shadow in to show it became sticky from user interaction
        expect(pageFooter.classList.contains('content-fixed')).toBe(true);
        expect(pageFooter.classList.contains('content-fixed-onload')).toBe(false); // check the class for onload isn't applied

        // elements are stopped by adding inline styles
        expect(pageFooter.style.position).toEqual('absolute');
        expect(pageFooter.style.top).toEqual(`${furthestBottomPoint - pageFooter.offsetHeight}px`);

      });

      test("if window resizes so bottom is above the bottom of the element, the element should be marked so it becomes sticky to the user", () => {

        // resize window so the bottom is 30px above the bottom of the element
        screenMock.window.resizeTo({ height: 560, width: screenMock.window.width });
        jest.advanceTimersByTime(60); // fake advance of time to something similar to that for a resize

        // `content-fixed` fades the drop-shadow in to show it became sticky from user interaction
        expect(pageFooter.classList.contains('content-fixed')).toBe(true);
        expect(pageFooter.classList.contains('content-fixed-onload')).toBe(false); // check the class for onload isn't applied

      });

    });

    describe("if scrollToRevealElement is called with an element", () => {

      let link;
      let linkBottom;

      beforeEach(() => {

        const contentBottom = getScreenItemBottomPosition(content);

        content.insertAdjacentHTML('afterEnd', '<a href="" id="formatting-options">Formatting options</a>');
        link = document.querySelector('#formatting-options');

        screenMock.mockPositionAndDimension('link', link, {
          offsetHeight: 25, // 25px smaller than the sticky
          offsetWidth: 727,
          offsetTop: contentBottom
        });

        linkBottom = getScreenItemBottomPosition(link);

        // move the sticky over the link. It's 50px high so this position will cause it to overlap.
        screenMock.scrollTo((linkBottom - windowHeight) + 5);

        window.GOVUK.stickAtBottomWhenScrolling.init();

      });

      afterEach(() => {

        screenMock.window.spies.window.scrollTo.mockClear();

      });

      test("the window should scroll so the element is revealed", () => {

        // update inputForm position as DOM normally would
        pageFooter.offsetTop = screenMock.window.bottom - pageFooter.offsetHeight;

        let stickyPosition = getStickyGroupPosition(screenMock, { stickyEls: [pageFooter], edge: 'bottom' });

        // sticky position should overlap link position
        expect(stickyPosition.top).toBeLessThanOrEqual(link.offsetTop);
        expect(stickyPosition.bottom).toBeGreaterThanOrEqual(linkBottom);

        window.GOVUK.stickAtBottomWhenScrolling.scrollToRevealElement(link)

        stickyPosition = getStickyGroupPosition(screenMock, { stickyEls: [pageFooter], edge: 'bottom' });

        // the top of the sticky element should be at the bottom of the link
        expect(screenMock.window.spies.window.scrollTo.mock.calls[0]).toEqual([0, (linkBottom + pageFooter.offsetHeight) - windowHeight]);

      });

    });

    describe("if viewport bottom starts above element bottom", () => {

      let pageFooterBottom;
      let pageFooterShim;

      beforeEach(() => {

        // scroll position defaults to 0 so bottom of window starts at 940px. Element bottom defaults to 1160px so will be sticky on load.
        pageFooterBottom = getScreenItemBottomPosition(pageFooter);

        // shim will be inserted, inheriting space in the page from pageFooter so store this data
        const pageFooterData = {
          offsetHeight: pageFooter.offsetHeight,
          offsetWidth: pageFooter.offsetWidth,
          offsetTop: pageFooter.offsetTop
        };

        window.GOVUK.stickAtBottomWhenScrolling.init();

        // add mock for shim
        pageFooterShim = document.querySelector('.shim');
        screenMock.mockPositionAndDimension('pageFooterShim', pageFooterShim, pageFooterData);

      });

      test("if window is scrolled so bottom of it is below the bottom of the element, the element should be made not sticky", () => {

        // scroll so bottom of window is below bottom of element
        screenMock.scrollTo((pageFooterBottom - windowHeight) + 10);
        jest.advanceTimersByTime(60); // fake advance of time to something similar to that for a scroll

        expect(pageFooter.classList.contains('content-fixed')).toBe(false);
        expect(pageFooter.classList.contains('content-fixed-onload')).toBe(false); // check the class for onload isn't applied

      });

      test("if window resizes so bottom is below the bottom of the element, the element should be made not sticky", () => {

        // resize window so the bottom is 30px above the bottom of the element
        screenMock.window.resizeTo({ height: pageFooterBottom + 10, width: screenMock.window.width });
        jest.advanceTimersByTime(60); // fake advance of time to something similar to that for a resize

        expect(pageFooter.classList.contains('content-fixed')).toBe(false);
        expect(pageFooter.classList.contains('content-fixed-onload')).toBe(false);

      });

      test("if window resizes so bottom is above the furthest point the bottom of the element can go in the scroll area, the element should be stopped", () => {

        const furthestBottomPoint = getFurthestBottomPoint(pageFooter.offsetHeight);

        // resize window so the bottom is above the furthest point
        screenMock.window.resizeTo({ height: furthestBottomPoint - 10, width: screenMock.window.width });
        jest.advanceTimersByTime(60); // fake advance of time to something similar to that for a resize

        expect(pageFooter.classList.contains('content-fixed')).toBe(false); // applied if made sticky after page load
        expect(pageFooter.classList.contains('content-fixed-onload')).toBe(true); // check the class for onload isn't applied

        // elements are stopped by adding inline styles
        expect(pageFooter.style.position).toEqual('absolute');
        expect(pageFooter.style.top).toEqual(`${furthestBottomPoint - pageFooter.offsetHeight}px`);

      });

    });

    describe("if element is made sticky and another element underneath it is focused", () => {

      let checkbox;
      let checkboxBottom;

      beforeEach(() => {

        const contentBottom = getScreenItemBottomPosition(content);

        content.insertAdjacentHTML('afterEnd', '<input type="checkbox" name="confirm" value="yes" />');
        checkbox = document.querySelector('input[type=checkbox]');

        screenMock.mockPositionAndDimension('checkbox', checkbox, {
          offsetHeight: 40, // 10px smaller than the sticky
          offsetWidth: 727,
          offsetTop: contentBottom
        });

        checkboxBottom = getScreenItemBottomPosition(checkbox);

        // move the sticky over the checkbox. It's 50px high so this position will cause it to overlap.
        screenMock.scrollTo((checkboxBottom - windowHeight) + 5);

        window.GOVUK.stickAtBottomWhenScrolling.init();

      });

      afterEach(() => {

        screenMock.window.spies.window.scrollTo.mockClear();

      });

      test("the window should scroll so the focused element is revealed", () => {

        // update inputForm position as DOM normally would
        pageFooter.offsetTop = screenMock.window.bottom - pageFooter.offsetHeight;

        let stickyPosition = getStickyGroupPosition(screenMock, { stickyEls: [pageFooter], edge: 'bottom' });

        // sticky position should overlap checkbox position
        expect(stickyPosition.top).toBeLessThanOrEqual(checkbox.offsetTop);
        expect(stickyPosition.bottom).toBeGreaterThanOrEqual(checkboxBottom);

        // the sticky element (page footer) is 50 high so should cover the last of the radios if the bottom edge of the viewport is at its bottom
        checkbox.focus();

        stickyPosition = getStickyGroupPosition(screenMock, { stickyEls: [pageFooter], edge: 'bottom' });

        // the top of the sticky element should be at the bottom of the checkbox
        expect(screenMock.window.spies.window.scrollTo.mock.calls[0]).toEqual([0, (checkboxBottom + pageFooter.offsetHeight) - windowHeight]);

      });

    });

    describe("if element is made sticky and overlaps a textarea", () => {

      let textarea;
      let textareaBottom;
      let caretCoordinates;
      let caretCoordinatesMock;

      beforeEach(() => {

        const contentBottom = getScreenItemBottomPosition(content);

        content.insertAdjacentHTML('afterEnd', '<textarea name="notes"></textarea>');
        textarea = document.querySelector('textarea');

        // line height: 30px, text height: 19px, 10 lines.
        screenMock.mockPositionAndDimension('textarea', textarea, {
          offsetHeight: 300,
          offsetWidth: 727,
          offsetTop: contentBottom
        });

        textareaBottom = getScreenItemBottomPosition(textarea);

        // start caret on the last line
        caretCoordinates = new CaretCoordinates();
        caretCoordinates.moveToLine(10);
        caretCoordinatesMock = jest.fn(() => caretCoordinates);
        window.getCaretCoordinates = caretCoordinatesMock;

        // move the sticky so it overlaps the bottom 50px of the textarea.
        screenMock.scrollTo(textareaBottom - windowHeight);

        // update content position as DOM normally would
        pageFooter.offsetTop = screenMock.window.bottom - pageFooter.offsetHeight;

        window.GOVUK.stickAtBottomWhenScrolling.init();

      });

      afterEach(() => {

        screenMock.window.spies.window.scrollTo.mockClear();
        caretCoordinatesMock.mockClear();

      });

      test("if the textarea receives focus while its caret is underneath, the window should scroll to reveal the caret", () => {

        const stickyPosition = getStickyGroupPosition(screenMock, { stickyEls: [pageFooter], edge: 'bottom' });
        const caretPosition = getCaretPosition(caretCoordinates, textarea);

        // sticky position should overlap caret position
        expect(stickyPosition.top).toBeLessThan(caretPosition.bottom);

        textarea.focus();

        // the top of the sticky element should be at the bottom of the caret
        expect(screenMock.window.spies.window.scrollTo.mock.calls[0]).toEqual([0, caretPosition.bottom - (windowHeight - stickyPosition.height)]);

      });

      test("if the caret is moved so it isn't underneath the sticky element, the window shouldn't scroll", () => {

        // start caret on 8th line which isn't under the sticky element.
        caretCoordinates.moveToLine(8);

        const stickyPosition = getStickyGroupPosition(screenMock, { stickyEls: [pageFooter], edge: 'bottom' });
        const caretPosition = getCaretPosition(caretCoordinates, textarea);

        // the sticky element should be below the caret
        expect(stickyPosition.top).toBeGreaterThan(caretPosition.bottom);

        textarea.focus();

        // no scrolling should have happened
        expect(screenMock.window.spies.window.scrollTo.mock.calls.length).toEqual(0);

      });

      test("if the caret is moved to be underneath the sticky element, the window should scroll to reveal the caret", () => {

        // start caret on 8th line which isn't under the sticky element.
        caretCoordinates.moveToLine(8);

        // make sure the textarea has focus
        textarea.focus();

        // move the caret underneath the sticky element
        caretCoordinates.moveToLine(9);

        const stickyPosition = getStickyGroupPosition(screenMock, { stickyEls: [pageFooter], edge: 'bottom' });
        const caretPosition = getCaretPosition(caretCoordinates, textarea);

        // sticky position should overlap caret position
        expect(stickyPosition.top).toBeLessThan(caretPosition.bottom);

        // simulate a press of the down arrow
        helpers.triggerEvent(textarea, 'keyup', { interface: window.KeyboardEvent });

        // the top of the sticky element should be at the bottom of the caret
        expect(screenMock.window.spies.window.scrollTo.mock.calls[0]).toEqual([0, caretPosition.bottom - (windowHeight - stickyPosition.height)]);

      });

    });

    describe("if mode is set to 'dialog' and multiple sticky elements have the same scroll area", () => {

      let radios;

      beforeEach(() => {

        const contentBottom = getScreenItemBottomPosition(content);

        // set mode to 'dialog' so sticky elements are treated as one item
        window.GOVUK.stickAtBottomWhenScrolling.setMode('dialog')

        // add another sticky element before the form footer
        radios = helpers.getRadioGroup({
          cssClasses: ['js-stick-at-bottom-when-scrolling'],
          name: 'choose-send-time',
          label: 'Choose send time',
          fields: [
            {
              label: 'Now',
              value: 'now',
              checked: true
            },
            {
              label: 'Tomorrow',
              value: 'tomorrow',
              checked: false
            },
            {
              label: 'Friday',
              value: 'friday',
              checked: false
            }
          ]
        });

        pageFooter.parentNode.insertBefore(radios, pageFooter);
        screenMock.mockPositionAndDimension('radios', radios, {
          offsetHeight: 175,
          offsetWidth: 727,
          offsetTop: contentBottom
        });
        const radiosBottom = getScreenItemBottomPosition(radios);

        // adjust other screen items

        // pageFooter top should be at the radios bottom
        pageFooter.offsetTop = radiosBottom;

      });

      afterEach(() => {

        window.GOVUK.stickAtBottomWhenScrolling.setMode('default');

      });

      describe("if window bottom is above the bottom of the lowest element on load", () => {

        let pageFooterBottom;

        beforeEach(() => {

          pageFooterBottom = getScreenItemBottomPosition(pageFooter);

          // scroll to just above the element
          screenMock.scrollTo((pageFooterBottom - windowHeight) - 10);

          window.GOVUK.stickAtBottomWhenScrolling.init();

        });

        test("both should be marked as already sticky", () => {

          expect(pageFooter.classList.contains('content-fixed-onload')).toBe(true);
          expect(pageFooter.classList.contains('content-fixed')).toBe(false);

        });

        test("the highest element should have the drop-shadow", () => {

          expect(radios.classList.contains('content-fixed__bottom')).toBe(true);

        });

        test("they should be stacked from the bottom edge of the viewport in the order they appear in the document", () => {

          expect(radios.style.bottom).toEqual(`${pageFooter.offsetHeight - PADDING_BETWEEN_STICKYS}px`);

        });

      });

      describe("if window bottom is above the furthest point the lowest element can go on load", () => {

        let headingBottom;

        beforeEach(() => {

          const dialogHeight = (pageFooter.offsetHeight + radios.offsetHeight) - PADDING_BETWEEN_STICKYS;
          const furthestBottomPoint = getFurthestBottomPoint(dialogHeight);

          // change window size so its bottom can go past stopping position
          const windowHeight = 200;

          headingBottom = getScreenItemBottomPosition(heading);

          screenMock.window.setHeightTo(windowHeight);

          screenMock.scrollTo((furthestBottomPoint - windowHeight) - 10)

          window.GOVUK.stickAtBottomWhenScrolling.init();

        });

        test("both should be stopped so the top of their stack is at the furthest point", () => {

          expect(radios.style.position).toEqual('absolute');
          expect(pageFooter.style.position).toEqual('absolute');
          expect(radios.style.top).toEqual(`${headingBottom + PADDING_BEFORE_STOPPING_POINT}px`);
          expect(pageFooter.style.top).toEqual(`${headingBottom + PADDING_BEFORE_STOPPING_POINT + (radios.offsetHeight - PADDING_BETWEEN_STICKYS)}px`);

        });

      });

      describe("if the group of sticky elements is taller than the window when stuck together", () => {

        let windowHeight;
        let pageFooterBottom;

        // the sticky behaviour should stick as many elements as possible to the bottom of the viewport
        // the rest should be put back into their place in the document
        beforeEach(() => {

          windowHeight = 460;

          function makeStickysBiggerThanWindow () {

            // make the radios too big to fit in the viewport
            const fields = ['Saturday', 'Sunday', 'Monday', 'Tuesday', 'Wednesday', 'Thursday', 'Friday']
              .map(field => {
                return {
                  label: field,
                  value: field.toLowerCase(),
                  checked: false
                }
              });

            radios.querySelector('fieldset').insertAdjacentHTML('beforeend', helpers.getRadios(fields, 'days'));

            radios.offsetHeight = 475;

          };

          function adjustScreenPositions () {

            const radiosBottom = getScreenItemBottomPosition(radios);

            screenMock.window.setHeightTo(windowHeight);

            // formFooter top should be at the radios bottom
            pageFooter.offsetTop = radiosBottom;

          };

          makeStickysBiggerThanWindow();
          adjustScreenPositions();

          pageFooterBottom = getScreenItemBottomPosition(pageFooter);

          screenMock.scrollTo((pageFooterBottom - windowHeight) - 10);

          window.GOVUK.stickAtBottomWhenScrolling.init()

        });

        test("the number of elements in the group that are stuck should be reduced until it fits into the viewport", () => {

          // when the group is stuck, pageFooter is closest to the viewport edge so should stay sticky
          expect(pageFooter.classList.contains('content-fixed-onload')).toBe(true);
          expect(pageFooter.classList.contains('content-fixed')).toBe(false);

          // radios are made not sticky
          expect(radios.classList.contains('content-fixed-onload')).toBe(false);
          expect(radios.classList.contains('content-fixed')).toBe(false);

        });

        test("window is scrolled so the sticky elements (stuck and not stuck) appear in sequence", () => {

          // we assume it's useful to start by seeing all the elements in the position they normally sit in the page
          expect(screenMock.window.top).toEqual(pageFooterBottom - windowHeight);

        });

      });

    });

  });

});
