From 73b207e93a19e152cf45c45dd11d0cf137c6a97d Mon Sep 17 00:00:00 2001 From: mattmiklic Date: Wed, 23 Sep 2026 11:40:15 -0400 Subject: [PATCH 01/14] 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/14] 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/14] 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/14] 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/14] 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/14] 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/14] 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/14] 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/14] 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/14] 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/14] 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/14] 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/14] 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/14] 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;