cfpb/owning-a-home

View on GitHub
src/static/js/modules/form-explainer.js

Summary

Maintainability
C
7 hrs
Test Coverage
'use strict';

var $ = jQuery = require('jquery');
var sticky = require('../../vendor/sticky/jquery.sticky.js');
require('../../vendor/jquery.easing/jquery.easing.js');
require("jquery.scrollto");
require('../../vendor/cf-expandables/cf-expandables.js');
var debounce = require('debounce');


var formExplainer = {
  pageCount:   0,
  currentPage: 1,
  pageName:    'form'
};

// Constants. These variables should not change.
var $WRAPPER;
var $TABS;
var $PAGINATION;
var $WINDOW;
var TOTAL;

// Keys to sections on page.
// TODO: Determine whether this is used.
var termsList = '.explain_terms';

// TODO: define category list in calling page, and
// also pass it to form-explainer.html to construct tabs
var CATEGORIES = [ 'checklist', 'definitions' ];
var DEFAULT_TYPE = 'checklist';
var $INITIAL_TAB;

var resized;

var stickBottom = 'js-sticky-bottom';

formExplainer.getPageEl = function( pageNum ) {
  return $( '#explain_page-' + pageNum );
};

formExplainer.getPageElements = function( pageNum ) {
  var $page = formExplainer.getPageEl( pageNum );

  return {
    $page:            $page,
    $imageMap:        $page.find( '.image-map' ),
    $imageMapImage:   $page.find( '.image-map_image' ),
    $imageMapWrapper: $page.find( '.image-map_wrapper' ),
    $terms:           $page.find( '.terms' )
  };
};

formExplainer.calculateNewImageWidth = function( imageWidth, imageHeight, windowHeight ) {
  var imageMapImageRatio = ( imageWidth + 2 ) / ( imageHeight + 2 );

  return ( windowHeight - 60 ) * imageMapImageRatio + 30;
};

formExplainer.resizeImage = function( els, $window, windowResize ) {

  var pageWidth = els.$page.width();
  var $image = els.$imageMapImage;
  var currentHeight = $image.height();
  var currentWidth = $image.width();
  var actualWidth = $image.data( 'actual-width' );
  var actualHeight = $image.data( 'actual-height' );
  var windowHeight = $window.innerHeight() - 60;
  var newWidth;
  var newWidthPercentage;

  // If the image is too tall for the window, resize it proportionally,
  // then update the adjacent terms column width to fit.
  // On window resize, also check if image is now too small & resize,
  // but only if we've stored the actual image dimensions for comparison.
  if ( currentHeight > windowHeight || ( windowResize && actualWidth && actualHeight ) ) {
    // determine new width
    newWidth = formExplainer.calculateNewImageWidth( currentWidth, currentHeight, $window.height() );
    if ( newWidth > actualWidth ) {
      newWidth = actualWidth;
    }
    // update element widths
    newWidthPercentage = newWidth / pageWidth * 100;
    // on screen less than 800px wide, the terms need a minimum 33%
    // width or they become too narrow to read
    if ( $window.width() <= 800 && newWidthPercentage > 67 ) {
      newWidthPercentage = 67;
    }
    els.$imageMap.css( 'width', newWidthPercentage + '%' );
    $PAGINATION.css( 'width', newWidthPercentage + '%' );
    els.$terms.css( 'width', ( 100 - newWidthPercentage ) + '%' );
  }
};

formExplainer.setImageElementWidths = function( els ) {
  // When the sticky plugin is applied to the image, it adds position fixed,
  // and the image's width is no longer constrained to its parent.
  // To fix this we will give it its own width that is equal to the parent.
  // (IE8 wants a width on the wrapper too)
  var containerWidth = els.$imageMap.width();
  els.$imageMapWrapper.width( containerWidth );
  els.$imageMapImage.width( containerWidth );
};

formExplainer.storeImageDimensions = function( $image ) {
  $image.data( 'actual-width', $image.width() );
  $image.data( 'actual-height', $image.height() );
};

formExplainer.stickImage = function( $el ) {
  $el.sticky( { topSpacing: 30 } );
};

/**
 * Limit .image-map_image to the height of the window and then adjust the two
 * columns to match.
 * @param {Object} els - TODO: Add description.
 * @param {number} pageNum - TODO: Add description.
 */
