wikimedia/mediawiki-extensions-MobileFrontend

View on GitHub
src/mobile.startup/Toggler.js

Summary

Maintainability
C
7 hrs
Test Coverage
const
    util = require( './util' ),
    escapeSelector = util.escapeSelector,
    arrowOptions = {
        icon: 'expand',
        isSmall: true,
        additionalClassNames: 'indicator'
    },
    Icon = require( './Icon' );
const isCollapsedByDefault = require( './isCollapsedByDefault.js' );

/**
 * @typedef {Object} ToggledEvent
 * @prop {boolean} expanded True if section is opened, false if closed.
 * @prop {Page} page
 * @prop {jQuery.Object} $heading
 * @memberof module:mobile.startup
 * @ignore
 */

/**
 * A class for enabling toggling
 *
 * Toggling can be disabled on a sepcific heading by adding the
 * collapsible-heading-disabled class.
 *
 * @class Toggler
 * @param {Object} options
 * @param {OO.EventEmitter} options.eventBus Object used to emit section-toggled events.
 * @param {jQuery.Object} options.$container to apply toggling to
 * @param {string} options.prefix a prefix to use for the id.
 * @param {Page} options.page to allow storage of session for future visits
 */
function Toggler( options ) {
    this.eventBus = options.eventBus;
    this.$container = options.$container;
    this.prefix = options.prefix;
    this.page = options.page;
    this._enable();
}

/**
 * Using the settings module looks at what sections were previously expanded on
 * existing page.
 *
 * @param {Page} page
 * @return {Object} representing open sections
 * @ignore
 */
function getExpandedSections( page ) {
    const expandedSections = mw.storage.session.getObject( 'expandedSections' ) || {};
    expandedSections[page.title] = expandedSections[page.title] || {};
    return expandedSections;
}

/**
 * Save expandedSections to sessionStorage
 *
 * @param {Object} expandedSections
 * @ignore
 */
function saveExpandedSections( expandedSections ) {
    mw.storage.session.setObject(
        'expandedSections', expandedSections
    );
}

/**
 * Given an expanded heading, store it to sessionStorage.
 * If the heading is collapsed, remove it from sessionStorage.
 *
 * @param {jQuery.Object} $heading - A heading belonging to a section
 * @param {Page} page
 * @ignore
 */
function storeSectionToggleState( $heading, page ) {
    const headline = $heading.find( '.mw-headline' ).attr( 'id' ),
        expandedSections = getExpandedSections( page );

    if ( headline && expandedSections[page.title] ) {
        const isSectionOpen = $heading.hasClass( 'open-block' );
        if ( isSectionOpen ) {
            expandedSections[page.title][headline] = true;
        } else {
            delete expandedSections[page.title][headline];
        }

        saveExpandedSections( expandedSections );
    }
}

/**
 * Expand sections that were previously expanded before leaving this page.
 *
 * @param {Toggler} toggler
 * @param {jQuery.Object} $container
 * @param {Page} page
 * @ignore
 */
function expandStoredSections( toggler, $container, page ) {
    const expandedSections = getExpandedSections( page ),
        $headlines = $container.find( '.section-heading span' );

    $headlines.each( function () {
        const $headline = $container.find( this );
        const $sectionHeading = $headline.parents( '.section-heading' );
        // toggle only if the section is not already expanded
        if (
            expandedSections[page.title][$headline.attr( 'id' )] &&
            !$sectionHeading.hasClass( 'open-block' )
        ) {
            toggler.toggle( $sectionHeading, true );
        }
    } );
}

/**
 * Check if sections should be collapsed by default
 *
 * @return {boolean}
 */
Toggler.prototype.isCollapsedByDefault = function () {
    if ( this._isCollapsedByDefault === undefined ) {
        // Thess classes override site settings and user preferences. For example:
        // * ...-collapsed used on talk pages by DiscussionTools. (T321618, T322628)
        // * ...-expanded used in previews (T336572)
        const $override = this.$container.closest( '.collapsible-headings-collapsed, .collapsible-headings-expanded' );
        if ( $override.length ) {
            this._isCollapsedByDefault = $override.hasClass( 'collapsible-headings-collapsed' );
        } else {
            // Check site config
            this._isCollapsedByDefault = isCollapsedByDefault();
        }
    }
    return this._isCollapsedByDefault;
};

