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
This commit is contained in:
Jan Drewniak 2023-01-11 09:52:45 -05:00 committed by Jdrewniak
parent 3c761ff518
commit 4d1c0b8940
6 changed files with 125 additions and 12 deletions

View file

@ -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",

View file

@ -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 };

View file

@ -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 ) );
}
/**

View file

@ -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;
}
}

View file

@ -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,

View file

@ -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',