Dynamically expand/collapse sub-sections in ToC based on # of headings

Server render the table of contents in a collapsed state when the total
number of headings is equal or greater than the value of
`$wgVectorTableOfContentsCollapseAtCount`. Otherwise, the table of
contents will be server rendered in its "expanded" state.

In addition:

* Revise table of contents tests to call one `assertion` per element so
  that it is easier to see the exact element that may fail an assertion.
* Revise table of contents tests to call a mount function that can merge
  props to allow for a more flexible set of tests.
* Revise table of contents tests by wrapping a `describe` around tests
  that expect the same prop state.
* Adds typedef for table of sections props

Bug: T300973
Depends-On: Ifaee451e1903f2accd0ada2f2ed6dfa3f83037b6
Change-Id: I382200bc603b6abf757a91f14a8a55a6581969bd
This commit is contained in:
Nicholas Ray 2022-02-17 17:19:50 -07:00
parent 8dde172451
commit 6e9506dcad
7 changed files with 346 additions and 125 deletions

View File

@ -639,6 +639,7 @@ class SkinVector extends SkinMustache {
Constants::FEATURE_STICKY_HEADER_EDIT
)
) : false,
'data-toc' => $this->getTocData( $parentData['data-toc'] ?? [] )
] );
if ( !$this->isTableOfContentsVisibleInSidebar() ) {
@ -696,6 +697,25 @@ class SkinVector extends SkinMustache {
return $commonSkinData;
}
/**
* Annotates table of contents data with Vector-specific information.
*
* @param array $tocData
* @return array
*/
private function getTocData( array $tocData ): array {
if ( empty( $tocData ) ) {
return [];
}
return array_merge( $tocData, [
'vector-is-collapse-sections-enabled' =>
$tocData[ 'number-section-count'] >= $this->getConfig()->get(
'VectorTableOfContentsCollapseAtCount'
)
] );
}
/**
* Annotates search box with Vector-specific information
*

View File

@ -1,4 +1,4 @@
<li id="toc-{{anchor}}" class="sidebar-toc-list-item sidebar-toc-level-{{toclevel}}">
<li id="toc-{{anchor}}" class="sidebar-toc-list-item sidebar-toc-level-{{toclevel}}{{^vector-is-collapse-sections-enabled}} sidebar-toc-list-item-expanded{{/vector-is-collapse-sections-enabled}}">
<a class="sidebar-toc-link" href="#{{anchor}}">
<div class="sidebar-toc-text">
<span class="sidebar-toc-numb">{{number}}</span>{{{line}}}</div>

View File

@ -6,32 +6,32 @@ const LINK_CLASS = 'sidebar-toc-link';
const TOGGLE_CLASS = 'sidebar-toc-toggle';
/**
* Called when a list item is clicked.
*
* @callback onHeadingClick
* @param {string} id The id of the clicked list item.
*/
/**
* Called when an arrow is clicked.
*
* @callback onToggleClick
* @param {string} id The id of the list item corresponding to the arrow.
*/
/**
* @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.
*/
/**
* Initializes the sidebar's Table of Contents.
*
* @param {Object} props
* @param {HTMLElement} props.container
* @param {onHeadingClick} props.onHeadingClick
* @param {onToggleClick} props.onToggleClick
* @param {TableOfContentsProps} props
* @return {TableOfContents}
*/
module.exports = function tableOfContents( props ) {
let /** @type {HTMLElement | undefined} */ activeTopSection;
let /** @type {HTMLElement | undefined} */ activeSubSection;
let /** @type {Array<HTMLElement>} */ expandedSections = [];
let /** @type {Array<HTMLElement>} */ expandedSections;
/**
* @typedef {Object} activeSectionIds
@ -212,7 +212,20 @@ module.exports = function tableOfContents( props ) {
} );
}
bindClickListener();
/**
* Binds event listeners and sets the default state of the component.
*/
function initialize() {
// Sync component state to the default rendered state of the table of contents.
expandedSections = Array.from(
props.container.querySelectorAll( `.${EXPANDED_SECTION_CLASS}` )
);
// Bind event listeners.
bindClickListener();
}
initialize();
/**
* @typedef {Object} TableOfContents

View File

@ -496,6 +496,10 @@
"default": false
},
"description": "@var When `VectorTableOfContents` is enabled, the sticky table of contents is shown."
},
"VectorTableOfContentsCollapseAtCount": {
"value": 20,
"description": "@var When `VectorTableOfContents` is enabled, the minimum number of headings required to collapse all headings in the sticky table of contents by default."
}
},
"ServiceWiringFiles": [

View File

@ -1,6 +1,49 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`Table of contents renders 1`] = `
exports[`Table of contents when \`vector-is-collapse-sections-enabled\` is false renders 1`] = `
"<nav id=\\"mw-panel-toc\\" class=\\"sidebar-toc\\" role=\\"navigation\\" aria-labelledby=\\"sidebar-toc-header\\">
<div class=\\"sidebar-toc-header\\">
<h2 class=\\"sidebar-toc-title\\" aria-hidden=\\"true\\">Contents</h2>
</div>
<ul id=\\"table-of-contents\\">
<li id=\\"toc-foo\\" class=\\"sidebar-toc-list-item sidebar-toc-level-1 sidebar-toc-list-item-expanded\\">
<a class=\\"sidebar-toc-link\\" href=\\"#foo\\">
<div class=\\"sidebar-toc-text\\">
<span class=\\"sidebar-toc-numb\\">1</span>foo</div>
</a>
<ul class=\\"sidebar-toc-list\\"></ul>
<button class=\\"mw-ui-icon mw-ui-icon-wikimedia-downTriangle mw-ui-icon-small sidebar-toc-toggle\\"></button>
</li>
<li id=\\"toc-bar\\" class=\\"sidebar-toc-list-item sidebar-toc-level-1 sidebar-toc-list-item-expanded\\">
<a class=\\"sidebar-toc-link\\" href=\\"#bar\\">
<div class=\\"sidebar-toc-text\\">
<span class=\\"sidebar-toc-numb\\">2</span>bar</div>
</a>
<ul class=\\"sidebar-toc-list\\"><li id=\\"toc-baz\\" class=\\"sidebar-toc-list-item sidebar-toc-level-2\\">
<a class=\\"sidebar-toc-link\\" href=\\"#baz\\">
<div class=\\"sidebar-toc-text\\">
<span class=\\"sidebar-toc-numb\\">2.1</span>baz</div>
</a>
<ul class=\\"sidebar-toc-list\\">
</ul>
</li>
</ul>
<button class=\\"mw-ui-icon mw-ui-icon-wikimedia-downTriangle mw-ui-icon-small sidebar-toc-toggle\\"></button>
</li>
<li id=\\"toc-qux\\" class=\\"sidebar-toc-list-item sidebar-toc-level-1 sidebar-toc-list-item-expanded\\">
<a class=\\"sidebar-toc-link\\" href=\\"#qux\\">
<div class=\\"sidebar-toc-text\\">
<span class=\\"sidebar-toc-numb\\">3</span>qux</div>
</a>
<ul class=\\"sidebar-toc-list\\"></ul>
<button class=\\"mw-ui-icon mw-ui-icon-wikimedia-downTriangle mw-ui-icon-small sidebar-toc-toggle\\"></button>
</li>
</ul>
</nav>
"
`;
exports[`Table of contents when \`vector-is-collapse-sections-enabled\` is true renders 1`] = `
"<nav id=\\"mw-panel-toc\\" class=\\"sidebar-toc\\" role=\\"navigation\\" aria-labelledby=\\"sidebar-toc-header\\">
<div class=\\"sidebar-toc-header\\">
<h2 class=\\"sidebar-toc-title\\" aria-hidden=\\"true\\">Contents</h2>

View File

@ -6,139 +6,181 @@ const tableOfContentsTopSectionTemplate = fs.readFileSync( 'includes/templates/T
const tableOfContentsLineTemplate = fs.readFileSync( 'includes/templates/TableOfContents__line.mustache', 'utf8' );
const initTableOfContents = require( '../../resources/skins.vector.es6/tableOfContents.js' );
let toc, fooSection, barSection, bazSection, quxSection;
let fooSection, barSection, bazSection, quxSection;
const onHeadingClick = jest.fn();
const onToggleClick = jest.fn();
const templateData = {
'array-sections': [ {
toclevel: 1,
number: '1',
line: 'foo',
anchor: 'foo',
'array-sections': null
}, {
toclevel: 1,
number: '2',
line: 'bar',
anchor: 'bar',
/**
* @param {Object} templateProps
* @return {string}
*/
function render( templateProps = {} ) {
const templateData = Object.assign( {
'vector-is-collapse-sections-enabled': false,
'array-sections': [ {
toclevel: 2,
number: '2.1',
line: 'baz',
anchor: 'baz',
toclevel: 1,
number: '1',
line: 'foo',
anchor: 'foo',
'array-sections': null
}, {
toclevel: 1,
number: '2',
line: 'bar',
anchor: 'bar',
'array-sections': [ {
toclevel: 2,
number: '2.1',
line: 'baz',
anchor: 'baz',
'array-sections': null
} ]
}, {
toclevel: 1,
number: '3',
line: 'qux',
anchor: 'qux',
'array-sections': null
} ]
}, {
toclevel: 1,
number: '3',
line: 'qux',
anchor: 'qux',
'array-sections': null
} ]
};
}, templateProps );
/* eslint-disable camelcase */
const renderedHTML = mustache.render( tableOfContentsTemplate, templateData, {
TableOfContents__topSection: tableOfContentsTopSectionTemplate,
TableOfContents__line: tableOfContentsLineTemplate
} );
/* eslint-enable camelcase */
/* eslint-disable camelcase */
return mustache.render( tableOfContentsTemplate, templateData, {
TableOfContents__topSection: tableOfContentsTopSectionTemplate,
TableOfContents__line: tableOfContentsLineTemplate
} );
/* eslint-enable camelcase */
}
beforeEach( () => {
document.body.innerHTML = renderedHTML;
toc = initTableOfContents( {
/**
* @param {Object} templateProps
* @return {initTableOfContents.TableOfContents}
*/
function mount( templateProps = {} ) {
document.body.innerHTML = render( templateProps );
const toc = initTableOfContents( {
container: /** @type {HTMLElement} */ document.getElementById( 'mw-panel-toc' ),
onHeadingClick,
onToggleClick
} );
fooSection = /** @type {HTMLElement} */ document.getElementById( 'toc-foo' );
barSection = /** @type {HTMLElement} */ document.getElementById( 'toc-bar' );
bazSection = /** @type {HTMLElement} */ document.getElementById( 'toc-baz' );
quxSection = /** @type {HTMLElement} */ document.getElementById( 'toc-qux' );
} );
test( 'Table of contents renders', () => {
expect( document.body.innerHTML ).toMatchSnapshot();
} );
return toc;
}
test( 'Table of contents changes the active sections', () => {
toc.changeActiveSection( 'toc-foo' );
expect(
fooSection.classList.contains( toc.ACTIVE_SECTION_CLASS ) &&
!barSection.classList.contains( toc.ACTIVE_SECTION_CLASS ) &&
!bazSection.classList.contains( toc.ACTIVE_SECTION_CLASS ) &&
!quxSection.classList.contains( toc.ACTIVE_SECTION_CLASS )
).toEqual( true );
describe( 'Table of contents', () => {
describe( 'binds event listeners', () => {
test( 'for onHeadingClick', () => {
const toc = mount();
const heading = document.querySelector( `#toc-foo .${toc.LINK_CLASS}` );
heading.click();
toc.changeActiveSection( 'toc-bar' );
expect(
!fooSection.classList.contains( toc.ACTIVE_SECTION_CLASS ) &&
barSection.classList.contains( toc.ACTIVE_SECTION_CLASS ) &&
!bazSection.classList.contains( toc.ACTIVE_SECTION_CLASS ) &&
!quxSection.classList.contains( toc.ACTIVE_SECTION_CLASS )
).toEqual( true );
expect( onToggleClick ).not.toBeCalled();
expect( onHeadingClick ).toBeCalled();
} );
test( 'for onToggleClick', () => {
const toc = mount();
const toggle = document.querySelector( `#toc-bar .${toc.TOGGLE_CLASS}` );
toggle.click();
toc.changeActiveSection( 'toc-baz' );
expect(
!fooSection.classList.contains( toc.ACTIVE_SECTION_CLASS ) &&
barSection.classList.contains( toc.ACTIVE_SECTION_CLASS ) &&
bazSection.classList.contains( toc.ACTIVE_SECTION_CLASS ) &&
!quxSection.classList.contains( toc.ACTIVE_SECTION_CLASS )
).toEqual( true );
toc.changeActiveSection( 'toc-qux' );
expect(
!fooSection.classList.contains( toc.ACTIVE_SECTION_CLASS ) &&
!barSection.classList.contains( toc.ACTIVE_SECTION_CLASS ) &&
!bazSection.classList.contains( toc.ACTIVE_SECTION_CLASS ) &&
quxSection.classList.contains( toc.ACTIVE_SECTION_CLASS )
).toEqual( true );
} );
test( 'Table of contents expands sections', () => {
toc.expandSection( 'toc-foo' );
expect(
fooSection.classList.contains( toc.EXPANDED_SECTION_CLASS ) &&
!barSection.classList.contains( toc.EXPANDED_SECTION_CLASS ) &&
!bazSection.classList.contains( toc.EXPANDED_SECTION_CLASS ) &&
!quxSection.classList.contains( toc.EXPANDED_SECTION_CLASS )
).toEqual( true );
toc.expandSection( 'toc-bar' );
expect(
fooSection.classList.contains( toc.EXPANDED_SECTION_CLASS ) &&
barSection.classList.contains( toc.EXPANDED_SECTION_CLASS ) &&
!bazSection.classList.contains( toc.EXPANDED_SECTION_CLASS ) &&
!quxSection.classList.contains( toc.EXPANDED_SECTION_CLASS )
).toEqual( true );
} );
test( 'Table of contents toggles expanded sections', () => {
toc.toggleExpandSection( 'toc-foo' );
expect(
fooSection.classList.contains( toc.EXPANDED_SECTION_CLASS )
).toEqual( true );
toc.toggleExpandSection( 'toc-foo' );
expect(
fooSection.classList.contains( toc.EXPANDED_SECTION_CLASS )
).toEqual( false );
} );
describe( 'Table of contents binds event listeners', () => {
test( 'for onHeadingClick', () => {
const heading = document.querySelector( `#toc-foo .${toc.LINK_CLASS}` );
heading.click();
expect( onToggleClick ).not.toBeCalled();
expect( onHeadingClick ).toBeCalled();
expect( onHeadingClick ).not.toBeCalled();
expect( onToggleClick ).toBeCalled();
} );
} );
test( 'for onToggleClick', () => {
const toggle = document.querySelector( `#toc-bar .${toc.TOGGLE_CLASS}` );
toggle.click();
expect( onHeadingClick ).not.toBeCalled();
expect( onToggleClick ).toBeCalled();
describe( 'when changing sections', () => {
test( 'applies correct class', () => {
const toc = mount( { 'vector-is-collapse-sections-enabled': true } );
toc.changeActiveSection( 'toc-foo' );
expect( fooSection.classList.contains( toc.ACTIVE_SECTION_CLASS ) ).toEqual( true );
expect( barSection.classList.contains( toc.ACTIVE_SECTION_CLASS ) ).toEqual( false );
expect( bazSection.classList.contains( toc.ACTIVE_SECTION_CLASS ) ).toEqual( false );
expect( quxSection.classList.contains( toc.ACTIVE_SECTION_CLASS ) ).toEqual( false );
toc.changeActiveSection( 'toc-bar' );
expect( fooSection.classList.contains( toc.ACTIVE_SECTION_CLASS ) ).toEqual( false );
expect( barSection.classList.contains( toc.ACTIVE_SECTION_CLASS ) ).toEqual( true );
expect( bazSection.classList.contains( toc.ACTIVE_SECTION_CLASS ) ).toEqual( false );
expect( quxSection.classList.contains( toc.ACTIVE_SECTION_CLASS ) ).toEqual( false );
toc.changeActiveSection( 'toc-baz' );
expect( fooSection.classList.contains( toc.ACTIVE_SECTION_CLASS ) ).toEqual( false );
expect( barSection.classList.contains( toc.ACTIVE_SECTION_CLASS ) ).toEqual( true );
expect( bazSection.classList.contains( toc.ACTIVE_SECTION_CLASS ) ).toEqual( true );
expect( quxSection.classList.contains( toc.ACTIVE_SECTION_CLASS ) ).toEqual( false );
toc.changeActiveSection( 'toc-qux' );
expect( fooSection.classList.contains( toc.ACTIVE_SECTION_CLASS ) ).toEqual( false );
expect( barSection.classList.contains( toc.ACTIVE_SECTION_CLASS ) ).toEqual( false );
expect( bazSection.classList.contains( toc.ACTIVE_SECTION_CLASS ) ).toEqual( false );
expect( quxSection.classList.contains( toc.ACTIVE_SECTION_CLASS ) ).toEqual( true );
} );
} );
describe( 'when `vector-is-collapse-sections-enabled` is false', () => {
test( 'renders', () => {
mount();
expect( document.body.innerHTML ).toMatchSnapshot();
} );
test( 'expands sections', () => {
const toc = mount();
toc.expandSection( 'toc-foo' );
expect( fooSection.classList.contains( toc.EXPANDED_SECTION_CLASS ) ).toEqual( true );
expect( barSection.classList.contains( toc.EXPANDED_SECTION_CLASS ) ).toEqual( true );
expect( bazSection.classList.contains( toc.EXPANDED_SECTION_CLASS ) ).toEqual( false );
expect( quxSection.classList.contains( toc.EXPANDED_SECTION_CLASS ) ).toEqual( true );
toc.expandSection( 'toc-bar' );
expect( fooSection.classList.contains( toc.EXPANDED_SECTION_CLASS ) ).toEqual( true );
expect( barSection.classList.contains( toc.EXPANDED_SECTION_CLASS ) ).toEqual( true );
expect( bazSection.classList.contains( toc.EXPANDED_SECTION_CLASS ) ).toEqual( false );
expect( quxSection.classList.contains( toc.EXPANDED_SECTION_CLASS ) ).toEqual( true );
} );
test( 'toggles expanded sections', () => {
const toc = mount();
toc.toggleExpandSection( 'toc-foo' );
expect( fooSection.classList.contains( toc.EXPANDED_SECTION_CLASS ) ).toEqual( false );
toc.toggleExpandSection( 'toc-foo' );
expect( fooSection.classList.contains( toc.EXPANDED_SECTION_CLASS ) ).toEqual( true );
} );
} );
describe( 'when `vector-is-collapse-sections-enabled` is true', () => {
test( 'renders', () => {
mount( { 'vector-is-collapse-sections-enabled': true } );
expect( document.body.innerHTML ).toMatchSnapshot();
} );
test( 'expands sections', () => {
const toc = mount( { 'vector-is-collapse-sections-enabled': true } );
toc.expandSection( 'toc-foo' );
expect( fooSection.classList.contains( toc.EXPANDED_SECTION_CLASS ) ).toEqual( true );
expect( barSection.classList.contains( toc.EXPANDED_SECTION_CLASS ) ).toEqual( false );
expect( bazSection.classList.contains( toc.EXPANDED_SECTION_CLASS ) ).toEqual( false );
expect( quxSection.classList.contains( toc.EXPANDED_SECTION_CLASS ) ).toEqual( false );
toc.expandSection( 'toc-bar' );
expect( fooSection.classList.contains( toc.EXPANDED_SECTION_CLASS ) ).toEqual( true );
expect( barSection.classList.contains( toc.EXPANDED_SECTION_CLASS ) ).toEqual( true );
expect( bazSection.classList.contains( toc.EXPANDED_SECTION_CLASS ) ).toEqual( false );
expect( quxSection.classList.contains( toc.EXPANDED_SECTION_CLASS ) ).toEqual( false );
} );
test( 'toggles expanded sections', () => {
const toc = mount( { 'vector-is-collapse-sections-enabled': true } );
toc.toggleExpandSection( 'toc-foo' );
expect( fooSection.classList.contains( toc.EXPANDED_SECTION_CLASS ) ).toEqual( true );
toc.toggleExpandSection( 'toc-foo' );
expect( fooSection.classList.contains( toc.EXPANDED_SECTION_CLASS ) ).toEqual( false );
} );
} );
} );

View File

@ -18,7 +18,7 @@ use Wikimedia\TestingAccessWrapper;
class SkinVectorTest extends MediaWikiIntegrationTestCase {
/**
* @return \VectorTemplate
* @return \SkinVector
*/
private function provideVectorTemplateObject() {
$template = new SkinVector( [ 'name' => 'vector' ] );
@ -44,6 +44,105 @@ class SkinVectorTest extends MediaWikiIntegrationTestCase {
return in_array( $search, $values );
}
public function provideGetTocData() {
$tocData = [
'number-section-count' => 2,
'array-sections' => [
[
'toclevel' => 1,
'level' => '2',
'line' => 'A',
'number' => '1',
'index' => '1',
'fromtitle' => 'Test',
'byteoffset' => 231,
'anchor' => 'A',
'array-sections' => [
[
'toclevel' => 2,
'level' => '4',
'line' => 'A1',
'number' => '1.1',
'index' => '2',
'fromtitle' => 'Test',
'byteoffset' => 245,
'anchor' => 'A1'
]
]
],
]
];
return [
// When zero sections
[
// $tocData
[],
// wgVectorTableOfContentsCollapseAtCount
1,
// expected 'vector-is-collapse-sections-enabled' value
false
],
// When number of multiple sections is lower than configured value
[
// $tocData
$tocData,
// wgVectorTableOfContentsCollapseAtCount
3,
// expected 'vector-is-collapse-sections-enabled' value
false
],
// When number of multiple sections is equal to the configured value
[
// $tocData
$tocData,
// wgVectorTableOfContentsCollapseAtCount
2,
// expected 'vector-is-collapse-sections-enabled' value
true
],
// When number of multiple sections is higher than configured value
[
// $tocData
$tocData,
// wgVectorTableOfContentsCollapseAtCount
1,
// expected 'vector-is-collapse-sections-enabled' value
true
],
];
}
/**
* @covers ::getTocData
* @dataProvider provideGetTOCData
*/
public function testGetTocData(
array $tocData,
int $configValue,
bool $expected
) {
$this->setMwGlobals( [
'wgVectorTableOfContentsCollapseAtCount' => $configValue
] );
$skinVector = new SkinVector( [ 'name' => 'vector-2022' ] );
$openSkinVector = TestingAccessWrapper::newFromObject( $skinVector );
$data = $openSkinVector->getTocData( $tocData );
if ( empty( $tocData ) ) {
$this->assertEquals( [], $data, 'toc data is empty when given an empty array' );
return;
}
$this->assertArrayHasKey( 'vector-is-collapse-sections-enabled', $data );
$this->assertEquals(
$expected,
$data['vector-is-collapse-sections-enabled'],
'vector-is-collapse-sections-enabled has correct value'
);
$this->assertArrayHasKey( 'array-sections', $data );
}
/**
* @covers ::getTemplateData
*/