2022-12-07 22:15:31 +00:00
|
|
|
const features = require( './features.js' );
|
2022-10-19 20:43:40 +00:00
|
|
|
const PINNED_HEADER_CLASS = 'vector-pinnable-header-pinned';
|
|
|
|
const UNPINNED_HEADER_CLASS = 'vector-pinnable-header-unpinned';
|
2023-03-23 20:03:13 +00:00
|
|
|
const popupNotification = require( './popupNotification.js' );
|
2022-10-19 20:43:40 +00:00
|
|
|
|
2023-01-11 14:52:45 +00:00
|
|
|
/**
|
|
|
|
* Callback for matchMedia listener that overrides the pinnable header's stored state
|
2023-03-13 19:24:14 +00:00
|
|
|
* at a certain breakpoint and forces it to unpin.
|
2023-01-11 14:52:45 +00:00
|
|
|
* 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;
|
|
|
|
}
|
|
|
|
|
|
|
|
if ( e.matches && savedPinnedState === true ) {
|
2022-10-21 18:37:26 +00:00
|
|
|
features.toggleDocClasses( featureName, false );
|
2023-01-11 14:52:45 +00:00
|
|
|
movePinnableElement( pinnableElementId, unpinnedContainerId );
|
|
|
|
}
|
|
|
|
|
|
|
|
if ( !e.matches && savedPinnedState === true ) {
|
2022-10-21 18:37:26 +00:00
|
|
|
features.toggleDocClasses( featureName, true );
|
2023-01-11 14:52:45 +00:00
|
|
|
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 ) );
|
|
|
|
}
|
|
|
|
|
2022-10-19 20:43:40 +00:00
|
|
|
/**
|
2022-12-16 19:15:56 +00:00
|
|
|
* Toggle classes on the body and pinnable element
|
|
|
|
*
|
|
|
|
* @param {HTMLElement} header pinnable element
|
|
|
|
*/
|
|
|
|
function togglePinnableClasses( header ) {
|
2023-03-10 18:41:54 +00:00
|
|
|
const featureName = /** @type {string} */ ( header.dataset.featureName );
|
2022-12-16 19:15:56 +00:00
|
|
|
|
2023-03-10 18:41:54 +00:00
|
|
|
// Leverage features.js to toggle the body classes and persist the state
|
|
|
|
// for logged-in users.
|
|
|
|
features.toggle( featureName );
|
2022-12-16 19:15:56 +00:00
|
|
|
|
|
|
|
// Toggle pinned class
|
|
|
|
header.classList.toggle( PINNED_HEADER_CLASS );
|
|
|
|
header.classList.toggle( UNPINNED_HEADER_CLASS );
|
|
|
|
}
|
|
|
|
|
2023-03-23 20:03:13 +00:00
|
|
|
/**
|
|
|
|
* Create the indicators for the pinnable element
|
|
|
|
*
|
2023-04-03 21:16:52 +00:00
|
|
|
* @param {string} pinnableElementId
|
2023-03-23 20:03:13 +00:00
|
|
|
*/
|
|
|
|
function addPinnableElementIndicator( pinnableElementId ) {
|
|
|
|
const dropdownSelector = document.querySelector( `#${pinnableElementId}-dropdown` );
|
|
|
|
const container = dropdownSelector && dropdownSelector.parentElement;
|
|
|
|
if ( container ) {
|
|
|
|
// Possible messages include:
|
|
|
|
// * vector-page-tools-unpinned-popup
|
|
|
|
// * vector-main-menu-unpinned-popup
|
|
|
|
const message = mw.msg( `${pinnableElementId}-unpinned-popup` );
|
2023-04-24 12:26:48 +00:00
|
|
|
popupNotification.add( container, message, pinnableElementId )
|
|
|
|
.then( ( popupWidget ) => {
|
|
|
|
if ( popupWidget ) {
|
|
|
|
popupNotification.show( popupWidget );
|
|
|
|
}
|
|
|
|
} );
|
2023-03-23 20:03:13 +00:00
|
|
|
}
|
|
|
|
}
|
|
|
|
|
2022-12-16 19:15:56 +00:00
|
|
|
/**
|
|
|
|
* Event handler that toggles the pinnable elements pinned state.
|
|
|
|
* Also moves the pinned element when those params are provided
|
|
|
|
* (via data attributes).
|
|
|
|
*
|
|
|
|
* @param {HTMLElement} header PinnableHeader element.
|
|
|
|
*/
|
2023-01-11 14:52:45 +00:00
|
|
|
function pinnableElementClickHandler( header ) {
|
2022-12-16 19:15:56 +00:00
|
|
|
const {
|
|
|
|
pinnableElementId,
|
|
|
|
pinnedContainerId,
|
|
|
|
unpinnedContainerId
|
|
|
|
} = header.dataset;
|
|
|
|
|
|
|
|
togglePinnableClasses( header );
|
|
|
|
|
2023-03-23 20:03:13 +00:00
|
|
|
const isPinnedElement = isPinned( header );
|
2022-12-16 19:15:56 +00:00
|
|
|
// Optional functionality of moving the pinnable element in the DOM
|
|
|
|
// to different containers based on it's pinned status
|
|
|
|
if ( pinnableElementId && pinnedContainerId && unpinnedContainerId ) {
|
2023-03-10 18:41:54 +00:00
|
|
|
setSavedPinnableState( header );
|
2023-03-23 20:03:13 +00:00
|
|
|
const newContainerId = isPinnedElement ? pinnedContainerId : unpinnedContainerId;
|
2022-12-16 19:15:56 +00:00
|
|
|
movePinnableElement( pinnableElementId, newContainerId );
|
2023-10-18 11:08:59 +00:00
|
|
|
window.dispatchEvent( new Event( 'resize' ) );
|
2023-02-01 20:31:53 +00:00
|
|
|
setFocusAfterToggle( pinnableElementId );
|
2023-03-23 20:03:13 +00:00
|
|
|
if ( !isPinnedElement ) {
|
|
|
|
addPinnableElementIndicator( pinnableElementId );
|
|
|
|
}
|
2023-02-01 20:31:53 +00:00
|
|
|
}
|
|
|
|
}
|
|
|
|
|
|
|
|
/**
|
|
|
|
* Sets focus on the correct toggle button depending on the pinned state.
|
|
|
|
* Also opens the dropdown containing the unpinned element.
|
|
|
|
*
|
|
|
|
* @param {string} pinnableElementId
|
|
|
|
*/
|
|
|
|
function setFocusAfterToggle( pinnableElementId ) {
|
|
|
|
let focusElement;
|
|
|
|
const pinnableElement = document.getElementById( pinnableElementId );
|
|
|
|
const header = /** @type {HTMLElement|null} */ ( pinnableElement && pinnableElement.querySelector( '.vector-pinnable-header' ) );
|
|
|
|
if ( !pinnableElement || !header ) {
|
|
|
|
return;
|
|
|
|
}
|
|
|
|
if ( isPinned( header ) ) {
|
|
|
|
focusElement = /** @type {HTMLElement|null} */ ( pinnableElement.querySelector( '.vector-pinnable-header-unpin-button' ) );
|
|
|
|
} else {
|
|
|
|
const dropdown = pinnableElement.closest( '.vector-dropdown' );
|
|
|
|
focusElement = /** @type {HTMLInputElement|null} */ ( dropdown && dropdown.querySelector( '.vector-menu-checkbox' ) );
|
|
|
|
}
|
|
|
|
if ( focusElement ) {
|
|
|
|
focusElement.focus();
|
2022-12-16 19:15:56 +00:00
|
|
|
}
|
|
|
|
}
|
|
|
|
|
|
|
|
/**
|
|
|
|
* Binds all the toggle buttons in a pinnableElement
|
|
|
|
* to the click handler that enables pinnability.
|
|
|
|
*
|
2022-10-19 20:43:40 +00:00
|
|
|
* @param {HTMLElement} header
|
|
|
|
*/
|
|
|
|
function bindPinnableToggleButtons( header ) {
|
|
|
|
const toggleButtons = header.querySelectorAll( '.vector-pinnable-header-toggle-button' );
|
|
|
|
toggleButtons.forEach( function ( button ) {
|
2023-01-11 14:52:45 +00:00
|
|
|
button.addEventListener( 'click', pinnableElementClickHandler.bind( null, header ) );
|
2022-10-19 20:43:40 +00:00
|
|
|
} );
|
2023-03-10 18:41:54 +00:00
|
|
|
}
|
|
|
|
|
|
|
|
/**
|
|
|
|
* Binds pinnable breakpoint to allow automatic unpinning
|
|
|
|
* of pinnable elements with pinnedContainerId and unpinnedContainerId defined
|
|
|
|
*
|
|
|
|
* @param {HTMLElement} header
|
|
|
|
*/
|
|
|
|
function bindPinnableBreakpoint( header ) {
|
|
|
|
const { pinnedContainerId, unpinnedContainerId } = header.dataset;
|
|
|
|
if ( !unpinnedContainerId || !pinnedContainerId ) {
|
|
|
|
return;
|
|
|
|
}
|
|
|
|
|
2023-03-13 20:52:45 +00:00
|
|
|
const pinnableBreakpoint = window.matchMedia( '(max-width: 999px)' );
|
2023-03-10 18:41:54 +00:00
|
|
|
// Set saved pinned state for narrow breakpoint behaviour.
|
2023-01-11 14:52:45 +00:00
|
|
|
setSavedPinnableState( header );
|
|
|
|
// Check the breakpoint in case an override is needed on pageload.
|
|
|
|
disablePinningAtBreakpoint( header, pinnableBreakpoint );
|
2023-03-10 18:41:54 +00:00
|
|
|
|
2023-01-11 14:52:45 +00:00
|
|
|
// Add match media handler.
|
2023-01-22 16:37:56 +00:00
|
|
|
if ( pinnableBreakpoint.addEventListener ) {
|
|
|
|
pinnableBreakpoint.addEventListener( 'change', disablePinningAtBreakpoint.bind( null, header ) );
|
|
|
|
} else {
|
|
|
|
// Before Safari 14, MediaQueryList is based on EventTarget,
|
|
|
|
// so you must use addListener() and removeListener() to observe media query lists.
|
|
|
|
pinnableBreakpoint.addListener( disablePinningAtBreakpoint.bind( null, header ) );
|
|
|
|
}
|
2022-10-19 20:43:40 +00:00
|
|
|
}
|
|
|
|
|
2022-12-07 22:15:31 +00:00
|
|
|
/**
|
|
|
|
* @param {HTMLElement} header
|
|
|
|
* @return {boolean} Returns true if the element is pinned and false otherwise.
|
|
|
|
*/
|
|
|
|
function isPinned( header ) {
|
2023-03-10 18:41:54 +00:00
|
|
|
const featureName = /** @type {string} */ ( header.dataset.featureName );
|
|
|
|
return features.isEnabled( featureName );
|
2022-12-07 22:15:31 +00:00
|
|
|
}
|
|
|
|
|
2023-10-25 16:54:06 +00:00
|
|
|
/**
|
|
|
|
* Ensures the header classes are in sync with the pinnable headers state
|
|
|
|
* in the case that it's moved via movePinnableElement().
|
|
|
|
* @param {HTMLElement} pinnableElement
|
|
|
|
*/
|
|
|
|
function updatePinnableHeaderClass( pinnableElement ) {
|
|
|
|
const header = pinnableElement.querySelector( '.vector-pinnable-header' );
|
|
|
|
|
|
|
|
// Because Typescript
|
|
|
|
if ( !header || !( header instanceof HTMLElement ) ) {
|
|
|
|
return;
|
|
|
|
}
|
|
|
|
|
|
|
|
// Toggle header classes
|
|
|
|
if ( isPinned( header ) ) {
|
|
|
|
header.classList.add( PINNED_HEADER_CLASS );
|
|
|
|
header.classList.remove( UNPINNED_HEADER_CLASS );
|
|
|
|
} else {
|
|
|
|
header.classList.remove( PINNED_HEADER_CLASS );
|
|
|
|
header.classList.add( UNPINNED_HEADER_CLASS );
|
|
|
|
}
|
|
|
|
}
|
|
|
|
|
2022-10-19 20:43:40 +00:00
|
|
|
/**
|
|
|
|
* @param {string} pinnableElementId
|
2022-11-21 22:16:07 +00:00
|
|
|
* @param {string} newContainerId
|
2022-10-19 20:43:40 +00:00
|
|
|
*/
|
2022-11-21 22:16:07 +00:00
|
|
|
function movePinnableElement( pinnableElementId, newContainerId ) {
|
2022-10-19 20:43:40 +00:00
|
|
|
const pinnableElem = document.getElementById( pinnableElementId );
|
2022-11-21 22:16:07 +00:00
|
|
|
const newContainer = document.getElementById( newContainerId );
|
2022-10-19 20:43:40 +00:00
|
|
|
const currContainer = /** @type {HTMLElement} */ ( pinnableElem && pinnableElem.parentElement );
|
|
|
|
|
2022-11-21 22:16:07 +00:00
|
|
|
if ( !pinnableElem || !newContainer || !currContainer ) {
|
2022-10-19 20:43:40 +00:00
|
|
|
return;
|
|
|
|
}
|
|
|
|
|
|
|
|
// Avoid moving element if unnecessary
|
2022-11-21 22:16:07 +00:00
|
|
|
if ( currContainer.id !== newContainerId ) {
|
2022-10-19 20:43:40 +00:00
|
|
|
newContainer.insertAdjacentElement( 'beforeend', pinnableElem );
|
2023-10-25 16:54:06 +00:00
|
|
|
updatePinnableHeaderClass( pinnableElem );
|
2022-10-19 20:43:40 +00:00
|
|
|
}
|
2023-03-23 20:03:13 +00:00
|
|
|
|
2023-04-24 12:26:48 +00:00
|
|
|
popupNotification.hideAll();
|
2022-10-19 20:43:40 +00:00
|
|
|
}
|
|
|
|
|
2022-11-28 21:08:40 +00:00
|
|
|
function initPinnableElement() {
|
2022-10-19 20:43:40 +00:00
|
|
|
const pinnableHeader = /** @type {NodeListOf<HTMLElement>} */ ( document.querySelectorAll( '.vector-pinnable-header' ) );
|
2023-03-10 18:41:54 +00:00
|
|
|
pinnableHeader.forEach( ( header ) => {
|
|
|
|
if ( header.dataset.featureName && header.dataset.pinnableElementId ) {
|
|
|
|
bindPinnableToggleButtons( header );
|
|
|
|
bindPinnableBreakpoint( header );
|
|
|
|
}
|
|
|
|
} );
|
2022-10-19 20:43:40 +00:00
|
|
|
}
|
|
|
|
|
2023-09-22 21:05:20 +00:00
|
|
|
/**
|
|
|
|
* Checks if at least one of the elements in the HTML document is pinned based on CSS class names.
|
|
|
|
*
|
|
|
|
* @function
|
|
|
|
* @return {boolean} True if at least one pinned element is found, otherwise false.
|
|
|
|
*/
|
|
|
|
function hasPinnedElements() {
|
|
|
|
const suffixesToCheck = [ 'pinned-clientpref-1', 'pinned-enabled' ];
|
|
|
|
const htmlElement = document.documentElement;
|
|
|
|
return Array.from( htmlElement.classList ).some( ( className ) => {
|
|
|
|
return suffixesToCheck.some( ( suffix ) => className.endsWith( suffix ) );
|
|
|
|
} );
|
|
|
|
}
|
|
|
|
|
2022-10-19 20:43:40 +00:00
|
|
|
module.exports = {
|
2023-09-22 21:05:20 +00:00
|
|
|
hasPinnedElements,
|
2022-11-28 21:08:40 +00:00
|
|
|
initPinnableElement,
|
2022-10-19 20:43:40 +00:00
|
|
|
movePinnableElement,
|
2023-02-01 20:31:53 +00:00
|
|
|
setFocusAfterToggle,
|
2022-12-07 22:15:31 +00:00
|
|
|
isPinned,
|
2022-10-19 20:43:40 +00:00
|
|
|
PINNED_HEADER_CLASS,
|
|
|
|
UNPINNED_HEADER_CLASS
|
|
|
|
};
|