formExplainer.fitAndStickToWindow = function( els, pageNum ) {
  // http://stackoverflow.com/questions/318630/get-real-image-width-and-height-with-javascript-in-safari-chrome
  $( '<img/>' )
    .load( function() {
      // store image width for use in calculations on window resize
      if ( pageNum ) {
        formExplainer.storeImageDimensions( els.$imageMapImage );
      }

      // if image is too tall/small, fit it to window dimensions
      formExplainer.resizeImage( els, $WINDOW, !pageNum );

      // set width values on image elements
      formExplainer.setImageElementWidths( els );

      if ( pageNum || els.$imageMapImage.closest( '.sticky-wrapper' ).length === 0 ) {
        // stick image to window
        formExplainer.stickImage( els.$imageMapWrapper );        
      } 

      formExplainer.updateStickiness( els, $WINDOW.scrollTop() );
      
      // hide pages except for first
      if ( pageNum > 1 ) {
        els.$page.hide();
      }
    } )
    // This order is necessary so that IE8 doesn't fire the onload event
    // before the src is set for cached images
    // http://stackoverflow.com/questions/14429656/onload-callback-on-image-not-called-in-ie8
    .attr( 'src', els.$imageMapImage.attr( 'src' ) );
};

/**
 * Override sticky() if the viewport has been scrolled past $currentPage so that
 * the sticky element does not overlap content that comes after $currentPage.
 * @param {Object} els - TODO: Add description.
 * @param {Object} windowScrollTop - TODO: Add description.
 */
formExplainer.updateStickiness = function( els, windowScrollTop ) {
  var max = els.$page.offset().top + els.$page.height() - els.$imageMapWrapper.height();
  if ( windowScrollTop >= max && !els.$imageMapWrapper.hasClass( stickBottom ) ) {
    els.$imageMapWrapper.addClass( stickBottom );
  } else if ( windowScrollTop < max && els.$imageMapWrapper.hasClass( stickBottom ) ) {
    els.$imageMapWrapper.removeClass( stickBottom );
  }
};

/**
 * Paginate through the various form pages.
 * @param {Object} direction - TODO: Add description.
 */
function paginate( direction ) {
  var currentPage = formExplainer.currentPage;
  var increment = direction === 'next' ? 1 : -1;
  var newPage = currentPage + increment;

  // Move to the next or previous page if it's not the first or last page.
  if ( direction === 'next' && newPage <= formExplainer.pageCount ||
       direction === 'prev' && newPage >= 1 ) {
    loadPage( currentPage, newPage );
  }
}

function loadPage( lastPage, pageNum ) {
  formExplainer.currentPage = pageNum;
  $( '.form-explainer_page-link' ).removeClass( 'current-page' );
  $( '.form-explainer_page-link[data-page=' + pageNum + ']' ).addClass( 'current-page' );

  // Scroll the window up to the tabs.
  $.scrollTo( $( '.explain_pagination' ), {
    duration: 600,
    offset:   -30
  } );

  // After scrolling the window, fade out the current page.
  var fadeOutTimeout = window.setTimeout( function() {
    formExplainer.getPageEl( lastPage ).fadeOut( 450 );
    window.clearTimeout( fadeOutTimeout );
  }, 600 );

  // After fading out the current page, fade in the new page.
  var fadeInTimeout = window.setTimeout( function() {
    formExplainer.getPageEl( pageNum ).fadeIn( 700 );
    stickyHack();
    window.clearTimeout( fadeInTimeout );

    if ( resized ) {
      formExplainer.setupImage( pageNum );
    }
    // update paging buttons
    if ( formExplainer.pageCount > 1 ) {
      $( '.form-explainer_page-buttons button' ).removeClass( 'btn__disabled' );

      if ( pageNum === 1 ) {
        $( '.form-explainer_page-buttons .prev' ).addClass( 'btn__disabled' );
      } else if ( pageNum === formExplainer.pageCount ) {
        $( '.form-explainer_page-buttons .next' ).addClass( 'btn__disabled' );
      }
    }
  }, 1050 );
}

formExplainer.setupImage = function( pageNum, pageLoad ) {
  var pageEls = formExplainer.getPageElements( pageNum );
  if ( $WINDOW.width() >= 600 ) {
    // update widths & stickiness on larger screens
    // we only pass in the pageNum on pageLoad, when
    // pages after the first will be hidden once they're
    // fully loaded & we've calculated their widths
    formExplainer.fitAndStickToWindow( pageEls, pageLoad ? pageNum : null );
  } else if ( !pageLoad ) {
    // if this is called on screen resize instead of page load,
    // remove width values & call unstick on the imageWrapper
    pageEls.$imageMapWrapper.width( '' ).removeClass( stickBottom );
    pageEls.$imageMap.width( '' );
    pageEls.$imageMapImage.width( '' );
    pageEls.$terms.width( '' );
    pageEls.$imageMapWrapper.unstick();
  } else if ( pageNum > 1 ) {
    // on page load, hide pages except first
    pageEls.$page.hide();
  }
};

