/**
* Pages module.
*
* @module pages
*/
import $ from 'jquery';
import events from './event';
import config from 'enketo/config';
import { getSiblingElement, getAncestors } from './dom-utils';
import 'jquery-touchswipe';
export default {
/**
* @type {boolean}
* @default
*/
active: false,
/**
* @type {Array|jQuery}
* @default
*/
current: null,
/**
* @type {jQuery}
*/
activePages: [],
/**
* @type {Function}
*/
init() {
if ( !this.form ) {
throw new Error( 'Repeats module not correctly instantiated with form property.' );
}
if ( this.form.view.html.classList.contains( 'pages' ) ) {
const allPages = [ ...this.form.view.html.querySelectorAll( '.question, .or-appearance-field-list' ) ]
.concat( [ ...this.form.view.html.querySelectorAll( '.or-repeat.or-appearance-field-list + .or-repeat-info' ) ] )
.filter( el => {
// something tells me there is a more efficient way to doing this
// e.g. by selecting the descendants of the .or-appearance-field-list and removing those
return !el.parentElement.closest( '.or-appearance-field-list' ) && !( el.matches( '.question' ) && el.querySelector( '[data-for]' ) );
} )
.map( el => {
el.setAttribute( 'role', 'page' );
return el;
} );
if ( allPages.length > 0 || allPages[ 0 ].classList.contains( 'or-repeat' ) ) {
const formWrapper = this.form.view.html.parentNode;
this.$formFooter = $( formWrapper.querySelector( '.form-footer' ) );
this.$btnFirst = this.$formFooter.find( '.first-page' );
this.$btnPrev = this.$formFooter.find( '.previous-page' );
this.$btnNext = this.$formFooter.find( '.next-page' );
this.$btnNext.attr( 'tabindex', 2 );
this.$btnLast = this.$formFooter.find( '.last-page' );
this.$toc = $( formWrapper.querySelector( '.pages-toc__list' ) );
this._updateAllActive( allPages );
this._updateToc();
this._toggleButtons( 0 );
this._setButtonHandlers();
this._setRepeatHandlers();
this._setBranchHandlers();
this._setSwipeHandlers();
this._setTocHandlers();
this._setLangChangeHandlers();
this.active = true;
this._flipToFirst();
}
/*else {
form.view.$.removeClass( 'pages' );
}*/
}
},
/**
* flips to the page provided as jQueried parameter or the page containing
* the jQueried element provided as parameter
* alternatively, (e.g. if a top level repeat without field-list appearance is provided as parameter)
* it flips to the page contained with the jQueried parameter;
*
* @param {jQuery} $e - Element on page to flip to
*/
flipToPageContaining( $e ) {
const e = $e[ 0 ];
const closest = e.closest( '[role="page"]' );
if ( closest ) {
this._flipTo( closest );
} else {
// If $e is a comment question, and it is not inside a group, there will be no closest.
const referer = e.querySelector( '[data-for]' );
const ancestor = e.closest( '.or-repeat, form.or' );
if ( referer && ancestor ) {
const linkedQuestion = ancestor.querySelector( `[name="${referer.dataset.for}"]` );
if ( linkedQuestion ) {
this._flipTo( linkedQuestion.closest( '[role="page"]' ) );
}
}
}
this.$toc.parent().find( '.pages-toc__overlay' ).click();
},
/**
* sets button handlers
*/
_setButtonHandlers() {
const that = this;
// Make sure eventhandlers are not duplicated after resetting form.
this.$btnFirst.off( '.pagemode' ).on( 'click.pagemode', () => {
if ( !that.form.pageNavigationBlocked ) {
that._flipToFirst();
}
return false;
} );
this.$btnPrev.off( '.pagemode' ).on( 'click.pagemode', () => {
if ( !that.form.pageNavigationBlocked ) {
that._prev();
}
return false;
} );
this.$btnNext.off( '.pagemode' ).on( 'click.pagemode', () => {
if ( !that.form.pageNavigationBlocked ) {
that._next();
}
return false;
} );
this.$btnLast.off( '.pagemode' ).on( 'click.pagemode', () => {
if ( !that.form.pageNavigationBlocked ) {
that._flipToLast();
}
return false;
} );
},
/**
* sets swipe handlers
*/
_setSwipeHandlers() {
if ( config.swipePage === false ) {
return;
}
const that = this;
const $main = this.form.view.$.closest( '.main' );
$main.swipe( 'destroy' );
$main.swipe( {
allowPageScroll: 'vertical',
threshold: 250,
preventDefaultEvents: false,
swipeLeft() {
that.$btnNext.click();
},
swipeRight() {
that.$btnPrev.click();
},
swipeStatus( evt, phase ) {
if ( phase === 'start' ) {
/*
* Triggering blur will fire a change event on the currently focused form control
* This will trigger validation and is required to block page navigation on swipe
* with form.pageNavigationBlocked
* The only potential problem with this approach is that the threshold (250ms)
* may theoretically not be sufficient to ensure validation is completed to
* set form.pageNavigationBlocked to true. The edge case will be very slow devices
* and/or amazingly complex constraint expressions.
*/
const focused = that._getCurrent() ? that._getCurrent().querySelector( ':focus' ) : null;
if ( focused ) {
focused.blur();
}
}
}
} );
},
/**
* sets toc handlers
*/
_setTocHandlers() {
const that = this;
this.$toc
.on( 'click', 'a', function() {
if ( !that.form.pageNavigationBlocked ) {
if ( this.parentElement && this.parentElement.getAttribute( 'tocId' ) ) {
const tocId = parseInt( this.parentElement.getAttribute( 'tocId' ), 10 );
const destItem = that.form.toc.tocItems.find( item => item.tocId === tocId );
if ( destItem && destItem.element ) {
const destEl = destItem.element;
that.form.goToTarget( destEl );
}
}
}
return false;
} )
.parent().find( '.pages-toc__overlay' ).on( 'click', () => {
that.$toc.parent().find( '#toc-toggle' ).prop( 'checked', false );
} );
},
/**
* sets repeat handlers
*/
_setRepeatHandlers() {
// TODO: can be optimized by smartly updating the active pages
this.form.view.html.addEventListener( events.AddRepeat().type, event => {
this._updateAllActive();
// Don't flip if the user didn't create the repeat with the + button.
// or if is the default first instance created during loading.
// except if the new repeat is actually the first page in the form, or contains the first page
if ( event.detail.trigger === 'user' || this.activePages[ 0 ] === event.target || getAncestors( this.activePages[ 0 ], '.or-repeat' ).includes( event.target ) ) {
this.flipToPageContaining( $( event.target ) );
} else {
this._toggleButtons();
}
} );
this.form.view.html.addEventListener( events.RemoveRepeat().type, event => {
// if the current page is removed
// note that that.current will have length 1 even if it was removed from DOM!
if ( this.current && !this.current.closest( 'html' ) ) {
this._updateAllActive();
let $target = $( event.target ).prev();
if ( $target.length === 0 ) {
$target = $( event.target );
}
// is it best to go to previous page always?
this.flipToPageContaining( $target );
}
} );
},
/**
* sets branch handlers
*/
_setBranchHandlers() {
const that = this;
// TODO: can be optimized by smartly updating the active pages
this.form.view.$
//.off( 'changebranch.pagemode' )
.on( 'changebranch.pagemode', () => {
that._updateAllActive();
// If the current page has become inactive (e.g. a form whose first page during load becomes non-relevant)
if ( !that.activePages.includes( that.current ) ) {
that._next();
}
that._toggleButtons();
} );
},
/**
* sets language change handlers
*/
_setLangChangeHandlers() {
this.form.view.html
.addEventListener( events.ChangeLanguage().type, () => {
this._updateToc();
} );
},
/**
* @return {Element} current page
*/
_getCurrent() {
return this.current;
},
/**
* @param {Array<Node>} all - all elements that represent a page
*/
_updateAllActive( all ) {
all = all || [ ...this.form.view.html.querySelectorAll( '[role="page"]' ) ];
this.activePages = all.filter( el => {
return !el.closest( '.disabled' ) &&
( el.matches( '.question' ) || el.querySelector( '.question:not(.disabled)' ) ||
// or-repeat-info is only considered a page by itself if it has no sibling repeats
// When there are siblings repeats, we use CSS trickery to show the + button underneath the last
// repeat.
( el.matches( '.or-repeat-info' ) && !getSiblingElement( el, '.or-repeat' ) ) );
} );
this._updateToc();
},
/**
* @param {number} currentIndex - current index
* @return {jQuery} Previous page
*/
_getPrev( currentIndex ) {
return this.activePages[ currentIndex - 1 ];
},
/**
* @param {number} currentIndex - current index
* @return {jQuery} Next page
*/
_getNext( currentIndex ) {
return this.activePages[ currentIndex + 1 ];
},
/**
* @return {number} Current page index
*/
_getCurrentIndex() {
return this.activePages.findIndex( el => el === this.current );
},
/**
* Changes the `pages.next()` function to return a `Promise`, wrapping one of the following values:
*
* @return {Promise} wrapping {boolean} or {number}. If a {number}, this is the index into
* `activePages` of the new current page; if a {boolean}, {false} means that validation
* failed, and {true} that validation passed, but the page did not change.
*/
_next() {
const that = this;
let currentIndex;
let validate;
currentIndex = this._getCurrentIndex();
validate = ( config.validatePage === false || !this.current ) ? Promise.resolve( true ) : this.form.validateContent( $( this.current ) );
return validate
.then( valid => {
let next, newIndex;
if ( !valid ) {
return false;
}
next = that._getNext( currentIndex );
if ( next ) {
newIndex = currentIndex + 1;
that._flipTo( next, newIndex );
//return newIndex;
}
return true;
} );
},
/**
* Switches to previous page
*/
_prev() {
const currentIndex = this._getCurrentIndex();
const prev = this._getPrev( currentIndex );
if ( prev ) {
this._flipTo( prev, currentIndex - 1 );
}
},
/**
* @param {Element} pageEl - page element
*/
_setToCurrent( pageEl ) {
pageEl.classList.add( 'current', 'hidden' );
// Was just added, for animation?
pageEl.classList.remove( 'hidden' );
getAncestors( pageEl, '.or-group, .or-group-data, .or-repeat', '.or' )
.forEach( el => el.classList.add( 'contains-current' ) );
this.current = pageEl;
},
/**
* Switches to a page
*
* @param {Element} pageEl - page element
* @param {number} newIndex - new index
*/
_flipTo( pageEl, newIndex ) {
// if there is a current page (note: if current page was removed it is not null, hence the .closest('html') check)
if ( this.current && this.current.closest( 'html' ) ) {
// if current page is not same as pageEl
if ( this.current !== pageEl ) {
this.current.classList.remove( 'current', 'fade-out' );
getAncestors( this.current, '.or-group, .or-group-data, .or-repeat', '.or' )
.forEach( el => el.classList.remove( 'contains-current' ) );
this._setToCurrent( pageEl );
this._focusOnFirstQuestion( pageEl );
this._toggleButtons( newIndex );
pageEl.dispatchEvent( events.PageFlip() );
}
} else if ( pageEl ) {
this._setToCurrent( pageEl );
this._focusOnFirstQuestion( pageEl );
this._toggleButtons( newIndex );
pageEl.dispatchEvent( events.PageFlip() );
pageEl.setAttribute( 'tabindex', 1 );
}
},
/**
* Switches to first page
*/
_flipToFirst() {
this._flipTo( this.activePages[ 0 ] );
},
/**
* Switches to last page
*/
_flipToLast() {
this._flipTo( this.activePages[ this.activePages.length - 1 ] );
},
/**
* Focuses on first question and scrolls it into view
*
* @param {Element} pageEl - page element
*/
_focusOnFirstQuestion( pageEl ) {
//triggering fake focus in case element cannot be focused (if hidden by widget)
$( pageEl )
.find( '.question:not(.disabled)' )
.addBack( '.question:not(.disabled)' )
.filter( function() {
return $( this ).parentsUntil( '.or', '.disabled' ).length === 0;
} )
.eq( 0 )
.find( 'input, select, textarea' )
.eq( 0 )
.trigger( 'fakefocus' );
// focus on element
pageEl.focus();
pageEl.scrollIntoView();
},
/**
* Updates status of navigation buttons
*
* @param {number} [index] - index of current page
*/
_toggleButtons( index = this._getCurrentIndex() ) {
const next = this._getNext( index );
const prev = this._getPrev( index );
this.$btnNext.add( this.$btnLast ).toggleClass( 'disabled', !next );
this.$btnPrev.add( this.$btnFirst ).toggleClass( 'disabled', !prev );
this.$formFooter.toggleClass( 'end', !next );
},
/**
* Updates Table of Contents
*/
_updateToc() {
if ( this.$toc.length ) {
// regenerate complete ToC from first enabled question/group label of each page
this.$toc.empty()[ 0 ].append( this.form.toc.getHtmlFragment() );
this.$toc.closest( '.pages-toc' ).removeClass( 'hide' );
}
}
};