Source: mobile.startup/Toggler.js

var browser = require( './Browser' ).getSingleton(),
	util = require( './util' ),
	escapeSelector = util.escapeSelector,
	arrowOptions = {
		icon: 'expand',
		isSmall: true,
		additionalClassNames: 'indicator'
	},
	Icon = require( './Icon' );

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

/**
 * 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
 */
function getExpandedSections( page ) {
	var expandedSections = mw.storage.session.getObject( 'expandedSections' ) || {};
	expandedSections[page.title] = expandedSections[page.title] || {};
	return expandedSections;
}

/**
 * Save expandedSections to sessionStorage
 *
 * @param {Object} expandedSections
 */
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
 */
function storeSectionToggleState( $heading, page ) {
	var headline = $heading.find( '.mw-headline' ).attr( 'id' ),
		expandedSections = getExpandedSections( page );

	if ( headline && expandedSections[page.title] ) {
		var 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
 */
function expandStoredSections( toggler, $container, page ) {
	var $sectionHeading, $headline,
		expandedSections = getExpandedSections( page ),
		$headlines = $container.find( '.section-heading span' );

	$headlines.each( function () {
		$headline = $container.find( this );
		$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)
		var $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 = mw.config.get( 'wgMFCollapseSectionsByDefault' ) &&
				// Only collapse on narrow devices
				!browser.isWideScreen() &&
				// Section collapsing can be disabled in MobilePreferences
				!document.documentElement.classList.contains(
					'mf-expand-sections-clientpref-1'
				);
		}
	}
	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;
	}

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

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

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

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

	var $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}
		 */

		self.eventBus.emit( 'section-toggled', {
			expanded: wasExpanded,
			$heading
		} );
		/**
		 * @event mobileFrontend.section-toggled
		 * @internal for use inside ExternalGuidance.
		 */
		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
 */
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 ) {
	var $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;
	}

	var $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 () {
	var 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 ) {
		var $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' ) ) {
			var $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
					var 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;
			var 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
		var 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 ) ) {
				var 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() {
		var 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;