UNPKG

samagra-x-enketo-core

Version:

Extensible Enketo form engine

439 lines (418 loc) 16.2 kB
/** * 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' ); } } };