Merge "Add hash fragment support to table of contents"

This commit is contained in:
jenkins-bot 2023-03-09 19:46:39 +00:00 committed by Gerrit Code Review
commit e47d30a119
5 changed files with 257 additions and 60 deletions

View file

@ -168,42 +168,40 @@ const setupTableOfContents = ( tocElement, bodyContent, initSectionObserverFn )
return null;
}
const handleTocSectionChange = () => {
// eslint-disable-next-line no-use-before-define
sectionObserver.pause();
// T297614: We want the link that the user has clicked inside the TOC or the
// section that corresponds to the hashchange event to be "active" (e.g.
// bolded) regardless of whether the browser's scroll position corresponds
// to that section. Therefore, we need to temporarily ignore section
// observer until the browser has finished scrolling to the section (if
// needed).
//
// However, because the scroll event happens asynchronously after the user
// clicks on a link and may not even happen at all (e.g. the user has
// scrolled all the way to the bottom and clicks a section that is already
// in the viewport), determining when we should resume section observer is a
// bit tricky.
//
// Because a scroll event may not even be triggered after clicking the link,
// we instead allow the browser to perform a maximum number of repaints
// before resuming sectionObserver. Per T297614#7687656, Firefox 97.0 wasn't
// consistently activating the table of contents section that the user
// clicked even after waiting 2 frames. After further investigation, it
// sometimes waits up to 3 frames before painting the new scroll position so
// we have that as the limit.
deferUntilFrame( () => {
// eslint-disable-next-line no-use-before-define
sectionObserver.resume();
}, 3 );
};
const tableOfContents = initTableOfContents( {
container: tocElement,
onHeadingClick: ( id ) => {
// eslint-disable-next-line no-use-before-define
sectionObserver.pause();
tableOfContents.expandSection( id );
tableOfContents.changeActiveSection( id );
// T297614: We want the link that the user has clicked inside the TOC to
// be "active" (e.g. bolded) regardless of whether the browser's scroll
// position corresponds to that section. Therefore, we need to temporarily
// ignore section observer until the browser has finished scrolling to the
// section (if needed).
//
// However, because the scroll event happens asyncronously after the user
// clicks on a link and may not even happen at all (e.g. the user has
// scrolled all the way to the bottom and clicks a section that is already
// in the viewport), determining when we should resume section observer is
// a bit tricky.
//
// Because a scroll event may not even be triggered after clicking the
// link, we instead allow the browser to perform a maximum number of
// repaints before resuming sectionObserver. Per T297614#7687656, Firefox
// 97.0 wasn't consistently activating the table of contents section that
// the user clicked even after waiting 2 frames. After further
// investigation, it sometimes waits up to 3 frames before painting the
// new scroll position so we have that as the limit.
//
// eslint-disable-next-line no-use-before-define
deferUntilFrame( () => sectionObserver.resume(), 3 );
},
onToggleClick: ( id ) => {
tableOfContents.toggleExpandSection( id );
},
onHeadingClick: handleTocSectionChange,
onHashChange: handleTocSectionChange,
onTogglePinned: () => {
updateTocLocation();
pinnableElement.setFocusAfterToggle( TOC_ID );
@ -234,6 +232,46 @@ const setupTableOfContents = ( tocElement, bodyContent, initSectionObserverFn )
} );
} );
mw.hook( 've.deactivationComplete' ).add( updateElements );
const setInitialActiveSection = () => {
const hash = location.hash.slice( 1 );
// If hash fragment is blank, determine the active section with section
// observer.
if ( hash === '' ) {
sectionObserver.calcIntersection();
return;
}
// T325086: If hash fragment is present and corresponds to a toc section,
// expand the section.
// @ts-ignore
const hashSection = /** @type {HTMLElement|null} */ ( mw.util.getTargetFromFragment( `${TOC_SECTION_ID_PREFIX}${hash}` ) );
if ( hashSection ) {
tableOfContents.expandSection( hashSection.id );
}
// T325086: If hash fragment corresponds to a section AND the user is at
// bottom of page, activate the section. Otherwise, use section observer to
// calculate the active section.
//
// Note that even if a hash fragment is present, it's possible for the
// browser to scroll to a position that is different from the position of
// the section that corresponds to the hash fragment. This can happen when
// the browser remembers a prior scroll position after refreshing the page,
// for example.
if (
hashSection &&
Math.round( window.innerHeight + window.scrollY ) >= document.body.scrollHeight
) {
tableOfContents.changeActiveSection( hashSection.id );
} else {
// Fallback to section observer's calculation for the active section.
sectionObserver.calcIntersection();
}
};
setInitialActiveSection();
return tableOfContents;
};

