Skip to content

Commit

Permalink
[Dataviews] Table layout: Ensure focus is not lost on interaction (#5…
Browse files Browse the repository at this point in the history
…7340)

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.
  • Loading branch information
Andrew Hayward authored Jan 5, 2024
1 parent 58c4c13 commit c804a69
Showing 1 changed file with 122 additions and 55 deletions.
177 changes: 122 additions & 55 deletions packages/dataviews/src/view-table.js
Original file line number Diff line number Diff line change
@@ -1,3 +1,8 @@
/**
* External dependencies
*/
import classNames from 'classnames';

/**
* WordPress dependencies
*/
Expand All @@ -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
Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -76,6 +92,7 @@ function HeaderMenu( { field, view, onChangeView } ) {
size="compact"
className="dataviews-table-header-button"
style={ { padding: 0 } }
ref={ ref }
>
{ field.header }
{ isSorted && (
Expand Down Expand Up @@ -132,6 +149,7 @@ function HeaderMenu( { field, view, onChangeView } ) {
<DropdownMenuItem
prefix={ <Icon icon={ unseen } /> }
onClick={ () => {
onHide( field );
onChangeView( {
...view,
hiddenFields: view.hiddenFields.concat(
Expand Down Expand Up @@ -275,7 +293,7 @@ function HeaderMenu( { field, view, onChangeView } ) {
</WithSeparators>
</DropdownMenu>
);
}
} );

function WithSeparators( { children } ) {
return Children.toArray( children )
Expand All @@ -298,62 +316,106 @@ 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 ) &&
! [ view.layout.mediaField, view.layout.primaryField ].includes(
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 (
<div className="dataviews-loading">
<h3>{ __( 'Loading' ) }</h3>
</div>
);
}
const sortValues = { asc: 'ascending', desc: 'descending' };

return (
<div className="dataviews-table-view-wrapper">
{ hasData && (
<table className="dataviews-table-view">
<thead>
<tr>
{ visibleFields.map( ( field ) => (
<th
key={ field.id }
style={ {
width: field.width || undefined,
minWidth: field.minWidth || undefined,
maxWidth: field.maxWidth || undefined,
<table
className="dataviews-table-view"
aria-busy={ isLoading }
aria-describedby={ tableNoticeId }
>
<thead>
<tr>
{ visibleFields.map( ( field, index ) => (
<th
key={ field.id }
style={ {
width: field.width || undefined,
minWidth: field.minWidth || undefined,
maxWidth: field.maxWidth || undefined,
} }
data-field-id={ field.id }
aria-sort={
view.sort?.field === field.id &&
sortValues[ view.sort.direction ]
}
scope="col"
>
<HeaderMenu
ref={ ( node ) => {
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"
>
<HeaderMenu
field={ field }
view={ view }
onChangeView={ onChangeView }
/>
</th>
) ) }
{ !! actions?.length && (
<th data-field-id="actions">
{ __( 'Actions' ) }
</th>
) }
</tr>
</thead>
<tbody>
{ usedData.map( ( item ) => (
field={ field }
view={ view }
onChangeView={ onChangeView }
onHide={ onHide }
/>
</th>
) ) }
{ !! actions?.length && (
<th data-field-id="actions">{ __( 'Actions' ) }</th>
) }
</tr>
</thead>
<tbody>
{ hasData &&
usedData.map( ( item ) => (
<tr key={ getItemId( item ) }>
{ visibleFields.map( ( field ) => (
<td
Expand Down Expand Up @@ -381,14 +443,19 @@ function ViewTable( {
) }
</tr>
) ) }
</tbody>
</table>
) }
{ ! hasData && (
<div className="dataviews-no-results">
<p>{ __( 'No results' ) }</p>
</div>
) }
</tbody>
</table>
<div
className={ classNames( 'dataviews-table-status', {
'dataviews-loading': isLoading,
'dataviews-no-results': ! hasData && ! isLoading,
} ) }
id={ tableNoticeId }
>
{ ! hasData && (
<p>{ isLoading ? __( 'Loading…' ) : __( 'No results' ) }</p>
) }
</div>
</div>
);
}
Expand Down

0 comments on commit c804a69

Please sign in to comment.