/**
 * Given a heading, toggle it and any of its children
 *
 * @memberof Toggler
 * @instance
 * @param {jQuery.Object} $heading A heading belonging to a section
 * @param {boolean} fromSaved Section is being toggled from a saved state
 * @return {boolean}
 */
Toggler.prototype.toggle = function ( $heading, fromSaved ) {
    if ( !fromSaved && $heading.hasClass( 'collapsible-heading-disabled' ) ) {
        return false;
    }

    const self = this,
        wasExpanded = $heading.is( '.open-block' );

    $heading.toggleClass( 'open-block' );

    arrowOptions.rotation = wasExpanded ? 0 : 180;
    const newIndicator = new Icon( arrowOptions );
    const $indicatorElement = $heading.data( 'indicator' );
    if ( $indicatorElement ) {
        $indicatorElement.replaceWith( newIndicator.$el );
        $heading.data( 'indicator', newIndicator.$el );
    }

    const $headingLabel = $heading.find( '.mw-headline' );
    $headingLabel.attr( 'aria-expanded', !wasExpanded );

    const $content = $heading.next();
    if ( $content.hasClass( 'open-block' ) ) {
        $content.removeClass( 'open-block' );
        // jquery doesn't allow custom values for the hidden attribute it seems.
        $content.get( 0 ).setAttribute( 'hidden', 'until-found' );
    } else {
        $content.addClass( 'open-block' );
        $content.removeAttr( 'hidden' );
    }

    /* T239418 We consider this event as a low-priority one and emit it asynchronously.
    This ensures that any logic associated with section toggling is async and not contributing
    directly to a slow click/press event handler.

    Currently costly reflow-inducing viewport size computation is being done for lazy-loaded
    images by the main listener to this event. */
    mw.requestIdleCallback( () => {
        /**
         * Global event emitted after a section has been toggled
         *
         * @event ~section-toggled
         * @type {ToggledEvent}
         * @memberof module:mobile.startup~Toggler
         * @ignore
         */

        self.eventBus.emit( 'section-toggled', {
            expanded: wasExpanded,
            $heading
        } );
        /**
         * Internal for use inside ExternalGuidance.
         *
         * @event ~'mobileFrontend.section-toggled'
         * @memberof Hooks
         */
        mw.hook( 'mobileFrontend.section-toggled' ).fire( {
            expanded: wasExpanded,
            $heading
        } );
    } );

    if ( this.isCollapsedByDefault() ) {
        storeSectionToggleState( $heading, this.page );
    }
    return true;
};

/**
 * Enables toggling via enter and space keys
 *
 * @param {Toggler} toggler instance.
 * @param {jQuery.Object} $heading
 * @ignore
 */
function enableKeyboardActions( toggler, $heading ) {
    $heading.on( 'keypress', ( ev ) => {
        if ( ev.which === 13 || ev.which === 32 ) {
            // Only handle keypresses on the "Enter" or "Space" keys
            toggler.toggle( $heading );
        }
    } ).find( 'a' ).on( 'keypress mouseup', ( ev ) => ev.stopPropagation() );
}

/**
 * Reveals an element and its parent section as identified by it's id
 *
 * @memberof Toggler
 * @instance
 * @param {string} id An element ID within the $container
 * @return {boolean} Target ID was found
 */
Toggler.prototype.reveal = function ( id ) {
    let $target;
    // jQuery will throw for hashes containing certain characters which can break toggling
    try {
        $target = this.$container.find( '#' + escapeSelector( id ) );
    } catch ( e ) {}
    if ( !$target || !$target.length ) {
        return false;
    }

    let $heading = $target.parents( '.collapsible-heading' );
    // The heading is not a section heading, check if in a content block!
    if ( !$heading.length ) {
        $heading = $target.parents( '.collapsible-block' ).prev( '.collapsible-heading' );
    }
    if ( $heading.length && !$heading.hasClass( 'open-block' ) ) {
        this.toggle( $heading );
    }
    if ( $heading.length ) {
        // scroll again after opening section (opening section makes the page longer)
        window.scrollTo( 0, $target.offset().top );
    }
    return true;
};