View file

@ -50,9 +50,9 @@ module.exports = function sectionObserver( props ) {
onIntersection: () => {}
}, props );
let /** @type {boolean} */ inThrottle = false;
let /** @type {number | undefined} */ timeoutId;
let /** @type {HTMLElement | undefined} */ current;
// eslint-disable-next-line compat/compat
const observer = new IntersectionObserver( ( entries ) => {
let /** @type {IntersectionObserverEntry | undefined} */ closestNegativeEntry;
let /** @type {IntersectionObserverEntry | undefined} */ closestPositiveEntry;
@ -105,6 +105,9 @@ module.exports = function sectionObserver( props ) {
observer.disconnect();
} );
/**
* Calculate the intersection of each observed element.
*/
function calcIntersection() {
// IntersectionObserver will asynchronously calculate the boundingClientRect
// of each observed element off the main thread after `observe` is called.
@ -120,12 +123,10 @@ module.exports = function sectionObserver( props ) {
function handleScroll() {
// Throttle the scroll event handler to fire at a rate limited by `props.throttleMs`.
if ( !inThrottle ) {
inThrottle = true;
setTimeout( () => {
if ( !timeoutId ) {
timeoutId = window.setTimeout( () => {
calcIntersection();
inThrottle = false;
timeoutId = undefined;
}, props.throttleMs );
}
}
@ -143,6 +144,8 @@ module.exports = function sectionObserver( props ) {
*/
function pause() {
unbindScrollListener();
clearTimeout( timeoutId );
timeoutId = undefined;
// Assume current is no longer valid while paused.
current = undefined;
}
@ -173,17 +176,17 @@ module.exports = function sectionObserver( props ) {
}
bindScrollListener();
// Calculate intersection on page load.
calcIntersection();
/**
* @typedef {Object} SectionObserver
* @property {calcIntersection} calcIntersection
* @property {pause} pause
* @property {resume} resume
* @property {unmount} unmount
* @property {setElements} setElements
*/
return {
calcIntersection,
pause,
resume,
unmount,

View file

@ -9,7 +9,9 @@ const templateTocLine = require( /** @type {string} */ ( './templates/TableOfCon
* TableOfContents Config object for filling mustache templates
*/
const tableOfContentsConfig = require( /** @type {string} */ ( './tableOfContentsConfig.json' ) );
const deferUntilFrame = require( './deferUntilFrame.js' );
const SECTION_ID_PREFIX = 'toc-';
const SECTION_CLASS = 'vector-toc-list-item';
const ACTIVE_SECTION_CLASS = 'vector-toc-list-item-active';
const EXPANDED_SECTION_CLASS = 'vector-toc-list-item-expanded';
@ -20,10 +22,23 @@ const TOGGLE_CLASS = 'vector-toc-toggle';
const TOC_CONTENTS_ID = 'mw-panel-toc-list';
/**
* Fired when the user clicks a toc link. Note that this callback takes
* precedence over the onHashChange callback. The onHashChange callback will not
* be called when the user clicks a toc link.
*
* @callback onHeadingClick
* @param {string} id The id of the clicked list item.
*/
/**
* Fired when the page's hash fragment has changed. Note that if the user clicks
* a link inside the TOC, the `onHeadingClick` callback will fire instead of the
* `onHashChange` callback to avoid redundant behavior.
*
* @callback onHashChange
* @param {string} id The id of the list item that corresponds to the hash change event.
*/
/**
* @callback onToggleClick
* @param {string} id The id of the list item corresponding to the arrow.
@ -43,7 +58,9 @@ const TOC_CONTENTS_ID = 'mw-panel-toc-list';
* @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 {onHashChange} onHashChange Called when a hash change event
* matches the id of a LINK_CLASS anchor element.
* @property {onToggleClick} [onToggleClick] Called when an arrow is clicked.
* @property {onTogglePinned} onTogglePinned Called when pinned toggle buttons are clicked.
*/
@ -235,7 +252,7 @@ module.exports = function tableOfContents( props ) {
}
const topSection = /** @type {HTMLElement} */ ( tocSection.closest( `.${TOP_SECTION_CLASS}` ) );
const toggle = tocSection.querySelector( `.${TOGGLE_CLASS}` );
const toggle = topSection.querySelector( `.${TOGGLE_CLASS}` );
if ( topSection && toggle && expandedSections.indexOf( topSection ) < 0 ) {
toggle.setAttribute( 'aria-expanded', 'true' );
@ -328,6 +345,44 @@ module.exports = function tableOfContents( props ) {
} );
}
/**
* Event handler for hash change event.
*/
function handleHashChange() {
const hash = location.hash.slice( 1 );
const listItem =
// @ts-ignore
/** @type {HTMLElement|null} */ ( mw.util.getTargetFromFragment( `${SECTION_ID_PREFIX}${hash}` ) );
if ( !listItem ) {
return;
}
expandSection( listItem.id );
changeActiveSection( listItem.id );
props.onHashChange( listItem.id );
}
/**
* Bind event listener for hash change events that match the hash of
* LINK_CLASS.
*
* Note that if the user clicks a link inside the TOC, the onHeadingClick
* callback will fire instead of the onHashChange callback, since it takes
* precedence.
*/
function bindHashChangeListener() {
window.addEventListener( 'hashchange', handleHashChange );
}
/**
* Unbinds event listener for hash change events.
*/
function unbindHashChangeListener() {
window.removeEventListener( 'hashchange', handleHashChange );
}
/**
* Bind event listener for clicking on show/hide Table of Contents links.
*/
@ -358,12 +413,27 @@ module.exports = function tableOfContents( props ) {
// In case section link contains HTML,
// test if click occurs on any child elements.
if ( e.target.closest( `.${LINK_CLASS}` ) ) {
// Temporarily unbind the hash change listener to avoid redundant
// behavior caused by firing both the onHeadingClick callback and the
// onHashChange callback. Instead, only fire the onHeadingClick
// callback.
unbindHashChangeListener();
expandSection( tocSection.id );
changeActiveSection( tocSection.id );
props.onHeadingClick( tocSection.id );
deferUntilFrame( () => {
bindHashChangeListener();
}, 3 );
}
// Toggle button does not contain child elements,
// so classList check will suffice.
if ( e.target.classList.contains( TOGGLE_CLASS ) ) {
props.onToggleClick( tocSection.id );
toggleExpandSection( tocSection.id );
if ( props.onToggleClick ) {
props.onToggleClick( tocSection.id );
}
}
}
@ -385,6 +455,7 @@ module.exports = function tableOfContents( props ) {
// Bind event listeners.
bindSubsectionToggleListeners();
bindPinnedToggleListeners();
bindHashChangeListener();
}
/**
@ -541,6 +612,17 @@ module.exports = function tableOfContents( props ) {
return data;
}
/**
* Cleans up the hash change event listener to prevent memory leaks. This
* should be called when the table of contents is permanently no longer
* needed.
*
* @ignore
*/
function unmount() {
unbindHashChangeListener();
}
initialize();
/**
@ -550,6 +632,7 @@ module.exports = function tableOfContents( props ) {
* @property {expandSection} expandSection
* @property {toggleExpandSection} toggleExpandSection
* @property {updateTocToggleStyles} updateTocToggleStyles
* @property {unmount} unmount
* @property {string} ACTIVE_SECTION_CLASS
* @property {string} ACTIVE_TOP_SECTION_CLASS
* @property {string} EXPANDED_SECTION_CLASS
@ -562,6 +645,7 @@ module.exports = function tableOfContents( props ) {
changeActiveSection,
toggleExpandSection,
updateTocToggleStyles,
unmount,
ACTIVE_SECTION_CLASS,
ACTIVE_TOP_SECTION_CLASS,
EXPANDED_SECTION_CLASS,

View file

@ -159,7 +159,8 @@ const sectionObserverFn = () => ( {
resume: () => {},
mount: () => {},
unmount: () => {},
setElements: () => {}
setElements: () => {},
calcIntersection: () => {}
} );
describe( 'Table of contents re-rendering', () => {

View file

@ -15,6 +15,7 @@ let /** @type {HTMLElement} */ container,
/** @type {HTMLElement} */ quxSection,
/** @type {HTMLElement} */ quuxSection;
const onHeadingClick = jest.fn();
const onHashChange = jest.fn();
const onToggleClick = jest.fn();
const onTogglePinned = jest.fn();
@ -110,30 +111,46 @@ function mount( templateProps = {} ) {
return initTableOfContents( {
container,
onHeadingClick,
onHashChange,
onToggleClick,
onTogglePinned
} );
}
describe( 'Table of contents', () => {
/**
* @type {module:TableOfContents~TableOfContents}
*/
let toc;
beforeEach( () => {
// @ts-ignore
global.window.matchMedia = jest.fn( () => ( {} ) );
} );
afterEach( () => {
if ( toc ) {
toc.unmount();
toc = undefined;
}
// @ts-ignore
mw.util.getTargetFromFragment = undefined;
} );
describe( 'renders', () => {
test( 'when `vector-is-collapse-sections-enabled` is false', () => {
const toc = mount();
toc = mount();
expect( document.body.innerHTML ).toMatchSnapshot();
expect( barSection.classList.contains( toc.EXPANDED_SECTION_CLASS ) ).toEqual( true );
} );
test( 'when `vector-is-collapse-sections-enabled` is true', () => {
const toc = mount( { 'vector-is-collapse-sections-enabled': true } );
toc = mount( { 'vector-is-collapse-sections-enabled': true } );
expect( document.body.innerHTML ).toMatchSnapshot();
expect( barSection.classList.contains( toc.EXPANDED_SECTION_CLASS ) ).toEqual( false );
} );
test( 'toggles for top level parent sections', () => {
const toc = mount();
toc = mount();
expect( fooSection.getElementsByClassName( toc.TOGGLE_CLASS ).length ).toEqual( 0 );
expect( barSection.getElementsByClassName( toc.TOGGLE_CLASS ).length ).toEqual( 1 );
expect( bazSection.getElementsByClassName( toc.TOGGLE_CLASS ).length ).toEqual( 0 );
@ -144,26 +161,46 @@ describe( 'Table of contents', () => {
describe( 'binds event listeners', () => {
test( 'for onHeadingClick', () => {
const toc = mount();
toc = mount();
const heading = /** @type {HTMLElement} */ ( document.querySelector( `#toc-foo .${toc.LINK_CLASS}` ) );
heading.click();
expect( onToggleClick ).not.toBeCalled();
expect( onHashChange ).not.toBeCalled();
expect( onHeadingClick ).toBeCalled();
} );
test( 'for onToggleClick', () => {
const toc = mount();
toc = mount();
const toggle = /** @type {HTMLElement} */ ( document.querySelector( `#toc-bar .${toc.TOGGLE_CLASS}` ) );
toggle.click();
expect( onHeadingClick ).not.toBeCalled();
expect( onHashChange ).not.toBeCalled();
expect( onToggleClick ).toBeCalled();
} );
test( 'for onHashChange', () => {
// @ts-ignore
mw.util.getTargetFromFragment = jest.fn().mockImplementation( ( hash ) => {
return hash === 'toc-foo' ? fooSection : null;
} );
mount();
// Jest doesn't trigger a hashchange event when setting a hash location.
location.hash = 'foo';
window.dispatchEvent( new HashChangeEvent( 'hashchange', {
oldURL: 'http://example.com',
newURL: 'http://example.com#foo'
} ) );
expect( onHeadingClick ).not.toBeCalled();
expect( onToggleClick ).not.toBeCalled();
expect( onHashChange ).toBeCalled();
} );
} );
describe( 'applies correct classes', () => {
test( 'when changing active sections', () => {
const toc = mount( { 'vector-is-collapse-sections-enabled': true } );
toc = mount( { 'vector-is-collapse-sections-enabled': true } );
let activeSections;
let activeTopSections;
@ -190,13 +227,13 @@ describe( 'Table of contents', () => {
} );
test( 'when expanding sections', () => {
const toc = mount();
toc = mount();
toc.expandSection( 'toc-bar' );
expect( barSection.classList.contains( toc.EXPANDED_SECTION_CLASS ) ).toEqual( true );
} );
test( 'when toggling sections', () => {
const toc = mount();
toc = mount();
toc.toggleExpandSection( 'toc-bar' );
expect( barSection.classList.contains( toc.EXPANDED_SECTION_CLASS ) ).toEqual( false );
toc.toggleExpandSection( 'toc-bar' );
@ -206,14 +243,14 @@ describe( 'Table of contents', () => {
describe( 'applies the correct aria attributes', () => {
test( 'when initialized', () => {
const toc = mount();
toc = mount();
const toggleButton = /** @type {HTMLElement} */ ( barSection.querySelector( `.${toc.TOGGLE_CLASS}` ) );
expect( toggleButton.getAttribute( 'aria-expanded' ) ).toEqual( 'true' );
} );
test( 'when expanding sections', () => {
const toc = mount();
toc = mount();
const toggleButton = /** @type {HTMLElement} */ ( barSection.querySelector( `.${toc.TOGGLE_CLASS}` ) );
toc.expandSection( 'toc-bar' );
@ -221,7 +258,7 @@ describe( 'Table of contents', () => {
} );
test( 'when toggling sections', () => {
const toc = mount();
toc = mount();
const toggleButton = /** @type {HTMLElement} */ ( barSection.querySelector( `.${toc.TOGGLE_CLASS}` ) );
toc.toggleExpandSection( 'toc-bar' );
@ -232,9 +269,43 @@ describe( 'Table of contents', () => {
} );
} );
describe( 'when the hash fragment changes', () => {
test( 'expands and activates corresponding section', () => {
// @ts-ignore
mw.util.getTargetFromFragment = jest.fn().mockImplementation( ( hash ) => {
return hash === 'toc-qux' ? quxSection : null;
} );
toc = mount( { 'vector-is-collapse-sections-enabled': true } );
expect(
quxSection.classList.contains( toc.ACTIVE_SECTION_CLASS )
).toEqual( false );
// Jest doesn't trigger a hashchange event when setting a hash location.
location.hash = 'qux';
window.dispatchEvent( new HashChangeEvent( 'hashchange', {
oldURL: 'http://example.com',
newURL: 'http://example.com#qux'
} ) );
const activeSections = container.querySelectorAll( `.${toc.ACTIVE_SECTION_CLASS}` );
const activeTopSections = container.querySelectorAll( `.${toc.ACTIVE_TOP_SECTION_CLASS}` );
expect( activeSections.length ).toEqual( 1 );
expect( activeTopSections.length ).toEqual( 1 );
expect(
barSection.classList.contains( toc.ACTIVE_TOP_SECTION_CLASS )
).toEqual( true );
expect(
barSection.classList.contains( toc.EXPANDED_SECTION_CLASS )
).toEqual( true );
expect(
quxSection.classList.contains( toc.ACTIVE_SECTION_CLASS )
).toEqual( true );
} );
} );
describe( 'reloadTableOfContents', () => {
test( 're-renders toc when wikipage.tableOfContents hook is fired with empty sections', async () => {
const toc = mount();
toc = mount();
await toc.reloadTableOfContents( [] );
expect( document.body.innerHTML ).toMatchSnapshot();
@ -281,7 +352,7 @@ describe( 'Table of contents', () => {
};
} );
const toc = mount();
toc = mount();
const toggleButton = /** @type {HTMLElement} */ ( barSection.querySelector( `.${toc.TOGGLE_CLASS}` ) );
// Collapse section.