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(); ?> - - +
+
+ ' . + /* translators: Hidden accessibility text. */ + __( 'Ordered by Comment Date, descending.' ) . + ''; + } else { + $this->print_table_description(); + } + ?> + + + print_column_headers(); ?> + + - - display_rows_or_placeholder(); ?> - + + display_rows_or_placeholder(); ?> + - - items; - $this->items = $this->extra_items; - $this->display_rows_or_placeholder(); - $this->items = $items; - ?> - + + items; + $this->items = $this->extra_items; + $this->display_rows_or_placeholder(); + $this->items = $items; + ?> + - - - print_column_headers( false ); ?> - - + + + print_column_headers( false ); ?> + + -
+ + 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 ); ?> - - - -
+
+ + 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 + + + + + + + + + + + + + + + +
+
+ +
+
+
+ +
+
+
+ + + + + + +
Post title
+
+
+
+ + + + 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 = + '
Post title
'; + 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(); +} );