js/toc.js

/**
 * Table of Contents (toc) module.
 *
 * @module toc
 */

import { getAncestors, getSiblingElement } from './dom-utils';

export default {
    /**
     * @type {Array}
     * @default
     */
    tocItems: [],
    /**
     * @type {number}
     * @default
     */
    _maxTocLevel: [],
    /**
     * Generate ToC Items
     */
    generateTocItems() {
        this.tocItems = [];
        const tocElements = [ ...this.form.view.$[ 0 ].querySelectorAll( '.question:not([role="comment"]), .or-group' ) ]
            .filter( tocEl => {
                return !tocEl.closest( '.disabled' ) &&
                    ( tocEl.matches( '.question' ) || tocEl.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.
                        ( tocEl.matches( '.or-repeat-info' ) && !getSiblingElement( tocEl, '.or-repeat' ) ) );
            } )
            .filter( tocEl => !tocEl.classList.contains( 'or-repeat-info' ) );
        tocElements.forEach( ( element, index ) => {
            const groupParents = getAncestors( element, '.or-group' );
            this.tocItems.push( {
                element: element,
                level: groupParents.length,
                parent: groupParents.length > 0 ? groupParents[ groupParents.length - 1 ] : null,
                tocId: index,
                tocParentId: null
            } );
        } );

        this._maxTocLevel = Math.max.apply( Math, this.tocItems.map( el => el.level ) );
        const newTocParents = this.tocItems.filter( item => item.level < this._maxTocLevel && item.element.classList.contains( 'or-group' ) );

        this.tocItems.forEach( item => {
            const parentItem = newTocParents.find( parent => item.parent === parent.element );
            if ( parentItem ) {
                item.tocParentId = parentItem.tocId;
            }
        } );
    },
    /**
     * Generate ToC Html Fragment
     *
     * @return {DocumentFragment} HTML list element containing Table of Contents
     */
    getHtmlFragment() {
        this.generateTocItems();

        const toc = document.createDocumentFragment();

        let currentTocLevel = 0;
        do {
            const currentTocLevelItems = this.tocItems.filter( item => item.level === currentTocLevel );

            if ( currentTocLevel === 0 ) {
                this._buildTocHtmlList( currentTocLevelItems, toc );
            } else {
                const currentLevelParentIds = [ ...new Set( currentTocLevelItems.map( item => item.tocParentId ) ) ];

                currentLevelParentIds.forEach( parentId => {
                    const tocList = document.createElement( 'ul' );
                    const currentLTocevelItemsWithSameIds = currentTocLevelItems.filter( item => item.tocParentId === parentId );

                    this._buildTocHtmlList( currentLTocevelItemsWithSameIds, tocList );

                    const tocParent = toc.querySelectorAll( '[tocId="' + parentId + '"]' )[ 0 ];
                    tocParent.appendChild( tocList );
                } );
            }

            currentTocLevel++;
        } while ( currentTocLevel <= this._maxTocLevel );

        return toc;
    },
    /**
     * Get Title of Current ToC Element
     *
     * @param {Element} el - HTML element that serves as page
     */
    _getTitle( el ) {
        let tocItemText;
        const labelEl = el.querySelector( '.question-label.active' );
        if ( labelEl ) {
            tocItemText = labelEl.textContent;
        } else {
            const hintEl = el.querySelector( '.or-hint.active' );
            if ( hintEl ) {
                tocItemText = hintEl.textContent;
            }
        }
        tocItemText = tocItemText && tocItemText.length > 20 ? `${tocItemText.substring( 0,20 )}...` : tocItemText;

        return tocItemText;
    },
    /**
     * Builds List of ToC Items
     *
     * @param {Array<object>} items - ToC list of items
     * @param {Element} appendTo - HTML Element to append ToC list to
     */
    _buildTocHtmlList( items, appendTo ) {
        if ( items.length > 0 ) {
            items.forEach( item => {
                const tocListItem = document.createElement( 'li' );
                if ( item.element.classList.contains( 'or-group' ) ) {
                    const groupTocTitle = document.createElement( 'summary' );
                    groupTocTitle.textContent = this._getTitle( item.element ) || `[${item.tocId + 1}]`;

                    const groupToc = document.createElement( 'details' );
                    groupToc.setAttribute( 'tocId', item.tocId );
                    if ( item.tocParentId !== null ) {
                        groupToc.setAttribute( 'tocParentId', item.tocParentId );
                    }
                    groupToc.append( groupTocTitle );

                    tocListItem.append( groupToc );
                } else {
                    const a = document.createElement( 'a' );
                    a.textContent = this._getTitle( item.element ) || `[${item.tocId + 1}]`;

                    tocListItem.setAttribute( 'tocId', item.tocId );
                    tocListItem.setAttribute( 'role', 'pageLink' );
                    if ( item.tocParentId !== null ) {
                        tocListItem.setAttribute( 'tocParentId', item.tocParentId );
                    }
                    tocListItem.append( a );
                }
                appendTo.append( tocListItem );
            } );
        }
    }
};