From 4d1c0b8940128c8344cacd8f96a3c8d55f722df4 Mon Sep 17 00:00:00 2001 From: Jan Drewniak Date: Wed, 11 Jan 2023 09:52:45 -0500 Subject: [PATCH] Override PinnableElement behaviour at low resolutions. At resolutions below 1000px, we want pinned elements such as the Page Tools menu and Main Menu to collapse. This behaviour is temporary and when the browser is resized, the pinned elements should revert to their previous pinned state. We also want to remove the ability to pin these menus at low resolutions, so the "hide/move" button is hidden. A new matchMedia event handler is added to PinnableElement.js to handle this behaviour. CSS is also added to hide the pinned menus at low resolution. This is to account for the situation where the page is loaded at narrow widths, with pinned elements, and the JS hasn't loaded yet. features.js is refactors so that class toggling can happen independently of saving the state to user preferences (since we want to toggle the classes but not save the state at lower resolutions). Bug: T326364 Change-Id: I3113ab83deb15843e04ed63ec767a85c522517b5 --- jsdoc.json | 2 + resources/skins.vector.es6/features.js | 30 +++++--- resources/skins.vector.es6/pinnableElement.js | 69 ++++++++++++++++++- .../components/PinnableElement.less | 14 ++++ .../components/PinnableHeader.less | 4 ++ tests/jest/pinnableElement.test.js | 18 +++++ 6 files changed, 125 insertions(+), 12 deletions(-) diff --git a/jsdoc.json b/jsdoc.json index 9a67c1ee8..b490cab80 100644 --- a/jsdoc.json +++ b/jsdoc.json @@ -27,6 +27,8 @@ "HTMLElement": "https://developer.mozilla.org/docs/Web/API/HTMLElement", "IntersectionObserver": "https://developer.mozilla.org/docs/Web/API/IntersectionObserver", "IntersectionObserverEntry": "https://developer.mozilla.org/en-US/docs/Web/API/IntersectionObserverEntry", + "MediaQueryList": "https://developer.mozilla.org/en-US/docs/Web/API/MediaQueryList", + "MediaQueryListEvent": "https://developer.mozilla.org/en-US/docs/Web/API/MediaQueryListEvent", "Node": "https://developer.mozilla.org/docs/Web/API/Node", "NodeList": "https://developer.mozilla.org/docs/Web/API/NodeList", "HTMLInputElement": "https://developer.mozilla.org/docs/Web/API/HTMLInputElement", diff --git a/resources/skins.vector.es6/features.js b/resources/skins.vector.es6/features.js index 2f32a2c7d..0da49b0fb 100644 --- a/resources/skins.vector.es6/features.js +++ b/resources/skins.vector.es6/features.js @@ -32,27 +32,40 @@ function save( feature, enabled ) { } /** - * @param {string} name + * + * @param {string} name feature name + * @param {boolean} [override] option to force enabled or disabled state. + * + * @return {boolean} The new feature state (false=disabled, true=enabled). * @throws {Error} if unknown feature toggled. */ -function toggle( name ) { +function toggleBodyClasses( name, override ) { const featureClassEnabled = 'vector-feature-' + name + '-enabled', classList = document.body.classList, featureClassDisabled = 'vector-feature-' + name + '-disabled'; - if ( classList.contains( featureClassDisabled ) ) { + if ( classList.contains( featureClassDisabled ) || override === true ) { classList.remove( featureClassDisabled ); classList.add( featureClassEnabled ); - save( name, true ); - } else if ( classList.contains( featureClassEnabled ) ) { + return true; + } else if ( classList.contains( featureClassEnabled ) || override === false ) { classList.add( featureClassDisabled ); classList.remove( featureClassEnabled ); - save( name, false ); + return false; } else { throw new Error( 'Attempt to toggle unknown feature: ' + name ); } } +/** + * @param {string} name + * @throws {Error} if unknown feature toggled. + */ +function toggle( name ) { + const featureState = toggleBodyClasses( name ); + save( name, featureState ); +} + /** * Checks if the feature is enabled. * @@ -65,7 +78,4 @@ function isEnabled( name ) { ); } -module.exports = { - isEnabled: isEnabled, - toggle: toggle -}; +module.exports = { isEnabled, toggle, toggleBodyClasses }; diff --git a/resources/skins.vector.es6/pinnableElement.js b/resources/skins.vector.es6/pinnableElement.js index 6f1fe08e4..d4f7208ec 100644 --- a/resources/skins.vector.es6/pinnableElement.js +++ b/resources/skins.vector.es6/pinnableElement.js @@ -2,6 +2,63 @@ const features = require( './features.js' ); const PINNED_HEADER_CLASS = 'vector-pinnable-header-pinned'; const UNPINNED_HEADER_CLASS = 'vector-pinnable-header-unpinned'; +/** + * Callback for matchMedia listener that overrides the pinnable header's stored state + * at a certain breakpoint and forces it to unpin. Also hides the pinnable button + * at that breakpoint to disable pinning. + * Usage of 'e.matches' assumes a `max-width` not `min-width` media query. + * + * @param {HTMLElement} header + * @param {MediaQueryList|MediaQueryListEvent} e + */ +function disablePinningAtBreakpoint( header, e ) { + const { + pinnableElementId, + pinnedContainerId, + unpinnedContainerId, + featureName + } = header.dataset; + const savedPinnedState = JSON.parse( header.dataset.savedPinnedState || 'false' ); + + // (typescript null check) + if ( !( pinnableElementId && unpinnedContainerId && pinnedContainerId && featureName ) ) { + return; + } + + // Hide the button at lower resolutions. + header.hidden = e.matches; + + // FIXME: Class toggling should be centralized instead of being + // handled here, in features.js and togglePinnableClasses(). + if ( e.matches && savedPinnedState === true ) { + features.toggleBodyClasses( featureName, false ); + header.classList.remove( PINNED_HEADER_CLASS ); + header.classList.add( UNPINNED_HEADER_CLASS ); + movePinnableElement( pinnableElementId, unpinnedContainerId ); + } + + if ( !e.matches && savedPinnedState === true ) { + features.toggleBodyClasses( featureName, true ); + header.classList.add( PINNED_HEADER_CLASS ); + header.classList.remove( UNPINNED_HEADER_CLASS ); + movePinnableElement( pinnableElementId, pinnedContainerId ); + } +} + +/** + * Saves the persistent pinnable state in the element's dataset + * so that it can be overridden at lower resolutions and the + * reverted to at wider resolutions. + * + * This is not necessarily the elements current state, but it + * seeks to represent the state of the saved user preference. + * + * @param {HTMLElement} header + */ +function setSavedPinnableState( header ) { + header.dataset.savedPinnedState = String( isPinned( header ) ); +} + /** * Toggle classes on the body and pinnable element * @@ -32,7 +89,7 @@ function togglePinnableClasses( header ) { * * @param {HTMLElement} header PinnableHeader element. */ -function togglePinnableElement( header ) { +function pinnableElementClickHandler( header ) { const { pinnableElementId, pinnedContainerId, @@ -47,6 +104,7 @@ function togglePinnableElement( header ) { const newContainerId = isPinned( header ) ? pinnedContainerId : unpinnedContainerId; movePinnableElement( pinnableElementId, newContainerId ); } + setSavedPinnableState( header ); } /** @@ -60,11 +118,18 @@ function bindPinnableToggleButtons( header ) { return; } + const pinnableBreakpoint = window.matchMedia( '(max-width: 1000px)' ); const toggleButtons = header.querySelectorAll( '.vector-pinnable-header-toggle-button' ); toggleButtons.forEach( function ( button ) { - button.addEventListener( 'click', togglePinnableElement.bind( null, header ) ); + button.addEventListener( 'click', pinnableElementClickHandler.bind( null, header ) ); } ); + // set saved pinned state for narrow breakpoint behaviour. + setSavedPinnableState( header ); + // Check the breakpoint in case an override is needed on pageload. + disablePinningAtBreakpoint( header, pinnableBreakpoint ); + // Add match media handler. + pinnableBreakpoint.addEventListener( 'change', disablePinningAtBreakpoint.bind( null, header ) ); } /** diff --git a/resources/skins.vector.styles/components/PinnableElement.less b/resources/skins.vector.styles/components/PinnableElement.less index ea15cda3e..d7dc79b62 100644 --- a/resources/skins.vector.styles/components/PinnableElement.less +++ b/resources/skins.vector.styles/components/PinnableElement.less @@ -1,3 +1,5 @@ +@import '../../common/variables.less'; + .vector-feature-page-tools-enabled .vector-pinnable-element { & > * + * { // Apply top border to every children of pinnable elements except the first @@ -27,3 +29,15 @@ padding-right: 0; } } + +/** + * At lower resolutions, we want to hide the pinned containers since these + * elements collapse (become unpinned) at this resolution via PinnableElement.js. + * Although this is handled in JS, this rule prevents the pinned menu from + * appearing on pageload, at low resolutions, before the JS kicks in. + */ +@media screen and ( max-width: @max-width-tablet ) { + .vector-pinned-container { + display: none; + } +} diff --git a/resources/skins.vector.styles/components/PinnableHeader.less b/resources/skins.vector.styles/components/PinnableHeader.less index 9a5f8a435..25122e200 100644 --- a/resources/skins.vector.styles/components/PinnableHeader.less +++ b/resources/skins.vector.styles/components/PinnableHeader.less @@ -6,6 +6,10 @@ } } +.vector-pinnable-header[ hidden ] { + display: none; +} + // FIXME: Remove .sidebar-toc after I5b9228380f5c4674ef424d33127a5cb4010822da is in prod for 5 days .vector-page-tools, .sidebar-toc, diff --git a/tests/jest/pinnableElement.test.js b/tests/jest/pinnableElement.test.js index 82a6c1bc4..3b0549f07 100644 --- a/tests/jest/pinnableElement.test.js +++ b/tests/jest/pinnableElement.test.js @@ -6,6 +6,24 @@ const fs = require( 'fs' ); const pinnableHeaderTemplate = fs.readFileSync( 'includes/templates/PinnableHeader.mustache', 'utf8' ); const pinnableElement = require( '../../resources/skins.vector.es6/pinnableElement.js' ); +/** + * Mock for matchMedia, which is not included in JSDOM. + * https://jestjs.io/docs/26.x/manual-mocks#mocking-methods-which-are-not-implemented-in-jsdom + */ +Object.defineProperty( window, 'matchMedia', { + writable: true, + value: jest.fn().mockImplementation( query => ( { + matches: false, + media: query, + onchange: null, + addListener: jest.fn(), // deprecated + removeListener: jest.fn(), // deprecated + addEventListener: jest.fn(), + removeEventListener: jest.fn(), + dispatchEvent: jest.fn() + } ) ) +} ); + const simpleData = { 'is-pinned': false, 'data-name': 'simple',