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();
+ } );
+} );