diff --git a/jest.config.js b/jest.config.js index 1b96b1c78..5a52ad267 100644 --- a/jest.config.js +++ b/jest.config.js @@ -29,10 +29,10 @@ module.exports = { // An object that configures minimum threshold enforcement for coverage results coverageThreshold: { global: { - branches: 25, - functions: 29, - lines: 32, - statements: 32 + branches: 26, + functions: 32, + lines: 34, + statements: 34 } }, diff --git a/jest.setup.js b/jest.setup.js index a2309966c..1dc3b76a9 100644 --- a/jest.setup.js +++ b/jest.setup.js @@ -4,3 +4,4 @@ var mockMediaWiki = require( '@wikimedia/mw-node-qunit/src/mockMediaWiki.js' ); global.mw = mockMediaWiki(); global.$ = require('jquery'); global.mw.util.showPortlet = function() {}; +global.mw.Api.prototype.saveOption = function() {}; diff --git a/resources/skins.vector.js/features.js b/resources/skins.vector.js/features.js new file mode 100644 index 000000000..b12cf33cd --- /dev/null +++ b/resources/skins.vector.js/features.js @@ -0,0 +1,58 @@ +/** @interface MwApi */ + +var /** @type {MwApi} */api, + debounce = require( /** @type {string} */ ( 'mediawiki.util' ) ).debounce; + +/** + * Saves preference to user preferences and/or localStorage. + * + * @param {string} feature + * @param {boolean} enabled + */ +function save( feature, enabled ) { + var featuresJSON, + // @ts-ignore + features = mw.storage.get( 'VectorFeatures' ) || '{}'; + + try { + featuresJSON = JSON.parse( features ); + } catch ( e ) { + featuresJSON = {}; + } + featuresJSON[ feature ] = enabled; + // @ts-ignore + mw.storage.set( 'VectorFeatures', JSON.stringify( featuresJSON ) ); + + if ( !mw.user.isAnon() ) { + debounce( function () { + api = api || new mw.Api(); + api.saveOption( 'vector-' + feature, enabled ? 1 : 0 ); + }, 500 )(); + } +} + +/** + * @param {string} name + * @throws {Error} if unknown feature toggled. + */ +function toggle( name ) { + var featureClassEnabled = 'vector-feature-' + name + '-enabled', + classList = document.body.classList, + featureClassDisabled = 'vector-feature-' + name + '-disabled'; + + if ( classList.contains( featureClassDisabled ) ) { + classList.remove( featureClassDisabled ); + classList.add( featureClassEnabled ); + save( name, true ); + } else if ( classList.contains( featureClassEnabled ) ) { + classList.add( featureClassDisabled ); + classList.remove( featureClassEnabled ); + save( name, false ); + } else { + throw new Error( 'Attempt to toggle unknown feature: ' + name ); + } +} + +module.exports = { + toggle: toggle +}; diff --git a/resources/skins.vector.js/images/fullscreen-close.svg b/resources/skins.vector.js/images/fullscreen-close.svg new file mode 100644 index 000000000..b1a1a5eab --- /dev/null +++ b/resources/skins.vector.js/images/fullscreen-close.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/resources/skins.vector.js/images/fullscreen.svg b/resources/skins.vector.js/images/fullscreen.svg new file mode 100644 index 000000000..371b53ace --- /dev/null +++ b/resources/skins.vector.js/images/fullscreen.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/resources/skins.vector.js/limitedWidthToggle.js b/resources/skins.vector.js/limitedWidthToggle.js new file mode 100644 index 000000000..a395f6d0c --- /dev/null +++ b/resources/skins.vector.js/limitedWidthToggle.js @@ -0,0 +1,15 @@ +var features = require( './features.js' ); + +/** + * adds a toggle button + */ +function init() { + var toggle = document.createElement( 'div' ); + toggle.classList.add( 'mw-ui-icon', 'mw-ui-icon-element', 'mw-ui-button', 'vector-limited-width-toggle' ); + document.body.appendChild( toggle ); + toggle.addEventListener( 'click', function () { + features.toggle( 'limited-width' ); + } ); +} + +module.exports = init; diff --git a/resources/skins.vector.js/limitedWidthToggle.less b/resources/skins.vector.js/limitedWidthToggle.less new file mode 100644 index 000000000..6710443b5 --- /dev/null +++ b/resources/skins.vector.js/limitedWidthToggle.less @@ -0,0 +1,25 @@ +.vector-limited-width-toggle { + display: none; +} + +// Note on certain pages the control will have no effect e.g. Special:RecentChanges +// Defining this at 1600px was a product decision so do not change it +// (more context at https://phabricator.wikimedia.org/T319449#8346630) +@media ( min-width: 1600px ) { + .vector-feature-visual-enhancement-next-enabled .vector-limited-width-toggle { + display: block; + position: fixed; + bottom: 8px; + right: 8px; + + &:before { + background-image: url( images/fullscreen.svg ); + } + } + + .vector-feature-visual-enhancement-next-enabled.vector-feature-limited-width-content-disable { + .vector-limited-width-toggle:before { + background-image: url( images/fullscreen-close.svg ); + } + } +} diff --git a/resources/skins.vector.js/skin.js b/resources/skins.vector.js/skin.js index d92afd0e4..91b12cbe8 100644 --- a/resources/skins.vector.js/skin.js +++ b/resources/skins.vector.js/skin.js @@ -3,6 +3,7 @@ var languageButton = require( './languageButton.js' ), initSearchLoader = require( './searchLoader.js' ).initSearchLoader, dropdownMenus = require( './dropdownMenus.js' ).dropdownMenus, sidebarPersistence = require( './sidebarPersistence.js' ), + limitedWidthToggle = require( './limitedWidthToggle.js' ), watchstar = require( './watchstar.js' ), // @ts-ignore menuTabs = require( './menuTabs.js' ), @@ -81,6 +82,7 @@ function main( window ) { // menuTabs should follow `dropdownMenus` as that can move menu items from a // tab menu to a dropdown. menuTabs(); + limitedWidthToggle(); addNamespacesGadgetSupport(); if ( document.body.classList.contains( 'vector-feature-visual-enhancement-next-enabled' ) ) { watchstar(); diff --git a/skin.json b/skin.json index 4a4b1814e..8c102e3be 100644 --- a/skin.json +++ b/skin.json @@ -381,6 +381,7 @@ "mediawiki.page.ready", "mediawiki.page.watch.ajax", "mediawiki.util", + "mediawiki.storage", "mediawiki.experiments" ], "messages": [ @@ -391,6 +392,9 @@ ] }, "skins.vector.js": { + "styles": [ + "resources/skins.vector.js/limitedWidthToggle.less" + ], "packageFiles": [ "resources/skins.vector.js/skin.js", { @@ -403,6 +407,8 @@ "resources/skins.vector.js/sidebarPersistence.js", "resources/skins.vector.js/languageButton.js", "resources/skins.vector.js/echo.js", + "resources/skins.vector.js/limitedWidthToggle.js", + "resources/skins.vector.js/features.js", "resources/skins.vector.js/searchLoader.js", "resources/skins.vector.js/menuTabs.js" ], diff --git a/tests/jest/__mocks__/mediawiki.util.js b/tests/jest/__mocks__/mediawiki.util.js new file mode 100644 index 000000000..d708b804c --- /dev/null +++ b/tests/jest/__mocks__/mediawiki.util.js @@ -0,0 +1,3 @@ +module.exports = { + debounce: ( /** @type {Function} */fn ) => fn +}; diff --git a/tests/jest/skins.vector.js/features.test.js b/tests/jest/skins.vector.js/features.test.js new file mode 100644 index 000000000..c0a4cce26 --- /dev/null +++ b/tests/jest/skins.vector.js/features.test.js @@ -0,0 +1,34 @@ +const features = require( '../../../resources/skins.vector.js/features.js' ); + +describe( 'features', () => { + beforeEach( () => { + document.body.setAttribute( 'class', 'vector-feature-foo-disabled vector-feature-bar-enabled hello' ); + } ); + + test( 'toggle', () => { + features.toggle( 'foo' ); + features.toggle( 'bar' ); + + expect( + document.body.classList.contains( 'vector-feature-foo-enabled' ) + ).toBe( true ); + expect( + document.body.classList.contains( 'vector-feature-foo-disabled' ) + ).toBe( false ); + expect( + document.body.classList.contains( 'vector-feature-bar-disabled' ) + ).toBe( true ); + expect( + document.body.classList.contains( 'vector-feature-bar-enabled' ) + ).toBe( false ); + expect( + document.body.classList.contains( 'hello' ) + ).toBe( true ); + } ); + + test( 'toggle unknown feature', () => { + expect( () => { + features.toggle( 'unknown' ); + } ).toThrow(); + } ); +} );