From c804a695e570733114d08ab49965e7ecd22d97e4 Mon Sep 17 00:00:00 2001 From: Andrew Hayward Date: Fri, 5 Jan 2024 12:49:15 +0000 Subject: [PATCH] [Dataviews] Table layout: Ensure focus is not lost on interaction (#57340) When tables are sorted or paginated, focus can be lost if the new data takes time to come back. This patch keeps the table header in place, whether data exists or not, ensuring focus is not lost. Additionally, when columns are hidden from the column header menu, focus is also lost as the header menu no longer exists. This patch adds an `onHide` callback to each header menu, which when called will put the focus on a fallback header menu. --- packages/dataviews/src/view-table.js | 177 ++++++++++++++++++--------- 1 file changed, 122 insertions(+), 55 deletions(-) diff --git a/packages/dataviews/src/view-table.js b/packages/dataviews/src/view-table.js index 5bfcba5def4aa3..083931bb5203ec 100644 --- a/packages/dataviews/src/view-table.js +++ b/packages/dataviews/src/view-table.js @@ -1,3 +1,8 @@ +/** + * External dependencies + */ +import classNames from 'classnames'; + /** * WordPress dependencies */ @@ -9,7 +14,15 @@ import { Icon, privateApis as componentsPrivateApis, } from '@wordpress/components'; -import { Children, Fragment } from '@wordpress/element'; +import { + Children, + Fragment, + forwardRef, + useEffect, + useId, + useRef, + useState, +} from '@wordpress/element'; /** * Internal dependencies @@ -41,7 +54,10 @@ const sanitizeOperators = ( field ) => { ); }; -function HeaderMenu( { field, view, onChangeView } ) { +const HeaderMenu = forwardRef( function HeaderMenu( + { field, view, onChangeView, onHide }, + ref +) { const isHidable = field.enableHiding !== false; const isSortable = field.enableSorting !== false; @@ -76,6 +92,7 @@ function HeaderMenu( { field, view, onChangeView } ) { size="compact" className="dataviews-table-header-button" style={ { padding: 0 } } + ref={ ref } > { field.header } { isSorted && ( @@ -132,6 +149,7 @@ function HeaderMenu( { field, view, onChangeView } ) { } onClick={ () => { + onHide( field ); onChangeView( { ...view, hiddenFields: view.hiddenFields.concat( @@ -275,7 +293,7 @@ function HeaderMenu( { field, view, onChangeView } ) { ); -} +} ); function WithSeparators( { children } ) { return Children.toArray( children ) @@ -298,6 +316,35 @@ function ViewTable( { isLoading = false, deferredRendering, } ) { + const headerMenuRefs = useRef( new Map() ); + const headerMenuToFocusRef = useRef(); + const [ nextHeaderMenuToFocus, setNextHeaderMenuToFocus ] = useState(); + + useEffect( () => { + if ( headerMenuToFocusRef.current ) { + headerMenuToFocusRef.current.focus(); + headerMenuToFocusRef.current = undefined; + } + } ); + + const asyncData = useAsyncList( data ); + const tableNoticeId = useId(); + + if ( nextHeaderMenuToFocus ) { + // If we need to force focus, we short-circuit rendering here + // to prevent any additional work while we handle that. + // Clearing out the focus directive is necessary to make sure + // future renders don't cause unexpected focus jumps. + headerMenuToFocusRef.current = nextHeaderMenuToFocus; + setNextHeaderMenuToFocus(); + return; + } + + const onHide = ( field ) => { + const hidden = headerMenuRefs.current.get( field.id ); + const fallback = headerMenuRefs.current.get( hidden.fallback ); + setNextHeaderMenuToFocus( fallback?.node ); + }; const visibleFields = fields.filter( ( field ) => ! view.hiddenFields.includes( field.id ) && @@ -305,55 +352,70 @@ function ViewTable( { field.id ) ); - const shownData = useAsyncList( data ); - const usedData = deferredRendering ? shownData : data; + const usedData = deferredRendering ? asyncData : data; const hasData = !! usedData?.length; - if ( isLoading ) { - // TODO:Add spinner or progress bar.. - return ( -
-

{ __( 'Loading' ) }

-
- ); - } const sortValues = { asc: 'ascending', desc: 'descending' }; + return (
- { hasData && ( - - - - { visibleFields.map( ( field ) => ( - + + { visibleFields.map( ( field, index ) => ( + - ) ) } - { !! actions?.length && ( - - ) } - - - - { usedData.map( ( item ) => ( + field={ field } + view={ view } + onChangeView={ onChangeView } + onHide={ onHide } + /> + + ) ) } + { !! actions?.length && ( + + ) } + + + + { hasData && + usedData.map( ( item ) => ( { visibleFields.map( ( field ) => ( -
+
+ { + if ( node ) { + headerMenuRefs.current.set( + field.id, + { + node, + fallback: + visibleFields[ + index > 0 + ? index - 1 + : 1 + ]?.id, + } + ); + } else { + headerMenuRefs.current.delete( + field.id + ); + } } } - data-field-id={ field.id } - aria-sort={ - view.sort?.field === field.id && - sortValues[ view.sort.direction ] - } - scope="col" - > - - - { __( 'Actions' ) } -
{ __( 'Actions' ) }
) ) } -
- ) } - { ! hasData && ( -
-

{ __( 'No results' ) }

-
- ) } + + +
+ { ! hasData && ( +

{ isLoading ? __( 'Loading…' ) : __( 'No results' ) }

+ ) } +
); }