/**
 * Enables section toggling in a given container.
 *
 * @memberof Toggler
 * @instance
 * @private
 */
Toggler.prototype._enable = function () {
    const self = this;

    // FIXME This should use .find() instead of .children(), some extensions like Wikibase
    // want to toggle other headlines than direct descendants of $container. (T95889)
    this.$container.children( '.section-heading' ).each( function ( i ) {
        const $heading = self.$container.find( this ),
            $headingLabel = $heading.find( '.mw-headline' ),
            $indicator = $heading.find( '.indicator' ),
            id = self.prefix + 'collapsible-block-' + i;
        // Be sure there is a `section` wrapping the section content.
        // Otherwise, collapsible sections for this page is not enabled.
        if ( $heading.next().is( 'section' ) ) {
            const $content = $heading.next( 'section' );
            $heading
                .addClass( 'collapsible-heading ' )
                .data( 'section-number', i )
                .on( 'click', ( ev ) => {
                    // don't toggle, if the click target was a link
                    // (a link in a section heading)
                    // See T117880
                    const clickedLink = ev.target.closest( 'a' );
                    if ( !clickedLink || !clickedLink.href ) {
                        // prevent taps/clicks on edit button after toggling (T58209)
                        ev.preventDefault();
                        self.toggle( $heading );
                    }
                } );
            $headingLabel
                .attr( {
                    tabindex: 0,
                    role: 'button',
                    'aria-controls': id,
                    'aria-expanded': 'false'
                } );

            arrowOptions.rotation = !self.isCollapsedByDefault() ? 180 : 0;
            const indicator = new Icon( arrowOptions );
            if ( $indicator.length ) {
                // replace the existing indicator
                $indicator.replaceWith( indicator.$el );
            } else {
                indicator.prependTo( $heading );
            }
            $heading.data( 'indicator', indicator.$el );
            $content
                .addClass( 'collapsible-block' )
                .eq( 0 )
                .attr( {
                    // We need to give each content block a unique id as that's
                    // the only way we can tell screen readers what element we're
                    // referring to via `aria-controls`.
                    id
                } )
                .on( 'beforematch', () => self.toggle( $heading ) )
                .addClass( 'collapsible-block-js' )
                .get( 0 ).setAttribute( 'hidden', 'until-found' );

            enableKeyboardActions( self, $heading );

            if ( !self.isCollapsedByDefault() ) {
                // Expand sections by default on wide screen devices
                // or if the expand sections setting is set.
                // The wide screen logic for determining whether to collapse sections initially
                // should be kept in sync with mobileoptions#initLocalStorageElements().
                self.toggle( $heading );
            }
        }
    } );

    /**
     * Checks the existing hash and toggles open any section that contains the fragment.
     *
     * @method
     */
    function checkHash() {
        // eslint-disable-next-line no-restricted-properties
        let hash = window.location.hash;
        if ( hash.indexOf( '#' ) === 0 ) {
            hash = hash.slice( 1 );
            // Per https://html.spec.whatwg.org/multipage/browsing-the-web.html#target-element
            // we try the raw fragment first, then the percent-decoded fragment.
            if ( !self.reveal( hash ) ) {
                const decodedHash = mw.util.percentDecodeFragment( hash );
                if ( decodedHash ) {
                    self.reveal( decodedHash );
                }
            }
        }
    }

    /**
     * Checks the value of wgInternalRedirectTargetUrl and sets the hash if present.
     * checkHash() will reveal the collapsed section that contains it afterwards.
     *
     * @method
     */
    function checkInternalRedirectAndHash() {
        const internalRedirect = mw.config.get( 'wgInternalRedirectTargetUrl' ),
            internalRedirectHash = internalRedirect ? internalRedirect.split( '#' )[1] : false;

        if ( internalRedirectHash ) {
            // eslint-disable-next-line no-restricted-properties
            window.location.hash = internalRedirectHash;
        }
    }

    checkInternalRedirectAndHash();
    checkHash();

    util.getWindow().on( 'hashchange', () => checkHash() );

    if ( this.isCollapsedByDefault() && this.page ) {
        expandStoredSections( this, this.$container, this.page );
    }
};

Toggler._getExpandedSections = getExpandedSections;
Toggler._expandStoredSections = expandStoredSections;

module.exports = Toggler;