diff --git a/includes/Constants.php b/includes/Constants.php index 285026ca..13336c15 100644 --- a/includes/Constants.php +++ b/includes/Constants.php @@ -206,26 +206,6 @@ final class Constants { */ public const FEATURE_LANGUAGE_ALERT_IN_SIDEBAR = 'LanguageAlertInSidebar'; - /** - * @var string - */ - public const REQUIREMENT_TABLE_OF_CONTENTS = 'TableOfContents'; - - /** - * @var string - */ - public const CONFIG_TABLE_OF_CONTENTS = 'VectorTableOfContents'; - - /** - * @var string - */ - public const QUERY_PARAM_TABLE_OF_CONTENTS = 'tableofcontents'; - - /** - * @var string - */ - public const FEATURE_TABLE_OF_CONTENTS = 'TableOfContents'; - /** * @var string */ diff --git a/includes/Hooks.php b/includes/Hooks.php index 15c0cd51..063b0eb5 100644 --- a/includes/Hooks.php +++ b/includes/Hooks.php @@ -543,41 +543,14 @@ class Hooks implements } $classes = []; - $isTocABTestEnabled = $sk->isTOCABTestEnabled(); - if ( $isTocABTestEnabled || $sk->isTOCEnabled() ) { - $classes[] = 'vector-toc-enabled'; - } if ( $sk->isTableOfContentsVisibleInSidebar() && $isTocABTestEnabled ) { - /** @var \WikimediaEvents\WebABTest\WebABTestArticleIdFactory */ - $webABTestArticleIdFactory = MediaWikiServices::getInstance()->getService( - Constants::WEB_AB_TEST_ARTICLE_ID_FACTORY_SERVICE - ); $experimentConfig = $config->get( Constants::CONFIG_WEB_AB_TEST_ENROLLMENT ); - $bucketKeys = array_keys( $experimentConfig['buckets'] ); - - $ab = $webABTestArticleIdFactory->makeWebABTestArticleIdStrategy( - $webABTestArticleIdFactory->filterExcludedBucket( $bucketKeys ), - 1 - $experimentConfig['buckets']['unsampled']['samplingRate'], - Constants::QUERY_PARAM_TABLE_OF_CONTENTS, - $sk->getContext() - ); - - if ( !$ab ) { - return $classes; - } - - $bucket = $ab->getBucket(); - - if ( $bucket ) { - $experimentName = $experimentConfig[ 'name' ]; - $classes[] = $experimentName; - $classes[] = "$experimentName-$bucket"; - } + $classes[] = $experimentConfig[ 'name' ]; } return $classes; diff --git a/includes/ServiceWiring.php b/includes/ServiceWiring.php index d692826d..ae9cba0c 100644 --- a/includes/ServiceWiring.php +++ b/includes/ServiceWiring.php @@ -145,29 +145,6 @@ return [ ] ); - // Feature: T297610: Table of Contents - // ================================ - $featureManager->registerRequirement( - new OverridableConfigRequirement( - $services->getMainConfig(), - $context->getUser(), - $context->getRequest(), - null, - Constants::CONFIG_TABLE_OF_CONTENTS, - Constants::REQUIREMENT_TABLE_OF_CONTENTS, - Constants::QUERY_PARAM_TABLE_OF_CONTENTS, - null - ) - ); - - $featureManager->registerFeature( - Constants::FEATURE_TABLE_OF_CONTENTS, - [ - Constants::REQUIREMENT_FULLY_INITIALISED, - Constants::REQUIREMENT_TABLE_OF_CONTENTS - ] - ); - // Feature: Sticky header // ================================ $featureManager->registerRequirement( @@ -222,15 +199,13 @@ return [ ) ); - // Requires both table of contents and title above tabs - // to be enabled to simplify the number of variants it needs - // to consider. + // Requires title above tabs to be enabled to simplify the + // number of variants it needs to consider. $featureManager->registerFeature( Constants::FEATURE_GRID, [ Constants::REQUIREMENT_FULLY_INITIALISED, Constants::REQUIREMENT_GRID, - Constants::REQUIREMENT_TABLE_OF_CONTENTS, ] ); diff --git a/includes/SkinVector22.php b/includes/SkinVector22.php index e44648fb..75e4c7e2 100644 --- a/includes/SkinVector22.php +++ b/includes/SkinVector22.php @@ -22,6 +22,8 @@ class SkinVector22 extends SkinVector { public function __construct( array $options ) { if ( !$this->isTOCABTestEnabled() ) { $options['toc'] = !$this->isTableOfContentsVisibleInSidebar(); + } else { + $options['styles'][] = 'skins.vector.AB.styles'; } parent::__construct( $options ); @@ -39,23 +41,9 @@ class SkinVector22 extends SkinVector { MediaWikiServices::getInstance()->hasService( Constants::WEB_AB_TEST_ARTICLE_ID_FACTORY_SERVICE ); } - /** - * Returns whether or not the table of contents is enabled through - * FeatureManager. - * - * @internal - * @return bool - */ - public function isTOCEnabled() { - $featureManager = VectorServices::getFeatureManager(); - - return $featureManager->isFeatureEnabled( Constants::FEATURE_TABLE_OF_CONTENTS ); - } - /** * Determines if the Table of Contents should be visible. - * TOC is visible on main namespaces except for the Main Page - * when the feature flag is on. + * TOC is visible on main namespaces except for the Main Page. * * @internal * @return bool @@ -74,7 +62,7 @@ class SkinVector22 extends SkinVector { return $title->getArticleID() !== 0; } - return $this->isTOCEnabled(); + return true; } /** diff --git a/resources/skins.vector.AB.styles.less b/resources/skins.vector.AB.styles.less new file mode 100644 index 00000000..69dd0ffc --- /dev/null +++ b/resources/skins.vector.AB.styles.less @@ -0,0 +1,7 @@ +.skin-vector-toc-experiment-control .mw-table-of-contents-container, +.skin-vector-toc-experiment-treatment #toc, +.skin-vector-toc-experiment-unsampled .mw-table-of-contents-container { + // This trumps any layout rules e.g. vector-layout-grid + /* stylelint-disable-next-line declaration-no-important */ + display: none !important; +} diff --git a/resources/skins.vector.es6/linkHijack.js b/resources/skins.vector.es6/linkHijack.js deleted file mode 100644 index 136ba3bc..00000000 --- a/resources/skins.vector.es6/linkHijack.js +++ /dev/null @@ -1,71 +0,0 @@ -/** - * Appends a query param to the `href` attribute of any clicked anchor element - * or anchor element that is the parent of a clicked HTMLElement. Links that - * lead to different origins or have the same pathname and have a hash fragment - * are ignored. - * - * @param {string} key - * @param {string} value - * @return {Function} A cleanup function is returned that - * removes any event listeners that were added. - */ -function linkHijack( key, value ) { - /** - * @param {MouseEvent} e - */ - function handleClick( e ) { - if ( !e.target || !( e.target instanceof HTMLElement ) ) { - return; - } - - const anchor = e.target.closest( 'a' ); - if ( !anchor ) { - return; - } - - let locationUrl; - let oldUrl; - try { - // eslint-disable-next-line compat/compat - locationUrl = new URL( location.href ); - // eslint-disable-next-line compat/compat - oldUrl = new URL( anchor.href ); - - } catch ( error ) { - // A TypeError may be thrown for invalid URLs. In that case, return - // gracefully. - return; - } - - if ( - locationUrl.origin !== oldUrl.origin || - ( locationUrl.pathname === oldUrl.pathname && oldUrl.hash ) - ) { - // Return early if link leads to host outside the current one or if the - // url contains a pathname that is the same as the current one and also - // has a hash fragment (e.g. this occurs with links in the TOC and we - // don't want to trigger a refresh of the page by appending a query - // param). - return; - } - - // eslint-disable-next-line compat/compat - const params = new URLSearchParams( oldUrl.search ); - if ( !params.has( key ) ) { - params.append( key, value ); - } - - // eslint-disable-next-line compat/compat - const newUrl = new URL( `${oldUrl.origin}${oldUrl.pathname}?${params}${oldUrl.hash}` ); - anchor.setAttribute( 'href', newUrl.toString() ); - - } - - document.body.addEventListener( 'click', handleClick ); - - return () => { - document.body.removeEventListener( 'click', handleClick ); - }; -} - -module.exports = linkHijack; diff --git a/resources/skins.vector.es6/main.js b/resources/skins.vector.es6/main.js index 97580a58..1f72d915 100644 --- a/resources/skins.vector.es6/main.js +++ b/resources/skins.vector.es6/main.js @@ -7,7 +7,6 @@ const initSectionObserver = require( './sectionObserver.js' ), initTableOfContents = require( './tableOfContents.js' ), deferUntilFrame = require( './deferUntilFrame.js' ), - linkHijack = require( './linkHijack.js' ), ABTestConfig = require( /** @type {string} */ ( './config.json' ) ).wgVectorWebABTestEnrollment || {}, TOC_ID = 'mw-panel-toc', TOC_ID_LEGACY = 'toc', @@ -18,7 +17,6 @@ const TOC_SCROLL_HOOK = 'table_of_contents', PAGE_TITLE_SCROLL_HOOK = 'page_title', PAGE_TITLE_INTERSECTION_CLASS = 'vector-below-page-title', - TOC_QUERY_PARAM = 'tableofcontents', TOC_EXPERIMENT_NAME = 'skin-vector-toc-experiment'; /** @@ -214,10 +212,6 @@ const main = () => { initExperiment( ABTestConfig ); const isInTreatmentBucket = !!experiment && experiment.isInTreatmentBucket(); - if ( experiment && experiment.isInSample() ) { - linkHijack( TOC_QUERY_PARAM, isInTreatmentBucket ? '1' : '0' ); - } - if ( experiment && !isInTreatmentBucket ) { // Return early if the old TOC is shown. return; diff --git a/resources/skins.vector.styles/components/Sidebar.less b/resources/skins.vector.styles/components/Sidebar.less index d41d53cf..b809fc6a 100644 --- a/resources/skins.vector.styles/components/Sidebar.less +++ b/resources/skins.vector.styles/components/Sidebar.less @@ -18,68 +18,54 @@ #p-navigation .vector-menu-heading { display: none; } + + // Temporary magic number, will be calculated after TOC specs are finalized + padding: 12px 19px 12px 9px; + background-image: none; + background-color: @background-color-secondary--modern; } -// FIXME: Delete this selector when .vector-toc-enabled is removed (T310527) -body:not( .vector-toc-enabled ) .mw-sidebar { - width: @width-grid-column-one; - // To avoid the white part of the gradient colliding with the sidebar links - // we apply top and bottom padding. - padding: 8px 0 40px @padding-left-sidebar; - background-image: linear-gradient( to bottom, @background-color-base 0%, @background-color-secondary--modern 10%, @background-color-secondary--modern 90%, @background-color-base 100% ); -} +// Update styling to account for TOC. +.mw-sidebar, +.sidebar-toc, +.sidebar-toc:after { + // Match styles between TOC and fade element to ensure the fade covers the correct area + width: @width-sidebar; + margin-left: 0; -// Update styling when TOC is enabled -.vector-toc-enabled { - .mw-sidebar, - .sidebar-toc, - .sidebar-toc:after { - // Match styles between TOC and fade element to ensure the fade covers the correct area - width: @width-sidebar; - margin-left: 0; - - @media ( min-width: @min-width-desktop-wide ) { - width: @width-sidebar-wide; - margin-left: @margin-start-sidebar-content; - } - } - - .mw-sidebar { - // Temporary magic number, will be calculated after TOC specs are finalized - padding: 12px 19px 12px 9px; - background-image: none; - background-color: @background-color-secondary--modern; - } - - & .vector-layout-grid { - @media ( min-width: @min-width-desktop ) { - .mw-sidebar { - // Prevent grid row gap from affecting TOC spacing when main menu is open - margin-bottom: -@grid-row-gap; - } - } - - .sidebar-toc { - // Use margin-top to align TOC rather than grid row gap - // Applies when the TOC sticky and when the main menu is both open and closed. - margin-top: @margin-top-sidebar-toc; - } + @media ( min-width: @min-width-desktop-wide ) { + width: @width-sidebar-wide; + margin-left: @margin-start-sidebar-content; } } -// FIXME: Merge margin-top styles with above when .vector-toc-enabled is removed (T310527) -.sidebar-toc { - .vector-toc-enabled .vector-layout-legacy & { +.vector-layout-grid { + @media ( min-width: @min-width-desktop ) { + .mw-sidebar { + // Prevent grid row gap from affecting TOC spacing when main menu is open + margin-bottom: -@grid-row-gap; + } + } + + .sidebar-toc { + // Use margin-top to align TOC rather than grid row gap + // Applies when the TOC sticky and when the main menu is both open and closed. + margin-top: @margin-top-sidebar-toc; + } +} + +.vector-layout-legacy { + .sidebar-toc { // Main menu is closed margin-top: @margin-top-sidebar-toc_title_inline; } - .vector-toc-enabled .vector-layout-legacy @{selector-workspace-container-sidebar-open} & { + @{selector-workspace-container-sidebar-open} .sidebar-toc { // Main menu is open margin-top: @margin-top-sidebar-toc; } - .vector-toc-enabled.vector-sticky-header-visible .vector-layout-legacy & { + .vector-sticky-header-visible & .sidebar-toc { // Sticky header is visible margin-top: @margin-top-sidebar-toc; } @@ -152,7 +138,6 @@ body:not( .vector-toc-enabled ) .mw-sidebar { // instead to avoid hidden rendering. visibility: hidden; opacity: 0; - transform: translateX( -100% ); } @media ( min-width: ( @max-width-workspace-container + ( 2 * @padding-horizontal-page-container ) ) ) { @@ -161,27 +146,3 @@ body:not( .vector-toc-enabled ) .mw-sidebar { border-right: @border-width-base @border-style-base @border-color-sidebar; } } - -// Disable transitions on page load. No-JS users will unfortunately miss out. A similar pattern is -// used in Minerva's DropDownList. See enableCssAnimations() in skin.vector.js/index.js for context -// and additional details on how this class is added. -.vector-animations-ready { - // Enable transition on all widths by default. - .mw-sidebar { - transition-property: transform, opacity, visibility; - transition-duration: @transition-duration-base; - transition-timing-function: ease-out; - } - - @media ( max-width: @max-width-mobile ) { - .mw-sidebar { - transition: none; - } - } - - // Enable sidebar button transitions. - #mw-sidebar-button { - transition-property: background-color, border-color, box-shadow; - transition-duration: @transition-duration-base; - } -} diff --git a/resources/skins.vector.styles/layouts/screen.less b/resources/skins.vector.styles/layouts/screen.less index 4f44cd0d..5accf1f7 100644 --- a/resources/skins.vector.styles/layouts/screen.less +++ b/resources/skins.vector.styles/layouts/screen.less @@ -170,8 +170,9 @@ body { } .vector-layout-legacy #mw-panel { - position: absolute; + position: static; top: 0; + float: none; // Sidebar is displaced from the workspace container so that the // sidebar is flush with the edge of the screen at small widths. left: -@padding-horizontal-page-container; @@ -182,15 +183,9 @@ body { } } -// Update positioning when TOC is enabled -.vector-toc-enabled .vector-layout-legacy #mw-panel { - position: static; - float: none; -} - // Add float at higher resolutions @media ( min-width: @min-width-desktop ) { - .vector-toc-enabled .vector-layout-legacy #mw-panel { + .vector-layout-legacy #mw-panel { float: left; } } @@ -315,18 +310,8 @@ body { padding-bottom: 82px; } -// FIXME: Delete when .vector-toc-enabled is removed (T310527) -// We want it to appear like the sidebar is going into/coming out of -// `.mw-page-container`, but we can't use `overflow: hidden` on -// `.mw-page-container` because that will cut off the sidebar. Therefore, we -// calculate the maximum distance from the start of `mw-page-container` to the -// start of the sidebar. -@{selector-workspace-container-sidebar-closed} .mw-sidebar { - transform: translateX( -( @max-width-page-container - @max-width-workspace-container ) / 2 ); -} - // Hide sidebar entirely when the checkbox is disabled and the TOC is enabled -.vector-toc-enabled @{selector-workspace-container-sidebar-closed} .mw-sidebar { +@{selector-workspace-container-sidebar-closed} .mw-sidebar { display: none; } @@ -347,13 +332,8 @@ body { } @media ( max-width: @max-width-margin-start-content ) { - // Adjusts the content and mw-article-toolbar-container. + // Increase margin to account for TOC .vector-layout-legacy @{selector-workspace-container-sidebar-open} .mw-content-container { - margin-left: @margin-start-content; - } - - // Increase margin when TOC is enabled - .vector-toc-enabled .vector-layout-legacy @{selector-workspace-container-sidebar-open} .mw-content-container { margin-left: @margin-toc-start-content; } @@ -364,11 +344,6 @@ body { } } -.skin-vector-toc-experiment-control .mw-table-of-contents-container, -.skin-vector-toc-experiment-unsampled .mw-table-of-contents-container { - display: none; -} - // Cannot use display: none on legacy TOC because it needs to be accessible // to scrollObserver for the TOC A/B test (T303297) // Instead we hide the contents of the legacy TOC and reset it's styles @@ -389,9 +364,9 @@ body { // HTML but only one is actually visible. Prevent the left margin from undesirably // applying if bucketed into the control or unsampled groups which won't show // the new TOC. -.skin-vector-disable-max-width .vector-toc-enabled .vector-layout-legacy @{selector-workspace-container-sidebar-open} .mw-content-container, +.skin-vector-disable-max-width .vector-layout-legacy @{selector-workspace-container-sidebar-open} .mw-content-container, body:not( .skin-vector-toc-experiment-control ):not( .skin-vector-toc-experiment-unsampled ) .vector-layout-legacy .vector-toc-visible .mw-workspace-container .mw-content-container, -.vector-toc-enabled .vector-layout-legacy @{selector-workspace-container-sidebar-open} .mw-content-container { +.vector-layout-legacy @{selector-workspace-container-sidebar-open} .mw-content-container { @media ( min-width: @min-width-desktop ) { margin-left: @margin-toc-start-content; } diff --git a/skin.json b/skin.json index 9e5cbc15..edfa5c0f 100644 --- a/skin.json +++ b/skin.json @@ -210,6 +210,17 @@ "resources/skins.vector.styles.legacy/skin-legacy.less" ] }, + "skins.vector.AB.styles": { + "class": "ResourceLoaderSkinModule", + "features": [ "toc" ], + "styles": [ + "resources/skins.vector.AB.styles.less" + ], + "targets": [ + "desktop", + "mobile" + ] + }, "skins.vector.styles": { "class": "ResourceLoaderSkinModule", "features": { @@ -224,7 +235,8 @@ "interface-category": true, "i18n-ordered-lists": true, "i18n-all-lists-margins": true, - "i18n-headings": true + "i18n-headings": true, + "toc": false }, "targets": [ "desktop", @@ -302,7 +314,6 @@ "resources/skins.vector.es6/tableOfContents.js", "resources/skins.vector.es6/sectionObserver.js", "resources/skins.vector.es6/deferUntilFrame.js", - "resources/skins.vector.es6/linkHijack.js", { "name": "resources/skins.vector.es6/config.json", "callback": "MediaWiki\\Skins\\Vector\\Hooks::getVectorResourceLoaderConfig" @@ -506,19 +517,13 @@ "value": false, "description": "@var boolean Temporary feature flag that disables saving the sidebar expanded/collapsed state as a user-preference (triggered via clicking the main menu icon). This is intended as a temporary kill-switch in the event that the DB is overloaded with writes to the user_options table." }, - "VectorTableOfContents": { - "value": { - "default": true - }, - "description": "@var When `VectorTableOfContents` is enabled, the sticky table of contents is shown." - }, "VectorTableOfContentsBeginning": { "value": true, "description": "@var boolean Temporary feature flag that controls link to beginning of article." }, "VectorTableOfContentsCollapseAtCount": { "value": 20, - "description": "@var When `VectorTableOfContents` is enabled, the minimum number of headings required to collapse all headings in the sticky table of contents by default." + "description": "@var The minimum number of headings required to collapse all headings in the sticky table of contents by default." }, "VectorGrid": { "value": false, diff --git a/tests/jest/linkHijack.test.js b/tests/jest/linkHijack.test.js deleted file mode 100644 index 3b0cc2e2..00000000 --- a/tests/jest/linkHijack.test.js +++ /dev/null @@ -1,158 +0,0 @@ -const linkHijack = require( '../../resources/skins.vector.es6/linkHijack.js' ); - -describe( 'linkHijack.js', () => { - let /** @type {jest.Mock} */ getHrefSpy; - let /** @type {Function | undefined} */ cleanup; - - beforeEach( () => { - cleanup = undefined; - getHrefSpy = jest.fn( () => 'http://localhost/wiki/Tree' ); - - // @ts-ignore - delete window.location; - // @ts-ignore - window.location = { - get href() { - return getHrefSpy(); - } - }; - - document.body.innerHTML = ` - Barack Obama - Barack Obama - Jump - Different Origin - Query Param - Query Param - Anchor without href - Anchor with invalid href -
- - `; - } ); - - afterEach( () => { - if ( cleanup ) { - cleanup(); - } - } ); - - describe( 'when link origin is same and pathname is different', () => { - it( 'appends a query param to anchor element when user clicks anchor element', () => { - const anchor = /** @type {HTMLAnchorElement} */ ( document.querySelector( '.anchor' ) ); - cleanup = linkHijack( 'tableofcontents', '1' ); - anchor.click(); - expect( anchor.href ).toBe( 'http://localhost/wiki/Barack_Obama?tableofcontents=1' ); - } ); - - it( 'appends a query param to anchor element when user clicks inner span element', () => { - const anchorWithSpan = /** @type {HTMLAnchorElement} */ ( document.querySelector( '.anchor-with-span' ) ); - const innerSpan = /** @type {HTMLSpanElement} */ ( document.querySelector( '.inner-span' ) ); - cleanup = linkHijack( 'tableofcontents', '1' ); - innerSpan.click(); - expect( anchorWithSpan.href ).toBe( 'http://localhost/wiki/Barack_Obama?tableofcontents=1' ); - } ); - } ); - - describe( 'when link origin is different and pathname is different', () => { - it( 'does not append a query param to the anchor element', () => { - const anchorWithDifferentOrigin = /** @type {HTMLAnchorElement} */ ( document.querySelector( '.anchor-with-different-origin' ) ); - cleanup = linkHijack( 'tableofcontents', '1' ); - anchorWithDifferentOrigin.click(); - expect( anchorWithDifferentOrigin.href ).toBe( 'http://www.google.com/' ); - } ); - } ); - - describe( 'when link origin is same and pathname is same and hash fragment is present', () => { - it( 'does not append a query param to the anchor element', () => { - const anchorWithHash = /** @type {HTMLAnchorElement} */ ( document.querySelector( '.anchor-with-hash' ) ); - cleanup = linkHijack( 'tableofcontents', '1' ); - anchorWithHash.click(); - expect( anchorWithHash.href ).toBe( 'http://localhost/wiki/Tree#jump' ); - } ); - } ); - - describe( 'when link already has the same query param', () => { - it( 'does not duplicate query params', () => { - const anchorWithQueryParam = /** @type {HTMLAnchorElement} */ ( document.querySelector( '.anchor-with-query-param' ) ); - cleanup = linkHijack( 'tableofcontents', '1' ); - anchorWithQueryParam.click(); - expect( anchorWithQueryParam.href ).toBe( 'http://localhost/wiki/Barack_Obama?tableofcontents=1' ); - } ); - } ); - - describe( 'when link already has different query param', () => { - it( 'appends query param', () => { - const anchorWithDifferentQueryParam = /** @type {HTMLAnchorElement} */ ( document.querySelector( '.anchor-with-different-query-param' ) ); - cleanup = linkHijack( 'tableofcontents', '1' ); - anchorWithDifferentQueryParam.click(); - expect( anchorWithDifferentQueryParam.href ).toBe( 'http://localhost/wiki/Barack_Obama?foo=1&tableofcontents=1' ); - } ); - } ); - - describe( 'when clicking on an element that is not an anchor element or a child of an anchor', () => { - it( 'does nothing (no errors)', () => { - const div = /** @type {HTMLDivElement}} */ ( document.querySelector( 'div' ) ); - cleanup = linkHijack( 'tableofcontents', '1' ); - div.click(); - } ); - } ); - - describe( 'when clicking on an element that is not an HTMLElement', () => { - it( 'does nothing (no errors)', () => { - const svg = /** @type {SVGElement}} */ ( document.querySelector( 'svg' ) ); - cleanup = linkHijack( 'tableofcontents', '1' ); - svg.dispatchEvent( new Event( 'click', { bubbles: true } ) ); - } ); - } ); - - describe( 'when clicking on an Anchor without an `href', () => { - it( 'handles it gracefully (no errors)', () => { - const anchor = /** @type {HTMLAnchorElement}} */ ( document.querySelector( '.anchor-without-href' ) ); - cleanup = linkHijack( 'tableofcontents', '1' ); - anchor.click(); - } ); - } ); - - describe( 'when clicking on an Anchor with an invalid `href', () => { - it( 'handles it gracefully (no errors)', () => { - const anchor = /** @type {HTMLAnchorElement}} */ ( document.querySelector( '.anchor-with-invalid-href' ) ); - cleanup = linkHijack( 'tableofcontents', '1' ); - anchor.click(); - } ); - } ); - - describe( 'when cleanup function is called', () => { - let /** @type {any} */ events; - - beforeEach( () => { - events = {}; - - jest.spyOn( document.body, 'addEventListener' ).mockImplementation( ( event ) => { - if ( !( event in events ) ) { - events[ event ] = 1; - } else { - events[ event ] += 1; - } - } ); - - jest.spyOn( document.body, 'removeEventListener' ).mockImplementation( ( event ) => { - events[ event ] -= 1; - if ( events[ event ] === 0 ) { - delete events[ event ]; - } - } ); - } ); - - afterEach( () => { - jest.restoreAllMocks(); - } ); - - it( 'removes added event listeners', () => { - linkHijack( 'tableofcontents', '1' )(); - linkHijack( 'tableofcontents', '1' )(); - - expect( Object.keys( events ).length ).toBe( 0 ); - } ); - } ); -} );