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;