From 73b207e93a19e152cf45c45dd11d0cf137c6a97d Mon Sep 17 00:00:00 2001 From: mattmiklic Date: Wed, 23 Sep 2026 11:40:15 -0400 Subject: [PATCH 01/21] List Tables: Allow horizontal scrolling on desktop. Wrap list tables in a named, keyboard-focusable scrolling region and apply minimum widths to desktop text columns. Keep the outer border on the viewport and retain the existing mobile layout. --- src/wp-admin/css/list-tables.css | 73 +++++++++++++++++++ .../includes/class-wp-comments-list-table.php | 3 + src/wp-admin/includes/class-wp-list-table.php | 3 + 3 files changed, 79 insertions(+) diff --git a/src/wp-admin/css/list-tables.css b/src/wp-admin/css/list-tables.css index 711a618ca5c79..e31efbc653f35 100644 --- a/src/wp-admin/css/list-tables.css +++ b/src/wp-admin/css/list-tables.css @@ -1790,6 +1790,79 @@ div.action-links, /* =Media Queries -------------------------------------------------------------- */ +@media screen and (min-width: 783px) { + .wp-list-table-scroll { + clear: both; + overflow-x: auto; + overflow-y: hidden; + 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; + } + + .wp-list-table-scroll:focus-visible { + outline: 2px solid var(--wp-admin-theme-color); + outline-offset: -2px; + } + + .wp-list-table-scroll > .fixed { + table-layout: 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; + } + + :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; + } + + .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-date { + min-width: 12em; + } + + .wp-list-table-scroll > .fixed th.column-primary { + min-width: 20em; + } +} + @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..b30f5b60e0fa1 100644 --- a/src/wp-admin/includes/class-wp-comments-list-table.php +++ b/src/wp-admin/includes/class-wp-comments-list-table.php @@ -610,8 +610,10 @@ public function display() { } $this->screen->render_screen_reader_content( 'heading_list' ); + $table_label = $this->screen->get_screen_reader_text( 'heading_list' ); ?> +
+
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..22d4252341d33 100644 --- a/src/wp-admin/includes/class-wp-list-table.php +++ b/src/wp-admin/includes/class-wp-list-table.php @@ -1629,7 +1629,9 @@ 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(); ?> @@ -1655,6 +1657,7 @@ public function display() {
+
display_tablenav( 'bottom' ); } From 26dee736e945b98437a9b1e0d76712ee98b11a22 Mon Sep 17 00:00:00 2001 From: mattmiklic Date: Wed, 23 Sep 2026 13:33:24 -0400 Subject: [PATCH 02/21] List Tables: Keep Comments sort arrows beside the icon. Retain the existing Comments column width as a desktop minimum so automatic table layout cannot shrink the header enough to wrap its sort arrows. --- src/wp-admin/css/list-tables.css | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/src/wp-admin/css/list-tables.css b/src/wp-admin/css/list-tables.css index e31efbc653f35..bc94b835b4d40 100644 --- a/src/wp-admin/css/list-tables.css +++ b/src/wp-admin/css/list-tables.css @@ -1854,6 +1854,10 @@ div.action-links, 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; } From b996dd5eea12db7b1063733efeeeb43f3f8999ce Mon Sep 17 00:00:00 2001 From: mattmiklic Date: Wed, 23 Sep 2026 14:40:47 -0400 Subject: [PATCH 03/21] List Tables: Indicate horizontal overflow with a shadow. Overlay a subtle shadow at the inline end of desktop list-table viewports while content remains beyond that edge. Use a scroll-driven CSS animation so the shadow disappears at the end and stays hidden when the table fits. Preserve the existing layout in browsers without support and at mobile widths. --- src/wp-admin/css/list-tables.css | 42 ++++++++++++++++++++++++++++++++ 1 file changed, 42 insertions(+) diff --git a/src/wp-admin/css/list-tables.css b/src/wp-admin/css/list-tables.css index bc94b835b4d40..5a60726b9b166 100644 --- a/src/wp-admin/css/list-tables.css +++ b/src/wp-admin/css/list-tables.css @@ -1865,6 +1865,48 @@ div.action-links, .wp-list-table-scroll > .fixed th.column-primary { min-width: 20em; } + + @supports (animation-timeline: scroll(inline)) { + /* Share the table's scrollable area so the shadow sticks to the viewport. */ + .wp-list-table-scroll { + display: grid; + } + + .wp-list-table-scroll > .widefat, + .wp-list-table-scroll::after { + grid-area: 1 / 1; + } + + .wp-list-table-scroll::after { + content: ""; + position: sticky; + inset-inline-end: 0; + justify-self: end; + width: 12px; + z-index: 1; + pointer-events: none; + /* rtl:ignore */ + background: linear-gradient(to left, rgba(0, 0, 0, 0.12), transparent); + /* Without overflow, the timeline is inactive and the shadow stays hidden. */ + opacity: 0; + animation: wp-list-table-scroll-shadow 1s step-end both; + animation-timeline: scroll(inline); + } + + .wp-list-table-scroll:dir(rtl)::after { + transform: scaleX(-1); + } + + @keyframes wp-list-table-scroll-shadow { + from { + opacity: 1; + } + + to { + opacity: 0; + } + } + } } @media screen and (max-width: 1100px) and (min-width: 782px), (max-width: 480px) { From 1436c6f9dfac29abe982b59bf319ae42489dc6d0 Mon Sep 17 00:00:00 2001 From: mattmiklic Date: Wed, 23 Sep 2026 16:21:04 -0400 Subject: [PATCH 04/21] List Tables: Use JavaScript for scroll shadows. Control the edge shadow through native scroll and resize handling in `common.js` so it no longer depends on CSS scroll-driven animation support. Track right-to-left positions and refresh observers when AJAX replaces a table, while preserving the existing desktop layout and mobile styles. --- src/js/_enqueues/admin/common.js | 89 ++++++++++++++++++++++++++++++++ src/wp-admin/css/list-tables.css | 69 +++++++++++-------------- 2 files changed, 118 insertions(+), 40 deletions(-) diff --git a/src/js/_enqueues/admin/common.js b/src/js/_enqueues/admin/common.js index fe83cc7e9f2ae..b14c1b37e452e 100644 --- a/src/js/_enqueues/admin/common.js +++ b/src/js/_enqueues/admin/common.js @@ -2235,6 +2235,95 @@ $( function( $ ) { }( jQuery, window )); +/** + * Indicates remaining horizontal content in 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 addScrollShadow( table ) { + const wrapper = table.parentElement; + + /** + * Shows the shadow while content remains in the reading direction. + * + * @since 7.2.0 + * + * @return {void} + */ + function update() { + const isRTL = window.getComputedStyle( wrapper ).direction === 'rtl'; + const scrollLeft = isRTL ? -wrapper.scrollLeft : wrapper.scrollLeft; + const remaining = wrapper.scrollWidth - wrapper.clientWidth - Math.max( 0, scrollLeft ); + + // Allow for fractional scroll positions at the end of the table. + wrapper.classList.toggle( 'has-scroll-overflow', remaining > 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, + cleanup: function() { + observer.disconnect(); + wrapper.removeEventListener( 'scroll', update ); + wrapper.classList.remove( 'has-scroll-overflow' ); + } + } ); + } + + /** + * 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 ) ) { + addScrollShadow( 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 5a60726b9b166..e585ade6be3ac 100644 --- a/src/wp-admin/css/list-tables.css +++ b/src/wp-admin/css/list-tables.css @@ -1866,46 +1866,35 @@ div.action-links, min-width: 20em; } - @supports (animation-timeline: scroll(inline)) { - /* Share the table's scrollable area so the shadow sticks to the viewport. */ - .wp-list-table-scroll { - display: grid; - } - - .wp-list-table-scroll > .widefat, - .wp-list-table-scroll::after { - grid-area: 1 / 1; - } - - .wp-list-table-scroll::after { - content: ""; - position: sticky; - inset-inline-end: 0; - justify-self: end; - width: 12px; - z-index: 1; - pointer-events: none; - /* rtl:ignore */ - background: linear-gradient(to left, rgba(0, 0, 0, 0.12), transparent); - /* Without overflow, the timeline is inactive and the shadow stays hidden. */ - opacity: 0; - animation: wp-list-table-scroll-shadow 1s step-end both; - animation-timeline: scroll(inline); - } - - .wp-list-table-scroll:dir(rtl)::after { - transform: scaleX(-1); - } - - @keyframes wp-list-table-scroll-shadow { - from { - opacity: 1; - } - - to { - opacity: 0; - } - } + /* Share the table's scrollable area so the shadow sticks to the viewport. */ + .wp-list-table-scroll { + display: grid; + } + + .wp-list-table-scroll > .widefat, + .wp-list-table-scroll::after { + grid-area: 1 / 1; + } + + .wp-list-table-scroll::after { + content: ""; + position: sticky; + inset-inline-end: 0; + justify-self: end; + width: 12px; + z-index: 1; + pointer-events: none; + /* rtl:ignore */ + background: linear-gradient(to left, rgba(0, 0, 0, 0.12), transparent); + opacity: 0; + } + + .wp-list-table-scroll.has-scroll-overflow::after { + opacity: 1; + } + + .wp-list-table-scroll:dir(rtl)::after { + transform: scaleX(-1); } } From 8d3511ff4700d91736d27921af1e9e0dd2d38769 Mon Sep 17 00:00:00 2001 From: mattmiklic Date: Wed, 23 Sep 2026 16:31:51 -0400 Subject: [PATCH 05/21] List Tables: Indicate overflow at both horizontal edges. Show a shadow at each edge when table content is hidden beyond it, including right-to-left layouts. Clamp scroll positions so fitting tables and elastic overscroll do not produce misleading shadows, and suppress horizontal rubber-banding with `overscroll-behavior-x: none` on the desktop wrapper. --- src/js/_enqueues/admin/common.js | 12 +++++++----- src/wp-admin/css/list-tables.css | 25 ++++++++++++++++++++----- 2 files changed, 27 insertions(+), 10 deletions(-) diff --git a/src/js/_enqueues/admin/common.js b/src/js/_enqueues/admin/common.js index b14c1b37e452e..5ac1e11a05d21 100644 --- a/src/js/_enqueues/admin/common.js +++ b/src/js/_enqueues/admin/common.js @@ -2264,7 +2264,7 @@ jQuery( function() { const wrapper = table.parentElement; /** - * Shows the shadow while content remains in the reading direction. + * Shows a shadow at each edge with hidden content. * * @since 7.2.0 * @@ -2273,10 +2273,12 @@ jQuery( function() { function update() { const isRTL = window.getComputedStyle( wrapper ).direction === 'rtl'; const scrollLeft = isRTL ? -wrapper.scrollLeft : wrapper.scrollLeft; - const remaining = wrapper.scrollWidth - wrapper.clientWidth - Math.max( 0, scrollLeft ); + const maxScroll = Math.max( 0, wrapper.scrollWidth - wrapper.clientWidth ); + const position = Math.min( maxScroll, Math.max( 0, scrollLeft ) ); - // Allow for fractional scroll positions at the end of the table. - wrapper.classList.toggle( 'has-scroll-overflow', remaining > 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 ); @@ -2290,7 +2292,7 @@ jQuery( function() { cleanup: function() { observer.disconnect(); wrapper.removeEventListener( 'scroll', update ); - wrapper.classList.remove( 'has-scroll-overflow' ); + wrapper.classList.remove( 'has-scroll-overflow-start', 'has-scroll-overflow-end' ); } } ); } diff --git a/src/wp-admin/css/list-tables.css b/src/wp-admin/css/list-tables.css index e585ade6be3ac..374b878a2a677 100644 --- a/src/wp-admin/css/list-tables.css +++ b/src/wp-admin/css/list-tables.css @@ -1795,6 +1795,7 @@ div.action-links, 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); } @@ -1866,33 +1867,47 @@ div.action-links, min-width: 20em; } - /* Share the table's scrollable area so the shadow sticks to the viewport. */ + /* Share the table's scrollable area so the shadows stick to the viewport. */ .wp-list-table-scroll { display: grid; } .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; - inset-inline-end: 0; - justify-self: end; width: 12px; z-index: 1; 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.12), transparent); + } + + .wp-list-table-scroll::after { + inset-inline-end: 0; + justify-self: end; /* rtl:ignore */ background: linear-gradient(to left, rgba(0, 0, 0, 0.12), transparent); - opacity: 0; } - .wp-list-table-scroll.has-scroll-overflow::after { + .wp-list-table-scroll.has-scroll-overflow-start::before, + .wp-list-table-scroll.has-scroll-overflow-end::after { opacity: 1; } + .wp-list-table-scroll:dir(rtl)::before, .wp-list-table-scroll:dir(rtl)::after { transform: scaleX(-1); } From 4385e1947c2d56d5093c6e1e0f5404f6d5389d7d Mon Sep 17 00:00:00 2001 From: mattmiklic Date: Wed, 23 Sep 2026 16:51:12 -0400 Subject: [PATCH 06/21] List Tables: Use the admin RTL class for scroll shadows. --- src/wp-admin/css/list-tables.css | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/wp-admin/css/list-tables.css b/src/wp-admin/css/list-tables.css index 374b878a2a677..6d408f0e93ff9 100644 --- a/src/wp-admin/css/list-tables.css +++ b/src/wp-admin/css/list-tables.css @@ -1907,8 +1907,8 @@ div.action-links, opacity: 1; } - .wp-list-table-scroll:dir(rtl)::before, - .wp-list-table-scroll:dir(rtl)::after { + .rtl .wp-list-table-scroll::before, + .rtl .wp-list-table-scroll::after { transform: scaleX(-1); } } From a1cf6e4f29b2736648b985a364a908f1c4e4cbf9 Mon Sep 17 00:00:00 2001 From: mattmiklic Date: Wed, 23 Sep 2026 18:37:21 -0400 Subject: [PATCH 07/21] List Tables: Add scroll shadow regression tests. Cover initialization, LTR and RTL scroll boundaries, resizing, and cleanup after table replacement. Exercise `common.js` through dedicated QUnit fixtures for normal and minified builds. --- .gitignore | 1 + Gruntfile.js | 8 +- tests/qunit/common.html | 48 +++++++++ tests/qunit/wp-admin/js/common.js | 171 ++++++++++++++++++++++++++++++ 4 files changed, 225 insertions(+), 3 deletions(-) create mode 100644 tests/qunit/common.html create mode 100644 tests/qunit/wp-admin/js/common.js 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 bedd71c9a984a..4327ccf734beb 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: { @@ -658,8 +658,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/tests/qunit/common.html b/tests/qunit/common.html new file mode 100644 index 0000000000000..39284e443b81e --- /dev/null +++ b/tests/qunit/common.html @@ -0,0 +1,48 @@ + + + + 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..d55e40787d664 --- /dev/null +++ b/tests/qunit/wp-admin/js/common.js @@ -0,0 +1,171 @@ +/* global sinon */ +jQuery( function() { + const content = document.getElementById( 'wpbody-content' ); + const tableMarkup = '
    Post title
    '; + const wrapperMarkup = '
    ' + tableMarkup + '
    '; + + function shadows( wrapper ) { + return [ + wrapper.classList.contains( 'has-scroll-overflow-start' ), + wrapper.classList.contains( 'has-scroll-overflow-end' ) + ]; + } + + function scrollTo( wrapper, position ) { + wrapper.scrollLeft = position; + wrapper.dispatchEvent( new Event( 'scroll' ) ); + } + + function afterResize() { + // ResizeObserver delivers changes after animation callbacks, before the next frame. + return new Promise( function( resolve ) { + window.requestAnimationFrame( function() { + window.requestAnimationFrame( resolve ); + } ); + } ); + } + + const initialShadows = shadows( content.firstElementChild ); + + QUnit.module( 'List table scroll shadows', { + 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 shadows for tables present on page load', function( assert ) { + assert.deepEqual( initialShadows, [ false, true ], 'Only the end has hidden content on page load.' ); + } ); + + [ 'ltr', 'rtl' ].forEach( function( direction ) { + QUnit.test( 'Updates both shadows when scrolling in ' + direction, function( assert ) { + const wrapper = this.wrapper; + const sign = direction === 'rtl' ? -1 : 1; + wrapper.dir = direction; + + scrollTo( wrapper, 0 ); + assert.deepEqual( shadows( wrapper ), [ false, true ], 'Only the end has hidden content at the start.' ); + + scrollTo( wrapper, sign * 200 ); + assert.deepEqual( shadows( wrapper ), [ true, true ], 'Both ends have hidden content in the middle.' ); + + scrollTo( wrapper, sign * 400 ); + assert.deepEqual( shadows( wrapper ), [ true, false ], 'Only the start has hidden content at the end.' ); + + scrollTo( wrapper, 0 ); + assert.deepEqual( shadows( wrapper ), [ false, true ], 'Returning to the start clears its shadow.' ); + } ); + + 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( shadows( wrapper ), [ false, true ], 'A fractional offset at the start does not show a shadow.' ); + + scrollTo( wrapper, sign * 1.5 ); + assert.deepEqual( shadows( wrapper ), [ true, true ], 'Content beyond the tolerance shows the start shadow.' ); + + scrollTo( wrapper, sign * 398.5 ); + assert.deepEqual( shadows( wrapper ), [ true, true ], 'Content beyond the tolerance shows the end shadow.' ); + + scrollTo( wrapper, sign * 399.5 ); + assert.deepEqual( shadows( wrapper ), [ true, false ], 'A fractional offset at the end does not show a shadow.' ); + + scrollTo( wrapper, sign * -20 ); + assert.deepEqual( shadows( wrapper ), [ false, true ], 'Overscrolling past the start does not show a start shadow.' ); + + scrollTo( wrapper, sign * 420 ); + assert.deepEqual( shadows( wrapper ), [ true, false ], 'Overscrolling past the end does not show an end shadow.' ); + + this.table.style.width = '600px'; + wrapper.dispatchEvent( new Event( 'scroll' ) ); + assert.deepEqual( shadows( wrapper ), [ false, false ], 'A fitting table has no shadows even with a stale scroll offset.' ); + } ); + } ); + + QUnit.test( 'Updates shadows when the table or its viewport changes width', async function( assert ) { + this.table.style.width = '600px'; + await afterResize(); + assert.deepEqual( shadows( this.wrapper ), [ false, false ], 'Shrinking the table clears the shadow without a scroll event.' ); + + this.wrapper.style.width = '400px'; + await afterResize(); + assert.deepEqual( shadows( this.wrapper ), [ false, true ], 'Shrinking the viewport reveals overflow without a scroll event.' ); + + this.wrapper.style.width = '800px'; + await afterResize(); + assert.deepEqual( shadows( this.wrapper ), [ false, false ], 'Widening the viewport clears the shadow.' ); + } ); + + 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( shadows( content.firstElementChild ), [ false, true ], 'The replacement is initialized.' ); + assert.deepEqual( shadows( oldWrapper ), [ false, false ], 'The old wrapper loses its shadows.' ); + assert.strictEqual( disconnect.callCount, 1, 'The old resize observer is disconnected.' ); + + scrollTo( oldWrapper, 200 ); + assert.deepEqual( shadows( oldWrapper ), [ false, false ], 'Scrolling the removed wrapper no longer updates shadows.' ); + + scrollTo( content.firstElementChild, 200 ); + assert.deepEqual( shadows( 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( shadows( this.wrapper ), [ false, false ], 'Resizing the replacement table updates its shadows.' ); + } ); + + QUnit.test( 'Moves shadow 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( shadows( oldWrapper ), [ false, false ], 'The old wrapper loses its shadows.' ); + assert.deepEqual( shadows( newWrapper ), [ false, true ], 'The new wrapper is initialized.' ); + + scrollTo( newWrapper, 200 ); + assert.deepEqual( shadows( newWrapper ), [ true, true ], 'The new wrapper responds to scrolling.' ); + } ); + + QUnit.start(); +} ); From bc1703fd056b334a036ab2ae04367c6e26172101 Mon Sep 17 00:00:00 2001 From: mattmiklic Date: Thu, 24 Sep 2026 12:14:51 -0400 Subject: [PATCH 08/21] List Tables: Add accessible scroll controls. Replace edge shadows with full-height buttons that scroll to adjacent columns. Keep their chevrons centered in the visible table area, respect reduced-motion preferences, and return focus to the table when a focused control reaches its boundary. Extend the QUnit coverage for directional paging, labels, focus handling, icon positioning, and cleanup after table replacement. Preserve the existing mobile layout. --- src/js/_enqueues/admin/common.js | 99 +++++++++++++++++- src/wp-admin/css/list-tables.css | 72 +++++++++---- tests/qunit/common.html | 2 +- tests/qunit/wp-admin/js/common.js | 162 ++++++++++++++++++++++++------ 4 files changed, 279 insertions(+), 56 deletions(-) diff --git a/src/js/_enqueues/admin/common.js b/src/js/_enqueues/admin/common.js index 5ac1e11a05d21..101a33a79a0d9 100644 --- a/src/js/_enqueues/admin/common.js +++ b/src/js/_enqueues/admin/common.js @@ -2251,6 +2251,28 @@ jQuery( function() { } const tables = new Map(); + const adminBar = document.getElementById( 'wpadminbar' ); + let iconFrame = 0; + + /** + * Positions the scroll icons once per animation frame. + * + * @since 7.2.0 + * + * @return {void} + */ + function scheduleIconPositions() { + if ( iconFrame ) { + return; + } + + iconFrame = window.requestAnimationFrame( function() { + iconFrame = 0; + tables.forEach( function( state ) { + state.positionIcons(); + } ); + } ); + } /** * Tracks a table's scroll position and dimensions. @@ -2260,11 +2282,61 @@ jQuery( function() { * @param {HTMLTableElement} table The table inside a scroll wrapper. * @return {void} */ - function addScrollShadow( table ) { + function addScrollControls( table ) { const wrapper = table.parentElement; + const edges = [ 'start', 'end' ].map( function( edge ) { + const strip = document.createElement( 'div' ); + const button = document.createElement( 'button' ); + const icon = document.createElement( 'span' ); + const label = edge === 'start' ? wp.i18n.__( 'Scroll to previous columns' ) : wp.i18n.__( 'Scroll to next columns' ); + + strip.className = 'wp-list-table-scroll-edge wp-list-table-scroll-edge-' + edge; + button.type = 'button'; + button.setAttribute( 'aria-label', label ); + button.title = label; + icon.className = 'dashicons dashicons-arrow-' + ( edge === 'start' ? 'left' : 'right' ) + '-alt2'; + icon.setAttribute( 'aria-hidden', 'true' ); + button.appendChild( icon ); + strip.appendChild( button ); + wrapper.appendChild( strip ); + + button.addEventListener( 'click', function() { + const isRTL = window.getComputedStyle( wrapper ).direction === 'rtl'; + const direction = ( edge === 'start' ? -1 : 1 ) * ( isRTL ? -1 : 1 ); + + wrapper.scrollBy( { + left: direction * Math.max( 1, wrapper.clientWidth - 64 ), + behavior: window.matchMedia( '(prefers-reduced-motion: reduce)' ).matches ? 'instant' : 'smooth' + } ); + } ); + + return { strip: strip, button: button, edge: edge }; + } ); /** - * Shows a shadow at each edge with hidden content. + * Centers the icons in the visible portion of the scroll controls. + * + * @since 7.2.0 + * + * @return {void} + */ + function positionIcons() { + const bounds = edges[ 0 ].button.getBoundingClientRect(); + const toolbarBottom = adminBar ? adminBar.getBoundingClientRect().bottom : 0; + const top = Math.max( bounds.top, toolbarBottom, 0 ); + const bottom = Math.min( bounds.bottom, window.innerHeight ); + + if ( bottom <= top || ! bounds.height ) { + return; + } + + // Dashicons are 20 pixels tall; keep the icon within its button. + const offset = Math.max( 0, Math.min( bounds.height - 20, ( top + bottom ) / 2 - bounds.top - 10 ) ); + wrapper.style.setProperty( '--wp-list-table-scroll-icon-top', offset + 'px' ); + } + + /** + * Shows a scroll control at each edge with hidden content. * * @since 7.2.0 * @@ -2275,13 +2347,24 @@ jQuery( function() { 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 ) ); + const focusedEdge = edges.find( function( edge ) { + return document.activeElement === edge.button; + } ); // 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 ); + + // Keep keyboard focus in the table when its scroll control disappears. + if ( focusedEdge && ! wrapper.classList.contains( 'has-scroll-overflow-' + focusedEdge.edge ) ) { + wrapper.focus( { preventScroll: true } ); + } } - const observer = new window.ResizeObserver( update ); + const observer = new window.ResizeObserver( function() { + update(); + scheduleIconPositions(); + } ); observer.observe( wrapper ); observer.observe( table ); wrapper.addEventListener( 'scroll', update, { passive: true } ); @@ -2289,10 +2372,15 @@ jQuery( function() { tables.set( table, { wrapper: wrapper, + positionIcons: positionIcons, cleanup: function() { observer.disconnect(); wrapper.removeEventListener( 'scroll', update ); wrapper.classList.remove( 'has-scroll-overflow-start', 'has-scroll-overflow-end' ); + wrapper.style.removeProperty( '--wp-list-table-scroll-icon-top' ); + edges.forEach( function( edge ) { + edge.strip.remove(); + } ); } } ); } @@ -2314,12 +2402,15 @@ jQuery( function() { content.querySelectorAll( '.wp-list-table-scroll > .wp-list-table' ).forEach( function( table ) { if ( ! tables.has( table ) ) { - addScrollShadow( table ); + addScrollControls( table ); } } ); + scheduleIconPositions(); } refreshTables(); + window.addEventListener( 'scroll', scheduleIconPositions, { passive: true, capture: true } ); + window.addEventListener( 'resize', scheduleIconPositions ); // Plugins live search replaces the table and its wrapper. const observer = new window.MutationObserver( refreshTables ); diff --git a/src/wp-admin/css/list-tables.css b/src/wp-admin/css/list-tables.css index 6d408f0e93ff9..4acd412bdce9f 100644 --- a/src/wp-admin/css/list-tables.css +++ b/src/wp-admin/css/list-tables.css @@ -1790,12 +1790,17 @@ div.action-links, /* =Media Queries -------------------------------------------------------------- */ +.wp-list-table-scroll-edge { + display: none; +} + @media screen and (min-width: 783px) { .wp-list-table-scroll { clear: both; overflow-x: auto; overflow-y: hidden; overscroll-behavior-x: none; + scroll-padding-inline: 32px; border: 1px solid #c3c4c7; box-shadow: 0 1px 1px rgba(0, 0, 0, 0.04); } @@ -1867,48 +1872,75 @@ div.action-links, min-width: 20em; } - /* Share the table's scrollable area so the shadows stick to the viewport. */ + /* Share the table's scrollable area so the controls stick to the viewport. */ .wp-list-table-scroll { display: grid; } .wp-list-table-scroll > .widefat, - .wp-list-table-scroll::before, - .wp-list-table-scroll::after { + .wp-list-table-scroll-edge { grid-area: 1 / 1; } - .wp-list-table-scroll::before, - .wp-list-table-scroll::after { - content: ""; + .wp-list-table-scroll-edge { + display: flex; position: sticky; - width: 12px; + width: 32px; + box-sizing: border-box; + background: #fff; z-index: 1; - pointer-events: none; - opacity: 0; + visibility: hidden; } - .wp-list-table-scroll::before { + .wp-list-table-scroll-edge-start { inset-inline-start: 0; justify-self: start; - /* rtl:ignore */ - background: linear-gradient(to right, rgba(0, 0, 0, 0.12), transparent); + border-inline-end: 1px solid #c3c4c7; } - .wp-list-table-scroll::after { + .wp-list-table-scroll-edge-end { inset-inline-end: 0; justify-self: end; - /* rtl:ignore */ - background: linear-gradient(to left, rgba(0, 0, 0, 0.12), transparent); + border-inline-start: 1px solid #c3c4c7; + } + + .wp-list-table-scroll.has-scroll-overflow-start > .wp-list-table-scroll-edge-start, + .wp-list-table-scroll.has-scroll-overflow-end > .wp-list-table-scroll-edge-end { + visibility: visible; + } + + .wp-list-table-scroll-edge button { + display: flex; + position: relative; + /* The pinned controls already occupy the space reserved by scroll padding. */ + scroll-margin-inline: -32px; + align-items: flex-start; + justify-content: center; + width: 100%; + margin: 0; + padding: 0; + border: 0; + background: #fff; + color: #3c434a; + cursor: pointer; } - .wp-list-table-scroll.has-scroll-overflow-start::before, - .wp-list-table-scroll.has-scroll-overflow-end::after { - opacity: 1; + .wp-list-table-scroll-edge .dashicons { + position: absolute; + top: var(--wp-list-table-scroll-icon-top, calc(50% - 10px)); + } + + .wp-list-table-scroll-edge button:hover { + background: #f0f0f1; + color: #135e96; + } + + .wp-list-table-scroll-edge button:focus-visible { + outline: 2px solid var(--wp-admin-theme-color); + outline-offset: -2px; } - .rtl .wp-list-table-scroll::before, - .rtl .wp-list-table-scroll::after { + .rtl .wp-list-table-scroll-edge .dashicons { transform: scaleX(-1); } } diff --git a/tests/qunit/common.html b/tests/qunit/common.html index 39284e443b81e..9d1b50c05764c 100644 --- a/tests/qunit/common.html +++ b/tests/qunit/common.html @@ -39,7 +39,7 @@
      -
      Post title
      +
      Post title
      diff --git a/tests/qunit/wp-admin/js/common.js b/tests/qunit/wp-admin/js/common.js index d55e40787d664..7ddce162ace7e 100644 --- a/tests/qunit/wp-admin/js/common.js +++ b/tests/qunit/wp-admin/js/common.js @@ -2,9 +2,9 @@ jQuery( function() { const content = document.getElementById( 'wpbody-content' ); const tableMarkup = '
      Post title
      '; - const wrapperMarkup = '
      ' + tableMarkup + '
      '; + const wrapperMarkup = '
      ' + tableMarkup + '
      '; - function shadows( wrapper ) { + function overflow( wrapper ) { return [ wrapper.classList.contains( 'has-scroll-overflow-start' ), wrapper.classList.contains( 'has-scroll-overflow-end' ) @@ -25,9 +25,9 @@ jQuery( function() { } ); } - const initialShadows = shadows( content.firstElementChild ); + const initialOverflow = overflow( content.firstElementChild ); - QUnit.module( 'List table scroll shadows', { + QUnit.module( 'List table scroll controls', { beforeEach: async function() { this.sandbox = sinon.createSandbox(); content.innerHTML = wrapperMarkup; @@ -43,27 +43,63 @@ jQuery( function() { } } ); - QUnit.test( 'Initializes shadows for tables present on page load', function( assert ) { - assert.deepEqual( initialShadows, [ false, true ], 'Only the end has hidden content on page load.' ); + 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.' ); + } ); + + QUnit.test( 'Creates native buttons with accessible labels', function( assert ) { + const wrapper = this.wrapper; + const labels = [ 'Scroll to previous columns', 'Scroll to next columns' ]; + + [ 'start', 'end' ].forEach( function( edge, index ) { + const button = wrapper.querySelector( '.wp-list-table-scroll-edge-' + edge ).firstElementChild; + assert.strictEqual( button.tagName, 'BUTTON', 'The ' + edge + ' control is a native button.' ); + assert.strictEqual( button.type, 'button', 'The control does not submit a form.' ); + assert.strictEqual( button.getAttribute( 'aria-label' ), labels[ index ], 'The button identifies its scroll direction.' ); + assert.strictEqual( button.title, labels[ index ], 'The tooltip matches the accessible label.' ); + assert.strictEqual( button.firstElementChild.getAttribute( 'aria-hidden' ), 'true', 'The decorative icon is hidden from assistive technology.' ); + } ); } ); [ 'ltr', 'rtl' ].forEach( function( direction ) { - QUnit.test( 'Updates both shadows when scrolling in ' + direction, function( assert ) { + QUnit.test( 'Scrolls by a viewport with overlap in ' + direction, async function( assert ) { + const wrapper = this.wrapper; + const sign = direction === 'rtl' ? -1 : 1; + const previous = wrapper.querySelector( '.wp-list-table-scroll-edge-start button' ); + const next = wrapper.querySelector( '.wp-list-table-scroll-edge-end button' ); + this.sandbox.stub( window, 'matchMedia' ).callThrough() + .withArgs( '(prefers-reduced-motion: reduce)' ).returns( { matches: true } ); + wrapper.dir = direction; + this.table.style.width = '2400px'; + await afterResize(); + scrollTo( wrapper, 0 ); + + next.click(); + assert.strictEqual( wrapper.scrollLeft, sign * 736, 'Next advances through the 800px viewport with 64px of overlap.' ); + + next.click(); + assert.strictEqual( wrapper.scrollLeft, sign * 1472, 'Another click advances from the current position.' ); + + previous.click(); + assert.strictEqual( wrapper.scrollLeft, sign * 736, 'Previous reverses the scroll 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( shadows( wrapper ), [ false, true ], 'Only the end has hidden content at the start.' ); + assert.deepEqual( overflow( wrapper ), [ false, true ], 'Only the end has hidden content at the start.' ); scrollTo( wrapper, sign * 200 ); - assert.deepEqual( shadows( wrapper ), [ true, true ], 'Both ends have hidden content in the middle.' ); + assert.deepEqual( overflow( wrapper ), [ true, true ], 'Both ends have hidden content in the middle.' ); scrollTo( wrapper, sign * 400 ); - assert.deepEqual( shadows( wrapper ), [ true, false ], 'Only the start has hidden content at the end.' ); + assert.deepEqual( overflow( wrapper ), [ true, false ], 'Only the start has hidden content at the end.' ); scrollTo( wrapper, 0 ); - assert.deepEqual( shadows( wrapper ), [ false, true ], 'Returning to the start clears its shadow.' ); + 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 ) { @@ -75,41 +111,98 @@ jQuery( function() { Object.defineProperty( wrapper, 'scrollLeft', { configurable: true, writable: true, value: 0 } ); scrollTo( wrapper, sign * 0.5 ); - assert.deepEqual( shadows( wrapper ), [ false, true ], 'A fractional offset at the start does not show a shadow.' ); + 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( shadows( wrapper ), [ true, true ], 'Content beyond the tolerance shows the start shadow.' ); + assert.deepEqual( overflow( wrapper ), [ true, true ], 'Content beyond the tolerance shows the start overflow indicator.' ); scrollTo( wrapper, sign * 398.5 ); - assert.deepEqual( shadows( wrapper ), [ true, true ], 'Content beyond the tolerance shows the end shadow.' ); + assert.deepEqual( overflow( wrapper ), [ true, true ], 'Content beyond the tolerance shows the end overflow indicator.' ); scrollTo( wrapper, sign * 399.5 ); - assert.deepEqual( shadows( wrapper ), [ true, false ], 'A fractional offset at the end does not show a shadow.' ); + assert.deepEqual( overflow( wrapper ), [ true, false ], 'A fractional offset at the end does not show an overflow indicator.' ); scrollTo( wrapper, sign * -20 ); - assert.deepEqual( shadows( wrapper ), [ false, true ], 'Overscrolling past the start does not show a start shadow.' ); + assert.deepEqual( overflow( wrapper ), [ false, true ], 'Overscrolling past the start does not show a start overflow indicator.' ); scrollTo( wrapper, sign * 420 ); - assert.deepEqual( shadows( wrapper ), [ true, false ], 'Overscrolling past the end does not show an end shadow.' ); + 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( shadows( wrapper ), [ false, false ], 'A fitting table has no shadows even with a stale scroll offset.' ); + assert.deepEqual( overflow( wrapper ), [ false, false ], 'A fitting table has no overflow indicators even with a stale scroll offset.' ); + } ); + } ); + + QUnit.test( 'Uses the current reduced-motion preference for button scrolling', function( assert ) { + const scrollBy = this.sandbox.stub( this.wrapper, 'scrollBy' ); + const preference = { matches: false }; + const next = this.wrapper.querySelector( '.wp-list-table-scroll-edge-end button' ); + this.sandbox.stub( window, 'matchMedia' ).callThrough() + .withArgs( '(prefers-reduced-motion: reduce)' ).returns( preference ); + + next.click(); + assert.deepEqual( scrollBy.firstCall.args, [ { left: 736, behavior: 'smooth' } ], 'Scrolling is smooth without reduced motion.' ); + + preference.matches = true; + next.click(); + assert.deepEqual( scrollBy.secondCall.args, [ { left: 736, behavior: 'instant' } ], 'Enabling reduced motion makes the next scroll immediate.' ); + } ); + + QUnit.test( 'Returns focus to the wrapper when a focused control disappears', async function( assert ) { + const wrapper = this.wrapper; + + [ 'start', 'end' ].forEach( function( edge ) { + const button = wrapper.querySelector( '.wp-list-table-scroll-edge-' + edge + ' button' ); + scrollTo( wrapper, 200 ); + button.focus( { preventScroll: true } ); + assert.strictEqual( document.activeElement, button, 'The ' + edge + ' control receives keyboard focus.' ); + + scrollTo( wrapper, 250 ); + assert.strictEqual( document.activeElement, button, 'Focus stays on a control while its edge still overflows.' ); + + scrollTo( wrapper, edge === 'start' ? 0 : 400 ); + assert.strictEqual( document.activeElement, wrapper, 'Focus returns to the wrapper at the ' + edge + ' boundary.' ); } ); + + scrollTo( wrapper, 0 ); + wrapper.querySelector( '.wp-list-table-scroll-edge-end button' ).focus( { preventScroll: true } ); + this.table.style.width = '600px'; + await afterResize(); + assert.strictEqual( document.activeElement, wrapper, 'Removing overflow on resize also returns focus to the wrapper.' ); + } ); + + QUnit.test( 'Centers icons in the visible area below the admin toolbar', async function( assert ) { + const button = this.wrapper.querySelector( '.wp-list-table-scroll-edge-start button' ); + const bounds = this.sandbox.stub( button, 'getBoundingClientRect' ); + // Supply viewport bounds independently of the off-screen fixture. + this.sandbox.stub( window, 'innerHeight' ).value( 600 ); + this.sandbox.stub( document.getElementById( 'wpadminbar' ), 'getBoundingClientRect' ) + .returns( new window.DOMRect( 0, 0, 800, 32 ) ); + + bounds.returns( new window.DOMRect( 0, 100, 32, 1000 ) ); + window.dispatchEvent( new Event( 'scroll' ) ); + await afterResize(); + assert.strictEqual( this.wrapper.style.getPropertyValue( '--wp-list-table-scroll-icon-top' ), '240px', 'The icon is centered in the visible part of a tall table.' ); + + bounds.returns( new window.DOMRect( 0, -100, 32, 1000 ) ); + window.dispatchEvent( new Event( 'scroll' ) ); + await afterResize(); + assert.strictEqual( this.wrapper.style.getPropertyValue( '--wp-list-table-scroll-icon-top' ), '406px', 'Page scrolling keeps the icon below the toolbar and within the viewport.' ); } ); - QUnit.test( 'Updates shadows when the table or its viewport changes width', async function( assert ) { + QUnit.test( 'Updates overflow indicators when the table or its viewport changes width', async function( assert ) { this.table.style.width = '600px'; await afterResize(); - assert.deepEqual( shadows( this.wrapper ), [ false, false ], 'Shrinking the table clears the shadow without a scroll event.' ); + assert.deepEqual( overflow( this.wrapper ), [ false, false ], 'Shrinking the table clears the overflow indicator without a scroll event.' ); this.wrapper.style.width = '400px'; await afterResize(); - assert.deepEqual( shadows( this.wrapper ), [ false, true ], 'Shrinking the viewport reveals overflow without a scroll event.' ); + assert.deepEqual( overflow( this.wrapper ), [ false, true ], 'Shrinking the viewport reveals overflow without a scroll event.' ); this.wrapper.style.width = '800px'; await afterResize(); - assert.deepEqual( shadows( this.wrapper ), [ false, false ], 'Widening the viewport clears the shadow.' ); + assert.deepEqual( overflow( this.wrapper ), [ false, false ], 'Widening the viewport clears the overflow indicator.' ); } ); QUnit.test( 'Cleans up a replaced wrapper and initializes its replacement', async function( assert ) { @@ -121,15 +214,18 @@ jQuery( function() { // Preserve overflow outside the observed content to expose a leaked scroll listener. document.getElementById( 'qunit-fixture' ).appendChild( oldWrapper ); await afterResize(); - assert.deepEqual( shadows( content.firstElementChild ), [ false, true ], 'The replacement is initialized.' ); - assert.deepEqual( shadows( oldWrapper ), [ false, false ], 'The old wrapper loses its shadows.' ); + 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( oldWrapper.querySelectorAll( '.wp-list-table-scroll-edge' ).length, 0, 'The old controls are removed.' ); + assert.strictEqual( oldWrapper.style.getPropertyValue( '--wp-list-table-scroll-icon-top' ), '', 'The old icon position is cleared.' ); + assert.strictEqual( content.firstElementChild.querySelectorAll( '.wp-list-table-scroll-edge' ).length, 2, 'The replacement has one pair of controls.' ); assert.strictEqual( disconnect.callCount, 1, 'The old resize observer is disconnected.' ); scrollTo( oldWrapper, 200 ); - assert.deepEqual( shadows( oldWrapper ), [ false, false ], 'Scrolling the removed wrapper no longer updates shadows.' ); + assert.deepEqual( overflow( oldWrapper ), [ false, false ], 'Scrolling the removed wrapper no longer updates overflow indicators.' ); scrollTo( content.firstElementChild, 200 ); - assert.deepEqual( shadows( content.firstElementChild ), [ true, true ], 'The replacement responds to scrolling.' ); + 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 ) { @@ -140,18 +236,20 @@ jQuery( function() { 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.' ); + assert.strictEqual( this.wrapper.querySelectorAll( '.wp-list-table-scroll-edge' ).length, 2, 'Changing a row does not duplicate controls.' ); 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.' ); + assert.strictEqual( this.wrapper.querySelectorAll( '.wp-list-table-scroll-edge' ).length, 2, 'Replacing the table creates one pair of controls.' ); this.wrapper.firstElementChild.style.width = '600px'; await afterResize(); - assert.deepEqual( shadows( this.wrapper ), [ false, false ], 'Resizing the replacement table updates its shadows.' ); + assert.deepEqual( overflow( this.wrapper ), [ false, false ], 'Resizing the replacement table updates its overflow indicators.' ); } ); - QUnit.test( 'Moves shadow tracking when a table moves to another wrapper', async function( assert ) { + QUnit.test( 'Moves scroll controls 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'; @@ -160,11 +258,13 @@ jQuery( function() { content.appendChild( newWrapper ); newWrapper.appendChild( this.table ); await afterResize(); - assert.deepEqual( shadows( oldWrapper ), [ false, false ], 'The old wrapper loses its shadows.' ); - assert.deepEqual( shadows( newWrapper ), [ false, true ], 'The new wrapper is initialized.' ); + assert.deepEqual( overflow( oldWrapper ), [ false, false ], 'The old wrapper loses its overflow indicators.' ); + assert.deepEqual( overflow( newWrapper ), [ false, true ], 'The new wrapper is initialized.' ); + assert.strictEqual( oldWrapper.querySelectorAll( '.wp-list-table-scroll-edge' ).length, 0, 'The old wrapper loses its controls.' ); + assert.strictEqual( newWrapper.querySelectorAll( '.wp-list-table-scroll-edge' ).length, 2, 'The new wrapper has one pair of controls.' ); scrollTo( newWrapper, 200 ); - assert.deepEqual( shadows( newWrapper ), [ true, true ], 'The new wrapper responds to scrolling.' ); + assert.deepEqual( overflow( newWrapper ), [ true, true ], 'The new wrapper responds to scrolling.' ); } ); QUnit.start(); From 4009efc7988f812e552477116e431fd89bdd656a Mon Sep 17 00:00:00 2001 From: mattmiklic Date: Fri, 25 Sep 2026 16:50:57 -0400 Subject: [PATCH 09/21] List Tables: Replace scroll controls with edge fades. --- src/js/_enqueues/admin/common.js | 99 ++------------------------- src/wp-admin/css/list-tables.css | 70 ++++++------------- tests/qunit/common.html | 2 +- tests/qunit/wp-admin/js/common.js | 107 ++---------------------------- 4 files changed, 28 insertions(+), 250 deletions(-) diff --git a/src/js/_enqueues/admin/common.js b/src/js/_enqueues/admin/common.js index 101a33a79a0d9..cf95df5249ed6 100644 --- a/src/js/_enqueues/admin/common.js +++ b/src/js/_enqueues/admin/common.js @@ -2251,28 +2251,6 @@ jQuery( function() { } const tables = new Map(); - const adminBar = document.getElementById( 'wpadminbar' ); - let iconFrame = 0; - - /** - * Positions the scroll icons once per animation frame. - * - * @since 7.2.0 - * - * @return {void} - */ - function scheduleIconPositions() { - if ( iconFrame ) { - return; - } - - iconFrame = window.requestAnimationFrame( function() { - iconFrame = 0; - tables.forEach( function( state ) { - state.positionIcons(); - } ); - } ); - } /** * Tracks a table's scroll position and dimensions. @@ -2282,61 +2260,11 @@ jQuery( function() { * @param {HTMLTableElement} table The table inside a scroll wrapper. * @return {void} */ - function addScrollControls( table ) { + function observeTableOverflow( table ) { const wrapper = table.parentElement; - const edges = [ 'start', 'end' ].map( function( edge ) { - const strip = document.createElement( 'div' ); - const button = document.createElement( 'button' ); - const icon = document.createElement( 'span' ); - const label = edge === 'start' ? wp.i18n.__( 'Scroll to previous columns' ) : wp.i18n.__( 'Scroll to next columns' ); - - strip.className = 'wp-list-table-scroll-edge wp-list-table-scroll-edge-' + edge; - button.type = 'button'; - button.setAttribute( 'aria-label', label ); - button.title = label; - icon.className = 'dashicons dashicons-arrow-' + ( edge === 'start' ? 'left' : 'right' ) + '-alt2'; - icon.setAttribute( 'aria-hidden', 'true' ); - button.appendChild( icon ); - strip.appendChild( button ); - wrapper.appendChild( strip ); - - button.addEventListener( 'click', function() { - const isRTL = window.getComputedStyle( wrapper ).direction === 'rtl'; - const direction = ( edge === 'start' ? -1 : 1 ) * ( isRTL ? -1 : 1 ); - - wrapper.scrollBy( { - left: direction * Math.max( 1, wrapper.clientWidth - 64 ), - behavior: window.matchMedia( '(prefers-reduced-motion: reduce)' ).matches ? 'instant' : 'smooth' - } ); - } ); - - return { strip: strip, button: button, edge: edge }; - } ); /** - * Centers the icons in the visible portion of the scroll controls. - * - * @since 7.2.0 - * - * @return {void} - */ - function positionIcons() { - const bounds = edges[ 0 ].button.getBoundingClientRect(); - const toolbarBottom = adminBar ? adminBar.getBoundingClientRect().bottom : 0; - const top = Math.max( bounds.top, toolbarBottom, 0 ); - const bottom = Math.min( bounds.bottom, window.innerHeight ); - - if ( bottom <= top || ! bounds.height ) { - return; - } - - // Dashicons are 20 pixels tall; keep the icon within its button. - const offset = Math.max( 0, Math.min( bounds.height - 20, ( top + bottom ) / 2 - bounds.top - 10 ) ); - wrapper.style.setProperty( '--wp-list-table-scroll-icon-top', offset + 'px' ); - } - - /** - * Shows a scroll control at each edge with hidden content. + * Updates the overflow indicator at each edge with hidden content. * * @since 7.2.0 * @@ -2347,24 +2275,13 @@ jQuery( function() { 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 ) ); - const focusedEdge = edges.find( function( edge ) { - return document.activeElement === edge.button; - } ); // 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 ); - - // Keep keyboard focus in the table when its scroll control disappears. - if ( focusedEdge && ! wrapper.classList.contains( 'has-scroll-overflow-' + focusedEdge.edge ) ) { - wrapper.focus( { preventScroll: true } ); - } } - const observer = new window.ResizeObserver( function() { - update(); - scheduleIconPositions(); - } ); + const observer = new window.ResizeObserver( update ); observer.observe( wrapper ); observer.observe( table ); wrapper.addEventListener( 'scroll', update, { passive: true } ); @@ -2372,15 +2289,10 @@ jQuery( function() { tables.set( table, { wrapper: wrapper, - positionIcons: positionIcons, cleanup: function() { observer.disconnect(); wrapper.removeEventListener( 'scroll', update ); wrapper.classList.remove( 'has-scroll-overflow-start', 'has-scroll-overflow-end' ); - wrapper.style.removeProperty( '--wp-list-table-scroll-icon-top' ); - edges.forEach( function( edge ) { - edge.strip.remove(); - } ); } } ); } @@ -2402,15 +2314,12 @@ jQuery( function() { content.querySelectorAll( '.wp-list-table-scroll > .wp-list-table' ).forEach( function( table ) { if ( ! tables.has( table ) ) { - addScrollControls( table ); + observeTableOverflow( table ); } } ); - scheduleIconPositions(); } refreshTables(); - window.addEventListener( 'scroll', scheduleIconPositions, { passive: true, capture: true } ); - window.addEventListener( 'resize', scheduleIconPositions ); // Plugins live search replaces the table and its wrapper. const observer = new window.MutationObserver( refreshTables ); diff --git a/src/wp-admin/css/list-tables.css b/src/wp-admin/css/list-tables.css index 4acd412bdce9f..cf931938660d1 100644 --- a/src/wp-admin/css/list-tables.css +++ b/src/wp-admin/css/list-tables.css @@ -1790,17 +1790,12 @@ div.action-links, /* =Media Queries -------------------------------------------------------------- */ -.wp-list-table-scroll-edge { - display: none; -} - @media screen and (min-width: 783px) { .wp-list-table-scroll { clear: both; overflow-x: auto; overflow-y: hidden; overscroll-behavior-x: none; - scroll-padding-inline: 32px; border: 1px solid #c3c4c7; box-shadow: 0 1px 1px rgba(0, 0, 0, 0.04); } @@ -1872,75 +1867,48 @@ div.action-links, min-width: 20em; } - /* Share the table's scrollable area so the controls stick to the viewport. */ + /* Share the table's scrollable area so the fades stick to the viewport. */ .wp-list-table-scroll { display: grid; } .wp-list-table-scroll > .widefat, - .wp-list-table-scroll-edge { + .wp-list-table-scroll::before, + .wp-list-table-scroll::after { grid-area: 1 / 1; } - .wp-list-table-scroll-edge { - display: flex; + .wp-list-table-scroll::before, + .wp-list-table-scroll::after { + content: ""; position: sticky; width: 32px; - box-sizing: border-box; - background: #fff; z-index: 1; - visibility: hidden; + pointer-events: none; + opacity: 0; } - .wp-list-table-scroll-edge-start { + .wp-list-table-scroll::before { inset-inline-start: 0; justify-self: start; - border-inline-end: 1px solid #c3c4c7; + /* rtl:ignore */ + background: linear-gradient(to right, #fff, rgba(255, 255, 255, 0)); } - .wp-list-table-scroll-edge-end { + .wp-list-table-scroll::after { inset-inline-end: 0; justify-self: end; - border-inline-start: 1px solid #c3c4c7; - } - - .wp-list-table-scroll.has-scroll-overflow-start > .wp-list-table-scroll-edge-start, - .wp-list-table-scroll.has-scroll-overflow-end > .wp-list-table-scroll-edge-end { - visibility: visible; - } - - .wp-list-table-scroll-edge button { - display: flex; - position: relative; - /* The pinned controls already occupy the space reserved by scroll padding. */ - scroll-margin-inline: -32px; - align-items: flex-start; - justify-content: center; - width: 100%; - margin: 0; - padding: 0; - border: 0; - background: #fff; - color: #3c434a; - cursor: pointer; + /* rtl:ignore */ + background: linear-gradient(to left, #fff, rgba(255, 255, 255, 0)); } - .wp-list-table-scroll-edge .dashicons { - position: absolute; - top: var(--wp-list-table-scroll-icon-top, calc(50% - 10px)); - } - - .wp-list-table-scroll-edge button:hover { - background: #f0f0f1; - color: #135e96; - } - - .wp-list-table-scroll-edge button:focus-visible { - outline: 2px solid var(--wp-admin-theme-color); - outline-offset: -2px; + .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-edge .dashicons { + .rtl .wp-list-table-scroll::before, + .rtl .wp-list-table-scroll::after { transform: scaleX(-1); } } diff --git a/tests/qunit/common.html b/tests/qunit/common.html index 9d1b50c05764c..39284e443b81e 100644 --- a/tests/qunit/common.html +++ b/tests/qunit/common.html @@ -39,7 +39,7 @@
        -
        Post title
        +
        Post title
        diff --git a/tests/qunit/wp-admin/js/common.js b/tests/qunit/wp-admin/js/common.js index 7ddce162ace7e..dc305145c5d04 100644 --- a/tests/qunit/wp-admin/js/common.js +++ b/tests/qunit/wp-admin/js/common.js @@ -2,7 +2,7 @@ jQuery( function() { const content = document.getElementById( 'wpbody-content' ); const tableMarkup = '
        Post title
        '; - const wrapperMarkup = '
        ' + tableMarkup + '
        '; + const wrapperMarkup = '
        ' + tableMarkup + '
        '; function overflow( wrapper ) { return [ @@ -27,7 +27,7 @@ jQuery( function() { const initialOverflow = overflow( content.firstElementChild ); - QUnit.module( 'List table scroll controls', { + QUnit.module( 'List table overflow indicators', { beforeEach: async function() { this.sandbox = sinon.createSandbox(); content.innerHTML = wrapperMarkup; @@ -45,45 +45,10 @@ jQuery( function() { 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.' ); - } ); - - QUnit.test( 'Creates native buttons with accessible labels', function( assert ) { - const wrapper = this.wrapper; - const labels = [ 'Scroll to previous columns', 'Scroll to next columns' ]; - - [ 'start', 'end' ].forEach( function( edge, index ) { - const button = wrapper.querySelector( '.wp-list-table-scroll-edge-' + edge ).firstElementChild; - assert.strictEqual( button.tagName, 'BUTTON', 'The ' + edge + ' control is a native button.' ); - assert.strictEqual( button.type, 'button', 'The control does not submit a form.' ); - assert.strictEqual( button.getAttribute( 'aria-label' ), labels[ index ], 'The button identifies its scroll direction.' ); - assert.strictEqual( button.title, labels[ index ], 'The tooltip matches the accessible label.' ); - assert.strictEqual( button.firstElementChild.getAttribute( 'aria-hidden' ), 'true', 'The decorative icon is hidden from assistive technology.' ); - } ); + assert.strictEqual( this.wrapper.childElementCount, 1, 'Initialization leaves only the table inside its wrapper.' ); } ); [ 'ltr', 'rtl' ].forEach( function( direction ) { - QUnit.test( 'Scrolls by a viewport with overlap in ' + direction, async function( assert ) { - const wrapper = this.wrapper; - const sign = direction === 'rtl' ? -1 : 1; - const previous = wrapper.querySelector( '.wp-list-table-scroll-edge-start button' ); - const next = wrapper.querySelector( '.wp-list-table-scroll-edge-end button' ); - this.sandbox.stub( window, 'matchMedia' ).callThrough() - .withArgs( '(prefers-reduced-motion: reduce)' ).returns( { matches: true } ); - wrapper.dir = direction; - this.table.style.width = '2400px'; - await afterResize(); - scrollTo( wrapper, 0 ); - - next.click(); - assert.strictEqual( wrapper.scrollLeft, sign * 736, 'Next advances through the 800px viewport with 64px of overlap.' ); - - next.click(); - assert.strictEqual( wrapper.scrollLeft, sign * 1472, 'Another click advances from the current position.' ); - - previous.click(); - assert.strictEqual( wrapper.scrollLeft, sign * 736, 'Previous reverses the scroll direction.' ); - } ); - QUnit.test( 'Updates both overflow indicators when scrolling in ' + direction, function( assert ) { const wrapper = this.wrapper; const sign = direction === 'rtl' ? -1 : 1; @@ -134,63 +99,6 @@ jQuery( function() { } ); } ); - QUnit.test( 'Uses the current reduced-motion preference for button scrolling', function( assert ) { - const scrollBy = this.sandbox.stub( this.wrapper, 'scrollBy' ); - const preference = { matches: false }; - const next = this.wrapper.querySelector( '.wp-list-table-scroll-edge-end button' ); - this.sandbox.stub( window, 'matchMedia' ).callThrough() - .withArgs( '(prefers-reduced-motion: reduce)' ).returns( preference ); - - next.click(); - assert.deepEqual( scrollBy.firstCall.args, [ { left: 736, behavior: 'smooth' } ], 'Scrolling is smooth without reduced motion.' ); - - preference.matches = true; - next.click(); - assert.deepEqual( scrollBy.secondCall.args, [ { left: 736, behavior: 'instant' } ], 'Enabling reduced motion makes the next scroll immediate.' ); - } ); - - QUnit.test( 'Returns focus to the wrapper when a focused control disappears', async function( assert ) { - const wrapper = this.wrapper; - - [ 'start', 'end' ].forEach( function( edge ) { - const button = wrapper.querySelector( '.wp-list-table-scroll-edge-' + edge + ' button' ); - scrollTo( wrapper, 200 ); - button.focus( { preventScroll: true } ); - assert.strictEqual( document.activeElement, button, 'The ' + edge + ' control receives keyboard focus.' ); - - scrollTo( wrapper, 250 ); - assert.strictEqual( document.activeElement, button, 'Focus stays on a control while its edge still overflows.' ); - - scrollTo( wrapper, edge === 'start' ? 0 : 400 ); - assert.strictEqual( document.activeElement, wrapper, 'Focus returns to the wrapper at the ' + edge + ' boundary.' ); - } ); - - scrollTo( wrapper, 0 ); - wrapper.querySelector( '.wp-list-table-scroll-edge-end button' ).focus( { preventScroll: true } ); - this.table.style.width = '600px'; - await afterResize(); - assert.strictEqual( document.activeElement, wrapper, 'Removing overflow on resize also returns focus to the wrapper.' ); - } ); - - QUnit.test( 'Centers icons in the visible area below the admin toolbar', async function( assert ) { - const button = this.wrapper.querySelector( '.wp-list-table-scroll-edge-start button' ); - const bounds = this.sandbox.stub( button, 'getBoundingClientRect' ); - // Supply viewport bounds independently of the off-screen fixture. - this.sandbox.stub( window, 'innerHeight' ).value( 600 ); - this.sandbox.stub( document.getElementById( 'wpadminbar' ), 'getBoundingClientRect' ) - .returns( new window.DOMRect( 0, 0, 800, 32 ) ); - - bounds.returns( new window.DOMRect( 0, 100, 32, 1000 ) ); - window.dispatchEvent( new Event( 'scroll' ) ); - await afterResize(); - assert.strictEqual( this.wrapper.style.getPropertyValue( '--wp-list-table-scroll-icon-top' ), '240px', 'The icon is centered in the visible part of a tall table.' ); - - bounds.returns( new window.DOMRect( 0, -100, 32, 1000 ) ); - window.dispatchEvent( new Event( 'scroll' ) ); - await afterResize(); - assert.strictEqual( this.wrapper.style.getPropertyValue( '--wp-list-table-scroll-icon-top' ), '406px', 'Page scrolling keeps the icon below the toolbar and within the viewport.' ); - } ); - QUnit.test( 'Updates overflow indicators when the table or its viewport changes width', async function( assert ) { this.table.style.width = '600px'; await afterResize(); @@ -216,9 +124,6 @@ jQuery( function() { 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( oldWrapper.querySelectorAll( '.wp-list-table-scroll-edge' ).length, 0, 'The old controls are removed.' ); - assert.strictEqual( oldWrapper.style.getPropertyValue( '--wp-list-table-scroll-icon-top' ), '', 'The old icon position is cleared.' ); - assert.strictEqual( content.firstElementChild.querySelectorAll( '.wp-list-table-scroll-edge' ).length, 2, 'The replacement has one pair of controls.' ); assert.strictEqual( disconnect.callCount, 1, 'The old resize observer is disconnected.' ); scrollTo( oldWrapper, 200 ); @@ -236,20 +141,18 @@ jQuery( function() { 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.' ); - assert.strictEqual( this.wrapper.querySelectorAll( '.wp-list-table-scroll-edge' ).length, 2, 'Changing a row does not duplicate controls.' ); 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.' ); - assert.strictEqual( this.wrapper.querySelectorAll( '.wp-list-table-scroll-edge' ).length, 2, 'Replacing the table creates one pair of controls.' ); this.wrapper.firstElementChild.style.width = '600px'; await afterResize(); assert.deepEqual( overflow( this.wrapper ), [ false, false ], 'Resizing the replacement table updates its overflow indicators.' ); } ); - QUnit.test( 'Moves scroll controls when a table moves to another wrapper', async function( assert ) { + 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'; @@ -260,8 +163,6 @@ jQuery( function() { 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.' ); - assert.strictEqual( oldWrapper.querySelectorAll( '.wp-list-table-scroll-edge' ).length, 0, 'The old wrapper loses its controls.' ); - assert.strictEqual( newWrapper.querySelectorAll( '.wp-list-table-scroll-edge' ).length, 2, 'The new wrapper has one pair of controls.' ); scrollTo( newWrapper, 200 ); assert.deepEqual( overflow( newWrapper ), [ true, true ], 'The new wrapper responds to scrolling.' ); From 19a7caac96257f80f02db4dd202c1c9ca2ed8c39 Mon Sep 17 00:00:00 2001 From: mattmiklic Date: Fri, 25 Sep 2026 17:25:11 -0400 Subject: [PATCH 10/21] List Tables: Preserve keyboard focus at mobile widths. --- src/wp-admin/css/list-tables.css | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/src/wp-admin/css/list-tables.css b/src/wp-admin/css/list-tables.css index cf931938660d1..723b6b57e62c0 100644 --- a/src/wp-admin/css/list-tables.css +++ b/src/wp-admin/css/list-tables.css @@ -1787,6 +1787,11 @@ div.action-links, width: 80px; } +.wp-list-table-scroll:focus-visible { + outline: 2px solid var(--wp-admin-theme-color); + outline-offset: -2px; +} + /* =Media Queries -------------------------------------------------------------- */ @@ -1805,11 +1810,6 @@ div.action-links, box-shadow: none; } - .wp-list-table-scroll:focus-visible { - outline: 2px solid var(--wp-admin-theme-color); - outline-offset: -2px; - } - .wp-list-table-scroll > .fixed { table-layout: auto; } From 3b07994c178874a0ad0bf21a05e1b584ab725a4f Mon Sep 17 00:00:00 2001 From: mattmiklic Date: Wed, 30 Sep 2026 12:02:56 -0400 Subject: [PATCH 11/21] List Tables: Keep sorting focus rings visible. Draw focus indicators inside sortable links so the scroll viewport does not clip them. Move the high-contrast outline inward as well. --- src/wp-admin/css/list-tables.css | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/src/wp-admin/css/list-tables.css b/src/wp-admin/css/list-tables.css index 723b6b57e62c0..e92f63c93a495 100644 --- a/src/wp-admin/css/list-tables.css +++ b/src/wp-admin/css/list-tables.css @@ -1810,6 +1810,13 @@ div.action-links, 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; } From 6fce64227349b2c6d1174e2d30cbaec6d6384f10 Mon Sep 17 00:00:00 2001 From: mattmiklic Date: Wed, 30 Sep 2026 12:17:23 -0400 Subject: [PATCH 12/21] List Tables: Skip tab stops for non-scrolling wrappers. Keep list table wrappers in the tab order only when they provide horizontal scrolling. Preserve existing focus when overflow disappears, and restore the original `tabindex` attribute when overflow tracking ends. Add regression coverage for resizing, focus retention, visible overflow, and cleanup. --- src/js/_enqueues/admin/common.js | 16 +++++++-- tests/qunit/common.html | 2 +- tests/qunit/wp-admin/js/common.js | 59 +++++++++++++++++++++++++++++-- 3 files changed, 71 insertions(+), 6 deletions(-) diff --git a/src/js/_enqueues/admin/common.js b/src/js/_enqueues/admin/common.js index cf95df5249ed6..9e061da9c8c9d 100644 --- a/src/js/_enqueues/admin/common.js +++ b/src/js/_enqueues/admin/common.js @@ -2236,7 +2236,7 @@ $( function( $ ) { }( jQuery, window )); /** - * Indicates remaining horizontal content in list tables. + * Manages overflow indicators and keyboard access for list tables. * * @since 7.2.0 */ @@ -2262,20 +2262,25 @@ jQuery( function() { */ function observeTableOverflow( table ) { const wrapper = table.parentElement; + const tabIndex = wrapper.getAttribute( 'tabindex' ); /** - * Updates the overflow indicator at each edge with hidden content. + * Updates the overflow indicators and the wrapper's place in the Tab order. * * @since 7.2.0 * * @return {void} */ function update() { - const isRTL = window.getComputedStyle( wrapper ).direction === 'rtl'; + 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 ); @@ -2293,6 +2298,11 @@ jQuery( 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 ); + } } } ); } diff --git a/tests/qunit/common.html b/tests/qunit/common.html index 39284e443b81e..9d1b50c05764c 100644 --- a/tests/qunit/common.html +++ b/tests/qunit/common.html @@ -39,7 +39,7 @@
          -
          Post title
          +
          Post title
          diff --git a/tests/qunit/wp-admin/js/common.js b/tests/qunit/wp-admin/js/common.js index dc305145c5d04..794e105f37881 100644 --- a/tests/qunit/wp-admin/js/common.js +++ b/tests/qunit/wp-admin/js/common.js @@ -2,7 +2,7 @@ jQuery( function() { const content = document.getElementById( 'wpbody-content' ); const tableMarkup = '
          Post title
          '; - const wrapperMarkup = '
          ' + tableMarkup + '
          '; + const wrapperMarkup = '
          ' + tableMarkup + '
          '; function overflow( wrapper ) { return [ @@ -27,7 +27,7 @@ jQuery( function() { const initialOverflow = overflow( content.firstElementChild ); - QUnit.module( 'List table overflow indicators', { + QUnit.module( 'List table overflow', { beforeEach: async function() { this.sandbox = sinon.createSandbox(); content.innerHTML = wrapperMarkup; @@ -100,17 +100,53 @@ jQuery( function() { } ); 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 ) { @@ -152,6 +188,25 @@ jQuery( function() { 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' ); From b342025574cdb2d25cf6e81f5fd38b1abef31927 Mon Sep 17 00:00:00 2001 From: mattmiklic Date: Wed, 30 Sep 2026 12:50:06 -0400 Subject: [PATCH 13/21] List Tables: Align overflow code with coding standards. --- Gruntfile.js | 4 +- src/js/_enqueues/admin/common.js | 25 +- src/wp-admin/css/list-tables.css | 34 +- .../includes/class-wp-comments-list-table.php | 77 +-- src/wp-admin/includes/class-wp-list-table.php | 51 +- tests/qunit/common.html | 16 +- tests/qunit/wp-admin/js/common.js | 502 ++++++++++++------ 7 files changed, 469 insertions(+), 240 deletions(-) diff --git a/Gruntfile.js b/Gruntfile.js index 4327ccf734beb..156b88d13f59a 100644 --- a/Gruntfile.js +++ b/Gruntfile.js @@ -300,7 +300,7 @@ module.exports = function(grunt) { cwd: WORKING_DIR, src: [] }, - qunit: ['tests/qunit/compiled.html', 'tests/qunit/common-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,7 +660,7 @@ module.exports = function(grunt) { qunit: { files: { 'tests/qunit/compiled.html': 'tests/qunit/index.html', - 'tests/qunit/common-compiled.html': 'tests/qunit/common.html' + 'tests/qunit/common-compiled.html': 'tests/qunit/common.html', }, options: { processContent: function( src ) { diff --git a/src/js/_enqueues/admin/common.js b/src/js/_enqueues/admin/common.js index 9e061da9c8c9d..8abb3dce71be3 100644 --- a/src/js/_enqueues/admin/common.js +++ b/src/js/_enqueues/admin/common.js @@ -2258,6 +2258,7 @@ jQuery( function() { * @since 7.2.0 * * @param {HTMLTableElement} table The table inside a scroll wrapper. + * * @return {void} */ function observeTableOverflow( table ) { @@ -2279,7 +2280,8 @@ jQuery( function() { 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; + 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 ); @@ -2294,6 +2296,14 @@ jQuery( function() { 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 ); @@ -2303,7 +2313,7 @@ jQuery( function() { } else { wrapper.setAttribute( 'tabindex', tabIndex ); } - } + }, } ); } @@ -2322,11 +2332,12 @@ jQuery( function() { } } ); - content.querySelectorAll( '.wp-list-table-scroll > .wp-list-table' ).forEach( function( table ) { - if ( ! tables.has( table ) ) { - observeTableOverflow( table ); - } - } ); + content.querySelectorAll( '.wp-list-table-scroll > .wp-list-table' ) + .forEach( function( table ) { + if ( ! tables.has( table ) ) { + observeTableOverflow( table ); + } + } ); } refreshTables(); diff --git a/src/wp-admin/css/list-tables.css b/src/wp-admin/css/list-tables.css index e92f63c93a495..26dd0f25c968e 100644 --- a/src/wp-admin/css/list-tables.css +++ b/src/wp-admin/css/list-tables.css @@ -1796,7 +1796,10 @@ div.action-links, -------------------------------------------------------------- */ @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; @@ -1821,6 +1824,17 @@ div.action-links, 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 *, @@ -1841,17 +1855,6 @@ div.action-links, overflow-wrap: anywhere; } - :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; - } - .wp-list-table-scroll > .fixed th.column-author, .wp-list-table-scroll > .fixed th.column-categories, .wp-list-table-scroll > .fixed th.column-tags, @@ -1874,11 +1877,6 @@ div.action-links, min-width: 20em; } - /* Share the table's scrollable area so the fades stick to the viewport. */ - .wp-list-table-scroll { - display: grid; - } - .wp-list-table-scroll > .widefat, .wp-list-table-scroll::before, .wp-list-table-scroll::after { @@ -1889,8 +1887,8 @@ div.action-links, .wp-list-table-scroll::after { content: ""; position: sticky; - width: 32px; z-index: 1; + width: 32px; pointer-events: none; opacity: 0; } @@ -1898,6 +1896,7 @@ div.action-links, .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)); } @@ -1905,6 +1904,7 @@ div.action-links, .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)); } 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 b30f5b60e0fa1..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' ); @@ -614,48 +615,48 @@ public function display() { ?>
          - - ' . - /* 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 ); ?> + + -
          +
          _args['singular']; @@ -1632,31 +1633,31 @@ public function display() { $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 index 9d1b50c05764c..622b3a73e1021 100644 --- a/tests/qunit/common.html +++ b/tests/qunit/common.html @@ -2,7 +2,7 @@ WordPress Common Admin QUnit Test Suite - + @@ -37,9 +37,19 @@
          -
            +
            +
              +
              -
              Post title
              +
              + + + + + + +
              Post title
              +
              diff --git a/tests/qunit/wp-admin/js/common.js b/tests/qunit/wp-admin/js/common.js index 794e105f37881..7c58d9307b743 100644 --- a/tests/qunit/wp-admin/js/common.js +++ b/tests/qunit/wp-admin/js/common.js @@ -1,21 +1,54 @@ +/** + * 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 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' ) + 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 ) { @@ -37,95 +70,193 @@ jQuery( function() { }, 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.' ); + 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 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.' ); + 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.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.' ); - } ); + 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.' ); + 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.' ); + 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 = '#'; @@ -133,95 +264,170 @@ jQuery( function() { 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.' ); + assert.strictEqual( + document.activeElement, + link, + 'Focus can move to a link inside the fitting wrapper.' + ); } ); - 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.' ); + 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.' + ); + } + ); - scrollTo( oldWrapper, 200 ); - assert.deepEqual( overflow( oldWrapper ), [ false, false ], 'Scrolling the removed wrapper no longer updates overflow indicators.' ); + 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 ); - scrollTo( content.firstElementChild, 200 ); - assert.deepEqual( overflow( content.firstElementChild ), [ true, true ], 'The replacement responds to scrolling.' ); - } ); + content.innerHTML = wrapperMarkup; - 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' ); + // 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.' + ); + } + ); - 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.' ); + 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.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.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.firstElementChild.style.width = '600px'; - await afterResize(); - assert.deepEqual( overflow( this.wrapper ), [ false, false ], 'Resizing the replacement table updates its overflow indicators.' ); - } ); + 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 ); + 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.' + ); } - 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.' ); + 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 ); - scrollTo( newWrapper, 200 ); - assert.deepEqual( overflow( newWrapper ), [ true, true ], 'The new wrapper responds to scrolling.' ); - } ); + 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(); } ); From 05e34dcd7bc1d0095575a355c9d216cef14c9982 Mon Sep 17 00:00:00 2001 From: mattmiklic Date: Wed, 30 Sep 2026 13:58:47 -0400 Subject: [PATCH 14/21] List Tables: Refine overflow fades and focus outline. --- src/wp-admin/css/list-tables.css | 10 +++++++++- 1 file changed, 9 insertions(+), 1 deletion(-) diff --git a/src/wp-admin/css/list-tables.css b/src/wp-admin/css/list-tables.css index 53de0e65aa8d9..daceb823e2606 100644 --- a/src/wp-admin/css/list-tables.css +++ b/src/wp-admin/css/list-tables.css @@ -1791,7 +1791,7 @@ div.action-links, .wp-list-table-scroll:focus-visible { outline: 2px solid var(--wp-admin-theme-color); - outline-offset: -2px; + outline-offset: 0; } /* =Media Queries @@ -1922,6 +1922,14 @@ div.action-links, } } +@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; From d75ecfcc67c104a170992954b570e29c0fb73a99 Mon Sep 17 00:00:00 2001 From: mattmiklic Date: Tue, 20 Oct 2026 14:41:19 -0400 Subject: [PATCH 15/21] List Tables: Keep inline editing within the scroll viewport. --- src/wp-admin/css/list-tables.css | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/src/wp-admin/css/list-tables.css b/src/wp-admin/css/list-tables.css index daceb823e2606..0c138d2a94037 100644 --- a/src/wp-admin/css/list-tables.css +++ b/src/wp-admin/css/list-tables.css @@ -1802,6 +1802,7 @@ div.action-links, /* Share the table's scrollable area so the fades stick to the viewport. */ .wp-list-table-scroll { display: grid; + container-type: inline-size; clear: both; overflow-x: auto; overflow-y: hidden; @@ -1810,6 +1811,15 @@ div.action-links, box-shadow: 0 1px 1px rgba(0, 0, 0, 0.04); } + /* 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; From 5eb211c743db661a0c1ad901872b28e463558d32 Mon Sep 17 00:00:00 2001 From: mattmiklic Date: Tue, 20 Oct 2026 14:44:24 -0400 Subject: [PATCH 16/21] List Tables: Preserve checkbox column spacing. --- src/wp-admin/css/list-tables.css | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/src/wp-admin/css/list-tables.css b/src/wp-admin/css/list-tables.css index 0c138d2a94037..3b7c68861b05b 100644 --- a/src/wp-admin/css/list-tables.css +++ b/src/wp-admin/css/list-tables.css @@ -1836,6 +1836,11 @@ div.action-links, 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, From 2f074e0f3d3d540c5937afe855b018d2ed6e61c9 Mon Sep 17 00:00:00 2001 From: Weston Ruter Date: Tue, 20 Oct 2026 15:25:27 -0700 Subject: [PATCH 17/21] Add missing types for properties on list table subclass --- .../includes/class-wp-comments-list-table.php | 20 +++++++++++++++++++ 1 file changed, 20 insertions(+) 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 2cd8cfdc3d6bf..102016590b790 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; From 64bd1efe1d5a66f736cedf58ba040c156887b0a2 Mon Sep 17 00:00:00 2001 From: mattmiklic Date: Wed, 21 Oct 2026 09:35:16 -0400 Subject: [PATCH 18/21] List Tables: Refine markup and overflow initialization. --- src/js/_enqueues/admin/common.js | 4 +- .../includes/class-wp-comments-list-table.php | 88 +++++++++---------- src/wp-admin/includes/class-wp-list-table.php | 54 ++++++------ 3 files changed, 73 insertions(+), 73 deletions(-) diff --git a/src/js/_enqueues/admin/common.js b/src/js/_enqueues/admin/common.js index 42941974d34e7..d332b68087f99 100644 --- a/src/js/_enqueues/admin/common.js +++ b/src/js/_enqueues/admin/common.js @@ -2245,12 +2245,12 @@ jQuery( function() { const content = document.getElementById( 'wpbody-content' ); if ( ! content || - ! content.querySelector( '.wp-list-table-scroll' ) || - ! window.ResizeObserver + ! content.querySelector( '.wp-list-table-scroll' ) ) { return; } + /** @type {Map} */ const tables = new Map(); /** 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 102016590b790..7900b40824716 100644 --- a/src/wp-admin/includes/class-wp-comments-list-table.php +++ b/src/wp-admin/includes/class-wp-comments-list-table.php @@ -636,50 +636,50 @@ public function display() { $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 50c7d5891817e..f439dec067d2d 100644 --- a/src/wp-admin/includes/class-wp-list-table.php +++ b/src/wp-admin/includes/class-wp-list-table.php @@ -1653,33 +1653,33 @@ public function display() { $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' ); } From f91fac9439ac34ba33983dc7325bf29175ded8af Mon Sep 17 00:00:00 2001 From: mattmiklic Date: Wed, 21 Oct 2026 11:26:32 -0400 Subject: [PATCH 19/21] List Tables: Contain oversized content in comment cells. Cap the Comment column and let overflowing cells scroll independently so one oversized comment does not widen every row. Keep scrolling cells keyboard accessible, update their focusability after content changes, and restore original focus attributes when tracking ends. Add regression coverage for these behaviors. --- src/js/_enqueues/admin/common.js | 54 +++++++++++++++++++++++++++---- src/wp-admin/css/list-tables.css | 11 +++++++ tests/qunit/wp-admin/js/common.js | 42 ++++++++++++++++++++++++ 3 files changed, 100 insertions(+), 7 deletions(-) diff --git a/src/js/_enqueues/admin/common.js b/src/js/_enqueues/admin/common.js index d332b68087f99..88391f4b5f700 100644 --- a/src/js/_enqueues/admin/common.js +++ b/src/js/_enqueues/admin/common.js @@ -2250,9 +2250,27 @@ jQuery( function() { return; } - /** @type {Map} */ + /** @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. * @@ -2265,6 +2283,7 @@ jQuery( function() { function observeTableOverflow( table ) { const wrapper = table.parentElement; const tabIndex = wrapper.getAttribute( 'tabindex' ); + const commentCells = new Map(); /** * Updates the overflow indicators and the wrapper's place in the Tab order. @@ -2274,6 +2293,24 @@ jQuery( function() { * @return {void} */ function update() { + commentCells.forEach( function( originalTabIndex, cell ) { + if ( cell.closest( '.wp-list-table' ) !== table || ! table.contains( cell ) ) { + observer.unobserve( cell ); + restoreTabIndex( cell, originalTabIndex ); + commentCells.delete( cell ); + } + } ); + + table.querySelectorAll( ':scope > tbody > tr > .column-comment' ).forEach( function( cell ) { + if ( ! commentCells.has( cell ) ) { + commentCells.set( cell, cell.getAttribute( 'tabindex' ) ); + observer.observe( cell ); + } + const cellStyle = window.getComputedStyle( cell ); + cell.tabIndex = cell.scrollWidth > cell.clientWidth && + ( cellStyle.overflowX === 'auto' || cellStyle.overflowX === 'scroll' ) ? 0 : -1; + } ); + const style = window.getComputedStyle( wrapper ); const isRTL = style.direction === 'rtl'; const scrollLeft = isRTL ? -wrapper.scrollLeft : wrapper.scrollLeft; @@ -2297,6 +2334,7 @@ jQuery( function() { tables.set( table, { wrapper: wrapper, + update: update, /** * Releases overflow tracking and restores the wrapper's original Tab order. @@ -2309,11 +2347,11 @@ jQuery( 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 ); - } + restoreTabIndex( wrapper, tabIndex ); + commentCells.forEach( function( originalTabIndex, cell ) { + restoreTabIndex( cell, originalTabIndex ); + } ); + commentCells.clear(); }, } ); } @@ -2337,6 +2375,8 @@ jQuery( function() { .forEach( function( table ) { if ( ! tables.has( table ) ) { observeTableOverflow( table ); + } else { + tables.get( table ).update(); } } ); } @@ -2345,7 +2385,7 @@ jQuery( function() { // Plugins live search replaces the table and its wrapper. const observer = new window.MutationObserver( refreshTables ); - observer.observe( content, { childList: true, subtree: true } ); + observer.observe( content, { childList: true, characterData: true, subtree: true } ); } ); /** diff --git a/src/wp-admin/css/list-tables.css b/src/wp-admin/css/list-tables.css index 3b7c68861b05b..6663f54ece0e7 100644 --- a/src/wp-admin/css/list-tables.css +++ b/src/wp-admin/css/list-tables.css @@ -1811,6 +1811,17 @@ div.action-links, box-shadow: 0 1px 1px rgba(0, 0, 0, 0.04); } + /* Keep oversized comments from widening every row in the column. */ + .wp-list-table-scroll > .wp-list-table > tbody > tr > .column-comment { + max-width: 40em; + overflow: auto; + } + + .wp-list-table-scroll > .wp-list-table > tbody > tr > .column-comment:focus-visible { + outline: 2px solid var(--wp-admin-theme-color); + outline-offset: -2px; + } + /* Keep inline editing forms within the visible table container. */ .wp-list-table-scroll .inline-edit-wrapper { position: sticky; diff --git a/tests/qunit/wp-admin/js/common.js b/tests/qunit/wp-admin/js/common.js index 7c58d9307b743..35dc13eea0fae 100644 --- a/tests/qunit/wp-admin/js/common.js +++ b/tests/qunit/wp-admin/js/common.js @@ -237,6 +237,48 @@ jQuery( function() { } ); + QUnit.test( 'Keeps only scrollable comment cells in the Tab order', async function( assert ) { + this.table.style.width = '600px'; + this.table.innerHTML = '
              Wide comment
              Other column'; + const cell = this.table.querySelector( '.column-comment' ); + await afterResize(); + assert.ok( cell.scrollWidth > cell.clientWidth, 'The comment cell has overflow.' ); + assert.strictEqual( cell.tabIndex, 0, 'An overflowing comment cell can be reached by Tab.' ); + + cell.focus(); + cell.firstElementChild.remove(); + await afterResize(); + assert.strictEqual( cell.tabIndex, -1, 'A fitting cell is removed from the Tab order.' ); + assert.strictEqual( document.activeElement, cell, 'Removing overflow preserves existing focus.' ); + } ); + + QUnit.test( 'Updates comment keyboard access after content changes without resizing the cell', async function( assert ) { + this.table.style.width = '600px'; + this.table.innerHTML = '
              Short
              Other column'; + const cell = this.table.querySelector( '.column-comment' ); + await afterResize(); + assert.strictEqual( cell.tabIndex, -1, 'Short content does not add a Tab stop.' ); + + cell.firstElementChild.firstChild.data = 'W'.repeat( 200 ); + await afterResize(); + assert.strictEqual( cell.tabIndex, 0, 'Changed text makes the scrolling cell keyboard accessible.' ); + + cell.style.overflow = 'visible'; + this.table.style.width = '650px'; + await afterResize(); + assert.strictEqual( cell.tabIndex, -1, 'Visible overflow, as on mobile, does not add a Tab stop.' ); + } ); + + QUnit.test( 'Restores comment cell focus attributes when its table is removed', async function( assert ) { + this.table.innerHTML = 'Comment'; + const cell = this.table.querySelector( '.column-comment' ); + await afterResize(); + assert.strictEqual( cell.tabIndex, -1, 'A fitting cell is skipped while managed.' ); + this.table.remove(); + await afterResize(); + assert.strictEqual( cell.getAttribute( 'tabindex' ), '0', 'Cleanup restores the original attribute.' ); + } ); + QUnit.test( 'Preserves focus when a focused wrapper stops overflowing', async function( assert ) { this.wrapper.focus(); assert.strictEqual( From 00ff4900d9368505f4fb70878cda2abfa75664be Mon Sep 17 00:00:00 2001 From: mattmiklic Date: Wed, 21 Oct 2026 11:51:20 -0400 Subject: [PATCH 20/21] List Tables: Scroll oversized content within cells. Contain content blocks across list-table columns while keeping row actions outside their scrolling areas. Preserve keyboard access and comment double-click editing, and use the design-system thumb color with a transparent scrollbar track. Retain the existing content margins to leave room for overlay scrollbars. Update regression coverage for content scrolling and cleanup. --- src/js/_enqueues/admin/common.js | 57 +++++++++++++++------- src/js/_enqueues/admin/edit-comments.js | 8 ++-- src/wp-admin/css/list-tables.css | 13 +++-- tests/qunit/common.html | 4 ++ tests/qunit/wp-admin/js/common.js | 64 ++++++++++++++----------- 5 files changed, 95 insertions(+), 51 deletions(-) diff --git a/src/js/_enqueues/admin/common.js b/src/js/_enqueues/admin/common.js index 88391f4b5f700..2a55663f79e67 100644 --- a/src/js/_enqueues/admin/common.js +++ b/src/js/_enqueues/admin/common.js @@ -2283,7 +2283,7 @@ jQuery( function() { function observeTableOverflow( table ) { const wrapper = table.parentElement; const tabIndex = wrapper.getAttribute( 'tabindex' ); - const commentCells = new Map(); + const contentAreas = new Set(); /** * Updates the overflow indicators and the wrapper's place in the Tab order. @@ -2293,22 +2293,47 @@ jQuery( function() { * @return {void} */ function update() { - commentCells.forEach( function( originalTabIndex, cell ) { - if ( cell.closest( '.wp-list-table' ) !== table || ! table.contains( cell ) ) { - observer.unobserve( cell ); - restoreTabIndex( cell, originalTabIndex ); - commentCells.delete( cell ); + contentAreas.forEach( function( 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 > .column-comment' ).forEach( function( cell ) { - if ( ! commentCells.has( cell ) ) { - commentCells.set( cell, cell.getAttribute( 'tabindex' ) ); - observer.observe( cell ); + table.querySelectorAll( ':scope > tbody > tr > td, :scope > tbody > tr > th' ).forEach( function( cell ) { + if ( cell.colSpan > 1 || cell.closest( '.inline-edit-row, .inline-editor, #replyrow' ) ) { + return; + } + Array.from( cell.children ).forEach( function( 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( function( 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 cellStyle = window.getComputedStyle( cell ); - cell.tabIndex = cell.scrollWidth > cell.clientWidth && - ( cellStyle.overflowX === 'auto' || cellStyle.overflowX === 'scroll' ) ? 0 : -1; } ); const style = window.getComputedStyle( wrapper ); @@ -2348,10 +2373,10 @@ jQuery( function() { wrapper.removeEventListener( 'scroll', update ); wrapper.classList.remove( 'has-scroll-overflow-start', 'has-scroll-overflow-end' ); restoreTabIndex( wrapper, tabIndex ); - commentCells.forEach( function( originalTabIndex, cell ) { - restoreTabIndex( cell, originalTabIndex ); + contentAreas.forEach( function( area ) { + area.replaceWith( ...area.childNodes ); } ); - commentCells.clear(); + contentAreas.clear(); }, } ); } 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 6663f54ece0e7..abfe63c3019dd 100644 --- a/src/wp-admin/css/list-tables.css +++ b/src/wp-admin/css/list-tables.css @@ -1794,6 +1794,11 @@ div.action-links, outline-offset: 0; } +/* Preserve the existing mobile layout for content wrappers. */ +.wp-list-table-content-scroll { + display: contents; +} + /* =Media Queries -------------------------------------------------------------- */ @@ -1811,13 +1816,15 @@ div.action-links, box-shadow: 0 1px 1px rgba(0, 0, 0, 0.04); } - /* Keep oversized comments from widening every row in the column. */ - .wp-list-table-scroll > .wp-list-table > tbody > tr > .column-comment { + /* Keep oversized content blocks from widening every row in their column. */ + .wp-list-table-scroll .wp-list-table-content-scroll { + display: block; max-width: 40em; overflow: auto; + scrollbar-color: var(--wpds-color-background-thumb-neutral-weak) transparent; } - .wp-list-table-scroll > .wp-list-table > tbody > tr > .column-comment:focus-visible { + .wp-list-table-scroll .wp-list-table-content-scroll:focus-visible { outline: 2px solid var(--wp-admin-theme-color); outline-offset: -2px; } diff --git a/tests/qunit/common.html b/tests/qunit/common.html index 622b3a73e1021..8b880e93432d3 100644 --- a/tests/qunit/common.html +++ b/tests/qunit/common.html @@ -29,6 +29,10 @@ width: 1200px; border-collapse: collapse; } + .wp-list-table-content-scroll { + max-width: 200px; + overflow: auto; + } diff --git a/tests/qunit/wp-admin/js/common.js b/tests/qunit/wp-admin/js/common.js index 35dc13eea0fae..849fa57e52d19 100644 --- a/tests/qunit/wp-admin/js/common.js +++ b/tests/qunit/wp-admin/js/common.js @@ -237,46 +237,54 @@ jQuery( function() { } ); - QUnit.test( 'Keeps only scrollable comment cells in the Tab order', async function( assert ) { - this.table.style.width = '600px'; - this.table.innerHTML = '
              Wide comment
              Other column'; - const cell = this.table.querySelector( '.column-comment' ); - await afterResize(); - assert.ok( cell.scrollWidth > cell.clientWidth, 'The comment cell has overflow.' ); - assert.strictEqual( cell.tabIndex, 0, 'An overflowing comment cell can be reached by Tab.' ); - - cell.focus(); - cell.firstElementChild.remove(); + 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(); - assert.strictEqual( cell.tabIndex, -1, 'A fitting cell is removed from the Tab order.' ); - assert.strictEqual( document.activeElement, cell, 'Removing overflow preserves existing focus.' ); + 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.' ); } ); - QUnit.test( 'Updates comment keyboard access after content changes without resizing the cell', async function( assert ) { - this.table.style.width = '600px'; - this.table.innerHTML = '
              Short
              Other column'; - const cell = this.table.querySelector( '.column-comment' ); + QUnit.test( 'Updates content keyboard access after text changes', async function( assert ) { + this.table.innerHTML = '
              Short
              '; await afterResize(); - assert.strictEqual( cell.tabIndex, -1, 'Short content does not add a Tab stop.' ); - - cell.firstElementChild.firstChild.data = 'W'.repeat( 200 ); + 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( cell.tabIndex, 0, 'Changed text makes the scrolling cell keyboard accessible.' ); + assert.strictEqual( area.tabIndex, -1, 'Fitting content is removed from the Tab order.' ); + assert.strictEqual( document.activeElement, area, 'Existing focus is preserved.' ); + } ); - cell.style.overflow = 'visible'; - this.table.style.width = '650px'; + QUnit.test( 'Leaves existing plugin scrolling inside its content block', async function( assert ) { + this.table.innerHTML = '
              Plugin content
              '; await afterResize(); - assert.strictEqual( cell.tabIndex, -1, 'Visible overflow, as on mobile, does not add a Tab stop.' ); + 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( 'Restores comment cell focus attributes when its table is removed', async function( assert ) { - this.table.innerHTML = 'Comment'; - const cell = this.table.querySelector( '.column-comment' ); + QUnit.test( 'Removes generated content wrappers when a table is removed', async function( assert ) { + this.table.innerHTML = '
              Content
              '; await afterResize(); - assert.strictEqual( cell.tabIndex, -1, 'A fitting cell is skipped while managed.' ); + const cell = this.table.querySelector( 'td' ); this.table.remove(); await afterResize(); - assert.strictEqual( cell.getAttribute( 'tabindex' ), '0', 'Cleanup restores the original attribute.' ); + 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 ) { From 39d045bba6739af2f402937509429c9fbd52e59c Mon Sep 17 00:00:00 2001 From: mattmiklic Date: Wed, 21 Oct 2026 12:14:01 -0400 Subject: [PATCH 21/21] List Tables: Allow scrolling content columns to shrink. --- src/wp-admin/css/list-tables.css | 8 +++++-- tests/qunit/wp-admin/js/common.js | 38 +++++++++++++++++++++++++++++++ 2 files changed, 44 insertions(+), 2 deletions(-) diff --git a/src/wp-admin/css/list-tables.css b/src/wp-admin/css/list-tables.css index abfe63c3019dd..4a4325669f9d9 100644 --- a/src/wp-admin/css/list-tables.css +++ b/src/wp-admin/css/list-tables.css @@ -1816,9 +1816,13 @@ div.action-links, box-shadow: 0 1px 1px rgba(0, 0, 0, 0.04); } - /* Keep oversized content blocks from widening every row in their column. */ + /* + * 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: block; + display: grid; + grid-template-columns: minmax(0, 1fr); max-width: 40em; overflow: auto; scrollbar-color: var(--wpds-color-background-thumb-neutral-weak) transparent; diff --git a/tests/qunit/wp-admin/js/common.js b/tests/qunit/wp-admin/js/common.js index 849fa57e52d19..6a7cb7e13c840 100644 --- a/tests/qunit/wp-admin/js/common.js +++ b/tests/qunit/wp-admin/js/common.js @@ -252,6 +252,44 @@ jQuery( function() { 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(); + + // 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.' ); + + 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();