diff --git a/.gitignore b/.gitignore
index 15876fa47fee8..83fe09a5f87c0 100644
--- a/.gitignore
+++ b/.gitignore
@@ -15,6 +15,7 @@ wp-tests-config.php
/tests/phpunit/data/plugins/wordpress-importer
/tests/phpunit/data/.trac-ticket-cache*
/tests/qunit/compiled.html
+/tests/qunit/common-compiled.html
/tests/performance/**/*.test.results.json
/src/.wp-tests-version
/node_modules
diff --git a/Gruntfile.js b/Gruntfile.js
index c5547010974ed..7bb15a17d82d8 100644
--- a/Gruntfile.js
+++ b/Gruntfile.js
@@ -300,7 +300,7 @@ module.exports = function(grunt) {
cwd: WORKING_DIR,
src: []
},
- qunit: ['tests/qunit/compiled.html'],
+ qunit: [ 'tests/qunit/compiled.html', 'tests/qunit/common-compiled.html' ],
// This is only meant to run within a numbered branch after branching has occurred.
workflows: {
@@ -660,8 +660,10 @@ module.exports = function(grunt) {
files: {}
},
qunit: {
- src: 'tests/qunit/index.html',
- dest: 'tests/qunit/compiled.html',
+ files: {
+ 'tests/qunit/compiled.html': 'tests/qunit/index.html',
+ 'tests/qunit/common-compiled.html': 'tests/qunit/common.html',
+ },
options: {
processContent: function( src ) {
return src.replace( /(\".+?\/)build(\/.+?)(?:.min)?(.js\")/g , function( match, $1, $2, $3 ) {
diff --git a/src/js/_enqueues/admin/common.js b/src/js/_enqueues/admin/common.js
index fe83cc7e9f2ae..8abb3dce71be3 100644
--- a/src/js/_enqueues/admin/common.js
+++ b/src/js/_enqueues/admin/common.js
@@ -2235,6 +2235,118 @@ $( function( $ ) {
}( jQuery, window ));
+/**
+ * Manages overflow indicators and keyboard access for list tables.
+ *
+ * @since 7.2.0
+ */
+jQuery( function() {
+ const content = document.getElementById( 'wpbody-content' );
+ if (
+ ! content ||
+ ! content.querySelector( '.wp-list-table-scroll' ) ||
+ ! window.ResizeObserver
+ ) {
+ return;
+ }
+
+ const tables = new Map();
+
+ /**
+ * Tracks a table's scroll position and dimensions.
+ *
+ * @since 7.2.0
+ *
+ * @param {HTMLTableElement} table The table inside a scroll wrapper.
+ *
+ * @return {void}
+ */
+ function observeTableOverflow( table ) {
+ const wrapper = table.parentElement;
+ const tabIndex = wrapper.getAttribute( 'tabindex' );
+
+ /**
+ * Updates the overflow indicators and the wrapper's place in the Tab order.
+ *
+ * @since 7.2.0
+ *
+ * @return {void}
+ */
+ function update() {
+ const style = window.getComputedStyle( wrapper );
+ const isRTL = style.direction === 'rtl';
+ const scrollLeft = isRTL ? -wrapper.scrollLeft : wrapper.scrollLeft;
+ const maxScroll = Math.max( 0, wrapper.scrollWidth - wrapper.clientWidth );
+ const position = Math.min( maxScroll, Math.max( 0, scrollLeft ) );
+
+ // Skip non-scrolling wrappers without moving focus if the wrapper is already focused.
+ wrapper.tabIndex = maxScroll > 0 &&
+ ( style.overflowX === 'auto' || style.overflowX === 'scroll' ) ? 0 : -1;
+
+ // Allow for fractional scroll positions at either end of the table.
+ wrapper.classList.toggle( 'has-scroll-overflow-start', position > 1 );
+ wrapper.classList.toggle( 'has-scroll-overflow-end', maxScroll - position > 1 );
+ }
+
+ const observer = new window.ResizeObserver( update );
+ observer.observe( wrapper );
+ observer.observe( table );
+ wrapper.addEventListener( 'scroll', update, { passive: true } );
+ update();
+
+ tables.set( table, {
+ wrapper: wrapper,
+
+ /**
+ * Releases overflow tracking and restores the wrapper's original Tab order.
+ *
+ * @since 7.2.0
+ *
+ * @return {void}
+ */
+ cleanup: function() {
+ observer.disconnect();
+ wrapper.removeEventListener( 'scroll', update );
+ wrapper.classList.remove( 'has-scroll-overflow-start', 'has-scroll-overflow-end' );
+ if ( tabIndex === null ) {
+ wrapper.removeAttribute( 'tabindex' );
+ } else {
+ wrapper.setAttribute( 'tabindex', tabIndex );
+ }
+ },
+ } );
+ }
+
+ /**
+ * Initializes added tables and releases observers for removed tables.
+ *
+ * @since 7.2.0
+ *
+ * @return {void}
+ */
+ function refreshTables() {
+ tables.forEach( function( state, table ) {
+ if ( ! content.contains( table ) || table.parentElement !== state.wrapper ) {
+ state.cleanup();
+ tables.delete( table );
+ }
+ } );
+
+ content.querySelectorAll( '.wp-list-table-scroll > .wp-list-table' )
+ .forEach( function( table ) {
+ if ( ! tables.has( table ) ) {
+ observeTableOverflow( table );
+ }
+ } );
+ }
+
+ refreshTables();
+
+ // Plugins live search replaces the table and its wrapper.
+ const observer = new window.MutationObserver( refreshTables );
+ observer.observe( content, { childList: true, subtree: true } );
+} );
+
/**
* Freeze animated plugin icons when reduced motion is enabled.
*
diff --git a/src/wp-admin/css/list-tables.css b/src/wp-admin/css/list-tables.css
index f6105ad5a59fb..daceb823e2606 100644
--- a/src/wp-admin/css/list-tables.css
+++ b/src/wp-admin/css/list-tables.css
@@ -1789,9 +1789,147 @@ div.action-links,
width: 80px;
}
+.wp-list-table-scroll:focus-visible {
+ outline: 2px solid var(--wp-admin-theme-color);
+ outline-offset: 0;
+}
+
/* =Media Queries
-------------------------------------------------------------- */
+@media screen and (min-width: 783px) {
+
+ /* Share the table's scrollable area so the fades stick to the viewport. */
+ .wp-list-table-scroll {
+ display: grid;
+ clear: both;
+ overflow-x: auto;
+ overflow-y: hidden;
+ overscroll-behavior-x: none;
+ border: 1px solid #c3c4c7;
+ box-shadow: 0 1px 1px rgba(0, 0, 0, 0.04);
+ }
+
+ .wp-list-table-scroll > .widefat {
+ border: 0;
+ box-shadow: none;
+ }
+
+ /* Keep sorting links' focus indicators inside the scrolling viewport. */
+ .wp-list-table-scroll th.sortable a:focus,
+ .wp-list-table-scroll th.sorted a:focus {
+ box-shadow: inset 0 0 0 var(--wp-admin-border-width-focus, 1.5px) var(--wp-admin-theme-color, #3858e9);
+ outline-offset: -2px;
+ }
+
+ .wp-list-table-scroll > .fixed {
+ table-layout: auto;
+ }
+
+ :where(.wp-list-table-scroll) > .fixed .column-author,
+ :where(.wp-list-table-scroll) > .fixed .column-categories,
+ :where(.wp-list-table-scroll) > .fixed .column-tags,
+ :where(.wp-list-table-scroll) > .fixed .column-date,
+ :where(.wp-list-table-scroll) > .fixed .column-response,
+ :where(.wp-list-table-scroll) > .fixed .column-role,
+ :where(.wp-list-table-scroll) > .fixed .column-slug,
+ :where(.wp-list-table-scroll) > .fixed .column-locations {
+ width: auto;
+ }
+
+ /* Long words, URLs, and filenames must not set the table's intrinsic width. */
+ .wp-list-table-scroll > .fixed .column-primary,
+ .wp-list-table-scroll > .fixed .column-primary *,
+ .wp-list-table-scroll > .fixed .column-author,
+ .wp-list-table-scroll > .fixed .column-author *,
+ .wp-list-table-scroll > .fixed .column-categories,
+ .wp-list-table-scroll > .fixed .column-categories *,
+ .wp-list-table-scroll > .fixed .column-tags,
+ .wp-list-table-scroll > .fixed .column-tags *,
+ .wp-list-table-scroll > .fixed .column-response,
+ .wp-list-table-scroll > .fixed .column-response *,
+ .wp-list-table-scroll > .fixed .column-role,
+ .wp-list-table-scroll > .fixed .column-role *,
+ .wp-list-table-scroll > .fixed .column-slug,
+ .wp-list-table-scroll > .fixed .column-slug *,
+ .wp-list-table-scroll > .fixed .column-locations,
+ .wp-list-table-scroll > .fixed .column-locations * {
+ overflow-wrap: anywhere;
+ }
+
+ .wp-list-table-scroll > .fixed th.column-author,
+ .wp-list-table-scroll > .fixed th.column-categories,
+ .wp-list-table-scroll > .fixed th.column-tags,
+ .wp-list-table-scroll > .fixed th.column-response,
+ .wp-list-table-scroll > .fixed th.column-role,
+ .wp-list-table-scroll > .fixed th.column-slug,
+ .wp-list-table-scroll > .fixed th.column-locations {
+ min-width: 10em;
+ }
+
+ .wp-list-table-scroll > .fixed th.column-comments {
+ min-width: 5.5em;
+ }
+
+ .wp-list-table-scroll > .fixed th.column-date {
+ min-width: 12em;
+ }
+
+ .wp-list-table-scroll > .fixed th.column-primary {
+ min-width: 20em;
+ }
+
+ .wp-list-table-scroll > .widefat,
+ .wp-list-table-scroll::before,
+ .wp-list-table-scroll::after {
+ grid-area: 1 / 1;
+ }
+
+ .wp-list-table-scroll::before,
+ .wp-list-table-scroll::after {
+ content: "";
+ position: sticky;
+ z-index: 1;
+ width: 32px;
+ pointer-events: none;
+ opacity: 0;
+ }
+
+ .wp-list-table-scroll::before {
+ inset-inline-start: 0;
+ justify-self: start;
+
+ /* rtl:ignore */
+ background: linear-gradient(to right, #fff, rgba(255, 255, 255, 0));
+ }
+
+ .wp-list-table-scroll::after {
+ inset-inline-end: 0;
+ justify-self: end;
+
+ /* rtl:ignore */
+ background: linear-gradient(to left, #fff, rgba(255, 255, 255, 0));
+ }
+
+ .wp-list-table-scroll.has-scroll-overflow-start::before,
+ .wp-list-table-scroll.has-scroll-overflow-end::after {
+ opacity: 1;
+ }
+
+ .rtl .wp-list-table-scroll::before,
+ .rtl .wp-list-table-scroll::after {
+ transform: scaleX(-1);
+ }
+}
+
+@media screen and (min-width: 783px) and (prefers-reduced-motion: no-preference) {
+
+ .wp-list-table-scroll::before,
+ .wp-list-table-scroll::after {
+ transition: opacity 100ms cubic-bezier(0.15, 0, 0.15, 1);
+ }
+}
+
@media screen and (max-width: 1100px) and (min-width: 782px), (max-width: 480px) {
.plugin-card .action-links {
position: static;
diff --git a/src/wp-admin/includes/class-wp-comments-list-table.php b/src/wp-admin/includes/class-wp-comments-list-table.php
index 2b927a7f81a6a..9c4ca5653b1e3 100644
--- a/src/wp-admin/includes/class-wp-comments-list-table.php
+++ b/src/wp-admin/includes/class-wp-comments-list-table.php
@@ -596,6 +596,7 @@ protected function get_default_primary_column_name() {
* Overrides the parent display() method to render extra comments.
*
* @since 3.1.0
+ * @since 7.2.0 Added a scrollable wrapper around the table.
*/
public function display() {
wp_nonce_field( 'fetch-list-' . get_class( $this ), '_ajax_fetch_list_nonce' );
@@ -610,50 +611,53 @@ public function display() {
}
$this->screen->render_screen_reader_content( 'heading_list' );
+ $table_label = $this->screen->get_screen_reader_text( 'heading_list' );
?>
-
- ' .
- /* translators: Hidden accessibility text. */
- __( 'Ordered by Comment Date, descending.' ) .
- '';
- } else {
- $this->print_table_description();
- }
- ?>
-
-
- print_column_headers(); ?>
-
-
+
+
display_tablenav( 'bottom' );
diff --git a/src/wp-admin/includes/class-wp-list-table.php b/src/wp-admin/includes/class-wp-list-table.php
index 2792b4b3ec3d0..da7168578aa24 100644
--- a/src/wp-admin/includes/class-wp-list-table.php
+++ b/src/wp-admin/includes/class-wp-list-table.php
@@ -1622,6 +1622,7 @@ public function print_table_description() {
* Displays the table.
*
* @since 3.1.0
+ * @since 7.2.0 Added a scrollable wrapper around the table.
*/
public function display() {
$singular = $this->_args['singular'];
@@ -1629,32 +1630,35 @@ public function display() {
$this->display_tablenav( 'top' );
$this->screen->render_screen_reader_content( 'heading_list' );
+ $table_label = $this->screen->get_screen_reader_text( 'heading_list' );
?>
-
- print_table_description(); ?>
-
-
- print_column_headers(); ?>
-
-
-
-
- >
- display_rows_or_placeholder(); ?>
-
-
-
-
- print_column_headers( false ); ?>
-
-
-
-
+
display_tablenav( 'bottom' );
}
diff --git a/tests/qunit/common.html b/tests/qunit/common.html
new file mode 100644
index 0000000000000..622b3a73e1021
--- /dev/null
+++ b/tests/qunit/common.html
@@ -0,0 +1,58 @@
+
+
+
+ WordPress Common Admin QUnit Test Suite
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/tests/qunit/wp-admin/js/common.js b/tests/qunit/wp-admin/js/common.js
new file mode 100644
index 0000000000000..7c58d9307b743
--- /dev/null
+++ b/tests/qunit/wp-admin/js/common.js
@@ -0,0 +1,433 @@
+/**
+ * Tests list-table overflow indicators, keyboard access, and observer cleanup.
+ *
+ * @since 7.2.0
+ */
+
+/* global sinon */
+jQuery( function() {
+ const content = document.getElementById( 'wpbody-content' );
+ const tableMarkup =
+ '';
+ const wrapperMarkup = '' + tableMarkup + '
';
+
+ /**
+ * Gets the overflow indicator state at each edge of a wrapper.
+ *
+ * @since 7.2.0
+ *
+ * @param {HTMLElement} wrapper The list-table scroll wrapper.
+ *
+ * @return {boolean[]} The start and end overflow states, in that order.
+ */
+ function overflow( wrapper ) {
+ return [
+ wrapper.classList.contains( 'has-scroll-overflow-start' ),
+ wrapper.classList.contains( 'has-scroll-overflow-end' ),
+ ];
+ }
+
+ /**
+ * Sets a wrapper's scroll position and dispatches a scroll event.
+ *
+ * @since 7.2.0
+ *
+ * @param {HTMLElement} wrapper The list-table scroll wrapper.
+ * @param {number} position The horizontal scroll offset, in pixels.
+ *
+ * @return {void}
+ */
+ function scrollTo( wrapper, position ) {
+ wrapper.scrollLeft = position;
+ wrapper.dispatchEvent( new Event( 'scroll' ) );
+ }
+
+ /**
+ * Waits for resize observers to receive layout changes.
+ *
+ * @since 7.2.0
+ *
+ * @return {Promise} Resolves with the second animation frame's timestamp.
+ */
+ function afterResize() {
+ // ResizeObserver delivers changes after animation callbacks, before the next frame.
+ return new Promise( function( resolve ) {
+ window.requestAnimationFrame( function() {
+ window.requestAnimationFrame( resolve );
+ } );
+ } );
+ }
+
+ const initialOverflow = overflow( content.firstElementChild );
+
+ QUnit.module( 'List table overflow', {
+ beforeEach: async function() {
+ this.sandbox = sinon.createSandbox();
+ content.innerHTML = wrapperMarkup;
+ this.wrapper = content.firstElementChild;
+ this.table = this.wrapper.firstElementChild;
+ await afterResize();
+ },
+ afterEach: async function() {
+ content.replaceChildren();
+
+ // Allow the mutation observer to clean up before restoring spies.
+ await Promise.resolve();
+ this.sandbox.restore();
+ },
+ } );
+
+ QUnit.test( 'Initializes overflow indicators for tables present on page load', function( assert ) {
+ assert.deepEqual(
+ initialOverflow,
+ [ false, true ],
+ 'Only the end has hidden content on page load.'
+ );
+ assert.strictEqual(
+ this.wrapper.childElementCount,
+ 1,
+ 'Initialization leaves only the table inside its wrapper.'
+ );
+ } );
+
+ [ 'ltr', 'rtl' ].forEach( function( direction ) {
+ QUnit.test(
+ 'Updates both overflow indicators when scrolling in ' + direction,
+ function( assert ) {
+ const wrapper = this.wrapper;
+ const sign = direction === 'rtl' ? -1 : 1;
+ wrapper.dir = direction;
+
+ scrollTo( wrapper, 0 );
+ assert.deepEqual(
+ overflow( wrapper ),
+ [ false, true ],
+ 'Only the end has hidden content at the start.'
+ );
+
+ scrollTo( wrapper, sign * 200 );
+ assert.deepEqual(
+ overflow( wrapper ),
+ [ true, true ],
+ 'Both ends have hidden content in the middle.'
+ );
+
+ scrollTo( wrapper, sign * 400 );
+ assert.deepEqual(
+ overflow( wrapper ),
+ [ true, false ],
+ 'Only the start has hidden content at the end.'
+ );
+
+ scrollTo( wrapper, 0 );
+ assert.deepEqual(
+ overflow( wrapper ),
+ [ false, true ],
+ 'Returning to the start clears its overflow indicator.'
+ );
+ }
+ );
+
+ QUnit.test(
+ 'Handles fractional positions and elastic overscroll in ' + direction,
+ function( assert ) {
+ const wrapper = this.wrapper;
+ const sign = direction === 'rtl' ? -1 : 1;
+ wrapper.dir = direction;
+
+ // Supply positions that not all browsers expose at the default zoom level.
+ Object.defineProperty( wrapper, 'scrollLeft', {
+ configurable: true,
+ writable: true,
+ value: 0,
+ } );
+
+ scrollTo( wrapper, sign * 0.5 );
+ assert.deepEqual(
+ overflow( wrapper ),
+ [ false, true ],
+ 'A fractional offset at the start does not show an overflow indicator.'
+ );
+
+ scrollTo( wrapper, sign * 1.5 );
+ assert.deepEqual(
+ overflow( wrapper ),
+ [ true, true ],
+ 'Content beyond the tolerance shows the start overflow indicator.'
+ );
+
+ scrollTo( wrapper, sign * 398.5 );
+ assert.deepEqual(
+ overflow( wrapper ),
+ [ true, true ],
+ 'Content beyond the tolerance shows the end overflow indicator.'
+ );
+
+ scrollTo( wrapper, sign * 399.5 );
+ assert.deepEqual(
+ overflow( wrapper ),
+ [ true, false ],
+ 'A fractional offset at the end does not show an overflow indicator.'
+ );
+
+ scrollTo( wrapper, sign * -20 );
+ assert.deepEqual(
+ overflow( wrapper ),
+ [ false, true ],
+ 'Overscrolling past the start does not show a start overflow indicator.'
+ );
+
+ scrollTo( wrapper, sign * 420 );
+ assert.deepEqual(
+ overflow( wrapper ),
+ [ true, false ],
+ 'Overscrolling past the end does not show an end overflow indicator.'
+ );
+
+ this.table.style.width = '600px';
+ wrapper.dispatchEvent( new Event( 'scroll' ) );
+ assert.deepEqual(
+ overflow( wrapper ),
+ [ false, false ],
+ 'A fitting table has no overflow indicators even with a stale scroll offset.'
+ );
+ }
+ );
+ } );
+
+ QUnit.test(
+ 'Updates overflow indicators when the table or its viewport changes width',
+ async function( assert ) {
+ assert.strictEqual( this.wrapper.tabIndex, 0, 'A scrolling wrapper is in the Tab order.' );
+
+ this.table.style.width = '600px';
+ await afterResize();
+ assert.deepEqual(
+ overflow( this.wrapper ),
+ [ false, false ],
+ 'Shrinking the table clears the overflow indicator without a scroll event.'
+ );
+ assert.strictEqual( this.wrapper.tabIndex, -1, 'A fitting table does not add a Tab stop.' );
+
+ this.wrapper.style.width = '400px';
+ await afterResize();
+ assert.deepEqual(
+ overflow( this.wrapper ),
+ [ false, true ],
+ 'Shrinking the viewport reveals overflow without a scroll event.'
+ );
+ assert.strictEqual(
+ this.wrapper.tabIndex,
+ 0,
+ 'The wrapper returns to the Tab order when scrolling is needed.'
+ );
+
+ this.wrapper.style.width = '800px';
+ await afterResize();
+ assert.deepEqual(
+ overflow( this.wrapper ),
+ [ false, false ],
+ 'Widening the viewport clears the overflow indicator.'
+ );
+ assert.strictEqual(
+ this.wrapper.tabIndex,
+ -1,
+ 'The wrapper leaves the Tab order when the table fits again.'
+ );
+ }
+ );
+
+ QUnit.test( 'Preserves focus when a focused wrapper stops overflowing', async function( assert ) {
+ this.wrapper.focus();
+ assert.strictEqual(
+ document.activeElement,
+ this.wrapper,
+ 'The overflowing wrapper can receive focus.'
+ );
+
+ this.table.style.width = '600px';
+ await afterResize();
+ assert.strictEqual(
+ this.wrapper.tabIndex,
+ -1,
+ 'The fitting wrapper is removed from the Tab order.'
+ );
+ assert.strictEqual(
+ document.activeElement,
+ this.wrapper,
+ 'Removing overflow does not move focus.'
+ );
+
+ const link = document.createElement( 'a' );
+ link.href = '#';
+ link.textContent = 'Edit';
+ this.table.querySelector( 'td' ).appendChild( link );
+ link.focus();
+ assert.strictEqual( link.tabIndex, 0, 'Links inside a fitting wrapper remain in the Tab order.' );
+ assert.strictEqual(
+ document.activeElement,
+ link,
+ 'Focus can move to a link inside the fitting wrapper.'
+ );
+ } );
+
+ QUnit.test(
+ 'Skips wrappers with visible overflow, as in the mobile layout',
+ async function( assert ) {
+ this.wrapper.style.overflowX = 'visible';
+ this.wrapper.style.width = '400px';
+ await afterResize();
+ assert.ok(
+ this.wrapper.scrollWidth > this.wrapper.clientWidth,
+ 'The table extends beyond its wrapper.'
+ );
+ assert.strictEqual(
+ this.wrapper.tabIndex,
+ -1,
+ 'A wrapper that does not provide scrolling is not a Tab stop.'
+ );
+
+ this.wrapper.style.overflowX = 'auto';
+ this.wrapper.style.width = '800px';
+ await afterResize();
+ assert.strictEqual(
+ this.wrapper.tabIndex,
+ 0,
+ 'The wrapper returns to the Tab order when its scrolling layout returns.'
+ );
+ }
+ );
+
+ QUnit.test(
+ 'Cleans up a replaced wrapper and initializes its replacement',
+ async function( assert ) {
+ const oldWrapper = this.wrapper;
+ const disconnect = this.sandbox.spy( window.ResizeObserver.prototype, 'disconnect' );
+ scrollTo( oldWrapper, 200 );
+
+ content.innerHTML = wrapperMarkup;
+
+ // Preserve overflow outside the observed content to expose a leaked scroll listener.
+ document.getElementById( 'qunit-fixture' ).appendChild( oldWrapper );
+ await afterResize();
+ assert.deepEqual(
+ overflow( content.firstElementChild ),
+ [ false, true ],
+ 'The replacement is initialized.'
+ );
+ assert.deepEqual(
+ overflow( oldWrapper ),
+ [ false, false ],
+ 'The old wrapper loses its overflow indicators.'
+ );
+ assert.strictEqual( disconnect.callCount, 1, 'The old resize observer is disconnected.' );
+
+ scrollTo( oldWrapper, 200 );
+ assert.deepEqual(
+ overflow( oldWrapper ),
+ [ false, false ],
+ 'Scrolling the removed wrapper no longer updates overflow indicators.'
+ );
+
+ scrollTo( content.firstElementChild, 200 );
+ assert.deepEqual(
+ overflow( content.firstElementChild ),
+ [ true, true ],
+ 'The replacement responds to scrolling.'
+ );
+ }
+ );
+
+ QUnit.test(
+ 'Tracks a replacement table without duplicating observers for other mutations',
+ async function( assert ) {
+ const observe = this.sandbox.spy( window.ResizeObserver.prototype, 'observe' );
+ const disconnect = this.sandbox.spy( window.ResizeObserver.prototype, 'disconnect' );
+
+ this.table.querySelector( 'td' ).textContent = 'Updated post title';
+ await Promise.resolve();
+ assert.notOk( observe.called, 'Changing a row does not add another resize observer.' );
+ assert.notOk( disconnect.called, 'Changing a row retains the existing observer.' );
+
+ this.wrapper.innerHTML = tableMarkup;
+ await afterResize();
+ assert.strictEqual(
+ disconnect.callCount,
+ 1,
+ 'Replacing the table disconnects its old observer.'
+ );
+ assert.strictEqual(
+ observe.callCount,
+ 2,
+ 'The new observer watches the table and its wrapper.'
+ );
+
+ this.wrapper.firstElementChild.style.width = '600px';
+ await afterResize();
+ assert.deepEqual(
+ overflow( this.wrapper ),
+ [ false, false ],
+ 'Resizing the replacement table updates its overflow indicators.'
+ );
+ }
+ );
+
+ [ null, '0' ].forEach( function( tabIndex ) {
+ QUnit.test(
+ 'Restores the original tabindex attribute on cleanup: ' + tabIndex,
+ async function( assert ) {
+ const wrapper = document.createElement( 'div' );
+ wrapper.className = 'wp-list-table-scroll';
+ if ( tabIndex !== null ) {
+ wrapper.setAttribute( 'tabindex', tabIndex );
+ }
+ wrapper.innerHTML = tableMarkup;
+ wrapper.firstElementChild.style.width = '600px';
+ content.appendChild( wrapper );
+ await afterResize();
+ assert.strictEqual(
+ wrapper.tabIndex,
+ -1,
+ 'The fitting wrapper is removed from the Tab order.'
+ );
+
+ wrapper.remove();
+ await Promise.resolve();
+ assert.strictEqual(
+ wrapper.getAttribute( 'tabindex' ),
+ tabIndex,
+ 'Cleanup restores the original attribute.'
+ );
+ }
+ );
+ } );
+
+ QUnit.test(
+ 'Moves overflow tracking when a table moves to another wrapper',
+ async function( assert ) {
+ const oldWrapper = this.wrapper;
+ const newWrapper = document.createElement( 'div' );
+ newWrapper.className = 'wp-list-table-scroll';
+ scrollTo( oldWrapper, 200 );
+
+ content.appendChild( newWrapper );
+ newWrapper.appendChild( this.table );
+ await afterResize();
+ assert.deepEqual(
+ overflow( oldWrapper ),
+ [ false, false ],
+ 'The old wrapper loses its overflow indicators.'
+ );
+ assert.deepEqual( overflow( newWrapper ), [ false, true ], 'The new wrapper is initialized.' );
+
+ scrollTo( newWrapper, 200 );
+ assert.deepEqual(
+ overflow( newWrapper ),
+ [ true, true ],
+ 'The new wrapper responds to scrolling.'
+ );
+ }
+ );
+
+ QUnit.start();
+} );