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..309a246503480 100644 --- a/src/js/_enqueues/admin/common.js +++ b/src/js/_enqueues/admin/common.js @@ -2236,6 +2236,184 @@ $( 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(); + + /** + * Restores an element's original Tab order after overflow tracking ends. + * + * @since 7.2.0 + * + * @param {HTMLElement} element The previously observed element. + * @param {?string} tabIndex Its original tabindex attribute. + * + * @return {void} + */ + function restoreTabIndex( element, tabIndex ) { + if ( tabIndex === null ) { + element.removeAttribute( 'tabindex' ); + } else { + element.setAttribute( 'tabindex', tabIndex ); + } + } + + /** + * 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' ); + /** @type {Set} */ + const contentAreas = new Set(); + + /** + * Updates the overflow indicators and the wrapper's place in the Tab order. + * + * @since 7.2.0 + * + * @return {void} + */ + function update() { + contentAreas.forEach( ( area ) => { + if ( area.closest( '.wp-list-table' ) !== table || ! table.contains( area ) ) { + observer.unobserve( area ); + area.replaceWith( ...area.childNodes ); + contentAreas.delete( area ); + } + } ); + + table.querySelectorAll( ':scope > tbody > tr > td, :scope > tbody > tr > th' ).forEach( ( cell ) => { + if ( cell.colSpan > 1 || cell.closest( '.inline-edit-row, .inline-editor, #replyrow' ) ) { + return; + } + Array.from( cell.children ).forEach( ( block ) => { + if ( + ! block.matches( 'p, pre, div, blockquote, ul, ol, dl, figure, table' ) || + block.matches( '.row-actions, .hidden, .comment-author, .wp-list-table-content-scroll, [hidden]' ) || + window.getComputedStyle( block ).display === 'none' + ) { + return; + } + const area = document.createElement( 'div' ); + area.className = 'wp-list-table-content-scroll'; + block.before( area ); + area.append( block ); + contentAreas.add( area ); + observer.observe( area ); + } ); + } ); + + contentAreas.forEach( ( area ) => { + const areaStyle = window.getComputedStyle( area ); + const scrollable = area.scrollWidth > area.clientWidth && + ( areaStyle.overflowX === 'auto' || areaStyle.overflowX === 'scroll' ); + area.tabIndex = scrollable ? 0 : -1; + if ( scrollable ) { + area.setAttribute( 'role', 'region' ); + area.setAttribute( 'aria-label', wp.i18n.__( 'Scrollable content' ) ); + } else { + area.removeAttribute( 'role' ); + area.removeAttribute( 'aria-label' ); + } + } ); + + 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, + update, + + /** + * 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' ); + restoreTabIndex( wrapper, tabIndex ); + contentAreas.forEach( ( area ) => { + area.replaceWith( ...area.childNodes ); + } ); + contentAreas.clear(); + }, + } ); + } + + /** + * 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 ); + } else { + tables.get( table ).update(); + } + } ); + } + + refreshTables(); + + // Plugins live search replaces the table and its wrapper. + const observer = new window.MutationObserver( refreshTables ); + observer.observe( content, { childList: true, characterData: true, subtree: true } ); +} ); + /** * Freeze animated plugin icons when reduced motion is enabled. * diff --git a/src/js/_enqueues/admin/edit-comments.js b/src/js/_enqueues/admin/edit-comments.js index 7e89f1c0dd7cb..049dcc7094ad3 100644 --- a/src/js/_enqueues/admin/edit-comments.js +++ b/src/js/_enqueues/admin/edit-comments.js @@ -806,8 +806,8 @@ window.commentReply = { }); // Add events. - $('#the-comment-list .column-comment > p').on( 'dblclick', function(){ - commentReply.toggle($(this).parent()); + $('#the-comment-list .column-comment > p, #the-comment-list .column-comment > .wp-list-table-content-scroll > p').on( 'dblclick', function(){ + commentReply.toggle($(this).closest('.column-comment')); }); $('#doaction, #post-query-submit').on( 'click', function(){ @@ -833,8 +833,8 @@ window.commentReply = { */ addEvents : function(r) { r.each(function() { - $(this).find('.column-comment > p').on( 'dblclick', function(){ - commentReply.toggle($(this).parent()); + $(this).find('.column-comment > p, .column-comment > .wp-list-table-content-scroll > p').on( 'dblclick', function(){ + commentReply.toggle($(this).closest('.column-comment')); }); }); }, diff --git a/src/wp-admin/css/list-tables.css b/src/wp-admin/css/list-tables.css index f6105ad5a59fb..0c1a33d38cf9d 100644 --- a/src/wp-admin/css/list-tables.css +++ b/src/wp-admin/css/list-tables.css @@ -1789,9 +1789,190 @@ div.action-links, width: 80px; } +.wp-list-table-scroll:focus-visible { + outline: 2px solid var(--wp-admin-theme-color); + outline-offset: 0; +} + +/* Preserve the existing mobile layout for content wrappers. */ +.wp-list-table-content-scroll { + display: contents; +} + /* =Media Queries -------------------------------------------------------------- */ +@media screen and (min-width: 783px) { + + /* Share the table's scrollable area so the shadows stick to the 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); + } + + /* + * Cap oversized content while letting the column shrink below its intrinsic minimum width. + * A zero-minimum grid track keeps overflow inside the content wrapper. + */ + .wp-list-table-scroll .wp-list-table-content-scroll { + display: grid; + grid-template-columns: minmax(0, 1fr); + max-width: 40em; + overflow: auto; + scrollbar-color: var(--wpds-color-background-thumb-neutral-weak) transparent; + } + + .wp-list-table-scroll .wp-list-table-content-scroll:focus-visible { + outline: 2px solid var(--wp-admin-theme-color); + outline-offset: -2px; + } + + /* 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 { + position: sticky; + inset-inline-start: 0; + z-index: 2; + box-sizing: border-box; + width: 100cqi; + } + + .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; + } + + /* 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; + } + + .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: 12px; + pointer-events: none; + opacity: 0; + } + + .wp-list-table-scroll::before { + inset-inline-start: 0; + justify-self: start; + + /* rtl:ignore */ + background: linear-gradient(to right, rgba(0, 0, 0, 0.16), rgba(0, 0, 0, 0.04) 50%, rgba(0, 0, 0, 0)); + } + + .wp-list-table-scroll::after { + inset-inline-end: 0; + justify-self: end; + + /* rtl:ignore */ + background: linear-gradient(to left, rgba(0, 0, 0, 0.16), rgba(0, 0, 0, 0.04) 50%, rgba(0, 0, 0, 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 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..8b880e93432d3 --- /dev/null +++ b/tests/qunit/common.html @@ -0,0 +1,62 @@ + + + + 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..989e0530a7809 --- /dev/null +++ b/tests/qunit/wp-admin/js/common.js @@ -0,0 +1,534 @@ +/** + * 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( 'Scrolls oversized blocks in any column without moving row actions', async function( assert ) { + this.table.innerHTML = '

    Ordinary text.

    Wide content
    Other column'; + await afterResize(); + const cell = this.table.querySelector( '.column-example' ); + const area = cell.querySelector( 'pre' ).parentElement; + assert.ok( area.classList.contains( 'wp-list-table-content-scroll' ), 'The oversized block has a scrolling wrapper.' ); + assert.ok( area.scrollWidth > area.clientWidth, 'Only the content area overflows.' ); + assert.strictEqual( area.tabIndex, 0, 'The scrolling content is keyboard accessible.' ); + assert.strictEqual( cell.querySelector( '.row-actions' ).parentElement, cell, 'Actions stay outside the scroller.' ); + assert.strictEqual( cell.querySelector( 'p' ).parentElement.tabIndex, -1, 'Ordinary text adds no Tab stop.' ); + const actionX = cell.querySelector( '.row-actions' ).getBoundingClientRect().x; + area.scrollLeft = 100; + assert.strictEqual( cell.querySelector( '.row-actions' ).getBoundingClientRect().x, actionX, 'Scrolling content leaves actions in place.' ); + } ); + + [ 'ltr', 'rtl' ].forEach( function( direction ) { + QUnit.test( 'Shrinks oversized 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

    Ordinary text can wrap when the available space gets smaller.

    Wide content
    Plugin content
    Other content'; + await afterResize(); + + const sampleText = ' ' + 'W'.repeat( 200 ) + '\n Second line'; + const sample = document.createElement( 'pre' ); + sample.className = 'wrapping-sample'; + sample.style.whiteSpace = 'pre'; + sample.textContent = sampleText; + this.table.querySelector( 'td' ).appendChild( sample ); + 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 area = wrapper.querySelector( 'td > .wp-list-table-content-scroll > pre' ).parentElement; + wrapper.style.width = '800px'; + const wideWidth = area.clientWidth; + + wrapper.style.width = '600px'; + assert.ok( area.clientWidth < wideWidth, 'The content area shrinks when the table has less space.' ); + assert.strictEqual( wrapper.scrollWidth, wrapper.clientWidth, 'Oversized content does not force the fitting table to scroll.' ); + assert.ok( area.scrollWidth > area.clientWidth, 'The oversized block still scrolls inside its column.' ); + const ordinary = wrapper.querySelector( 'p' ).parentElement; + assert.strictEqual( ordinary.scrollWidth, ordinary.clientWidth, 'Ordinary text wraps without a scrollbar.' ); + const plugin = wrapper.querySelector( '.plugin-scroller' ); + assert.ok( plugin.scrollWidth > plugin.clientWidth, 'The plugin retains its own scrollbar after shrinking.' ); + assert.strictEqual( plugin.parentElement.scrollWidth, plugin.parentElement.clientWidth, 'The plugin does not acquire a second scrollbar.' ); + + const wrappedPre = wrapper.querySelector( '.wrapping-sample' ); + assert.strictEqual( wrappedPre.parentElement.scrollWidth, wrappedPre.parentElement.clientWidth, 'Plain preformatted text wraps without horizontal scrolling.' ); + assert.ok( wrappedPre.clientHeight > 3 * area.firstElementChild.clientHeight, 'Long preformatted lines wrap onto additional lines.' ); + assert.strictEqual( wrappedPre.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( 'Updates content keyboard access after text changes', async function( assert ) { + this.table.innerHTML = '
    Short
    '; + await afterResize(); + const pre = this.table.querySelector( 'pre' ); + const area = pre.parentElement; + assert.strictEqual( area.tabIndex, -1, 'Short content does not add a Tab stop.' ); + pre.firstChild.data = 'W'.repeat( 200 ); + await afterResize(); + assert.strictEqual( area.tabIndex, 0, 'Text changes make overflowing content keyboard accessible.' ); + area.focus(); + pre.firstChild.data = 'Short'; + await afterResize(); + assert.strictEqual( area.tabIndex, -1, 'Fitting content is removed from the Tab order.' ); + assert.strictEqual( document.activeElement, area, 'Existing focus is preserved.' ); + } ); + + QUnit.test( 'Leaves existing plugin scrolling inside its content block', async function( assert ) { + this.table.innerHTML = '
    Plugin content
    '; + await afterResize(); + const plugin = this.table.querySelector( '.plugin-scroller' ); + const area = plugin.parentElement; + assert.ok( plugin.scrollWidth > plugin.clientWidth, 'The plugin still owns its overflow.' ); + assert.strictEqual( area.scrollWidth, area.clientWidth, 'The enclosing content area does not add another scrollbar.' ); + assert.strictEqual( area.tabIndex, -1, 'The enclosing content area adds no Tab stop.' ); + } ); + + QUnit.test( 'Removes generated content wrappers when a table is removed', async function( assert ) { + this.table.innerHTML = '
    Content
    '; + await afterResize(); + const cell = this.table.querySelector( 'td' ); + this.table.remove(); + await afterResize(); + assert.strictEqual( cell.firstElementChild.tagName, 'PRE', 'Cleanup restores the original content structure.' ); + } ); + + 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(); +} );