Merge "Add collapsed TOC to sticky header by moving the TOC"

This commit is contained in:
jenkins-bot 2022-08-11 12:28:24 +00:00 committed by Gerrit Code Review
commit 03b6715713
14 changed files with 264 additions and 86 deletions

View file

@ -373,9 +373,23 @@ abstract class SkinVector extends SkinMustache {
}
$btns[] = $this->getAddSectionButtonData();
$tocPortletData = $this->decoratePortletData( 'data-sticky-header-toc', [
'id' => 'p-sticky-header-toc',
'class' => 'mw-portlet mw-portlet-sticky-header-toc vector-sticky-header-toc',
'html-items' => '',
'html-vector-menu-checkbox-attributes' => 'tabindex="-1"',
'html-vector-menu-heading-attributes' => 'tabindex="-1"',
'heading-class' => implode( ' ', [
self::CLASS_QUIET_BUTTON,
self::CLASS_ICON_BUTTON,
$this->iconClass( 'listBullet' )
] ),
] );
// Show sticky ULS if the ULS extension is enabled and the ULS in header is not hidden
$showStickyULS = $this->isULSExtensionEnabled() && !$this->shouldHideLanguages();
return [
'data-sticky-header-toc' => $tocPortletData,
'data-primary-action' => $showStickyULS ?
$this->getULSButtonData() : null,
'data-button-start' => [
@ -748,7 +762,9 @@ abstract class SkinVector extends SkinMustache {
if ( $this->isLegacy() ) {
$extraClasses[self::MENU_TYPE_TABS] .= ' vector-menu-tabs-legacy';
}
$portletData['heading-class'] = '';
if ( !isset( $portletData['heading-class'] ) ) {
$portletData['heading-class'] = '';
}
// Add target class to apply different icon to personal menu dropdown for logged in users.
if ( $portletData['id'] === 'p-personal' ) {
if ( $this->isLegacy() ) {
@ -835,6 +851,7 @@ abstract class SkinVector extends SkinMustache {
case 'data-user-menu':
case 'data-actions':
case 'data-variants':
case 'data-sticky-header-toc':
$type = self::MENU_TYPE_DROPDOWN;
break;
case 'data-views':

View file

@ -16,11 +16,13 @@
data-event-name="ui.dropdown-{{id}}"
class="vector-menu-checkbox{{#checkbox-class}} {{.}}{{/checkbox-class}}"
{{#aria-label}}aria-label="{{.}}"{{/aria-label}}
{{{html-vector-menu-checkbox-attributes}}}
/>
<label
id="{{id}}-label"
for="{{id}}-checkbox"
class="vector-menu-heading{{#heading-class}} {{.}}{{/heading-class}}"
{{{html-vector-menu-heading-attributes}}}
>
{{{html-vector-heading-icon}}}<span class="vector-menu-heading-label">{{label}}</span>
</label>

View file

@ -19,6 +19,12 @@
{{>SearchBox}}
{{/data-search}}
<div class="vector-sticky-header-context-bar">
<div class="vector-sticky-header-toc-container">
{{! TOC is cloned into this menu from the sidebar in stickyHeader.js. }}
{{#data-sticky-header-toc}}
{{>Menu}}
{{/data-sticky-header-toc}}
</div>
<div class="vector-sticky-header-context-bar-primary" {{{html-user-language-attributes}}}>{{{html-title}}}</div>
</div>
</div>

View file

@ -63,7 +63,6 @@
@size-sidebar-button: unit( 44 / @font-size-browser, em ); // Equals `2.75em`.
@size-icon: unit( 20 / @font-size-browser, em );
@size-indicator: unit( 12 / @font-size-browser, em );
// Copied from mediawiki.ui.icons
@icon-padding-md: unit( 12 / @font-size-browser, em );
@ -132,6 +131,7 @@
@margin-horizontal-sidebar-button-icon: unit( 12px / @font-size-browser, em ); // 0.75em @ 16
// Sidebar
@sidebar-toc-selector: ~'.mw-table-of-contents-container .sidebar-toc';
@width-sidebar-px: 220;
@width-sidebar-px-wide: 244;
@margin-toc-start-content: unit( ( @width-sidebar-px + 24 ) / @font-size-browser, em );

View file

@ -9,6 +9,7 @@ const
deferUntilFrame = require( './deferUntilFrame.js' ),
ABTestConfig = require( /** @type {string} */ ( './config.json' ) ).wgVectorWebABTestEnrollment || {},
stickyHeaderEditIconConfig = require( /** @type {string} */ ( './config.json' ) ).wgVectorStickyHeaderEdit || true,
STICKY_HEADER_VISIBLE_CLASS = 'vector-sticky-header-visible',
TOC_ID = 'mw-panel-toc',
TOC_ID_LEGACY = 'toc',
BODY_CONTENT_ID = 'bodyContent',
@ -16,10 +17,13 @@ const
TOC_SECTION_ID_PREFIX = 'toc-',
TOC_LEGACY_PLACEHOLDER_TAG = 'mw:tocplace',
TOC_SCROLL_HOOK = 'table_of_contents',
TOC_COLLAPSED_CLASS = 'vector-toc-collapsed',
PAGE_TITLE_SCROLL_HOOK = 'page_title',
PAGE_TITLE_INTERSECTION_CLASS = 'vector-below-page-title',
TOC_EXPERIMENT_NAME = 'skin-vector-toc-experiment';
const belowDesktopMedia = window.matchMedia( '(max-width: 999px)' );
/**
* @callback OnIntersection
* @param {HTMLElement} element The section that triggered the new intersection change.
@ -106,6 +110,24 @@ function initStickyHeaderABTests( abConfig, isStickyHeaderFeatureAllowed, getEna
};
}
/*
* Updates TOC's location in the DOM (in sidebar or sticky header)
* depending on if the TOC is collapsed and if the sticky header is visible
*
* @return {void}
*/
const updateTocLocation = () => {
const isTocCollapsed = document.body.classList.contains( TOC_COLLAPSED_CLASS );
const isStickyHeaderVisible = document.body.classList.contains( STICKY_HEADER_VISIBLE_CLASS );
const isBelowDesktop = belowDesktopMedia.matches;
if ( isTocCollapsed ) {
const tocLocation = isStickyHeaderVisible && !isBelowDesktop ? 'stickyheader' : 'sidebar';
stickyHeader.moveToc( tocLocation );
} else {
stickyHeader.moveToc( 'sidebar' );
}
};
/**
* @return {void}
*/
@ -117,7 +139,9 @@ const main = () => {
searchToggle( searchToggleElement );
}
//
// Sticky header
//
const
header = document.getElementById( stickyHeader.STICKY_HEADER_ID ),
stickyIntersection = document.getElementById( stickyHeader.FIRST_HEADING_ID ),
@ -141,22 +165,32 @@ const main = () => {
)
);
// Set up intersection observer for page title, used by sticky header
// Set up intersection observer for page title
// Used to show/hide sticky header and add class used by collapsible TOC (T307900)
const observer = scrollObserver.initScrollObserver(
() => {
if ( isStickyHeaderAllowed && showStickyHeader ) {
stickyHeader.show();
updateTocLocation();
}
document.body.classList.add( PAGE_TITLE_INTERSECTION_CLASS );
scrollObserver.fireScrollHook( 'down', PAGE_TITLE_SCROLL_HOOK );
},
() => {
if ( isStickyHeaderAllowed && showStickyHeader ) {
stickyHeader.hide();
updateTocLocation();
}
document.body.classList.remove( PAGE_TITLE_INTERSECTION_CLASS );
scrollObserver.fireScrollHook( 'up', PAGE_TITLE_SCROLL_HOOK );
}
);
// Handle toc location when sticky header is hidden on lower viewports
belowDesktopMedia.onchange = () => {
updateTocLocation();
};
if ( !showStickyHeader ) {
stickyHeader.hide();
}
@ -224,18 +258,6 @@ const main = () => {
return;
}
// T307900 Setup observer for collapsible TOC
if ( stickyIntersection ) {
scrollObserver.initScrollObserver(
() => {
document.body.classList.add( PAGE_TITLE_INTERSECTION_CLASS );
},
() => {
document.body.classList.remove( PAGE_TITLE_INTERSECTION_CLASS );
}
).observe( stickyIntersection );
}
const tableOfContents = initTableOfContents( {
container: tocElement,
onHeadingClick: ( id ) => {
@ -271,7 +293,8 @@ const main = () => {
},
onToggleClick: ( id ) => {
tableOfContents.toggleExpandSection( id );
}
},
onToggleCollapse: updateTocLocation
} );
const headingSelector = [
'h1', 'h2', 'h3', 'h4', 'h5', 'h6'

View file

@ -8,6 +8,7 @@ const
STICKY_HEADER_APPENDED_PARAM = [ 'wvprov', 'sticky-header' ],
STICKY_HEADER_VISIBLE_CLASS = 'vector-sticky-header-visible',
STICKY_HEADER_USER_MENU_CONTAINER_CLASS = 'vector-sticky-header-icon-end',
TOC_ID = 'mw-panel-toc',
FIRST_HEADING_ID = 'firstHeading',
USER_MENU_ID = 'p-personal',
ULS_STICKY_CLASS = 'uls-dialog-sticky',
@ -47,6 +48,32 @@ function hide() {
document.body.classList.add( ULS_HIDE_CLASS );
}
/**
* Moves the TOC element to a new parent container.
*
* @param {string} position The position to move the TOC into: sidebar or stickyheader
*/
function moveToc( position ) {
const toc = document.getElementById( TOC_ID );
const currTocContainer = toc && toc.parentElement;
if ( !toc || !currTocContainer ) {
return;
}
let newTocContainer;
const sidebarTocContainerClass = 'mw-table-of-contents-container';
const stickyHeaderTocContainerClass = 'vector-menu-content';
// Avoid moving TOC if unnecessary
if ( !currTocContainer.classList.contains( sidebarTocContainerClass ) && position === 'sidebar' ) {
newTocContainer = document.querySelector( `.${sidebarTocContainerClass}` );
} else if ( !currTocContainer.classList.contains( stickyHeaderTocContainerClass ) && position === 'stickyheader' ) {
newTocContainer = document.querySelector( `.vector-sticky-header-toc .${stickyHeaderTocContainerClass}` );
}
if ( newTocContainer ) {
newTocContainer.insertAdjacentElement( 'beforeend', toc );
}
}
/**
* Copies attribute from an element to another.
*
@ -574,6 +601,7 @@ function initStickyHeader( props ) {
module.exports = {
show,
hide,
moveToc,
prepareUserMenu,
isAllowedNamespace,
isAllowedAction,

View file

@ -18,11 +18,16 @@ const TOC_COLLAPSED_CLASS = 'vector-toc-collapsed';
* @param {string} id The id of the list item corresponding to the arrow.
*/
/**
* @callback onToggleCollapse
*/
/**
* @typedef {Object} TableOfContentsProps
* @property {HTMLElement} container The container element for the table of contents.
* @property {onHeadingClick} onHeadingClick Called when an arrow is clicked.
* @property {onToggleClick} onToggleClick Called when a list item is clicked.
* @property {onToggleCollapse} onToggleCollapse Called when collapse toggle buttons are clicked.
*/
/**
@ -288,6 +293,7 @@ module.exports = function tableOfContents( props ) {
showHideTocElement.forEach( function ( btn ) {
btn.addEventListener( 'click', () => {
document.body.classList.toggle( TOC_COLLAPSED_CLASS );
props.onToggleCollapse();
} );
} );
}

View file

@ -79,6 +79,23 @@
min-width: 0;
}
&-toc-container {
position: relative;
margin-left: -@icon-padding-md;
.vector-menu-heading {
display: none;
.vector-toc-collapsed & {
display: block;
}
}
.sidebar-toc {
margin: 0;
}
}
&-context-bar-primary {
overflow: hidden;
font-family: @font-family-serif;

View file

@ -82,19 +82,26 @@
}
}
// T302076 Add scrollable indicator as fade
.sidebar-toc:after {
content: '';
display: block;
position: absolute;
bottom: 0;
left: 0;
right: 0;
height: @sidebar-toc-fade-height;
background: linear-gradient( rgba( 255, 255, 255, 0 ), @background-color-page-container );
background-repeat: no-repeat;
background-position: -@sidebar-toc-right-padding; // T311436 Hacky way to prevent the fade from covering the scrollbar
pointer-events: none; // Make the link below the fade clickable
// T302076: Add fade scrollable indicator when TOC is in sidebar
// Avoid showing indicator when the TOC is collapsed in the page title, sticky header, or floating
@media ( min-width: @min-width-desktop ) {
@{sidebar-toc-selector}:after {
content: '';
display: block;
position: absolute;
bottom: 0;
left: 0;
right: 0;
height: @sidebar-toc-fade-height;
background: linear-gradient( rgba( 255, 255, 255, 0 ), @background-color-page-container );
background-repeat: no-repeat;
background-position: -@sidebar-toc-right-padding; // T311436 Hacky way to prevent the fade from covering the scrollbar
pointer-events: none; // Make the link below the fade clickable
.vector-toc-collapsed & {
display: none;
}
}
}
// Collapse ToC sections by default, excluding no-js

View file

@ -1,6 +1,8 @@
@import '../../common/variables.less';
@selector-collapsed-toc-closed: ~'#vector-toc-collapsed-checkbox:not( :checked )';
@height-collapsed-toc-button: 36px;
@padding-top-content-px: unit( @padding-top-content * @font-size-browser, px );
@selector-collapsed-toc-open: ~'#vector-toc-collapsed-checkbox:checked';
#vector-toc-collapsed-button {
display: none;
@ -24,42 +26,6 @@
z-index: @z-index-menu;
}
.mixin-collapse-toc-page-title {
#vector-toc-collapsed-button {
display: block;
}
.mw-table-of-contents-container {
position: relative;
.vector-layout-legacy & {
// !important needed to override rules in screen.less
top: 0 !important; /* stylelint-disable-line declaration-no-important */
}
}
.sidebar-toc {
position: absolute;
top: 44px;
left: -2px;
// !important needed to override rules in Sidebar.less
margin: 0 !important; /* stylelint-disable-line declaration-no-important */
// Dropdown styles
border: @border-width-base @border-style-base @border-color-base;
// Remove TOC fade
&:after {
display: none;
}
}
@{selector-collapsed-toc-closed} ~ .mw-table-of-contents-container .sidebar-toc {
// Hide the TOC when the button is not checked
display: none;
}
}
// Override button styles for the "move to sidebar/hide" links. Default hide.
.vector-toc-collapse-button,
.vector-toc-uncollapse-button {
@ -85,42 +51,90 @@
}
}
// Applies when TOC is collapsed in it's original DOM location
// Doesn't apply to the collapsed TOC in the sticky header
.mixin-toc-collapsed-unmoved() {
#vector-toc-collapsed-button {
display: block;
}
.mw-table-of-contents-container {
position: relative;
.vector-layout-legacy & {
// !important needed to override rules in screen.less
top: 0 !important; /* stylelint-disable-line declaration-no-important */
}
}
@{sidebar-toc-selector} {
display: none;
position: absolute;
margin: 0;
// FIXME: Collapsed TOC styles are not consistent with other vector dropdowns
border: @border-width-base @border-style-base @border-color-base;
}
@{selector-collapsed-toc-open} ~ @{sidebar-toc-selector} {
// Hide the TOC when the button is not checked
display: block;
}
}
.mixin-toc-collapsed-floating() {
#vector-toc-collapsed-button,
.sidebar-toc {
position: fixed;
}
#vector-toc-collapsed-button {
top: 0;
left: 0;
margin: 0;
}
.sidebar-toc {
top: @height-collapsed-toc-button; // TOC button height
left: 6px;
}
}
@media ( max-width: @max-width-tablet ) {
.mixin-collapse-toc-page-title();
// Collapsed to page title on narrow screens
.mixin-toc-collapsed-unmoved();
@{sidebar-toc-selector} {
top: ~'calc(@{height-collapsed-toc-button} + @{padding-top-content-px})'; // 44px
left: -4px;
}
// Collapsed to floating icon on narrow screens when below page
.vector-below-page-title {
#vector-toc-collapsed-button,
.sidebar-toc {
position: fixed;
}
#vector-toc-collapsed-button {
top: 0;
left: 0;
margin: 0;
}
.sidebar-toc {
// TOC button height
top: 36px;
left: 6px;
}
.mixin-toc-collapsed-floating();
}
}
@media ( min-width: @min-width-desktop ) {
@supports ( display: grid ) {
.client-js {
// Collapsed to page title
.vector-toc-collapsed .vector-layout-grid {
.mixin-collapse-toc-page-title();
.mixin-toc-collapsed-unmoved();
.mw-table-of-contents-container {
grid-area: content;
}
.sidebar-toc {
@{sidebar-toc-selector} {
top: ~'calc(@{height-collapsed-toc-button} + @{padding-top-content-px})'; // 44px
left: -@icon-padding-md;
}
// Collapsed to floating icon
// when sticky header not visible and below page title
body:not( .vector-sticky-header-visible ).vector-below-page-title& {
.mixin-toc-collapsed-floating();
}
}
.vector-toc-collapsed {

View file

@ -11,6 +11,16 @@ exports[`Sticky header renders 1`] = `
</button>
</div>
<div> </div> <div class=\\"vector-sticky-header-context-bar\\">
<div class=\\"vector-sticky-header-toc-container\\">
<div id=\\"p-sticky-header-toc\\" class=\\"vector-menu mw-portlet mw-portlet-sticky-header-toc vector-sticky-header-toc\\">
<div class=\\"vector-menu-content\\">
<ul class=\\"vector-menu-content-list\\"></ul>
</div>
</div>
</div>
<div class=\\"vector-sticky-header-context-bar-primary\\"></div>
</div>
</div>

View file

@ -1,3 +1,11 @@
// @ts-ignore
window.matchMedia = window.matchMedia || function () {
return {
matches: false,
onchange: () => {}
};
};
const { test } = require( '../../../resources/skins.vector.es6/main.js' );
const {
STICKY_HEADER_EXPERIMENT_NAME,

View file

@ -2,6 +2,7 @@ const mustache = require( 'mustache' );
const fs = require( 'fs' );
const stickyHeaderTemplate = fs.readFileSync( 'includes/templates/StickyHeader.mustache', 'utf8' );
const buttonTemplate = fs.readFileSync( 'includes/templates/Button.mustache', 'utf8' );
const menuTemplate = fs.readFileSync( 'includes/templates/Menu.mustache', 'utf8' );
const sticky = require( '../../resources/skins.vector.es6/stickyHeader.js' );
const { userLinksHTML } = require( './userLinksData.js' );
@ -58,6 +59,14 @@ const editButtonsTemplateData = [ {
} ];
const templateData = {
'data-sticky-header-toc': {
id: 'p-sticky-header-toc',
class: 'mw-portlet mw-portlet-sticky-header-toc vector-sticky-header-toc',
'html-items': '',
'html-vector-menu-checkbox-attributes': 'tabindex="-1"',
'html-vector-menu-heading-attributes': 'tabindex="-1"',
'heading-class': 'mw-ui-button mw-ui-quiet mw-ui-icon mw-ui-icon-element mw-ui-icon-wikimedia-listBullet'
},
'data-primary-action': {
id: 'p-lang-btn-sticky-header',
class: 'mw-interlanguage-selector',
@ -81,6 +90,7 @@ const templateData = {
const renderedHTML = mustache.render( stickyHeaderTemplate, templateData, {
Button: buttonTemplate,
Menu: menuTemplate,
SearchBox: '<div> </div>' // ignore SearchBox for this test
} );
@ -104,4 +114,32 @@ describe( 'sticky header', () => {
expect( newMenu.querySelectorAll( '.user-links-collapsible-item' ).length ).toBe( 0 );
expect( newMenu.querySelectorAll( '.mw-list-item-js' ).length ).toBe( 0 );
} );
describe( 'moveToc', () => {
const sidebarTocContainerClass = 'mw-table-of-contents-container';
const stickyHeaderTocContainerClass = 'vector-menu-content';
const tocId = 'mw-panel-toc';
function setupToc() {
const sidebarTocContainer = document.createElement( 'div' );
sidebarTocContainer.classList.add( sidebarTocContainerClass );
const toc = document.createElement( 'div' );
toc.setAttribute( 'id', tocId );
sidebarTocContainer.appendChild( toc );
document.body.appendChild( sidebarTocContainer );
}
test( 'moves toc to stickyheader and sidebar', () => {
setupToc();
const toc = /** @type {Element} */ ( document.getElementById( tocId ) );
expect( /** @type {Element} */
( toc.parentNode ).classList.contains( sidebarTocContainerClass ) ).toBeTruthy();
sticky.moveToc( 'stickyheader' );
expect( /** @type {Element} */
( toc.parentNode ).classList.contains( stickyHeaderTocContainerClass ) ).toBeTruthy();
sticky.moveToc( 'sidebar' );
expect( /** @type {Element} */
( toc.parentNode ).classList.contains( sidebarTocContainerClass ) ).toBeTruthy();
} );
} );
} );

View file

@ -11,6 +11,7 @@ let /** @type {HTMLElement} */ fooSection,
/** @type {HTMLElement} */ quuxSection;
const onHeadingClick = jest.fn();
const onToggleClick = jest.fn();
const onToggleCollapse = jest.fn();
/**
* @param {Object} templateProps
@ -80,7 +81,8 @@ function mount( templateProps = {} ) {
const toc = initTableOfContents( {
container: /** @type {HTMLElement} */ ( document.getElementById( 'mw-panel-toc' ) ),
onHeadingClick,
onToggleClick
onToggleClick,
onToggleCollapse
} );
fooSection = /** @type {HTMLElement} */ ( document.getElementById( 'toc-foo' ) );