formExplainer.initForm = function( $wrapper ) {
  // Loop through each page, setting its dimensions properly and activating the
  // sticky() plugin.

  var $pages = $wrapper.find( '.explain_page' );
  formExplainer.pageCount = $pages.length;
  if ( formExplainer.pageCount <= 1 ) {
    $( '.form-explainer_page-buttons' ).hide();
  }
  $pages.each( function( index ) {
    formExplainer.initPage( index + 1 );
  } );
};

/**
 * Initialize a page.
 * @param {Object} id - TODO: Add description.
 */
formExplainer.initPage = function( id ) {
  formExplainer.setupImage( id, true );
  formExplainer.setCategoryPlaceholders( id );
};

/**
 * Weird hack to get sticky() to update properly.
 * We're basically jinggling the window to force a sticky() repaint.
 * Sometimes it just needs a push I guess?
 */
function stickyHack() {
  $WINDOW.scrollTop( $WINDOW.scrollTop() + 1 );
  $WINDOW.scrollTop( $WINDOW.scrollTop() - 1 );
}

/**
 * Set category placeholders.
 * @param {Object} id - TODO: Add description.
 */
formExplainer.setCategoryPlaceholders = function( id ) {
  var $page = formExplainer.getPageEl( id );
  var placeholder;
  for ( var i = 0; i < CATEGORIES.length; i++ ) {
    var category = CATEGORIES[i];
    if ( !categoryHasContent( $page, category ) ) {
      placeholder = formExplainer.generatePlaceholderHtml( category );
      $page.find( '.explain_terms' ).append( placeholder );
    }
  }
};

formExplainer.generatePlaceholderHtml = function( category ) {
  return '<div class="expandable expandable__padded expandable__form-explainer ' +
              'expandable__form-explainer-' + category + ' ' +
              'expandable__form-explainer-placeholder">' +
    '<span class="expandable_header">' +
      'Click on "Get Definitions" above or page ahead to continue checking your ' + this.pageName + '.' +
    '</span>' +
  '</div>';
};

function categoryHasContent( $page, category ) {
  return $page.find( '.expandable__form-explainer-' + category ).length;
}

function filterExplainers( $currentTab, type ) {
  // Update the tab state
  $( '.explain_tabs .tab-list' ).removeClass( 'active-tab' );
  $currentTab.addClass( 'active-tab' );

  // Filter the expandables
  $WRAPPER.find( '.expandable__form-explainer' ).hide();
  $WRAPPER.find( '.image-map_overlay' ).hide();
  $WRAPPER.find( '.expandable__form-explainer-' + type ).show();
  $WRAPPER.find( '.image-map_overlay__' + type ).show();
}

function toggleScrollWatch() {
  $WINDOW.off( 'scroll.stickiness' );
  if ( $WINDOW.width() >= 600 ) {
    $WINDOW.on( 'scroll.stickiness', debounce(
      function() {
        var els = formExplainer.getPageElements( formExplainer.currentPage );
        formExplainer.updateStickiness( els, $WINDOW.scrollTop() );
      }, 20 ) );
  }
}

function addTabindex() {
  var $link = $( '.expandable__form-explainer .expandable_content a' );
  $link.attr( 'tabindex', 0 );
}

