diff --git a/.gitignore b/.gitignore index f96e379fbb304..311228572ea33 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 f96eda8ce5f61..bac3071532ef0 100644 --- a/Gruntfile.js +++ b/Gruntfile.js @@ -301,7 +301,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: { @@ -664,8 +664,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 43534fe60714b..0e277c91bea6a 100644 --- a/src/js/_enqueues/admin/common.js +++ b/src/js/_enqueues/admin/common.js @@ -2236,6 +2236,118 @@ $( function( $ ) { }( jQuery, window )); +/** + * Manages overflow indicators and keyboard access for list tables. + * + * @since 7.2.0 + */ +jQuery( () => { + const content = document.getElementById( 'wpbody-content' ); + if ( + ! content || + ! content.querySelector( '.wp-list-table-scroll' ) + ) { + return; + } + + /** @type {Map} */ + 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, + + /** + * Releases overflow tracking and restores the wrapper's original Tab order. + * + * @since 7.2.0 + * + * @return {void} + */ + cleanup: () => { + 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( ( 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( ( 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..ca15dc95a0480 100644 --- a/src/wp-admin/css/list-tables.css +++ b/src/wp-admin/css/list-tables.css @@ -1789,9 +1789,223 @@ 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) { + + /* Keep the table within a horizontally scrollable viewport. */ + .wp-list-table-scroll { + display: grid; + container-type: inline-size; + 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); + } + + /* Wrap preformatted text, including inline preformatting, while preserving whitespace. */ + .wp-list-table-scroll > .wp-list-table pre { + white-space: pre-wrap !important; + overflow-wrap: anywhere; + } + + /* Keep inline editing forms within the visible table container. */ + .wp-list-table-scroll .inline-edit-wrapper, + .wp-list-table-scroll #replyrow.inline-edit-row fieldset.comment-reply { + position: sticky; + inset-inline-start: 0; + z-index: 2; + box-sizing: border-box; + width: 100cqi; + } + + /* Account for the comment editing cell's 2px padding on each side. */ + .wp-list-table-scroll #replyrow.inline-edit-row fieldset.comment-reply { + inset-inline-start: 2px; + float: none; + width: calc(100cqi - 4px); + } + + .wp-list-table-scroll > .widefat { + grid-area: 1 / 1; + border: 0; + box-shadow: none; + } + + /* Keep sort indicators beside their labels when columns shrink. */ + .wp-list-table-scroll > .wp-list-table th.sortable a, + .wp-list-table-scroll > .wp-list-table th.sorted a { + display: flex; + align-items: center; + } + + /* Keep the sort icons' layout boxes from extending past the last column. */ + .wp-list-table-scroll .sorting-indicators { + overflow: clip; + } + + /* 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; + } + + /* Preserve the checkbox column's spacing under automatic table layout. */ + .wp-list-table-scroll > .fixed .check-column { + min-width: 2.2em; + } + + :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; + } + + /* Match the edge fades to Core's row backgrounds. */ + .wp-list-table-scroll > .wp-list-table > :is(thead, tbody, tfoot) > tr { + --list-table-row-background: #fff; + position: relative; + } + + .wp-list-table-scroll > .striped > tbody > :nth-child(odd), + .wp-list-table-scroll > .wp-list-table > tbody > .alternate, + .wp-list-table-scroll > .wp-list-table > tbody > .undo { + --list-table-row-background: #f6f7f7; + } + + .wp-list-table-scroll > .plugins > tbody > tr { + --list-table-row-background: #fff; + } + + .wp-list-table-scroll > .plugins > tbody > .active { + --list-table-row-background: color-mix(in srgb, var(--wp-admin-theme-color) 8%, #fff); + } + + .wp-list-table-scroll > .plugins > tbody > .paused { + --list-table-row-background: #f6f7f7; + } + + .wp-list-table-scroll > .wp-list-table > tbody > :is(.unapproved, .site-spammed, .site-mature) { + --list-table-row-background: #fcf9e8; + } + + .wp-list-table-scroll > .wp-list-table > tbody > :is(.site-deleted, .site-archived) { + --list-table-row-background: #fcf0f1; + } + + /* Core applies the 8% keyboard-selection tint to both the row and its cells. */ + .wp-list-table-scroll > .wp-list-table > tbody > .vim-current { + --list-table-row-background: color-mix(in srgb, var(--wp-admin-theme-color) 15.36%, #fff); + } + + /* Zero-width sticky cells paint the fades inward without adding column width. */ + .wp-list-table-scroll > .wp-list-table > :is(thead, tbody, tfoot) > tr::before, + .wp-list-table-scroll > .wp-list-table > :is(thead, tbody, tfoot) > tr::after { + content: ""; + display: table-cell; + position: sticky; + z-index: 1; + width: 0; + padding: 0; + + /* WebKit needs a nonzero, nontransparent border to paint the sticky cell's border image. */ + border: 0 solid var(--list-table-row-background); + border-top-width: 1px; + pointer-events: none; + opacity: 0; + } + + .wp-list-table-scroll > .wp-list-table > :is(thead, tbody, tfoot) > tr::before { + inset-inline-start: 0; + + /* rtl:ignore */ + border-image: linear-gradient(to right, var(--list-table-row-background), transparent) 0 100% / 0 32px 0 0 / 0 32px 0 0 stretch; + } + + .wp-list-table-scroll > .wp-list-table > :is(thead, tbody, tfoot) > tr::after { + inset-inline-end: 0; + + /* rtl:ignore */ + border-image: linear-gradient(to left, var(--list-table-row-background), transparent) 0 0 0 100% / 0 0 0 32px / 0 0 0 32px stretch; + } + + .wp-list-table-scroll.has-scroll-overflow-start > .wp-list-table > :is(thead, tbody, tfoot) > tr::before, + .wp-list-table-scroll.has-scroll-overflow-end > .wp-list-table > :is(thead, tbody, tfoot) > tr::after { + opacity: 1; + } + + /* Keep the same table grid in editing rows, without covering their forms. */ + .wp-list-table-scroll > .wp-list-table > tbody > :is(.inline-edit-row, .inline-editor, #replyrow)::before, + .wp-list-table-scroll > .wp-list-table > tbody > :is(.inline-edit-row, .inline-editor, #replyrow)::after { + visibility: hidden; + } + + .rtl .wp-list-table-scroll > .wp-list-table > :is(thead, tbody, tfoot) > tr::before, + .rtl .wp-list-table-scroll > .wp-list-table > :is(thead, tbody, tfoot) > tr::after { + transform: scaleX(-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 1dff5d26755ce..7900b40824716 100644 --- a/src/wp-admin/includes/class-wp-comments-list-table.php +++ b/src/wp-admin/includes/class-wp-comments-list-table.php @@ -20,6 +20,26 @@ class WP_Comments_List_Table extends WP_List_Table { public $pending_count = array(); + /** + * The current list of items. + * + * @since 3.1.0 + * + * @var array + */ + public $items; + + /** + * Extra comments fetched beyond the current page. + * + * These are rendered in the hidden `the-extra-comment-list` table body so that + * rows can be backfilled when comments are removed from the list via Ajax. + * + * @since 3.1.0 + * @since 4.2.0 Declared a previously dynamic property. + * + * @var array + */ public $extra_items; private $user_can; @@ -598,6 +618,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' ); @@ -612,50 +633,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_rows_or_placeholder(); ?> - - - - items; - $this->items = $this->extra_items; - $this->display_rows_or_placeholder(); - $this->items = $items; - ?> - - - - - print_column_headers( false ); ?> - - - -
+
+ + ' . + /* translators: Hidden accessibility text. */ + __( 'Ordered by Comment Date, descending.' ) . + ''; + } else { + $this->print_table_description(); + } + ?> + + + print_column_headers(); ?> + + + + + display_rows_or_placeholder(); ?> + + + + items; + $this->items = $this->extra_items; + $this->display_rows_or_placeholder(); + $this->items = $items; + ?> + + + + + 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 a63719d1d3bb2..f439dec067d2d 100644 --- a/src/wp-admin/includes/class-wp-list-table.php +++ b/src/wp-admin/includes/class-wp-list-table.php @@ -1643,6 +1643,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']; @@ -1650,32 +1651,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..bf3a16d93e4c1 --- /dev/null +++ b/tests/qunit/wp-admin/js/common.js @@ -0,0 +1,547 @@ +/** + * 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.' + ); + } + ); + + [ 'ltr', 'rtl' ].forEach( function( direction ) { + QUnit.test( 'Preserves narrow icon columns in ' + direction, async function( assert ) { + this.wrapper.style.width = '400px'; + this.table.classList.add( 'widefat', 'fixed' ); + this.table.style.width = '100%'; + this.table.innerHTML = 'TitleSPost title
    '; + await afterResize(); + assert.strictEqual( this.table.querySelector( '.score-icon' ).parentElement.tagName, 'TD', 'Inline indicators retain their original formatting context.' ); + + const frame = document.createElement( 'iframe' ); + frame.width = '1000'; + const loaded = new Promise( function( resolve ) { + frame.onload = resolve; + } ); + const stylesheet = new URL( '../../src/wp-admin/css/list-tables.css', document.baseURI ); + frame.srcdoc = '' + this.wrapper.outerHTML + ''; + document.getElementById( 'qunit-fixture' ).appendChild( frame ); + await loaded; + const table = frame.contentDocument.querySelector( 'table' ); + const icon = table.querySelector( '.score-icon' ); + assert.strictEqual( icon.parentElement.scrollWidth, icon.parentElement.clientWidth, 'The small indicator is fully visible without inner scrolling.' ); + const label = table.querySelector( 'a > span' ).getBoundingClientRect(); + const arrows = table.querySelector( '.sorting-indicators' ).getBoundingClientRect(); + assert.ok( arrows.top < label.bottom && arrows.bottom > label.top, 'Sort arrows stay beside the header icon.' ); + frame.remove(); + } ); + + QUnit.test( 'Keeps editing rows aligned with all columns in ' + direction, async function( assert ) { + const frame = document.createElement( 'iframe' ); + frame.width = '1000'; + const loaded = new Promise( function( resolve ) { + frame.onload = resolve; + } ); + const stylesheet = new URL( '../../src/wp-admin/css/list-tables.css', document.baseURI ); + frame.srcdoc = '
    OneTwoThreeFourFive
    ABCDE
    Quick Edit
    Inline editor
    Reply
    '; + document.getElementById( 'qunit-fixture' ).appendChild( frame ); + await loaded; + const wrapper = frame.contentDocument.querySelector( '.wp-list-table-scroll' ); + const table = wrapper.querySelector( 'table' ); + const cells = table.querySelectorAll( 'td[colspan]' ); + + [ 900, 600 ].forEach( function( width ) { + wrapper.style.width = width + 'px'; + cells.forEach( function( cell ) { + assert.ok( + Math.abs( cell.getBoundingClientRect().width - table.getBoundingClientRect().width ) < 1, + cell.textContent + ' spans every column with a ' + width + 'px viewport.' + ); + } ); + } ); + + cells.forEach( function( cell ) { + [ '::before', '::after' ].forEach( function( pseudo ) { + assert.strictEqual( frame.contentWindow.getComputedStyle( cell.parentElement, pseudo ).visibility, 'hidden', cell.textContent + ' hides its ' + pseudo + ' fade.' ); + } ); + } ); + const editor = table.querySelector( '.comment-reply' ); + [ 0, direction === 'rtl' ? -200 : 200 ].forEach( function( position ) { + wrapper.scrollLeft = position; + const viewport = wrapper.getBoundingClientRect(); + const bounds = editor.getBoundingClientRect(); + assert.ok( bounds.left >= viewport.left && bounds.right <= viewport.right, 'Comment Quick Edit stays within the visible viewport at scroll position ' + position + '.' ); + assert.ok( Math.abs( bounds.width - ( wrapper.clientWidth - 4 ) ) < 1, 'Comment Quick Edit fills the viewport inside the cell padding.' ); + } ); + frame.remove(); + } ); + + QUnit.test( 'Wraps preformatted content with its column in ' + direction, async function( assert ) { + this.table.classList.add( 'widefat', 'fixed' ); + this.table.style.width = '100%'; + this.table.innerHTML = 'ContentOther column
    Other content';
    +			const sampleText = '    ' + 'W'.repeat( 200 ) + '\n    Second line';
    +			this.table.querySelector( 'pre' ).textContent = sampleText;
    +			await afterResize();
    +
    +			// Use the production stylesheet in an isolated desktop viewport.
    +			const frame = document.createElement( 'iframe' );
    +			frame.width = '1000';
    +			const loaded = new Promise( function( resolve ) {
    +				frame.onload = resolve;
    +			} );
    +			const stylesheet = new URL( '../../src/wp-admin/css/list-tables.css', document.baseURI );
    +			frame.srcdoc = '' + this.wrapper.outerHTML + '';
    +			document.getElementById( 'qunit-fixture' ).appendChild( frame );
    +			await loaded;
    +			const wrapper = frame.contentDocument.querySelector( '.wp-list-table-scroll' );
    +			const pre = wrapper.querySelector( 'pre' );
    +			wrapper.style.width = '800px';
    +			const wideHeight = pre.clientHeight;
    +
    +			wrapper.style.width = '600px';
    +			assert.strictEqual( pre.parentElement.tagName, 'TD', 'Wrapping preserves the existing cell markup.' );
    +			assert.strictEqual( wrapper.scrollWidth, wrapper.clientWidth, 'Long preformatted text does not force the fitting table to scroll.' );
    +			assert.strictEqual( pre.scrollWidth, pre.clientWidth, 'Preformatted text wraps without an inner scrollbar.' );
    +			assert.ok( pre.clientHeight > wideHeight, 'Narrower columns wrap the text onto more lines.' );
    +			assert.strictEqual( pre.textContent, sampleText, 'Wrapping preserves the original whitespace and text.' );
    +
    +			wrapper.style.width = '400px';
    +			assert.ok( wrapper.scrollWidth > wrapper.clientWidth, 'Column minimum widths still cause table scrolling when needed.' );
    +			frame.remove();
    +		} );
    +	} );
    +
    +	QUnit.test( 'Preserves plugin-owned scrolling and content markup', async function( assert ) {
    +		this.table.innerHTML = '
    Plugin content
    '; + await afterResize(); + const cell = this.table.querySelector( 'td' ); + const plugin = cell.querySelector( '.plugin-scroller' ); + assert.strictEqual( plugin.parentElement, cell, 'The plugin retains its original parent for direct-child styling.' ); + assert.ok( plugin.scrollWidth > plugin.clientWidth, 'The plugin still owns its overflow.' ); + assert.strictEqual( plugin.tabIndex, 0, 'The plugin retains its own keyboard access.' ); + assert.strictEqual( cell.querySelector( '.row-actions' ).parentElement, cell, 'Actions retain their original parent.' ); + } ); + + 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(); +} );