// Kick things off on document ready.
$( document ).ready( function() {
  // cache elements
  $WRAPPER = $( '.explain' );
  $TABS = $WRAPPER.find( '.explain_tabs' );
  $PAGINATION = $WRAPPER.find( '.explain_pagination' );
  $WINDOW = $( window );
  TOTAL = parseInt( $PAGINATION.find( '.pagination_total' ).text(), 10 );
  $INITIAL_TAB = $( '.tab-link[data-target="' + DEFAULT_TYPE + '"]' ).closest( '.tab-list' );

  // set up the form pages for display
  formExplainer.initForm( $WRAPPER );

  // filter initial state
  filterExplainers( $INITIAL_TAB, DEFAULT_TYPE );

  // add scroll listener for larger windows
  toggleScrollWatch();

  // set tabindex for links in expandables content
  addTabindex();

  // add resize listener
  var prevWindowWidth = $WINDOW.width();
  var prevWindowHeight = $WINDOW.height();
  var isIE = $( 'html' ).hasClass( 'lt-ie9' );

  $( window ).on( 'resize', debounce( function() {
    // resize things
    // apparently IE fires a window resize event when anything in the page
    // resizes, so for IE we need to check that the window dimensions have
    // actually changed
    if ( isIE ) {
      var currWindowHeight = $WINDOW.height();
      var currWindowWidth = $WINDOW.width();

      if ( currWindowHeight === prevWindowHeight &&
           currWindowWidth === prevWindowWidth ) {
        return;
      }

      prevWindowWidth = currWindowWidth;
      prevWindowHeight = currWindowHeight;
    }
    resized = true;
    formExplainer.setupImage( formExplainer.currentPage );
    toggleScrollWatch();
  } ) );

  // Pagination events
  $WRAPPER.find( '.form-explainer_page-buttons button' ).on( 'click', function( event ) {
    var $target = $( event.currentTarget );
    if ( !$target.hasClass( 'btn__disabled' ) ) {
      var direction = $target.hasClass( 'prev' ) ? 'prev' : 'next';
      paginate( direction );
    }
  } );

  $WRAPPER.find( '.form-explainer_page-link' ).on( 'click', function( event ) {
    var $target = $( event.currentTarget );
    var pageNum = +$target.data( 'page' );
    var currentPage = formExplainer.currentPage;

    if ( !$target.hasClass( 'disabled' ) && pageNum !== currentPage ) {
      loadPage( currentPage, pageNum );
    }
  } );

  // Filter the expandables via the tabs
  $WRAPPER.on( 'click', '.explain_tabs .tab-list', function() {
    var $selectedTab = $( this );
    var explainerType = $selectedTab.find( '[data-target]' ).data( 'target' );

    filterExplainers( $selectedTab, explainerType );

    $.scrollTo( $TABS, {
      duration: 200,
      offset:   -30
    } );
  } );

  var expandableTimeout;
  var delay = isIE ? 1000 : 700;

  function updateImagePositionAfterAnimation() {
    // Check image position after expandable animation to make sure it is not
    // overlapping the footer.
    window.clearTimeout( expandableTimeout );
    setTimeout( function() {
      if ( $WINDOW.width() > 600 ) {
        var els = formExplainer.getPageElements( formExplainer.currentPage );
        formExplainer.updateStickiness( els, $WINDOW.scrollTop() );
      }
    }, delay );
  }

  function openAndScrollToExpandable( $targetExpandable ) {
    // If there's an expandable open above the targeted expandable,
    // it will be closed when this expandable opens, so we need
    // to subtract its height from this expandable's offset in order
    // to get a position to use in scrolling this expandable into view
    var prevExpanded = $targetExpandable.prevAll( '.expandable__expanded' );
    var offset = 0;
    if ( prevExpanded.length ) {
      offset = $( prevExpanded[0] ).find( '.expandable_content' ).height();
    }
    var pos = $targetExpandable.offset().top - offset;
    $.scrollTo( pos, {
      duration: 200,
      offset:   -30
    } );
    $targetExpandable.find( '.expandable_target' ).click();
  }

  // Update attention classes based on the expandable or image overlay
  // that was targeted.
  // Remove the attention class from all the expandables/overlays,
  // and then apply it to the target & its associated overlay
  // or expandable.
  function updateAttention( $target, className ) {
    var $associated;

    $( '.expandable__form-explainer, .image-map_overlay' ).removeClass( className );

    if ( typeof $target.attr( 'href' ) !== 'undefined' ) {
      $associated = $( $target.attr( 'href' ) );
    } else if ( typeof $target.attr( 'id' ) !== 'undefined' ) {
      $associated = $( '[href=#' + $target.attr( 'id' ) + ']' );
    }

    if ( typeof $associated !== 'undefined' ) {
      if ( className === 'has-attention' ) {
        $target.removeClass( 'hover-has-attention' );
        $associated.removeClass( 'hover-has-attention' );
      }
      $target.addClass( className );
      $associated.addClass( className );
    }
  }

  $WRAPPER.on( 'focus', '.expandable_target', function() {
    var $expandable = $( this ).closest( '.expandable__form-explainer' );
    updateAttention( $expandable, 'hover-has-attention' );
  } );

  $WRAPPER.on( 'mouseenter mouseleave', '.image-map_overlay, .expandable__form-explainer', function( event ) {
    event.preventDefault();
    updateAttention( $( this ), 'hover-has-attention' );
  } );

  // When an overlay is clicked, toggle the corresponding expandable and scroll
  // the page until it is in view.
  $WRAPPER.on( 'click', '.image-map_overlay', function( event ) {
    event.preventDefault();
    var $this = $( this ),
        itemID = $this.attr( 'href' ),
        $targetExpandable = $( itemID );
    updateAttention( $this, 'has-attention' );
    openAndScrollToExpandable( $targetExpandable );
    updateImagePositionAfterAnimation();
  } );

  $( '.expandable__form-explainer .expandable_target' ).on( 'keypress click', function( event ) {
    if ( event.which === 13 || event.type === 'click' ) {
      updateAttention( $( this ).closest( '.expandable__form-explainer' ), 'has-attention' );
      updateImagePositionAfterAnimation();
    }
  } );


} );

module.exports = formExplainer;